diff --git a/publish.cabal b/publish.cabal
--- a/publish.cabal
+++ b/publish.cabal
@@ -1,113 +1,130 @@
 cabal-version: 1.12
-name: publish
-version: 0.4.4
-license: BSD3
-license-file: LICENCE
-copyright: © 2016-2019 Operational Dynamics Consulting Pty Ltd, and Others
-maintainer: Andrew Cowie <andrew@operationaldynamics.com>
-author: Andrew Cowie <andrew@operationaldynamics.com>
-stability: experimental
-tested-with: ghc ==8.6
-homepage: https://github.com/oprdyn/publish#readme
-bug-reports: https://github.com/oprdyn/publish/issues
-synopsis: Publishing tools for papers, books, and presentations
-description:
-    Tools for rendering markdown-centric documents into PDFs.
-    .
-    A description of this package, a list of features, and some background
-    to its design is contained in the
-    <https://github.com/oprdyn/publish/blob/master/README.markdown README>
-    on GitHub.
-    .
-    The main program, @render@, is available via a Docker image at
-    <https://cloud.docker.com/u/oprdyn/repository/docker/oprdyn/publish-render/general docker.io/oprdyn/publish-render:latest>
-category: Text
-build-type: Simple
 
+-- This file has been generated from package.yaml by hpack version 0.31.1.
+--
+-- see: https://github.com/sol/hpack
+--
+-- hash: 547ada31362d64dd2c988a4b125cd34de323ad166da373e7cec366b84f9b4352
+
+name:           publish
+version:        2.0.1
+synopsis:       Publishing tools for papers, books, and presentations
+description:    Tools for rendering markdown-centric documents into PDFs.
+                .
+                A description of this package, a list of features, and some background
+                to its design is contained in the
+                <https://github.com/oprdyn/publish/blob/master/README.markdown README>
+                on GitHub.
+                .
+                The main program, @render@, is available via a Docker image at
+                <https://hub.docker.com/r/oprdyn/publish-render docker.io/oprdyn/publish-render:latest>
+category:       Text
+stability:      experimental
+homepage:       https://github.com/oprdyn/publish#readme
+bug-reports:    https://github.com/oprdyn/publish/issues
+author:         Andrew Cowie <andrew@operationaldynamics.com>
+maintainer:     Andrew Cowie <andrew@operationaldynamics.com>
+copyright:      © 2016-2019 Operational Dynamics Consulting Pty Ltd, and Others
+license:        BSD3
+license-file:   LICENCE
+tested-with:    GHC == 8.6
+build-type:     Simple
+
 source-repository head
-    type: git
-    location: https://github.com/oprdyn/publish
+  type: git
+  location: https://github.com/oprdyn/publish
 
 executable format
-    main-is: FormatMain.hs
-    hs-source-dirs: src
-    other-modules:
-        FormatDocument
-        PandocToMarkdown
-    default-language: Haskell2010
-    ghc-options: -threaded -Wall -Wwarn -fwarn-tabs
-    build-depends:
-        base >=4.11 && <5,
-        bytestring >=0.10.8.2 && <0.11,
-        chronologique >=0.3.1.1 && <0.4,
-        deepseq >=1.4.4.0 && <1.5,
-        directory >=1.3.3.0 && <1.4,
-        filepath >=1.4.2.1 && <1.5,
-        hinotify ==0.4.*,
-        pandoc ==2.5.*,
-        pandoc-types >=1.17.5.4 && <1.18,
-        template-haskell >=2.14.0.0 && <2.15,
-        text >=1.2.3.1 && <1.3,
-        typed-process >=0.2.4.0 && <0.3,
-        unbeliever >=0.9.3.2 && <0.10,
-        unix >=2.7.2.2 && <2.8,
-        unordered-containers >=0.2.9.0 && <0.3
+  main-is: FormatMain.hs
+  other-modules:
+      FormatDocument
+      PandocToMarkdown
+  hs-source-dirs:
+      src
+  ghc-options: -threaded -Wall -Wwarn -fwarn-tabs
+  build-depends:
+      base >=4.11 && <5
+    , bytestring
+    , chronologique
+    , deepseq
+    , directory
+    , filepath
+    , hinotify
+    , megaparsec
+    , pandoc
+    , pandoc-types
+    , template-haskell
+    , text
+    , typed-process
+    , unbeliever >=0.9.3.2
+    , unix
+    , unordered-containers
+  default-language: Haskell2010
 
 executable render
-    main-is: RenderMain.hs
-    hs-source-dirs: src
-    other-modules:
-        Environment
-        LatexPreamble
-        NotifyChanges
-        OutputParser
-        PandocToMarkdown
-        RenderDocument
-        Utilities
-    default-language: Haskell2010
-    ghc-options: -threaded -Wall -Wwarn -fwarn-tabs
-    build-depends:
-        base >=4.11 && <5,
-        bytestring >=0.10.8.2 && <0.11,
-        chronologique >=0.3.1.1 && <0.4,
-        deepseq >=1.4.4.0 && <1.5,
-        directory >=1.3.3.0 && <1.4,
-        filepath >=1.4.2.1 && <1.5,
-        hinotify ==0.4.*,
-        pandoc ==2.5.*,
-        pandoc-types >=1.17.5.4 && <1.18,
-        template-haskell >=2.14.0.0 && <2.15,
-        text >=1.2.3.1 && <1.3,
-        typed-process >=0.2.4.0 && <0.3,
-        unbeliever >=0.9.3.2 && <0.10,
-        unix >=2.7.2.2 && <2.8,
-        unordered-containers >=0.2.9.0 && <0.3
+  main-is: RenderMain.hs
+  other-modules:
+      Environment
+      LatexPreamble
+      NotifyChanges
+      LatexOutputReader
+      PandocToMarkdown
+      ParseBookfile
+      RenderDocument
+      Utilities
+  hs-source-dirs:
+      src
+  ghc-options: -threaded -Wall -Wwarn -fwarn-tabs
+  build-depends:
+      base >=4.11 && <5
+    , bytestring
+    , chronologique
+    , deepseq
+    , directory
+    , filepath
+    , hinotify
+    , megaparsec
+    , pandoc
+    , pandoc-types
+    , template-haskell
+    , text
+    , typed-process
+    , unbeliever >=0.9.3.2
+    , unix
+    , unordered-containers
+  default-language: Haskell2010
 
 test-suite check
-    type: exitcode-stdio-1.0
-    main-is: TestSuite.hs
-    hs-source-dirs: src tests
-    other-modules:
-        CheckTableProperties
-        CompareFragments
-        FormatDocument
-        PandocToMarkdown
-    default-language: Haskell2010
-    ghc-options: -threaded -Wall -Wwarn -fwarn-tabs -threaded
-    build-depends:
-        base >=4.11 && <5,
-        bytestring >=0.10.8.2 && <0.11,
-        chronologique >=0.3.1.1 && <0.4,
-        deepseq >=1.4.4.0 && <1.5,
-        directory >=1.3.3.0 && <1.4,
-        filepath >=1.4.2.1 && <1.5,
-        hinotify ==0.4.*,
-        hspec >=2.6.1 && <2.7,
-        pandoc ==2.5.*,
-        pandoc-types >=1.17.5.4 && <1.18,
-        template-haskell >=2.14.0.0 && <2.15,
-        text >=1.2.3.1 && <1.3,
-        typed-process >=0.2.4.0 && <0.3,
-        unbeliever >=0.9.3.2 && <0.10,
-        unix >=2.7.2.2 && <2.8,
-        unordered-containers >=0.2.9.0 && <0.3
+  type: exitcode-stdio-1.0
+  main-is: TestSuite.hs
+  other-modules:
+      CheckBookfileParser
+      CheckTableProperties
+      CompareFragments
+      Environment
+      FormatDocument
+      PandocToMarkdown
+      ParseBookfile
+  hs-source-dirs:
+      src
+      tests
+  ghc-options: -threaded -Wall -Wwarn -fwarn-tabs -threaded
+  build-depends:
+      base >=4.11 && <5
+    , bytestring
+    , chronologique
+    , deepseq
+    , directory
+    , filepath
+    , hinotify
+    , hspec
+    , megaparsec
+    , pandoc
+    , pandoc-types
+    , template-haskell
+    , text
+    , typed-process
+    , unbeliever >=0.9.3.2
+    , unix
+    , unordered-containers
+  default-language: Haskell2010
diff --git a/src/Environment.hs b/src/Environment.hs
--- a/src/Environment.hs
+++ b/src/Environment.hs
@@ -2,6 +2,7 @@
 (
       Env(..)
     , initial
+    , Bookfile(..)
 )
 where
 
@@ -20,3 +21,8 @@
     cwd <- getWorkingDirectory
     return (Env cwd [] "/dev/null" "/dev/null" "/dev/null")
 
+data Bookfile = Bookfile
+    { versionFrom :: Int
+    , preamblesFrom :: [FilePath]
+    , fragmentsFrom :: [FilePath]
+    } deriving (Show, Eq)
diff --git a/src/FormatDocument.hs b/src/FormatDocument.hs
--- a/src/FormatDocument.hs
+++ b/src/FormatDocument.hs
@@ -15,6 +15,7 @@
 import qualified Data.Text as T (Text)
 import qualified Data.Text.IO as T
 import System.IO (withFile, IOMode(..))
+import System.Directory (getFileSize, renameFile)
 import Text.Pandoc (runIOorExplode, readMarkdown, def
     , ReaderOptions(readerExtensions), pandocExtensions, disableExtension
     , Extension(..), Pandoc, Extensions)
@@ -37,6 +38,7 @@
 getFragmentName :: Program None FilePath
 getFragmentName = do
     params <- getCommandLine
+
     let fragment = case lookupArgument "document" params of
             Nothing -> error "invalid"
             Just file -> file
@@ -69,13 +71,30 @@
     runIOorExplode $ do
         readMarkdown readingOptions contents
 
+data Inplace = Inplace | Console
+
 writeResult :: FilePath -> Pandoc -> Program None ()
 writeResult file doc =
   let
-    result = file ++ "-tmp"
     contents' = pandocToMarkdown doc
-  in
-    liftIO $ do
-        withFile result WriteMode $ \handle ->
-            hWrite handle contents'
+    result = file ++ "~tmp"
+  in do
+    params <- getCommandLine
 
+    let mode = case lookupOptionFlag "inplace" params of
+            Just False  -> error "Invalid State"
+            Just True   -> Inplace
+            Nothing     -> Console
+
+    case mode of
+        Inplace -> liftIO $ do
+            withFile result WriteMode $ \handle ->
+                hWrite handle contents'
+
+            size <- getFileSize result
+            if size == 0
+                then error "Zero content, not overwriting"
+                else renameFile result file
+
+        Console -> liftIO $ do
+                hWrite stdout contents'
diff --git a/src/FormatMain.hs b/src/FormatMain.hs
--- a/src/FormatMain.hs
+++ b/src/FormatMain.hs
@@ -15,7 +15,13 @@
 main :: IO ()
 main = do
     context <- configure version None (simple
-        [ Argument "document" [quote|
+        [ Option "inplace" (Just 'i') Empty [quote|
+            Overwrite the original file with the reformatted version. WARNING
+            This tool is experimental. You should ensure you have a safe copy
+            of your original (ie, add it to Git's index) before running with
+            this option enabled.
+          |]
+        , Argument "document" [quote|
             The file containing the markdown to be reformatted
           |]
         ])
diff --git a/src/LatexOutputReader.hs b/src/LatexOutputReader.hs
new file mode 100644
--- /dev/null
+++ b/src/LatexOutputReader.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module LatexOutputReader
+    ( parseOutputForError
+    )
+where
+
+import Core.Text
+import qualified Data.ByteString.Lazy.Char8 as L
+
+{-
+The build command returned a non-zero exit code, so there is a
+reasonable assumption that there is indeed an error to be extracted.
+-}
+-- Originally written in lazy ByteString as that is output from readProcess
+parseOutputForError :: FilePath -> Rope -> Rope
+parseOutputForError tmpdir =
+  let
+    needle = L.pack tmpdir
+
+    stripBeginning [] = []
+    stripBeginning (b:bs) = if L.isPrefixOf needle b
+        then b : bs
+        else stripBeginning bs
+
+    dropEnding [] = []
+    dropEnding (b:bs) = if L.isPrefixOf "Output written on" b || "No pages of output." == b
+        then []
+        else b : dropEnding bs
+  in
+    intoRope . L.intercalate "\n" . dropEnding . stripBeginning . L.lines . fromRope
+
+
+-- Error stream from xelatex looks like this:
+{-
+/tmp/publish-Km3eN1/Junk.tex:8: Undefined control sequence.
+l.8 \broken
+           
+No pages of output.
+Transcript written on /tmp/publish-Km3eN1/Junk.log
+-}
diff --git a/src/LatexPreamble.hs b/src/LatexPreamble.hs
--- a/src/LatexPreamble.hs
+++ b/src/LatexPreamble.hs
@@ -3,6 +3,7 @@
 
 module LatexPreamble
     ( preamble
+    , beginning
     , ending
     )
 where
@@ -23,8 +24,6 @@
 \setsansfont{TeX Gyre Heros}[Scale=MatchLowercase]
 \setmonofont{Inconsolata}[Scale=MatchLowercase]
 
-%\usepackage[showframe, pass]{geometry}
-
 % use upquote for straight quotes in verbatim environments
 \usepackage{upquote}
 
@@ -90,6 +89,11 @@
 \setsubsecheadstyle{\normalsize\sffamily\bfseries}
 \setsubsubsecheadstyle{\normalsize\rmfamily\itshape}
 
+|]
+
+beginning :: Rope
+beginning = [quote|
+
 %
 % Output from Skylighting.styleToLaTeX
 %
@@ -135,13 +139,16 @@
 \newcommand{\VerbatimStringTok}[1]{\textcolor[rgb]{0.31,0.60,0.02}{#1}}
 \newcommand{\WarningTok}[1]{\textcolor[rgb]{0.56,0.35,0.01}{\textbf{\textit{#1}}}}
 
-\usepackage[normalem]{ulem}
+%
 % avoid problems with \sout in headers with hyperref:
-\pdfstringdefDisableCommands{\renewcommand{\sout}{}}
+%
 
+\usepackage[normalem]{ulem}
+\pdfstringdefDisableCommands{\renewcommand{\sout}{}}
 
 \begin{document}
 |]
+
 
 ending :: Rope
 ending = [quote|
diff --git a/src/OutputParser.hs b/src/OutputParser.hs
deleted file mode 100644
--- a/src/OutputParser.hs
+++ /dev/null
@@ -1,41 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-
-module OutputParser
-    ( parseOutputForError
-    )
-where
-
-import Core.Text
-import qualified Data.ByteString.Lazy.Char8 as L
-
-{-
-The build command returned a non-zero exit code, so there is a
-reasonable assumption that there is indeed an error to be extracted.
--}
--- Originally written in lazy ByteString as that is output from readProcess
-parseOutputForError :: FilePath -> Rope -> Rope
-parseOutputForError tmpdir =
-  let
-    needle = L.pack tmpdir
-
-    stripBeginning [] = []
-    stripBeginning (b:bs) = if L.isPrefixOf needle b
-        then b : bs
-        else stripBeginning bs
-
-    dropEnding [] = []
-    dropEnding (b:bs) = if L.isPrefixOf "Output written on" b || "No pages of output." == b
-        then []
-        else b : dropEnding bs
-  in
-    intoRope . L.intercalate "\n" . dropEnding . stripBeginning . L.lines . fromRope
-
-
--- Error stream from xelatex looks like this:
-{-
-/tmp/publish-Km3eN1/Junk.tex:8: Undefined control sequence.
-l.8 \broken
-           
-No pages of output.
-Transcript written on /tmp/publish-Km3eN1/Junk.log
--}
diff --git a/src/ParseBookfile.hs b/src/ParseBookfile.hs
new file mode 100644
--- /dev/null
+++ b/src/ParseBookfile.hs
@@ -0,0 +1,60 @@
+module ParseBookfile where
+
+import Control.Monad
+import Data.Void
+import Text.Megaparsec
+import Text.Megaparsec.Char
+import qualified Text.Megaparsec.Char.Lexer as L
+
+import Environment (Bookfile(..))
+
+-- use String, since we need FilePaths which are type aliases over String anyway.
+type Parser = Parsec Void String
+
+__VERSION__ :: Int
+__VERSION__ = 2
+
+parseMagicLine :: Parser Int
+parseMagicLine = do
+    void (char '%') <?> "first line to begin with % character"
+    void spaceChar <?> "a space character"
+    void (string "publish")
+    void spaceChar <?> "a space character"
+    void (char 'v') <?> "the character v and then a number"
+    v <- L.decimal <?> "the bookfile schema version number"
+    unless (v == __VERSION__) (fail ("currently recognized bookfile schema version is v" ++ show __VERSION__))
+    void newline
+    return v
+
+parseBeginLine :: Parser ()
+parseBeginLine = try $ label "begin marker" $ do
+    void (string "% begin")
+    void newline
+
+parseFileLine :: Parser FilePath
+parseFileLine = do
+    notFollowedBy (char '%')
+    file <- takeWhile1P (Just "line containing a filename") (/= '\n')
+    return file
+
+parseEndLine :: Parser ()
+parseEndLine = try $ label "end marker" $ do
+    void (string "% end")
+    void newline
+
+parseBlank :: Parser ()
+parseBlank = do
+    void (hidden (many newline))
+
+parseBookfile :: Parser Bookfile
+parseBookfile = do
+    version <- parseMagicLine
+    preambles <- many (parseBlank *> parseFileLine <* parseBlank)
+    parseBeginLine
+    fragments <- many (parseBlank *> parseFileLine <* parseBlank)
+    parseEndLine
+    return Bookfile
+        { versionFrom = version
+        , preamblesFrom = preambles
+        , fragmentsFrom = fragments
+        }
diff --git a/src/RenderDocument.hs b/src/RenderDocument.hs
--- a/src/RenderDocument.hs
+++ b/src/RenderDocument.hs
@@ -1,5 +1,6 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE QuasiQuotes #-}
 
 module RenderDocument
     ( program
@@ -11,9 +12,8 @@
 import Core.System
 import Core.Text
 import Data.Char (isSpace)
-import qualified Data.List as List (dropWhileEnd)
-import Data.Text (Text)
-import qualified Data.Text as T
+import qualified Data.List as List (dropWhileEnd, null)
+import Data.Maybe (isJust)
 import qualified Data.Text.IO as T
 import System.Directory (doesFileExist, doesDirectoryExist
     , copyFileWithMetadata)
@@ -25,14 +25,16 @@
 import System.Posix.Directory (changeWorkingDirectory)
 import System.Posix.Temp (mkdtemp)
 import System.Posix.User (getEffectiveUserID, getEffectiveGroupID)
+import Text.Megaparsec (runParser, errorBundlePretty)
 import Text.Pandoc (runIOorExplode, readMarkdown, writeLaTeX, def
     , readerExtensions, readerColumns, pandocExtensions
     , writerTopLevelDivision, TopLevelDivision(TopLevelChapter))
 
-import Environment (Env(..))
+import Environment (Env(..), Bookfile(..))
 import NotifyChanges (waitForChange)
-import LatexPreamble (preamble, ending)
-import OutputParser (parseOutputForError)
+import LatexPreamble (preamble, beginning, ending)
+import LatexOutputReader (parseOutputForError)
+import ParseBookfile (parseBookfile)
 import Utilities (ensureDirectory, execProcess, ifNewer, isNewer)
 
 data Mode = Once | Cycle
@@ -55,16 +57,27 @@
 
 
 renderDocument :: Mode -> FilePath -> Program Env [FilePath]
-renderDocument mode bookfile = do
+renderDocument mode file = do
     event "Read .book file"
-    files <- processBookFile bookfile
+    book <- processBookFile file
 
     event "Setup temporary directory"
-    setupTargetFile bookfile
+    setupTargetFile file
+    setupPreambleFile
+    validatePreamble book
 
+    let preambles = preamblesFrom book
+    let fragments = fragmentsFrom book
+
+    event "Convert preamble and begin fragments to LaTeX"
+    mapM_ processFragment preambles
+    setupBeginningFile
+
     event "Convert document fragments to LaTeX"
-    mapM_ processFragment files
+    mapM_ processFragment fragments
 
+    setupEndingFile
+
     event "Write intermediate LaTeX file"
     produceResult
 
@@ -79,7 +92,8 @@
             Cycle -> return ()
         )
 
-    return (bookfile:files)
+    -- question: original lists or filtered ones?
+    return (file : preambles ++ fragments)
 
 
 extractMode :: Parameters -> Program Env Mode
@@ -117,7 +131,7 @@
 
 
 setupTargetFile :: FilePath -> Program Env ()
-setupTargetFile book = do
+setupTargetFile file = do
     env <- getApplicationState
     let start = startingDirectoryFrom env
     let dotfile = start ++ "/.target"
@@ -148,44 +162,116 @@
     let master = tmpdir ++ "/" ++ base ++ ".tex"
         result = tmpdir ++ "/" ++ base ++ ".pdf"
 
-    first <- case lookupOptionFlag "builtin-preamble" params of
-        Nothing     -> return []
-        Just True   -> do
-            let name = "00_Beginning.latex"
-            let target = tmpdir ++ "/" ++ name
-            liftIO $ withFile target WriteMode $ \handle -> do
-                hWrite handle preamble
-            return [name]
-        Just _      -> invalid
-
     let env' = env
-            { intermediateFilenamesFrom = first
+            { intermediateFilenamesFrom = []
             , masterFilenameFrom = master
             , resultFilenameFrom = result
             , tempDirectoryFrom = tmpdir
             }
     setApplicationState env'
   where
-    base = takeBaseName book -- "/directory/file.ext" -> "file"
+    base = takeBaseName file -- "/directory/file.ext" -> "file"
 
     boom = userError "Temp dir no longer present"
 
     trim :: String -> String
     trim = List.dropWhileEnd isSpace
 
+setupPreambleFile :: Program Env ()
+setupPreambleFile = do
+    env <- getApplicationState
+    let tmpdir = tempDirectoryFrom env
 
-processBookFile :: FilePath -> Program Env [FilePath]
+    params <- getCommandLine
+    first <- case lookupOptionFlag "builtin-preamble" params of
+        Nothing     -> return []
+        Just True   -> do
+            let name = "00_Preamble.latex"
+            let target = tmpdir ++ "/" ++ name
+            liftIO $ withFile target WriteMode $ \handle -> do
+                hWrite handle preamble
+            return [name]
+        Just _      -> invalid
+
+    let env' = env { intermediateFilenamesFrom = first }
+    setApplicationState env'
+
+{-
+This could do a lot more; checking to see if \documentclass is present, for
+example. At present this covers the (likely common) failure mode of
+specifying neither -p nor a preamble in the bookfile.
+-}
+validatePreamble :: Bookfile -> Program Env ()
+validatePreamble book = do
+    params <- getCommandLine
+    let preambles = preamblesFrom book
+    let builtin = isJust (lookupOptionFlag "builtin-preamble" params)
+
+    if List.null preambles && not builtin
+        then do
+            write "error: no preamble\n"
+            let msg :: Rope = [quote|
+You need to either a) put the name of the file including the LaTeX
+preamble for your document in the .book file between the "% publish"
+and "% begin" lines, or b) specify the --builtin-preamble option on
+the command-line when running this program.
+|]
+            writeR msg
+            terminate 2
+        else
+            return ()
+
+setupBeginningFile :: Program Env ()
+setupBeginningFile = do
+    env <- getApplicationState
+    let tmpdir = tempDirectoryFrom env
+        files = intermediateFilenamesFrom env
+
+    file <- do
+        let name = "99_Beginning.latex"
+        let target = tmpdir ++ "/" ++ name
+        liftIO $ withFile target WriteMode $ \handle -> do
+            hWrite handle beginning
+        return name
+
+    let env' = env { intermediateFilenamesFrom = file : files }
+    setApplicationState env'
+
+setupEndingFile :: Program Env ()
+setupEndingFile = do
+    env <- getApplicationState
+    let tmpdir = tempDirectoryFrom env
+        files = intermediateFilenamesFrom env
+
+    file <- do
+        let name = "ZZ_Ending.latex"
+        let target = tmpdir ++ "/" ++ name
+        liftIO $ withFile target WriteMode $ \handle -> do
+            hWrite handle ending
+        return name
+
+    let env' = env { intermediateFilenamesFrom = file : files }
+    setApplicationState env'
+
+processBookFile :: FilePath -> Program Env Bookfile
 processBookFile file = do
-    contents <- liftIO (T.readFile file)
-    list <- filterM skipNotFound (possibilities contents)
-    debugS "fragments" (length list)
-    return list
-  where
-    -- filter out blank lines and lines commented out
-    possibilities :: Text -> [FilePath]
-    possibilities = map T.unpack . filter (not . T.null)
-        . filter (not . T.isPrefixOf "#") . T.lines
+    contents <- liftIO (readFile file)
 
+    let result = runParser parseBookfile file contents
+    bookfile <- case result of
+        Left err -> do
+            write (intoRope (errorBundlePretty err))
+            terminate 1
+        Right value -> return value
+
+    list1 <- filterM skipNotFound (preamblesFrom bookfile)
+    debugS "preambles" (length list1)
+
+    list2 <- filterM skipNotFound (fragmentsFrom bookfile)
+    debugS "fragments" (length list2)
+
+    return bookfile { preamblesFrom = list1, fragmentsFrom = list2 }
+  where
     skipNotFound :: FilePath -> Program t Bool
     skipNotFound fragment = do
         probe <- liftIO (doesFileExist fragment)
@@ -335,25 +421,13 @@
 produceResult :: Program Env ()
 produceResult = do
     env <- getApplicationState
-    let tmpdir = tempDirectoryFrom env
-        master = masterFilenameFrom env
+    let master = masterFilenameFrom env
         files = intermediateFilenamesFrom env
 
-    params <- getCommandLine
-    files' <- case lookupOptionFlag "builtin-preamble" params of
-        Nothing     -> return files
-        Just True   -> do
-            let name = "ZZ_Ending.latex"
-            let target = tmpdir ++ "/" ++ name
-            liftIO $ withFile target WriteMode $ \handle -> do
-                hWrite handle ending
-            return (name:files)
-        Just _      -> invalid
-
     debugS "master" master
     liftIO $ withFile master WriteMode $ \handle -> do
         hPutStrLn handle ("\\RequirePackage{import}")
-        forM_ (reverse files') $ \file -> do
+        forM_ (reverse files) $ \file -> do
             let (path,name) = splitFileName file
             hPutStrLn handle ("\\subimport{" ++ path ++ "}{" ++ name ++ "}")
 
diff --git a/src/RenderMain.hs b/src/RenderMain.hs
--- a/src/RenderMain.hs
+++ b/src/RenderMain.hs
@@ -42,10 +42,7 @@
         , Argument "bookfile" [quote|
             The file containing the list of fragments making up this book.
             If the argument is specified as "Hobbit.book" then "Hobbit"
-            will be used as the basename for the intermediate .latex file
-            and the final output .pdf file. The list file should contain
-            filenames, one per line, of the fragments you wish to render
-            into a complete document.
+            will be used as the basename for the final output .pdf file.
           |]
         ])
 
diff --git a/tests/CheckBookfileParser.hs b/tests/CheckBookfileParser.hs
new file mode 100644
--- /dev/null
+++ b/tests/CheckBookfileParser.hs
@@ -0,0 +1,67 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module CheckBookfileParser
+    ( checkBookfileParser
+    )
+where
+
+import Core.Text
+import Test.Hspec
+import Text.Megaparsec
+
+import Environment (Bookfile(..))
+import ParseBookfile
+
+checkBookfileParser :: Spec
+checkBookfileParser = do
+    describe "Parse bookfile format" $ do
+        it "Correctly parses a complete first line" $ do
+            parseMaybe parseMagicLine "% publish v2\n" `shouldBe` Just 2
+        it "Errors if first line has incorrect syntax" $ do
+            parseMaybe parseMagicLine "%\n" `shouldBe` Nothing
+            parseMaybe parseMagicLine "%publish\n" `shouldBe` Nothing
+            parseMaybe parseMagicLine "% publish\n" `shouldBe` Nothing
+            parseMaybe parseMagicLine "% publish \n" `shouldBe` Nothing
+            parseMaybe parseMagicLine "% publish v\n" `shouldBe` Nothing
+            parseMaybe parseMagicLine "% publish  v2\n" `shouldBe` Nothing
+            parseMaybe parseMagicLine "% publish v1\n" `shouldBe` Nothing
+            parseMaybe parseMagicLine "% publish v2 asdf\n" `shouldBe` Nothing
+
+        it "Correctly parses a preamble line" $ do
+            parseMaybe parseFileLine "preamble.latex" `shouldBe` Just "preamble.latex"
+        it "Parses two filenames in a list" $ do
+            parseMaybe (many (parseFileLine <* parseBlank)) "one.markdown\ntwo.markdown"
+                `shouldBe` Just (["one.markdown", "two.markdown"] :: [FilePath])
+
+        it "Parses two filenames with a blank line between them" $ do
+            parseMaybe (many (parseFileLine <* parseBlank)) [quote|
+one.markdown
+
+two.markdown
+            |]
+                `shouldBe` Just (["one.markdown", "two.markdown"] :: [FilePath])
+
+        it "Correctly parses a begin end end pragmas" $ do
+            parseMaybe parseBeginLine "% begin\n" `shouldBe` Just ()
+            parseMaybe parseEndLine "% end\n" `shouldBe` Just ()
+
+        it "Correctly parses a complete bookfile" $ do
+            parseMaybe parseBookfile [quote|
+% publish v2
+preamble.latex
+% begin
+Introduction.markdown
+Conclusion.markdown
+% end
+            |] `shouldBe` Just (Bookfile 2 ["preamble.latex"] ["Introduction.markdown", "Conclusion.markdown"])
+
+        it "Correctly parses a complete bookfile with no preamble" $ do
+            parseMaybe parseBookfile [quote|
+% publish v2
+% begin
+Introduction.markdown
+Conclusion.markdown
+% end
+            |] `shouldBe` Just (Bookfile 2 [] ["Introduction.markdown", "Conclusion.markdown"])
diff --git a/tests/CheckTableProperties.hs b/tests/CheckTableProperties.hs
--- a/tests/CheckTableProperties.hs
+++ b/tests/CheckTableProperties.hs
@@ -120,7 +120,7 @@
 -----------------------------------------------------------------------
             |]
 
-        it "a minimal complete example reformats properly" $
+        it "A minimal complete example reformats properly" $
           let
             table = [quote|
 | First | Second | Third |
diff --git a/tests/TestSuite.hs b/tests/TestSuite.hs
--- a/tests/TestSuite.hs
+++ b/tests/TestSuite.hs
@@ -2,8 +2,9 @@
 
 import Test.Hspec
 
-import Core.System
+import CheckBookfileParser
 import CheckTableProperties
+import Core.System
 import CompareFragments
 
 main :: IO ()
@@ -14,3 +15,4 @@
 suite = do
     checkTableProperties
     checkByComparingFragments
+    checkBookfileParser
