diff --git a/COPYING.md b/COPYING.md
new file mode 100644
--- /dev/null
+++ b/COPYING.md
@@ -0,0 +1,361 @@
+### GNU GENERAL PUBLIC LICENSE
+
+Version 2, June 1991
+
+    Copyright (C) 1989, 1991 Free Software Foundation, Inc.  
+    51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
+
+    Everyone is permitted to copy and distribute verbatim copies
+    of this license document, but changing it is not allowed.
+
+### Preamble
+
+The licenses for most software are designed to take away your freedom
+to share and change it. By contrast, the GNU General Public License is
+intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if
+you distribute copies of the software, or if you modify it.
+
+For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on,
+we want its recipients to know that what they have is not the
+original, so that any problems introduced by others will not reflect
+on the original authors' reputations.
+
+Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at
+all.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+### TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+**0.** This License applies to any program or other work which
+contains a notice placed by the copyright holder saying it may be
+distributed under the terms of this General Public License. The
+"Program", below, refers to any such program or work, and a "work
+based on the Program" means either the Program or any derivative work
+under copyright law: that is to say, a work containing the Program or
+a portion of it, either verbatim or with modifications and/or
+translated into another language. (Hereinafter, translation is
+included without limitation in the term "modification".) Each licensee
+is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the Program
+(independent of having been made by running the Program). Whether that
+is true depends on what the Program does.
+
+**1.** You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a
+fee.
+
+**2.** You may modify your copy or copies of the Program or any
+portion of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+  
+**a)** You must cause the modified files to carry prominent notices
+stating that you changed the files and the date of any change.
+
+  
+**b)** You must cause any work that you distribute or publish, that in
+whole or in part contains or is derived from the Program or any part
+thereof, to be licensed as a whole at no charge to all third parties
+under the terms of this License.
+
+  
+**c)** If the modified program normally reads commands interactively
+when run, you must cause it, when started running for such interactive
+use in the most ordinary way, to print or display an announcement
+including an appropriate copyright notice and a notice that there is
+no warranty (or else, saying that you provide a warranty) and that
+users may redistribute the program under these conditions, and telling
+the user how to view a copy of this License. (Exception: if the
+Program itself is interactive but does not normally print such an
+announcement, your work based on the Program is not required to print
+an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+**3.** You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+  
+**a)** Accompany it with the complete corresponding machine-readable
+source code, which must be distributed under the terms of Sections 1
+and 2 above on a medium customarily used for software interchange; or,
+
+  
+**b)** Accompany it with a written offer, valid for at least three
+years, to give any third party, for a charge no more than your cost of
+physically performing source distribution, a complete machine-readable
+copy of the corresponding source code, to be distributed under the
+terms of Sections 1 and 2 above on a medium customarily used for
+software interchange; or,
+
+  
+**c)** Accompany it with the information you received as to the offer
+to distribute corresponding source code. (This alternative is allowed
+only for noncommercial distribution and only if you received the
+program in object code or executable form with such an offer, in
+accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+**4.** You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt otherwise
+to copy, modify, sublicense or distribute the Program is void, and
+will automatically terminate your rights under this License. However,
+parties who have received copies, or rights, from you under this
+License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+**5.** You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+**6.** Each time you redistribute the Program (or any work based on
+the Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+**7.** If, as a consequence of a court judgment or allegation of
+patent infringement or for any other reason (not limited to patent
+issues), conditions are imposed on you (whether by court order,
+agreement or otherwise) that contradict the conditions of this
+License, they do not excuse you from the conditions of this License.
+If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations,
+then as a consequence you may not distribute the Program at all. For
+example, if a patent license would not permit royalty-free
+redistribution of the Program by all those who receive copies directly
+or indirectly through you, then the only way you could satisfy both it
+and this License would be to refrain entirely from distribution of the
+Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+**8.** If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+**9.** The Free Software Foundation may publish revised and/or new
+versions of the General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Program does not specify a
+version number of this License, you may choose any version ever
+published by the Free Software Foundation.
+
+**10.** If you wish to incorporate parts of the Program into other
+free programs whose distribution conditions are different, write to
+the author to ask for permission. For software which is copyrighted by
+the Free Software Foundation, write to the Free Software Foundation;
+we sometimes make exceptions for this. Our decision will be guided by
+the two goals of preserving the free status of all derivatives of our
+free software and of promoting the sharing and reuse of software
+generally.
+
+**NO WARRANTY**
+
+**11.** BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+**12.** IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+### END OF TERMS AND CONDITIONS
+
+### How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these
+terms.
+
+To do so, attach the following notices to the program. It is safest to
+attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    one line to give the program's name and an idea of what it does.
+    Copyright (C) yyyy  name of author
+
+    This program is free software; you can redistribute it and/or
+    modify it under the terms of the GNU General Public License
+    as published by the Free Software Foundation; either version 2
+    of the License, or (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+Also add information on how to contact you by electronic and paper
+mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details
+    type `show w'.  This is free software, and you are welcome
+    to redistribute it under certain conditions; type `show c' 
+    for details.
+
+The hypothetical commands \`show w' and \`show c' should show the
+appropriate parts of the General Public License. Of course, the
+commands you use may be called something other than \`show w' and
+\`show c'; they could even be mouse-clicks or menu items--whatever
+suits your program.
+
+You should also get your employer (if you work as a programmer) or
+your school, if any, to sign a "copyright disclaimer" for the program,
+if necessary. Here is a sample; alter the names:
+
+    Yoyodyne, Inc., hereby disclaims all copyright
+    interest in the program `Gnomovision'
+    (which makes passes at compilers) written 
+    by James Hacker.
+
+    signature of Ty Coon, 1 April 1989
+    Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library,
+you may consider it more useful to permit linking proprietary
+applications with the library. If this is what you want to do, use the
+[GNU Lesser General Public
+License](https://www.gnu.org/licenses/lgpl.html) instead of this
+License.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,6 @@
+# pandoc-lua-engine
+
+This package provides a Lua pandoc scripting engine based. It
+allows to write filters, custom readers, and custom writers in
+Lua.
+
diff --git a/pandoc-lua-engine.cabal b/pandoc-lua-engine.cabal
new file mode 100644
--- /dev/null
+++ b/pandoc-lua-engine.cabal
@@ -0,0 +1,145 @@
+cabal-version:       2.4
+name:                pandoc-lua-engine
+version:             0.1
+build-type:          Simple
+license:             GPL-2.0-or-later
+license-file:        COPYING.md
+copyright:           © 2006-2022 John MacFarlane, 2017-2022 Albert Krewinkel
+author:              John MacFarlane, Albert Krewinkel
+maintainer:          Albert Krewinkel <albert@hslua.org>
+bug-reports:         https://github.com/jgm/pandoc/issues
+homepage:            https://pandoc.org
+category:            Text
+tested-with:         GHC == 8.6.5
+                   , GHC == 8.8.4
+                   , GHC == 8.10.7
+                   , GHC == 9.0.2
+                   , GHC == 9.2.5
+                   , GHC == 9.4.4
+synopsis:            Lua engine to power custom pandoc conversions
+description:         This package provides a pandoc scripting engine based on
+                     Lua.
+extra-source-files:  README.md
+                   , test/bytestring.bin
+                   , test/bytestring.lua
+                   , test/bytestring-reader.lua
+                   , test/extensions.lua
+                   , test/lua/*.lua
+                   , test/lua/module/*.lua
+                   , test/lua/module/partial.test
+                   , test/lua/module/tiny.epub
+                   , test/sample.lua
+                   , test/tables.custom
+                   , test/tables.native
+                   , test/testsuite.native
+                   , test/writer.custom
+                   , test/writer-template.lua
+                   , test/writer-template.out.txt
+
+source-repository head
+  type:                git
+  location:            https://github.com/jgm/pandoc.git
+  subdir:              pandoc-lua-engine
+
+common common-options
+  default-language:    Haskell2010
+  build-depends:       base              >= 4.12 && < 5
+  ghc-options:         -Wall -fno-warn-unused-do-bind
+                       -Wincomplete-record-updates
+                       -Wnoncanonical-monad-instances
+                       -Wcpp-undef
+                       -Wincomplete-uni-patterns
+                       -Widentities
+                       -Wpartial-fields
+                       -Wmissing-export-lists
+                       -Wmissing-signatures
+                       -fhide-source-paths
+
+  if impl(ghc >= 8.10)
+    ghc-options:         -Wunused-packages
+
+  if impl(ghc >= 9.0)
+    ghc-options:         -Winvalid-haddock
+
+library
+  import:              common-options
+  hs-source-dirs:      src
+  exposed-modules:     Text.Pandoc.Lua
+  other-modules:       Text.Pandoc.Lua.Custom
+                     , Text.Pandoc.Lua.Filter
+                     , Text.Pandoc.Lua.Global
+                     , Text.Pandoc.Lua.Init
+                     , Text.Pandoc.Lua.Marshal.Chunks
+                     , Text.Pandoc.Lua.Marshal.CommonState
+                     , Text.Pandoc.Lua.Marshal.Context
+                     , Text.Pandoc.Lua.Marshal.Format
+                     , Text.Pandoc.Lua.Marshal.PandocError
+                     , Text.Pandoc.Lua.Marshal.ReaderOptions
+                     , Text.Pandoc.Lua.Marshal.Reference
+                     , Text.Pandoc.Lua.Marshal.Sources
+                     , Text.Pandoc.Lua.Marshal.Template
+                     , Text.Pandoc.Lua.Marshal.WriterOptions
+                     , Text.Pandoc.Lua.Module.CLI
+                     , Text.Pandoc.Lua.Module.Format
+                     , Text.Pandoc.Lua.Module.MediaBag
+                     , Text.Pandoc.Lua.Module.Pandoc
+                     , Text.Pandoc.Lua.Module.Scaffolding
+                     , Text.Pandoc.Lua.Module.Structure
+                     , Text.Pandoc.Lua.Module.System
+                     , Text.Pandoc.Lua.Module.Template
+                     , Text.Pandoc.Lua.Module.Types
+                     , Text.Pandoc.Lua.Module.Utils
+                     , Text.Pandoc.Lua.Orphans
+                     , Text.Pandoc.Lua.PandocLua
+                     , Text.Pandoc.Lua.Writer.Classic
+                     , Text.Pandoc.Lua.Writer.Scaffolding
+
+  build-depends:       SHA                   >= 1.6     && < 1.7
+                     , bytestring            >= 0.9     && < 0.12
+                     , citeproc              >= 0.8     && < 0.9
+                     , containers            >= 0.6.0.1 && < 0.7
+                     , data-default          >= 0.4     && < 0.8
+                     , doclayout             >= 0.4     && < 0.5
+                     , doctemplates          >= 0.11    && < 0.12
+                     , exceptions            >= 0.8     && < 0.11
+                     , hslua                 >= 2.2.1   && < 2.3
+                     , hslua-aeson           >= 2.2.1   && < 2.3
+                     , hslua-core            >= 2.2.1   && < 2.3
+                     , hslua-module-doclayout>= 1.0.4   && < 1.1
+                     , hslua-module-path     >= 1.0.3   && < 1.1
+                     , hslua-module-system   >= 1.0     && < 1.1
+                     , hslua-module-text     >= 1.0.3   && < 1.1
+                     , hslua-module-version  >= 1.0.3   && < 1.1
+                     , hslua-module-zip      >= 1.0.0   && < 1.1
+                     , lpeg                  >= 1.0.1   && < 1.1
+                     , mtl                   >= 2.2     && < 2.4
+                     , pandoc                >= 3.0     && < 3.1
+                     , pandoc-lua-marshal    >= 0.2     && < 0.3
+                     , pandoc-types          >= 1.22    && < 1.24
+                     , parsec                >= 3.1     && < 3.2
+                     , text                  >= 1.1.1   && < 2.1
+
+
+test-suite test-pandoc-lua-engine
+  import:              common-options
+  type:                exitcode-stdio-1.0
+  main-is:             test-pandoc-lua-engine.hs
+  hs-source-dirs:      test
+  build-depends:       pandoc-lua-engine
+                     , bytestring
+                     , directory
+                     , data-default
+                     , exceptions            >= 0.8     && < 0.11
+                     , filepath
+                     , hslua                 >= 2.1     && < 2.3
+                     , pandoc
+                     , pandoc-types          >= 1.22    && < 1.24
+                     , tasty
+                     , tasty-golden
+                     , tasty-hunit
+                     , tasty-lua             >= 1.0     && < 1.1
+                     , text                  >= 1.1.1   && < 2.1
+  other-modules:       Tests.Lua
+                     , Tests.Lua.Module
+                     , Tests.Lua.Reader
+                     , Tests.Lua.Writer
diff --git a/src/Text/Pandoc/Lua.hs b/src/Text/Pandoc/Lua.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications  #-}
+{- |
+   Module      : Text.Pandoc.Lua
+   Copyright   : Copyright © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Running pandoc Lua filters.
+-}
+module Text.Pandoc.Lua
+  ( -- * High-level functions
+    applyFilter
+  , loadCustom
+  -- * Low-level functions
+  , Global(..)
+  , setGlobals
+  , runLua
+  , runLuaNoEnv
+  -- * Engine
+  , getEngine
+  ) where
+
+import Control.Monad.IO.Class (MonadIO (liftIO))
+import HsLua.Core (getglobal, openlibs, run, top, tostring)
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.Filter (applyFilter)
+import Text.Pandoc.Lua.Global (Global (..), setGlobals)
+import Text.Pandoc.Lua.Init (runLua, runLuaNoEnv)
+import Text.Pandoc.Lua.Custom (loadCustom)
+import Text.Pandoc.Lua.Orphans ()
+import Text.Pandoc.Scripting (ScriptingEngine (..))
+import qualified Text.Pandoc.UTF8 as UTF8
+
+-- | Constructs the Lua scripting engine.
+getEngine :: MonadIO m => m ScriptingEngine
+getEngine = do
+  versionName <- liftIO . run @PandocError $ do
+    openlibs
+    getglobal "_VERSION"
+    tostring top
+  pure $ ScriptingEngine
+    { engineName = maybe "Lua (unknown version)" UTF8.toText versionName
+    , engineApplyFilter = applyFilter
+    , engineLoadCustom = loadCustom
+    }
diff --git a/src/Text/Pandoc/Lua/Custom.hs b/src/Text/Pandoc/Lua/Custom.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Custom.hs
@@ -0,0 +1,163 @@
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE TypeApplications    #-}
+{-# LANGUAGE TupleSections       #-}
+{- |
+   Module      : Text.Pandoc.Lua.Custom
+   Copyright   : © 2021-2023 Albert Krewinkel, John MacFarlane
+   License     : GPL-2.0-or-later
+   Maintainer  : Albert Krewinkel <albert+pandoc@tarleb.com>
+
+Supports custom parsers written in Lua which produce a Pandoc AST.
+-}
+module Text.Pandoc.Lua.Custom ( loadCustom ) where
+import Control.Exception
+import Control.Monad ((<=<), (<$!>))
+import Control.Monad.IO.Class (MonadIO)
+import Data.Maybe (fromMaybe)
+import HsLua as Lua hiding (Operation (Div))
+import HsLua.Core.Run (GCManagedState, newGCManagedState, withGCManagedState)
+import Text.Pandoc.Class (PandocMonad, findFileWithDataFallback)
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.Global (Global (..), setGlobals)
+import Text.Pandoc.Lua.Init (runLuaWith)
+import Text.Pandoc.Lua.Marshal.Format (peekExtensionsConfig)
+import Text.Pandoc.Lua.Marshal.Pandoc (peekPandoc)
+import Text.Pandoc.Lua.Marshal.WriterOptions (pushWriterOptions)
+import Text.Pandoc.Readers (Reader (..))
+import Text.Pandoc.Sources (ToSources(..))
+import Text.Pandoc.Scripting (CustomComponents (..))
+import Text.Pandoc.Writers (Writer (..))
+import qualified Text.Pandoc.Lua.Writer.Classic as Classic
+
+-- | Convert custom markup to Pandoc.
+loadCustom :: (PandocMonad m, MonadIO m)
+           => FilePath -> m (CustomComponents m)
+loadCustom luaFile = do
+  luaState <- liftIO newGCManagedState
+  luaFile' <- fromMaybe luaFile <$>
+              findFileWithDataFallback "custom"  luaFile
+  either throw pure <=< runLuaWith luaState $ do
+    let globals = [ PANDOC_SCRIPT_FILE luaFile ]
+    setGlobals globals
+    dofileTrace luaFile' >>= \case
+      OK -> pure ()
+      _  -> throwErrorAsException
+
+    mextsConf <- rawgetglobal "Extensions" >>= \case
+      TypeNil      -> pure Nothing
+      TypeFunction -> Just <$!> do
+        callTrace 0 1
+        forcePeek $ peekExtensionsConfig top `lastly` pop 1
+      _            -> Just <$!> do
+        forcePeek $ peekExtensionsConfig top `lastly` pop 1
+
+    mtemplate <- rawgetglobal "Template" >>= \case
+      TypeNil   -> pure Nothing
+      TypeFunction -> Just <$!> do
+        callTrace 0 1
+        forcePeek $ peekText top `lastly` pop 1
+      _ -> Just <$!> do
+        forcePeek $ peekText top `lastly` pop 1
+
+    mreader <- rawgetglobal "Reader" >>= \case
+      TypeNil -> do
+        pop 1
+        rawgetglobal "ByteStringReader" >>= \case
+          TypeNil -> pure Nothing
+          _ -> do
+            setfield registryindex readerField
+            pure . Just $ byteStringReader luaState
+      _ -> do
+        setfield registryindex readerField
+        pure . Just $ textReader luaState
+
+    mwriter <- rawgetglobal "Writer" >>= \case
+      TypeNil -> rawgetglobal "ByteStringWriter" >>= \case
+        TypeNil -> do
+          -- Neither `Writer` nor `BinaryWriter` are defined. Check for
+          -- "Doc"; if present, use the file as a classic writer.
+          docType <- rawgetglobal "Doc"
+          pop 3  -- remove nils/value of "Writer", "ByteStringWriter", "Doc"
+          pure $
+            if docType /= TypeFunction
+            then Nothing
+            else Just . TextWriter $ \opts doc ->
+              liftIO $ withGCManagedState luaState $
+              Classic.runCustom @PandocError opts doc
+        _ -> Just <$!> do
+          -- Binary writer. Writer function is on top of the stack.
+          setfield registryindex writerField
+          pure $ ByteStringWriter $ \opts doc ->
+            -- Call writer with document and writer options as arguments.
+            liftIO $ withGCManagedState luaState $ do
+              getfield registryindex writerField
+              push doc
+              pushWriterOptions opts
+              callTrace 2 1
+              forcePeek @PandocError $ peekLazyByteString top
+      _ -> Just <$!> do
+        -- New-type text writer. Writer function is on top of the stack.
+        setfield registryindex writerField
+        pure $ TextWriter $ \opts doc ->
+          liftIO $ withGCManagedState luaState $ do
+            getfield registryindex writerField
+            push doc
+            pushWriterOptions opts
+            callTrace 2 1
+            forcePeek @PandocError $ peekText top
+
+    pure $ CustomComponents
+      { customReader = mreader
+      , customWriter = mwriter
+      , customTemplate = mtemplate
+      , customExtensions = mextsConf
+      }
+
+-- | "Raw", non-metatable lookup of a key in the global table.
+--
+-- Most classic writers contain code that throws an error if a global
+-- is not present. This would break our check for the existence of a
+-- "Writer" function. We resort to raw access for that reason, but
+-- could also catch the error instead.
+--
+-- TODO: This function ensures the proper behavior of legacy custom
+-- writers. It should be replaced with 'getglobal' in the future.
+rawgetglobal :: LuaError e => Name -> LuaE e Lua.Type
+rawgetglobal x = do
+  pushglobaltable
+  pushName x
+  rawget (nth 2) <* remove (nth 2) -- remove global table
+
+-- | Name under which the reader function is stored in the registry.
+readerField :: Name
+readerField = "Pandoc Reader function"
+
+-- | Name under which the writer function is stored in the registry.
+writerField :: Name
+writerField = "Pandoc Writer function"
+
+-- | Runs a Lua action in a continueable environment.
+inLua :: MonadIO m => GCManagedState -> LuaE PandocError a -> m a
+inLua st = liftIO . withGCManagedState @PandocError st
+
+-- | Returns the ByteStringReader function
+byteStringReader :: MonadIO m => GCManagedState -> Reader m
+byteStringReader st = ByteStringReader $ \ropts input -> inLua st $ do
+  getfield registryindex readerField
+  push input
+  push ropts
+  pcallTrace 2 1 >>= \case
+    OK -> forcePeek $ peekPandoc top
+    _ -> throwErrorAsException
+
+-- | Returns the TextReader function
+textReader :: MonadIO m => GCManagedState -> Reader m
+textReader st = TextReader $ \ropts srcs -> inLua st $ do
+  let input = toSources srcs
+  getfield registryindex readerField
+  push input
+  push ropts
+  pcallTrace 2 1 >>= \case
+    OK -> forcePeek $ peekPandoc top
+    _ -> throwErrorAsException
diff --git a/src/Text/Pandoc/Lua/Filter.hs b/src/Text/Pandoc/Lua/Filter.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Filter.hs
@@ -0,0 +1,81 @@
+{-# LANGUAGE FlexibleContexts     #-}
+{-# LANGUAGE IncoherentInstances  #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# LANGUAGE ScopedTypeVariables  #-}
+{- |
+Module      : Text.Pandoc.Lua.Filter
+Copyright   : © 2012-2023 John MacFarlane,
+              © 2017-2023 Albert Krewinkel
+License     : GNU GPL, version 2 or above
+Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+Stability   : alpha
+
+Types and functions for running Lua filters.
+-}
+module Text.Pandoc.Lua.Filter
+  ( applyFilter
+  ) where
+import Control.Monad ((>=>), (<$!>))
+import HsLua as Lua
+import Text.Pandoc.Definition
+import Text.Pandoc.Filter (Environment (..))
+import Text.Pandoc.Lua.Marshal.AST
+import Text.Pandoc.Lua.Marshal.Filter
+import Text.Pandoc.Lua.Global (Global (..), setGlobals)
+import Text.Pandoc.Lua.Init (runLua)
+import Text.Pandoc.Lua.PandocLua ()
+import Control.Exception (throw)
+import qualified Data.Text as T
+import Text.Pandoc.Class (PandocMonad)
+import Control.Monad.Trans (MonadIO)
+import Text.Pandoc.Error (PandocError (PandocFilterError, PandocLuaError))
+
+-- | Transform document using the filter defined in the given file.
+runFilterFile :: FilePath -> Pandoc -> LuaE PandocError Pandoc
+runFilterFile filterPath doc = do
+  oldtop <- gettop
+  stat <- dofileTrace filterPath
+  if stat /= Lua.OK
+    then throwErrorAsException
+    else do
+      newtop <- gettop
+      -- Use the returned filters, or the implicitly defined global
+      -- filter if nothing was returned.
+      luaFilters <- forcePeek $
+        if newtop - oldtop >= 1
+        then peekList peekFilter top
+        else (:[]) <$!> (liftLua pushglobaltable *> peekFilter top)
+      settop oldtop
+      runAll luaFilters doc
+
+runAll :: [Filter] -> Pandoc -> LuaE PandocError Pandoc
+runAll = foldr ((>=>) . applyFully) return
+
+-- | Run the Lua filter in @filterPath@ for a transformation to the
+-- target format (first element in args). Pandoc uses Lua init files to
+-- setup the Lua interpreter.
+applyFilter :: (PandocMonad m, MonadIO m)
+            => Environment
+            -> [String]
+            -> FilePath
+            -> Pandoc
+            -> m Pandoc
+applyFilter fenv args fp doc = do
+  let globals = [ FORMAT $ case args of
+                    x:_ -> T.pack x
+                    _   -> ""
+                , PANDOC_READER_OPTIONS (envReaderOptions fenv)
+                , PANDOC_WRITER_OPTIONS (envWriterOptions fenv)
+                , PANDOC_SCRIPT_FILE fp
+                ]
+  runLua >=> forceResult fp $ do
+    setGlobals globals
+    runFilterFile fp doc
+
+forceResult :: (PandocMonad m, MonadIO m)
+            => FilePath -> Either PandocError Pandoc -> m Pandoc
+forceResult fp eitherResult = case eitherResult of
+  Right x  -> return x
+  Left err -> throw . PandocFilterError (T.pack fp) $ case err of
+    PandocLuaError msg -> msg
+    _                  -> T.pack $ show err
diff --git a/src/Text/Pandoc/Lua/Global.hs b/src/Text/Pandoc/Lua/Global.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Global.hs
@@ -0,0 +1,74 @@
+{-# LANGUAGE OverloadedStrings  #-}
+{- |
+   Module      : Text.Pandoc.Lua
+   Copyright   : Copyright © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Pandoc's Lua globals.
+-}
+module Text.Pandoc.Lua.Global
+  ( Global (..)
+  , setGlobals
+  ) where
+
+import HsLua as Lua
+import HsLua.Module.Version (pushVersion)
+import Text.Pandoc.Class (CommonState)
+import Text.Pandoc.Definition (Pandoc, pandocTypesVersion)
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.Marshal.CommonState (pushCommonState)
+import Text.Pandoc.Lua.Marshal.Pandoc (pushPandoc)
+import Text.Pandoc.Lua.Marshal.ReaderOptions (pushReaderOptionsReadonly)
+import Text.Pandoc.Lua.Marshal.WriterOptions (pushWriterOptions)
+import Text.Pandoc.Lua.PandocLua ()
+import Text.Pandoc.Options (ReaderOptions, WriterOptions)
+import Text.Pandoc.Version (pandocVersion)
+
+import qualified Data.Text as Text
+
+-- | Permissible global Lua variables.
+data Global =
+    FORMAT Text.Text
+  | PANDOC_API_VERSION
+  | PANDOC_DOCUMENT Pandoc
+  | PANDOC_READER_OPTIONS ReaderOptions
+  | PANDOC_WRITER_OPTIONS WriterOptions
+  | PANDOC_SCRIPT_FILE FilePath
+  | PANDOC_STATE CommonState
+  | PANDOC_VERSION
+  -- Cannot derive instance of Data because of CommonState
+
+-- | Set all given globals.
+setGlobals :: [Global] -> LuaE PandocError ()
+setGlobals = mapM_ setGlobal
+
+setGlobal :: Global -> LuaE PandocError ()
+setGlobal global = case global of
+  -- This could be simplified if Global was an instance of Data.
+  FORMAT format -> do
+    Lua.pushText format
+    Lua.setglobal "FORMAT"
+  PANDOC_API_VERSION -> do
+    pushVersion pandocTypesVersion
+    Lua.setglobal "PANDOC_API_VERSION"
+  PANDOC_DOCUMENT doc -> do
+    pushPandoc doc
+    Lua.setglobal "PANDOC_DOCUMENT"
+  PANDOC_READER_OPTIONS ropts -> do
+    pushReaderOptionsReadonly ropts
+    Lua.setglobal "PANDOC_READER_OPTIONS"
+  PANDOC_WRITER_OPTIONS wopts -> do
+    pushWriterOptions wopts
+    Lua.setglobal "PANDOC_WRITER_OPTIONS"
+  PANDOC_SCRIPT_FILE filePath -> do
+    Lua.pushString filePath
+    Lua.setglobal "PANDOC_SCRIPT_FILE"
+  PANDOC_STATE commonState -> do
+    pushCommonState commonState
+    Lua.setglobal "PANDOC_STATE"
+  PANDOC_VERSION              -> do
+    pushVersion pandocVersion
+    Lua.setglobal "PANDOC_VERSION"
diff --git a/src/Text/Pandoc/Lua/Init.hs b/src/Text/Pandoc/Lua/Init.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Init.hs
@@ -0,0 +1,217 @@
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes        #-}
+{- |
+   Module      : Text.Pandoc.Lua
+   Copyright   : Copyright © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Functions to initialize the Lua interpreter.
+-}
+module Text.Pandoc.Lua.Init
+  ( runLua
+  , runLuaNoEnv
+  , runLuaWith
+  ) where
+
+import Control.Monad (forM, forM_, when)
+import Control.Monad.Catch (throwM, try)
+import Control.Monad.Trans (MonadIO (..))
+import Data.Maybe (catMaybes)
+import HsLua as Lua hiding (status, try)
+import HsLua.Core.Run as Lua
+import Text.Pandoc.Class (PandocMonad (..))
+import Text.Pandoc.Data (readDataFile)
+import Text.Pandoc.Error (PandocError (PandocLuaError))
+import Text.Pandoc.Lua.Global (Global (..), setGlobals)
+import Text.Pandoc.Lua.Marshal.List (newListMetatable, pushListModule)
+import Text.Pandoc.Lua.PandocLua (PandocLua (..), liftPandocLua)
+import qualified Data.ByteString.Char8 as Char8
+import qualified Data.Text as T
+import qualified Lua.LPeg as LPeg
+import qualified HsLua.Aeson
+import qualified HsLua.Module.DocLayout as Module.Layout
+import qualified HsLua.Module.Path as Module.Path
+import qualified HsLua.Module.Text as Module.Text
+import qualified HsLua.Module.Zip as Module.Zip
+import qualified Text.Pandoc.Lua.Module.CLI as Pandoc.CLI
+import qualified Text.Pandoc.Lua.Module.Format as Pandoc.Format
+import qualified Text.Pandoc.Lua.Module.MediaBag as Pandoc.MediaBag
+import qualified Text.Pandoc.Lua.Module.Pandoc as Module.Pandoc
+import qualified Text.Pandoc.Lua.Module.Scaffolding as Pandoc.Scaffolding
+import qualified Text.Pandoc.Lua.Module.Structure as Pandoc.Structure
+import qualified Text.Pandoc.Lua.Module.System as Pandoc.System
+import qualified Text.Pandoc.Lua.Module.Template as Pandoc.Template
+import qualified Text.Pandoc.Lua.Module.Types as Pandoc.Types
+import qualified Text.Pandoc.Lua.Module.Utils as Pandoc.Utils
+
+-- | Run the Lua interpreter, using pandoc's default way of environment
+-- initialization.
+runLua :: (PandocMonad m, MonadIO m)
+       => LuaE PandocError a -> m (Either PandocError a)
+runLua action = do
+  runPandocLuaWith Lua.run . try $ do
+    initLuaState
+    liftPandocLua action
+
+runLuaWith :: (PandocMonad m, MonadIO m)
+           => GCManagedState -> LuaE PandocError a -> m (Either PandocError a)
+runLuaWith luaState action = do
+  runPandocLuaWith (withGCManagedState luaState) . try $ do
+    initLuaState
+    liftPandocLua action
+
+-- | Like 'runLua', but ignores all environment variables like @LUA_PATH@.
+runLuaNoEnv :: (PandocMonad m, MonadIO m)
+            => LuaE PandocError a -> m (Either PandocError a)
+runLuaNoEnv action = do
+  runPandocLuaWith Lua.run . try $ do
+    liftPandocLua $ do
+      -- This is undocumented, but works -- the code is adapted from the
+      -- `lua.c` sources for the default interpreter.
+      Lua.pushboolean True
+      Lua.setfield Lua.registryindex "LUA_NOENV"
+    initLuaState
+    liftPandocLua action
+
+-- | Modules that are loaded at startup and assigned to fields in the
+-- pandoc module.
+--
+-- Note that @pandoc.List@ is not included here for technical reasons;
+-- it must be handled separately.
+loadedModules :: [Module PandocError]
+loadedModules =
+  [ Pandoc.CLI.documentedModule
+  , Pandoc.Format.documentedModule
+  , Pandoc.MediaBag.documentedModule
+  , Pandoc.Scaffolding.documentedModule
+  , Pandoc.Structure.documentedModule
+  , Pandoc.System.documentedModule
+  , Pandoc.Template.documentedModule
+  , Pandoc.Types.documentedModule
+  , Pandoc.Utils.documentedModule
+  , Module.Layout.documentedModule { moduleName = "pandoc.layout" }
+  , Module.Path.documentedModule { moduleName = "pandoc.path" }
+  , Module.Text.documentedModule
+  , Module.Zip.documentedModule { moduleName = "pandoc.zip" }
+  ]
+
+-- | Initialize the lua state with all required values
+initLuaState :: PandocLua ()
+initLuaState = do
+  liftPandocLua Lua.openlibs
+  initJsonMetatable
+  initPandocModule
+  installLpegSearcher
+  setGlobalModules
+  loadInitScript "init.lua"
+ where
+  initPandocModule :: PandocLua ()
+  initPandocModule = liftPandocLua $ do
+    -- Push module table
+    registerModule Module.Pandoc.documentedModule
+    -- load modules and add them to the `pandoc` module table.
+    forM_ loadedModules $ \mdl -> do
+      registerModule mdl
+      let fieldname (Name mdlname) = Name .
+            maybe mdlname snd . Char8.uncons . snd $
+            Char8.break (== '.') mdlname
+      Lua.setfield (nth 2) (fieldname $ moduleName mdl)
+    -- pandoc.List is low-level and must be opened differently.
+    requirehs "pandoc.List" (const pushListModule)
+    setfield (nth 2) "List"
+    -- assign module to global variable
+    Lua.setglobal "pandoc"
+
+  loadInitScript :: FilePath -> PandocLua ()
+  loadInitScript scriptFile = do
+    script <- readDataFile scriptFile
+    status <- liftPandocLua $ Lua.dostring script
+    when (status /= Lua.OK) . liftPandocLua $ do
+      err <- popException
+      let prefix = "Couldn't load '" <> T.pack scriptFile <> "':\n"
+      throwM . PandocLuaError . (prefix <>) $ case err of
+        PandocLuaError msg -> msg
+        _                  -> T.pack $ show err
+
+  setGlobalModules :: PandocLua ()
+  setGlobalModules = liftPandocLua $ do
+    let globalModules =
+          [ ("lpeg", LPeg.luaopen_lpeg_ptr)  -- must be loaded first
+          , ("re", LPeg.luaopen_re_ptr)      -- re depends on lpeg
+          ]
+    loadedBuiltInModules <- fmap catMaybes . forM globalModules $
+      \(pkgname, luaopen) -> do
+        Lua.pushcfunction luaopen
+        usedBuiltIn <- Lua.pcall 0 1 Nothing >>= \case
+          OK -> do               -- all good, loading succeeded
+            -- register as loaded module so later modules can rely on this
+            Lua.getfield Lua.registryindex Lua.loaded
+            Lua.pushvalue (Lua.nth 2)
+            Lua.setfield (Lua.nth 2) pkgname
+            Lua.pop 1  -- pop _LOADED
+            return True
+          _  -> do               -- built-in library failed, load system lib
+            Lua.pop 1  -- ignore error message
+            -- Try loading via the normal package loading mechanism.
+            Lua.getglobal "require"
+            Lua.pushName pkgname
+            Lua.call 1 1  -- Throws an exception if loading failed again!
+            return False
+
+        -- Module on top of stack. Register as global
+        Lua.setglobal pkgname
+        return $ if usedBuiltIn then Just pkgname else Nothing
+
+    -- Remove module entry from _LOADED table in registry if we used a
+    -- built-in library. This ensures that later calls to @require@ will
+    -- prefer the shared library, if any.
+    forM_ loadedBuiltInModules $ \pkgname -> do
+      Lua.getfield Lua.registryindex Lua.loaded
+      Lua.pushnil
+      Lua.setfield (Lua.nth 2) pkgname
+      Lua.pop 1  -- registry
+
+  installLpegSearcher :: PandocLua ()
+  installLpegSearcher = liftPandocLua $ do
+    Lua.getglobal' "package.searchers"
+    Lua.pushHaskellFunction $ Lua.state >>= liftIO . LPeg.lpeg_searcher
+    Lua.rawseti (Lua.nth 2) . (+1) . fromIntegral =<< Lua.rawlen (Lua.nth 2)
+    Lua.pop 1  -- remove 'package.searchers' from stack
+
+-- | Setup the metatable that's assigned to Lua tables that were created
+-- from/via JSON arrays.
+initJsonMetatable :: PandocLua ()
+initJsonMetatable = liftPandocLua $ do
+  newListMetatable HsLua.Aeson.jsonarray (pure ())
+
+-- | Evaluate a @'PandocLua'@ computation, running all contained Lua
+-- operations.
+runPandocLuaWith :: (PandocMonad m, MonadIO m)
+                 => (forall b. LuaE PandocError b -> IO b)
+                 -> PandocLua a
+                 -> m a
+runPandocLuaWith runner pLua = do
+  origState <- getCommonState
+  globals <- defaultGlobals
+  (result, newState) <- liftIO . runner . unPandocLua $ do
+    putCommonState origState
+    liftPandocLua $ setGlobals globals
+    r <- pLua
+    c <- getCommonState
+    return (r, c)
+  putCommonState newState
+  return result
+
+-- | Global variables which should always be set.
+defaultGlobals :: PandocMonad m => m [Global]
+defaultGlobals = do
+  commonState <- getCommonState
+  return
+    [ PANDOC_API_VERSION
+    , PANDOC_STATE commonState
+    , PANDOC_VERSION
+    ]
diff --git a/src/Text/Pandoc/Lua/Marshal/Chunks.hs b/src/Text/Pandoc/Lua/Marshal/Chunks.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/Chunks.hs
@@ -0,0 +1,145 @@
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{- |
+   Module      : Text.Pandoc.Lua.Marshal.Chunks
+   Copyright   : © 2022 Albert Krewinkel
+   License     : GPL-2.0-or-later
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Marshaling chunks, i.e., pandoc subdocuments.
+-}
+module Text.Pandoc.Lua.Marshal.Chunks
+  ( peekChunk
+  , pushChunk
+  , peekChunkedDoc
+  , pushChunkedDoc
+  ) where
+
+import Control.Monad ((<$!>))
+import Data.Tree (Tree (..))
+import HsLua
+import Text.Pandoc.Chunks (Chunk (..), ChunkedDoc (..), SecInfo (..))
+import Text.Pandoc.Lua.Marshal.AST
+
+-- | Retrieves a 'Chunk' from the Lua stack.
+peekChunk :: LuaError e => Peeker e Chunk
+peekChunk = peekUD typeChunk
+
+-- | Pushes a 'Chunk' to the top of the Lua stack.
+pushChunk :: LuaError e => Pusher e Chunk
+pushChunk = pushUD typeChunk
+
+typeChunk :: LuaError e => DocumentedType e Chunk
+typeChunk = deftype "pandoc.Chunk"
+  [ operation Tostring $ lambda
+    ### liftPure show
+    <#> udparam typeChunk "chunk" "chunk to print in native format"
+    =#> functionResult pushString "string" "Haskell representation"
+  ]
+  [ property "heading"
+    "heading text"
+    (pushInlines, chunkHeading)
+    (peekInlinesFuzzy, \chunk inlns -> chunk{ chunkHeading = inlns })
+
+  , property "id"
+    "identifier"
+    (pushText, chunkId)
+    (peekText, \chunk ident -> chunk{ chunkId = ident })
+
+  , property "level"
+    "level of topmost heading in chunk"
+    (pushIntegral, chunkLevel)
+    (peekIntegral, \chunk level -> chunk{ chunkLevel = level })
+
+  , property "number"
+    "chunk number"
+    (pushIntegral, chunkNumber)
+    (peekIntegral, \chunk number -> chunk{ chunkNumber = number })
+
+  , property "section_number"
+    "hierarchical section number"
+    (pushMaybe pushText, chunkSectionNumber)
+    (peekMaybe peekText, \chunk secnum -> chunk{ chunkSectionNumber = secnum })
+
+  , property "path"
+    "target filepath for this chunk"
+    (pushString, chunkPath)
+    (peekString, \chunk path -> chunk{ chunkPath = path })
+
+  , property "up"
+    "link to the enclosing section chunk, if any"
+    (pushMaybe pushChunk, chunkUp)
+    (peekMaybe peekChunk, \chunk up -> chunk{ chunkUp = up })
+
+  , property "prev"
+    "link to the previous section, if any"
+    (pushMaybe pushChunk, chunkPrev)
+    (peekMaybe peekChunk, \chunk prev -> chunk{ chunkPrev = prev })
+
+  , property "next"
+    "link to the next section, if any"
+    (pushMaybe pushChunk, chunkNext)
+    (peekMaybe peekChunk, \chunk next' -> chunk{ chunkNext = next' })
+
+  , property "unlisted"
+    ( "whether the section in this chunk should be listed in the TOC" <>
+      "even if the chunk has no section number" )
+    (pushBool, chunkUnlisted)
+    (peekBool, \chunk unlisted -> chunk { chunkUnlisted = unlisted })
+
+  , property "contents"
+    "the chunk's block contents"
+    (pushBlocks, chunkContents)
+    (peekBlocksFuzzy, \chunk blks -> chunk{ chunkContents = blks })
+  ]
+
+-- | Retrieves a 'ChunkedDoc' from the Lua stack.
+peekChunkedDoc :: LuaError e => Peeker e ChunkedDoc
+peekChunkedDoc = peekUD typeChunkedDoc
+
+-- | Pushes a 'ChunkedDoc to the top of the Lua stack.
+pushChunkedDoc :: LuaError e => Pusher e ChunkedDoc
+pushChunkedDoc = pushUD typeChunkedDoc
+
+-- | Lua type for 'ChunkedDoc' values.
+typeChunkedDoc :: LuaError e => DocumentedType e ChunkedDoc
+typeChunkedDoc = deftype "pandoc.ChunkedDoc"
+  []
+  [ readonly "chunks"
+    "list of chunks that make up the document"
+    (pushList pushChunk, chunkedChunks)
+
+  , readonly "meta"
+    "the document's metadata"
+    (pushMeta, chunkedMeta)
+
+  , readonly "toc"
+    "table of contents information"
+    (pushTocTree, chunkedTOC)
+  ]
+
+-- | Pushes a TOC tree to the stack. The resulting object is a list with
+-- the top-level entry placed at index @0@ and all subentries as the
+-- rest of the list.
+pushTocTree :: LuaError e => Pusher e (Tree SecInfo)
+pushTocTree (Node secInfo subSecInfo) = do
+  pushList pushTocTree subSecInfo
+  pushSecInfo secInfo
+  rawseti (nth 2) 0
+
+pushSecInfo :: LuaError e => Pusher e SecInfo
+pushSecInfo = pushAsTable
+  [ ("title"  , pushInlines . secTitle)
+  , ("number" , maybe pushnil pushText . secNumber)
+  , ("id"     , pushText . secId)
+  , ("path"   , pushText . secPath)
+  , ("level"  , pushIntegral . secLevel)
+  ]
+
+peekMaybe :: LuaError e => Peeker e a -> Peeker e (Maybe a)
+peekMaybe p idx = liftLua (isnoneornil idx) >>= \case
+  True  -> pure Nothing
+  False -> Just <$!> p idx
+
+pushMaybe :: LuaError e => Pusher e a -> Pusher e (Maybe a)
+pushMaybe = maybe pushnil
diff --git a/src/Text/Pandoc/Lua/Marshal/CommonState.hs b/src/Text/Pandoc/Lua/Marshal/CommonState.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/CommonState.hs
@@ -0,0 +1,68 @@
+{-# LANGUAGE OverloadedStrings    #-}
+{- |
+   Module      : Text.Pandoc.Lua.Marshal.CommonState
+   Copyright   : © 2012-2023 John MacFarlane
+                 © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Instances to marshal (push) and unmarshal (peek) the common state.
+-}
+module Text.Pandoc.Lua.Marshal.CommonState
+  ( typeCommonState
+  , peekCommonState
+  , pushCommonState
+  ) where
+
+import HsLua
+import Text.Pandoc.Class (CommonState (..))
+import Text.Pandoc.Logging (LogMessage, showLogMessage)
+import Text.Pandoc.Lua.Marshal.List (pushPandocList)
+
+-- | Lua type used for the @CommonState@ object.
+typeCommonState :: LuaError e => DocumentedType e CommonState
+typeCommonState = deftype "pandoc CommonState" []
+  [ readonly "input_files" "input files passed to pandoc"
+      (pushPandocList pushString, stInputFiles)
+
+  , readonly "output_file" "the file to which pandoc will write"
+      (maybe pushnil pushString, stOutputFile)
+
+  , readonly "log" "list of log messages"
+      (pushPandocList (pushUD typeLogMessage), stLog)
+
+  , readonly "request_headers" "headers to add for HTTP requests"
+      (pushPandocList (pushPair pushText pushText), stRequestHeaders)
+
+  , readonly "resource_path"
+      "path to search for resources like included images"
+      (pushPandocList pushString, stResourcePath)
+
+  , readonly "source_url" "absolute URL + dir of 1st source file"
+      (maybe pushnil pushText, stSourceURL)
+
+  , readonly "user_data_dir" "directory to search for data files"
+      (maybe pushnil pushString, stUserDataDir)
+
+  , readonly "trace" "controls whether tracing messages are issued"
+      (pushBool, stTrace)
+
+  , readonly "verbosity" "verbosity level"
+      (pushString . show, stVerbosity)
+  ]
+
+peekCommonState :: LuaError e => Peeker e CommonState
+peekCommonState = peekUD typeCommonState
+
+pushCommonState :: LuaError e => Pusher e CommonState
+pushCommonState = pushUD typeCommonState
+
+typeLogMessage :: LuaError e => DocumentedType e LogMessage
+typeLogMessage = deftype "pandoc LogMessage"
+  [ operation Index $ defun "__tostring"
+      ### liftPure showLogMessage
+      <#> udparam typeLogMessage "msg" "object"
+      =#> functionResult pushText "string" "stringified log message"
+  ]
+  mempty -- no members
diff --git a/src/Text/Pandoc/Lua/Marshal/Context.hs b/src/Text/Pandoc/Lua/Marshal/Context.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/Context.hs
@@ -0,0 +1,73 @@
+{-# LANGUAGE FlexibleInstances    #-}
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{- |
+   Module      : Text.Pandoc.Lua.Marshaling.Context
+   Copyright   : © 2012-2023 John MacFarlane
+                 © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Marshaling instance for doctemplates Context and its components.
+-}
+module Text.Pandoc.Lua.Marshal.Context
+  ( peekContext
+  , pushContext
+  ) where
+
+import Control.Monad (when, (<$!>))
+import Data.Text (Text)
+import HsLua as Lua
+import HsLua.Module.DocLayout (peekDoc, pushDoc)
+import Text.DocTemplates (Context(..), Val(..))
+
+instance Pushable (Context Text) where
+  push = pushContext
+
+instance Pushable (Val Text) where
+  push = pushVal
+
+-- | Retrieves a template context from the Lua stack.
+peekContext :: LuaError e => Peeker e (Context Text)
+peekContext idx = Context <$!> peekMap peekText peekVal idx
+
+-- | Pushes a template context to the Lua stack.
+pushContext :: LuaError e => Pusher e (Context Text)
+pushContext ctx = do
+  pushMap pushText pushVal $ unContext ctx
+  created <- Lua.newmetatable "pandoc Context"
+  when created $ do
+    pushName "__concat"
+    pushHaskellFunction $ do
+      c1 <- forcePeek $ peekContext (nthBottom 1)
+      c2 <- forcePeek $ peekContext (nthBottom 2)
+      pushContext (c1 <> c2)
+      return 1
+    rawset (nth 3)
+  setmetatable (nth 2)
+
+pushVal :: LuaError e => Pusher e (Val Text)
+pushVal = \case
+  NullVal     -> Lua.pushnil
+  BoolVal b   -> Lua.pushBool b
+  MapVal ctx  -> pushContext ctx
+  ListVal xs  -> pushList pushVal xs
+  SimpleVal d -> pushDoc d
+
+peekVal :: LuaError e => Peeker e (Val Text)
+peekVal idx = liftLua (ltype idx) >>= \case
+  TypeNil      -> pure NullVal
+  TypeBoolean  -> BoolVal <$!> peekBool idx
+  TypeNumber   -> SimpleVal <$!> peekDoc idx
+  TypeString   -> SimpleVal <$!> peekDoc idx
+  TypeTable    -> do
+    len <- liftLua $ Lua.rawlen idx
+    if len <= 0
+      then MapVal <$!> peekContext idx
+      else ListVal <$!> peekList peekVal idx
+  TypeUserdata -> SimpleVal <$!> peekDoc idx
+  _ -> failPeek =<<
+       typeMismatchMessage "Doc, string, boolean, table, or nil" idx
diff --git a/src/Text/Pandoc/Lua/Marshal/Format.hs b/src/Text/Pandoc/Lua/Marshal/Format.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/Format.hs
@@ -0,0 +1,134 @@
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# LANGUAGE TupleSections        #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{- |
+   Module      : Text.Pandoc.Lua.Marshaling.Format
+   Copyright   : © 2022-2023 Albert Krewinkel
+   License     : GPL-2.0-or-later
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Marshaling functions and instance for format related types, including
+'Extensions' and 'ExtensionConfig'.
+-}
+module Text.Pandoc.Lua.Marshal.Format
+  ( peekExtensions
+  , pushExtensions
+  , peekExtensionsConfig
+  , pushExtensionsConfig
+  , peekFlavoredFormat
+  ) where
+
+import Control.Applicative ((<|>))
+import Control.Monad ((<$!>))
+import Data.Maybe (fromMaybe)
+import HsLua
+import Text.Pandoc.Error (PandocError (..))
+import Text.Pandoc.Extensions
+  ( Extension, Extensions, extensionsFromList, extensionsToList
+  , getDefaultExtensions, readExtension, showExtension )
+import Text.Pandoc.Format
+  ( ExtensionsConfig (..), ExtensionsDiff (..), FlavoredFormat (..)
+  , diffExtensions, parseFlavoredFormat)
+import Text.Pandoc.Lua.PandocLua (PandocLua (unPandocLua))
+
+-- | Retrieves a single 'Extension' from the Lua stack.
+peekExtension :: LuaError e => Peeker e Extension
+peekExtension idx = do
+  extString <- peekString idx
+  return $ readExtension extString
+{-# INLINE peekExtension #-}
+
+-- | Pushes an individual 'Extension' to the Lua stack.
+pushExtension :: LuaError e => Pusher e Extension
+pushExtension = pushText . showExtension
+{-# INLINE pushExtension #-}
+
+-- | Retrieves an 'Extensions' set from the Lua stack.
+peekExtensions :: LuaError e => Peeker e Extensions
+peekExtensions = fmap extensionsFromList . peekList peekExtension
+{-# INLINE peekExtensions #-}
+
+-- | Pushes a set of 'Extensions' to the top of the Lua stack.
+pushExtensions :: LuaError e => Pusher e Extensions
+pushExtensions = pushViaJSON
+{-# INLINE pushExtensions #-}
+
+instance Peekable Extensions where
+  safepeek = peekExtensions
+
+instance Pushable Extensions where
+  push = pushExtensions
+
+-- | Retrieves an 'ExtensionsConfig' value from the Lua stack.
+peekExtensionsConfig :: LuaError e => Peeker e ExtensionsConfig
+peekExtensionsConfig idx = do
+  diff <- peekExtensionsDiff idx
+  return $ ExtensionsConfig
+    { extsDefault   = extsToEnable diff
+    , extsSupported = extsToEnable diff <> extsToDisable diff
+    }
+
+-- | Pushes an 'ExtensionsConfig' value as a table with that maps
+-- extensions to their default enabled/disabled status.
+pushExtensionsConfig :: LuaError e => Pusher e ExtensionsConfig
+pushExtensionsConfig (ExtensionsConfig def supported) =
+  pushKeyValuePairs pushExtension pushBool $
+    map (,False) (extensionsToList supported) ++
+    map (,True)  (extensionsToList def)
+
+instance Peekable ExtensionsConfig where
+  safepeek = peekExtensionsConfig
+
+peekExtensionsDiff :: LuaError e => Peeker e ExtensionsDiff
+peekExtensionsDiff = typeChecked "table" istable $ \idx ->
+      (do
+          en <- peekFieldRaw (emptyOr (fmap Just . peekExtensions)) "enable" idx
+          di <- peekFieldRaw (emptyOr (fmap Just . peekExtensions)) "disable" idx
+          if (en, di) == (Nothing, Nothing)
+            then failPeek "At least on of  'enable' and 'disable' must be set"
+            else return $
+                 ExtensionsDiff (fromMaybe mempty en) (fromMaybe mempty di))
+  <|> -- two lists of extensions; the first is list assumed to contain those
+      -- extensions to be enabled
+      (uncurry ExtensionsDiff <$!> peekPair peekExtensions peekExtensions idx)
+  <|> (do
+          let
+          exts <- peekKeyValuePairs peekExtension peekEnabled idx
+          let enabled  = extensionsFromList . map fst $ filter snd exts
+          let disabled = extensionsFromList . map fst $ filter (not . snd) exts
+          return $ ExtensionsDiff enabled disabled)
+
+-- | Retrieves the activation status of an extension. True or the string
+-- @'enable'@ for activated, False or 'disable' for disabled.
+peekEnabled :: LuaError e => Peeker e Bool
+peekEnabled idx' = liftLua (ltype idx') >>= \case
+  TypeBoolean -> peekBool idx'
+  TypeString  -> peekText idx' >>= \case
+                   "disable" -> pure False
+                   "enable"  -> pure True
+                   _         -> failPeek "expected 'disable' or 'enable'"
+  _ -> failPeek "expected boolean or string"
+
+-- | Retrieves a flavored format from the Lua stack.
+peekFlavoredFormat :: Peeker PandocError FlavoredFormat
+peekFlavoredFormat idx = retrieving "flavored format" $
+  liftLua (ltype idx) >>= \case
+  TypeString -> peekText idx >>= liftLua . unPandocLua . parseFlavoredFormat
+  TypeTable -> do
+    let diffFor format idx' = peekExtensionsDiff idx' <|>
+          (getDefaultExtensions format `diffExtensions`) <$>
+          (typeChecked "table" istable peekExtensions idx')
+    format   <- peekFieldRaw peekText "format" idx
+    extsDiff <- peekFieldRaw (emptyOr (diffFor format)) "extensions" idx
+    return (FlavoredFormat format extsDiff)
+  _ -> failPeek =<< typeMismatchMessage "string or table" idx
+
+-- | Returns 'mempty' if the given stack index is @nil@, and the result
+-- of the peeker otherwise.
+emptyOr :: Monoid a => Peeker e a -> Peeker e a
+emptyOr p idx = do
+  nil <- liftLua (isnil idx)
+  if nil
+    then pure mempty
+    else p idx
diff --git a/src/Text/Pandoc/Lua/Marshal/PandocError.hs b/src/Text/Pandoc/Lua/Marshal/PandocError.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/PandocError.hs
@@ -0,0 +1,53 @@
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# LANGUAGE ScopedTypeVariables  #-}
+{- |
+   Module      : Text.Pandoc.Lua.Marshal.PandocError
+   Copyright   : © 2020-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Marshal of @'PandocError'@ values.
+-}
+module Text.Pandoc.Lua.Marshal.PandocError
+  ( peekPandocError
+  , pushPandocError
+  , typePandocError
+  )
+  where
+
+import HsLua (LuaError, Peeker, Pusher, liftLua, pushText)
+import HsLua.Packaging
+import Text.Pandoc.Error (PandocError (PandocLuaError), renderError)
+
+import qualified HsLua as Lua
+import qualified Text.Pandoc.UTF8 as UTF8
+
+-- | Lua userdata type definition for PandocError.
+typePandocError :: LuaError e => DocumentedType e PandocError
+typePandocError = deftype "PandocError"
+  [ operation Tostring $ defun "__tostring"
+    ### liftPure (\case
+                     PandocLuaError e -> e
+                     err              -> renderError err)
+    <#> udparam typePandocError "obj" "PandocError object"
+    =#> functionResult pushText "string" "string representation of error."
+  ]
+  mempty -- no members
+
+-- | Peek a @'PandocError'@ element to the Lua stack.
+pushPandocError :: LuaError e => Pusher e PandocError
+pushPandocError = pushUD typePandocError
+
+-- | Retrieve a @'PandocError'@ from the Lua stack.
+peekPandocError :: LuaError e => Peeker e PandocError
+peekPandocError idx = Lua.retrieving "PandocError" $
+  liftLua (Lua.ltype idx) >>= \case
+    Lua.TypeUserdata -> peekUD typePandocError idx
+    _ -> do
+      msg <- liftLua $ do
+        Lua.pushvalue idx
+        Lua.state >>= \l -> Lua.liftIO (Lua.popErrorMessage l)
+      return $ PandocLuaError (UTF8.toText msg)
diff --git a/src/Text/Pandoc/Lua/Marshal/ReaderOptions.hs b/src/Text/Pandoc/Lua/Marshal/ReaderOptions.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/ReaderOptions.hs
@@ -0,0 +1,135 @@
+{-# LANGUAGE CPP                  #-}
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# LANGUAGE ScopedTypeVariables  #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{- |
+   Module      : Text.Pandoc.Lua.Marshaling.ReaderOptions
+   Copyright   : © 2012-2023 John MacFarlane
+                 © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Marshaling instance for ReaderOptions and its components.
+-}
+module Text.Pandoc.Lua.Marshal.ReaderOptions
+  ( peekReaderOptions
+  , pushReaderOptions
+  , pushReaderOptionsReadonly
+  ) where
+
+import Data.Default (def)
+import HsLua as Lua
+import Text.Pandoc.Lua.Marshal.Format (peekExtensions, pushExtensions)
+import Text.Pandoc.Lua.Marshal.List (pushPandocList)
+import Text.Pandoc.Options (ReaderOptions (..))
+
+--
+-- Reader Options
+--
+
+-- | Retrieve a ReaderOptions value, either from a normal ReaderOptions
+-- value, from a read-only object, or from a table with the same
+-- keys as a ReaderOptions object.
+peekReaderOptions :: LuaError e => Peeker e ReaderOptions
+peekReaderOptions = retrieving "ReaderOptions" . \idx ->
+  liftLua (ltype idx) >>= \case
+    TypeUserdata -> choice [ peekUD typeReaderOptions
+                           , peekUD typeReaderOptionsReadonly
+                           ]
+                           idx
+    TypeTable    -> peekReaderOptionsTable idx
+    _            -> failPeek =<<
+                    typeMismatchMessage "ReaderOptions userdata or table" idx
+
+-- | Pushes a ReaderOptions value as userdata object.
+pushReaderOptions :: LuaError e => Pusher e ReaderOptions
+pushReaderOptions = pushUD typeReaderOptions
+
+-- | Pushes a ReaderOptions object, but makes it read-only.
+pushReaderOptionsReadonly :: LuaError e => Pusher e ReaderOptions
+pushReaderOptionsReadonly = pushUD typeReaderOptionsReadonly
+
+-- | ReaderOptions object type for read-only values.
+typeReaderOptionsReadonly :: LuaError e => DocumentedType e ReaderOptions
+typeReaderOptionsReadonly = deftype "ReaderOptions (read-only)"
+  [ operation Tostring $ lambda
+    ### liftPure show
+    <#> udparam typeReaderOptions "opts" "options to print in native format"
+    =#> functionResult pushString "string" "Haskell representation"
+  , operation Newindex $ lambda
+    ### (failLua "This ReaderOptions value is read-only.")
+    =?> "Throws an error when called, i.e., an assignment is made."
+  ]
+  readerOptionsMembers
+
+-- | 'ReaderOptions' object type.
+typeReaderOptions :: LuaError e => DocumentedType e ReaderOptions
+typeReaderOptions = deftype "ReaderOptions"
+  [ operation Tostring $ lambda
+    ### liftPure show
+    <#> udparam typeReaderOptions "opts" "options to print in native format"
+    =#> functionResult pushString "string" "Haskell representation"
+  ]
+  readerOptionsMembers
+
+-- | Member properties of 'ReaderOptions' Lua values.
+readerOptionsMembers :: LuaError e
+                     => [Member e (DocumentedFunction e) ReaderOptions]
+readerOptionsMembers =
+  [ property "abbreviations" ""
+      (pushSet pushText, readerAbbreviations)
+      (peekSet peekText, \opts x -> opts{ readerAbbreviations = x })
+  , property "columns" ""
+      (pushIntegral, readerColumns)
+      (peekIntegral, \opts x -> opts{ readerColumns = x })
+  , property "default_image_extension" ""
+      (pushText, readerDefaultImageExtension)
+      (peekText, \opts x -> opts{ readerDefaultImageExtension = x })
+  , property "extensions" ""
+      (pushExtensions, readerExtensions)
+      (peekExtensions, \opts x -> opts{ readerExtensions = x })
+  , property "indented_code_classes" ""
+      (pushPandocList pushText, readerIndentedCodeClasses)
+      (peekList peekText, \opts x -> opts{ readerIndentedCodeClasses = x })
+  , property "standalone" ""
+      (pushBool, readerStandalone)
+      (peekBool, \opts x -> opts{ readerStandalone = x })
+  , property "strip_comments" ""
+      (pushBool, readerStripComments)
+      (peekBool, \opts x -> opts{ readerStripComments = x })
+  , property "tab_stop" ""
+      (pushIntegral, readerTabStop)
+      (peekIntegral, \opts x -> opts{ readerTabStop = x })
+  , property "track_changes" ""
+      (pushViaJSON, readerTrackChanges)
+      (choice [peekRead, peekViaJSON], \opts x -> opts{ readerTrackChanges = x })
+  ]
+
+-- | Retrieves a 'ReaderOptions' object from a table on the stack, using
+-- the default values for all missing fields.
+--
+-- Internally, this pushes the default reader options, sets each
+-- key/value pair of the table in the userdata value, then retrieves the
+-- object again. This will update all fields and complain about unknown
+-- keys.
+peekReaderOptionsTable :: LuaError e => Peeker e ReaderOptions
+peekReaderOptionsTable idx = retrieving "ReaderOptions (table)" $ do
+  liftLua $ do
+    absidx <- absindex idx
+    pushUD typeReaderOptions def
+    let setFields = do
+          next absidx >>= \case
+            False -> return () -- all fields were copied
+            True -> do
+              pushvalue (nth 2) *> insert (nth 2)
+              settable (nth 4) -- set in userdata object
+              setFields
+    pushnil -- first key
+    setFields
+  peekUD typeReaderOptions top `lastly` pop 1
+
+instance Pushable ReaderOptions where
+  push = pushReaderOptions
diff --git a/src/Text/Pandoc/Lua/Marshal/Reference.hs b/src/Text/Pandoc/Lua/Marshal/Reference.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/Reference.hs
@@ -0,0 +1,96 @@
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# LANGUAGE ScopedTypeVariables  #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{- |
+   Module      : Text.Pandoc.Lua.Marshaling.ReaderOptions
+   Copyright   : © 2012-2023 John MacFarlane
+                 © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Marshal citeproc 'Reference' values.
+-}
+module Text.Pandoc.Lua.Marshal.Reference
+  ( pushReference
+  ) where
+
+import Citeproc.Types
+  ( Date (..), DateParts (..), ItemId (..), Name (..), Reference (..)
+  , Val (..), Variable, fromVariable
+  )
+import Control.Monad (forM_)
+import HsLua hiding (Name, Reference, pushName, peekName)
+import Text.Pandoc.Builder (Inlines, toList)
+import Text.Pandoc.Lua.Marshal.Inline (pushInlines)
+import Text.Pandoc.Lua.Marshal.List (pushPandocList)
+
+import qualified Data.Map as Map
+
+-- | Pushes a ReaderOptions value as userdata object.
+pushReference :: LuaError e => Pusher e (Reference Inlines)
+pushReference reference = do
+  pushAsTable [ ("id", pushItemId . referenceId)
+              , ("type", pushText . referenceType)
+              ]
+              reference
+  forM_ (Map.toList $ referenceVariables reference) $ \(var, val) -> do
+    pushVariable var
+    pushVal val
+    rawset (nth 3)
+
+-- | Pushes an 'ItemId' as a string.
+pushItemId :: Pusher e ItemId
+pushItemId = pushText . unItemId
+
+-- | Pushes a person's 'Name' as a table.
+pushName :: LuaError e => Pusher e Name
+pushName = pushAsTable
+  [ ("family"                , pushTextOrNil . nameFamily)
+  , ("given"                 , pushTextOrNil . nameGiven)
+  , ("dropping-particle"     , pushTextOrNil . nameDroppingParticle)
+  , ("non-dropping-particle" , pushTextOrNil . nameNonDroppingParticle)
+  , ("suffix"                , pushTextOrNil . nameSuffix)
+  , ("literal"               , pushTextOrNil . nameLiteral)
+  , ("comma-suffix"          , pushBoolOrNil . nameCommaSuffix)
+  , ("static-ordering"       , pushBoolOrNil . nameStaticOrdering)
+  ]
+  where
+    pushTextOrNil = \case
+      Nothing -> pushnil
+      Just xs -> pushText xs
+
+-- | Pushes a boolean, but uses @nil@ instead of @false@; table fields
+-- are not set unless the value is true.
+pushBoolOrNil :: Pusher e Bool
+pushBoolOrNil = \case
+  False -> pushnil
+  True  -> pushBool True
+
+-- | Pushes a 'Variable' as string.
+pushVariable :: Pusher e Variable
+pushVariable = pushText . fromVariable
+
+-- | Pushes a 'Val', i.e., a variable value.
+pushVal :: LuaError e => Pusher e (Val Inlines)
+pushVal = \case
+  TextVal t -> pushText t
+  FancyVal inlns -> pushInlines $ toList inlns
+  NumVal i       -> pushIntegral i
+  NamesVal names -> pushPandocList pushName names
+  DateVal date   -> pushDate date
+  _              -> pushText mempty
+
+-- | Pushes a 'Date' as table.
+pushDate :: LuaError e => Pusher e Date
+pushDate = pushAsTable
+  [ ("date-parts", pushPandocList pushDateParts . dateParts)
+  , ("circa", pushBoolOrNil . dateCirca)
+  , ("season", maybe pushnil pushIntegral . dateSeason)
+  , ("literal", maybe pushnil pushText . dateLiteral)
+  ]
+ where
+   -- date parts are lists of Int values
+   pushDateParts (DateParts dp) = pushPandocList pushIntegral dp
diff --git a/src/Text/Pandoc/Lua/Marshal/Sources.hs b/src/Text/Pandoc/Lua/Marshal/Sources.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/Sources.hs
@@ -0,0 +1,56 @@
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{- |
+Module      : Text.Pandoc.Lua.Marshaling.Sources
+Copyright   : © 2021-2023 Albert Krewinkel
+License     : GNU GPL, version 2 or above
+Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Marshal 'Sources'.
+-}
+module Text.Pandoc.Lua.Marshal.Sources
+  ( peekSources
+  , pushSources
+  ) where
+
+import Control.Monad ((<$!>))
+import Data.Text (Text)
+import HsLua as Lua
+import Text.Pandoc.Lua.Marshal.List (newListMetatable)
+import Text.Pandoc.Sources (Sources (..), toSources)
+import Text.Parsec (SourcePos, sourceName)
+
+-- | Pushes the 'Sources' as a list of lazy Lua objects.
+pushSources :: LuaError e => Pusher e Sources
+pushSources (Sources srcs) = do
+  pushList (pushUD typeSource) srcs
+  newListMetatable "pandoc Sources" $ do
+    pushName "__tostring"
+    pushHaskellFunction $ do
+      sources <- forcePeek $ peekList (peekUD typeSource) (nthBottom 1)
+      pushText . mconcat $ map snd sources
+      return 1
+    rawset (nth 3)
+  setmetatable (nth 2)
+
+-- | Retrieves sources from the stack.
+peekSources :: LuaError e => Peeker e Sources
+peekSources idx = liftLua (ltype idx) >>= \case
+  TypeString -> toSources <$!> peekText idx
+  TypeTable  -> Sources <$!> peekList (peekUD typeSource) idx
+  _          -> Sources . (:[]) <$!> peekUD typeSource idx
+
+-- | Source object type.
+typeSource :: LuaError e => DocumentedType e (SourcePos, Text)
+typeSource = deftype "pandoc input source"
+  [ operation Tostring $ lambda
+    ### liftPure snd
+    <#> udparam typeSource "srcs" "Source to print in native format"
+    =#> functionResult pushText "string" "Haskell representation"
+  ]
+  [ readonly "name" "source name"
+      (pushString, sourceName . fst)
+  , readonly "text" "source text"
+      (pushText, snd)
+  ]
diff --git a/src/Text/Pandoc/Lua/Marshal/Template.hs b/src/Text/Pandoc/Lua/Marshal/Template.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/Template.hs
@@ -0,0 +1,43 @@
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{- |
+Module      : Text.Pandoc.Lua.Marshal.Template
+Copyright   : © 2021-2023 Albert Krewinkel
+License     : GNU GPL, version 2 or above
+Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Marshal 'Template' 'Text'.
+-}
+module Text.Pandoc.Lua.Marshal.Template
+  ( pushTemplate
+  , peekTemplate
+  , typeTemplate
+  ) where
+
+import Data.Text (Text)
+import HsLua as Lua
+import HsLua.Core.Utf8 as Lua
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.PandocLua (unPandocLua)
+import Text.Pandoc.Templates (Template, compileTemplate, runWithDefaultPartials)
+
+-- | Pushes a 'Template' as a an opaque userdata value.
+pushTemplate :: LuaError e => Pusher e (Template Text)
+pushTemplate = pushUD typeTemplate
+
+-- | Retrieves a 'Template' 'Text' value from the stack.
+peekTemplate :: Peeker PandocError (Template Text)
+peekTemplate idx = liftLua (ltype idx) >>= \case
+  TypeString -> do
+    let path = "templates/default.custom"
+    let liftPM = liftLua . unPandocLua
+    tmpl <- peekText idx
+    liftPM (runWithDefaultPartials (compileTemplate path tmpl)) >>= \case
+      Left e  -> failPeek (Lua.fromString e)
+      Right t -> pure t
+  _ -> peekUD typeTemplate idx
+
+-- | Template object type.
+typeTemplate :: LuaError e => DocumentedType e (Template Text)
+typeTemplate = deftype "pandoc Template" [] []
diff --git a/src/Text/Pandoc/Lua/Marshal/WriterOptions.hs b/src/Text/Pandoc/Lua/Marshal/WriterOptions.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Marshal/WriterOptions.hs
@@ -0,0 +1,246 @@
+{-# LANGUAGE CPP                  #-}
+{-# LANGUAGE LambdaCase           #-}
+{-# LANGUAGE OverloadedStrings    #-}
+{-# LANGUAGE ScopedTypeVariables  #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{- |
+   Module      : Text.Pandoc.Lua.Marshaling.WriterOptions
+   Copyright   : © 2021-2023 Albert Krewinkel, John MacFarlane
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Marshaling instance for WriterOptions and its components.
+-}
+module Text.Pandoc.Lua.Marshal.WriterOptions
+  ( peekWriterOptions
+  , pushWriterOptions
+  ) where
+
+import Control.Applicative (optional)
+import Data.Default (def)
+import HsLua as Lua
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.Marshal.Context (peekContext, pushContext)
+import Text.Pandoc.Lua.Marshal.Format (peekExtensions, pushExtensions)
+import Text.Pandoc.Lua.Marshal.List (pushPandocList)
+import Text.Pandoc.Lua.Marshal.Template (peekTemplate, pushTemplate)
+import Text.Pandoc.Options (WriterOptions (..))
+
+--
+-- Writer Options
+--
+
+-- | Retrieve a WriterOptions value, either from a normal WriterOptions
+-- value, from a read-only object, or from a table with the same
+-- keys as a WriterOptions object.
+peekWriterOptions :: Peeker PandocError WriterOptions
+peekWriterOptions = retrieving "WriterOptions" . \idx ->
+  liftLua (ltype idx) >>= \case
+    TypeUserdata -> peekUD typeWriterOptions idx
+    TypeTable    -> peekWriterOptionsTable idx
+    _            -> failPeek =<<
+                    typeMismatchMessage "WriterOptions userdata or table" idx
+
+-- | Pushes a WriterOptions value as userdata object.
+pushWriterOptions :: Pusher PandocError WriterOptions
+pushWriterOptions = pushUD typeWriterOptions
+
+-- | 'WriterOptions' object type.
+typeWriterOptions :: DocumentedType PandocError WriterOptions
+typeWriterOptions = deftype "WriterOptions"
+  [ operation Tostring $ lambda
+    ### liftPure show
+    <#> udparam typeWriterOptions "opts" "options to print in native format"
+    =#> functionResult pushString "string" "Haskell representation"
+  ]
+  [ property "cite_method"
+    "How to print cites"
+    (pushViaJSON, writerCiteMethod)
+    (peekViaJSON, \opts x -> opts{ writerCiteMethod = x })
+
+  , property "columns"
+    "Characters in a line (for text wrapping)"
+    (pushIntegral, writerColumns)
+    (peekIntegral, \opts x -> opts{ writerColumns = x })
+
+  , property "dpi"
+    "DPI for pixel to/from inch/cm conversions"
+    (pushIntegral, writerDpi)
+    (peekIntegral, \opts x -> opts{ writerDpi = x })
+
+  , property "email_obfuscation"
+    "How to obfuscate emails"
+    (pushViaJSON, writerEmailObfuscation)
+    (peekViaJSON, \opts x -> opts{ writerEmailObfuscation = x })
+
+  , property "split_level"
+    "Level at which EPUB or chunked HTML documents are split into files"
+    (pushIntegral, writerSplitLevel)
+    (peekIntegral, \opts x -> opts{ writerSplitLevel = x })
+
+  , property "epub_chapter_level"
+    "Deprecated synonym for split_level"
+    (pushIntegral, writerSplitLevel)
+    (peekIntegral, \opts x -> opts{ writerSplitLevel = x })
+
+  , property "epub_fonts"
+    "Paths to fonts to embed"
+    (pushPandocList pushString, writerEpubFonts)
+    (peekList peekString, \opts x -> opts{ writerEpubFonts = x })
+
+  , property "epub_metadata"
+    "Metadata to include in EPUB"
+    (maybe pushnil pushText, writerEpubMetadata)
+    (optional . peekText, \opts x -> opts{ writerEpubMetadata = x })
+
+  , property "epub_subdirectory"
+    "Subdir for epub in OCF"
+    (pushText, writerEpubSubdirectory)
+    (peekText, \opts x -> opts{ writerEpubSubdirectory = x })
+
+  , property "extensions"
+    "Markdown extensions that can be used"
+    (pushExtensions, writerExtensions)
+    (peekExtensions, \opts x -> opts{ writerExtensions = x })
+
+  , property "highlight_style"
+    "Style to use for highlighting (nil = no highlighting)"
+    (maybe pushnil pushViaJSON, writerHighlightStyle)
+    (optional . peekViaJSON, \opts x -> opts{ writerHighlightStyle = x })
+
+  , property "html_math_method"
+    "How to print math in HTML"
+    (pushViaJSON, writerHTMLMathMethod)
+    (peekViaJSON, \opts x -> opts{ writerHTMLMathMethod = x })
+
+  , property "html_q_tags"
+    "Use @<q>@ tags for quotes in HTML"
+    (pushBool, writerHtmlQTags)
+    (peekBool, \opts x -> opts{ writerHtmlQTags = x })
+
+  , property "identifier_prefix"
+    "Prefix for section & note ids in HTML and for footnote marks in markdown"
+    (pushText, writerIdentifierPrefix)
+    (peekText, \opts x -> opts{ writerIdentifierPrefix = x })
+
+  , property "incremental"
+    "True if lists should be incremental"
+    (pushBool, writerIncremental)
+    (peekBool, \opts x -> opts{ writerIncremental = x })
+
+  , property "listings"
+    "Use listings package for code"
+    (pushBool, writerListings)
+    (peekBool, \opts x -> opts{ writerListings = x })
+
+  , property "number_offset"
+    "Starting number for section, subsection, ..."
+    (pushPandocList pushIntegral, writerNumberOffset)
+    (peekList peekIntegral, \opts x -> opts{ writerNumberOffset = x })
+
+  , property "number_sections"
+    "Number sections in LaTeX"
+    (pushBool, writerNumberSections)
+    (peekBool, \opts x -> opts{ writerNumberSections = x })
+
+  , property "prefer_ascii"
+    "Prefer ASCII representations of characters when possible"
+    (pushBool, writerPreferAscii)
+    (peekBool, \opts x -> opts{ writerPreferAscii = x })
+
+  , property "reference_doc"
+    "Path to reference document if specified"
+    (maybe pushnil pushString, writerReferenceDoc)
+    (optional . peekString, \opts x -> opts{ writerReferenceDoc = x })
+
+  , property "reference_links"
+    "Use reference links in writing markdown, rst"
+    (pushBool, writerReferenceLinks)
+    (peekBool, \opts x -> opts{ writerReferenceLinks = x })
+
+  , property "reference_location"
+    "Location of footnotes and references for writing markdown"
+    (pushViaJSON, writerReferenceLocation)
+    (peekViaJSON, \opts x -> opts{ writerReferenceLocation = x })
+
+  , property "section_divs"
+    "Put sections in div tags in HTML"
+    (pushBool, writerSectionDivs)
+    (peekBool, \opts x -> opts{ writerSectionDivs = x })
+
+  , property "setext_headers"
+    "Use setext headers for levels 1-2 in markdown"
+    (pushBool, writerSetextHeaders)
+    (peekBool, \opts x -> opts{ writerSetextHeaders = x })
+
+  , property "slide_level"
+    "Force header level of slides"
+    (maybe pushnil pushIntegral, writerSlideLevel)
+    (optional . peekIntegral, \opts x -> opts{ writerSlideLevel = x })
+
+  -- , property "syntax_map" "Syntax highlighting definition"
+  --   (pushViaJSON, writerSyntaxMap)
+  --   (peekViaJSON, \opts x -> opts{ writerSyntaxMap = x })
+    -- :: SyntaxMap
+
+  , property "tab_stop"
+    "Tabstop for conversion btw spaces and tabs"
+    (pushIntegral, writerTabStop)
+    (peekIntegral, \opts x -> opts{ writerTabStop = x })
+
+  , property "table_of_contents"
+    "Include table of contents"
+    (pushBool, writerTableOfContents)
+    (peekBool, \opts x -> opts{ writerTableOfContents = x })
+
+  , property "template"
+    "Template to use"
+    (maybe pushnil pushTemplate, writerTemplate)
+    (optional . peekTemplate, \opts x -> opts{ writerTemplate = x })
+    -- :: Maybe (Template Text)
+
+  , property "toc_depth"
+    "Number of levels to include in TOC"
+    (pushIntegral, writerTOCDepth)
+    (peekIntegral, \opts x -> opts{ writerTOCDepth = x })
+
+  , property "top_level_division"
+    "Type of top-level divisions"
+    (pushViaJSON, writerTopLevelDivision)
+    (peekViaJSON, \opts x -> opts{ writerTopLevelDivision = x })
+
+  , property "variables"
+    "Variables to set in template"
+    (pushContext, writerVariables)
+    (peekContext, \opts x -> opts{ writerVariables = x })
+
+  , property "wrap_text"
+    "Option for wrapping text"
+    (pushViaJSON, writerWrapText)
+    (peekViaJSON, \opts x -> opts{ writerWrapText = x })
+  ]
+
+-- | Retrieves a 'WriterOptions' object from a table on the stack, using
+-- the default values for all missing fields.
+--
+-- Internally, this pushes the default writer options, sets each
+-- key/value pair of the table in the userdata value, then retrieves the
+-- object again. This will update all fields and complain about unknown
+-- keys.
+peekWriterOptionsTable :: Peeker PandocError WriterOptions
+peekWriterOptionsTable idx = retrieving "WriterOptions (table)" $ do
+  liftLua $ do
+    absidx <- absindex idx
+    pushUD typeWriterOptions def
+    let setFields = do
+          next absidx >>= \case
+            False -> return () -- all fields were copied
+            True -> do
+              pushvalue (nth 2) *> insert (nth 2)
+              settable (nth 4) -- set in userdata object
+              setFields
+    pushnil -- first key
+    setFields
+  peekUD typeWriterOptions top `lastly` pop 1
diff --git a/src/Text/Pandoc/Lua/Module/CLI.hs b/src/Text/Pandoc/Lua/Module/CLI.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/CLI.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE OverloadedStrings #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.CLI
+   Copyright   : © 2022-2023 Albert Krewinkel
+   License     : GPL-2.0-or-later
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Command line helpers
+-}
+module Text.Pandoc.Lua.Module.CLI
+  ( documentedModule
+  ) where
+
+import Control.Applicative ((<|>))
+import HsLua ( Field (..), Module (..), (###), (<#>), (=#>), (#?)
+             , defun, failLua, functionResult, liftIO, parameter, pop
+             , pushViaJSON, rawgeti, top)
+import HsLua.Marshalling (lastly, liftLua, peekList, peekString)
+import Text.Pandoc.App (defaultOpts, options, parseOptionsFromArgs)
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.PandocLua ()
+import qualified Data.Text as T
+
+-- | Push the pandoc.types module on the Lua stack.
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc.cli"
+  , moduleDescription =
+      "Command line options and argument parsing."
+  , moduleFields =
+      [ Field
+        { fieldName = "default_options"
+        , fieldDescription = "Default CLI options, using a JSON-like " <>
+          "representation."
+        , fieldPushValue = pushViaJSON defaultOpts
+        }
+      ]
+  , moduleFunctions =
+      [ defun "parse_options"
+        ### parseOptions
+        <#> parameter peekArgs "{string,...}" "args"
+              "list of command line arguments"
+        =#> functionResult pushViaJSON "table"
+              "parsed options, using their JSON-like representation."
+        #? T.unlines
+           [ "Parses command line arguments into pandoc options."
+           , "Typically this function will be used in stand-alone pandoc Lua"
+           , "scripts, taking the list of arguments from the global `arg`."
+           ]
+      ]
+  , moduleOperations = []
+  }
+ where
+  peekArgs idx =
+    (,)
+    <$> (liftLua (rawgeti idx 0) *> (peekString top <|> pure "") `lastly` pop 1)
+    <*> peekList peekString idx
+
+  parseOptions (prg, args) =
+    liftIO (parseOptionsFromArgs options defaultOpts prg args) >>=
+    \case
+      Left e     -> failLua $ "Cannot process info option: " ++ show e
+      Right opts -> pure opts
diff --git a/src/Text/Pandoc/Lua/Module/Format.hs b/src/Text/Pandoc/Lua/Module/Format.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/Format.hs
@@ -0,0 +1,75 @@
+{-# LANGUAGE OverloadedStrings #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.Format
+   Copyright   : © 2022-2023 Albert Krewinkel
+   License     : GPL-2.0-or-later
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Lua module to handle pandoc templates.
+-}
+module Text.Pandoc.Lua.Module.Format
+  ( documentedModule
+  ) where
+
+import HsLua
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Extensions (getAllExtensions, getDefaultExtensions)
+import Text.Pandoc.Format (getExtensionsConfig)
+import Text.Pandoc.Lua.Marshal.Format (pushExtensions, pushExtensionsConfig)
+import Text.Pandoc.Lua.PandocLua ()
+
+import qualified Data.Text as T
+
+-- | The "pandoc.format" module.
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc.format"
+  , moduleDescription = T.unlines
+    [ "Pandoc formats and their extensions."
+    ]
+  , moduleFields = []
+  , moduleOperations = []
+  , moduleFunctions = functions
+  }
+
+-- | Extension module functions.
+functions :: [DocumentedFunction PandocError]
+functions =
+  [ defun "default_extensions"
+     ### liftPure getDefaultExtensions
+     <#> parameter peekText "string" "format" "format name"
+     =#> functionResult pushExtensions "FormatExtensions"
+           "default extensions enabled for `format`"
+     #? T.unlines
+        [ "Returns the list of default extensions of the given format; this"
+        , "function does not check if the format is supported, it will return"
+        , "a fallback list of extensions even for unknown formats."
+        ]
+
+  , defun "all_extensions"
+     ### liftPure getAllExtensions
+     <#> parameter peekText "string" "format" "format name"
+     =#> functionResult pushExtensions "FormatExtensions"
+           "all extensions supported for `format`"
+     #? T.unlines
+        [ "Returns the list of all valid extensions for a format."
+        , "No distinction is made between input and output; an extension"
+        , "can have an effect when reading a format but not when"
+        , "writing it, or *vice versa*."
+        ]
+
+  , defun "extensions"
+     ### liftPure getExtensionsConfig
+     <#> textParam "format" "format identifier"
+     =#> functionResult pushExtensionsConfig "table" "extensions config"
+     #? T.unlines
+        [ "Returns the extension configuration for the given format."
+        , "The configuration is represented as a table with all supported"
+        , "extensions as keys and their default status as value, with"
+        , "`true` indicating that the extension is enabled by default,"
+        , "while `false` marks a supported extension that's disabled."
+        , ""
+        , "This function can be used to assign a value to the `Extensions`"
+        , "global in custom readers and writers."
+        ]
+  ]
diff --git a/src/Text/Pandoc/Lua/Module/MediaBag.hs b/src/Text/Pandoc/Lua/Module/MediaBag.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/MediaBag.hs
@@ -0,0 +1,174 @@
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE OverloadedStrings #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.MediaBag
+   Copyright   : Copyright © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+The Lua module @pandoc.mediabag@.
+-}
+module Text.Pandoc.Lua.Module.MediaBag
+  ( documentedModule
+  ) where
+
+import Prelude hiding (lookup)
+import Data.Maybe (fromMaybe)
+import Data.Version (makeVersion)
+import HsLua ( LuaE, DocumentedFunction, Module (..)
+             , (<#>), (###), (=#>), (=?>), (#?), defun, functionResult
+             , opt, parameter, since, stringParam, textParam)
+import Text.Pandoc.Class ( CommonState (..), fetchItem, fillMediaBag
+                         , getMediaBag, modifyCommonState, setMediaBag)
+import Text.Pandoc.Class.IO (writeMedia)
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.Marshal.Pandoc (peekPandoc, pushPandoc)
+import Text.Pandoc.Lua.Marshal.List (pushPandocList)
+import Text.Pandoc.Lua.Orphans ()
+import Text.Pandoc.Lua.PandocLua (unPandocLua)
+import Text.Pandoc.MIME (MimeType)
+
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.Text as T
+import qualified HsLua as Lua
+import qualified Text.Pandoc.MediaBag as MB
+
+--
+-- MediaBag submodule
+--
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc.mediabag"
+  , moduleDescription = "mediabag access"
+  , moduleFields = []
+  , moduleFunctions =
+      [ delete
+      , empty
+      , fetch
+      , fill
+      , insert
+      , items
+      , list
+      , lookup
+      , write
+      ]
+  , moduleOperations = []
+  }
+
+-- | Delete a single item from the media bag.
+delete :: DocumentedFunction PandocError
+delete = defun "delete"
+  ### (\fp -> unPandocLua $ modifyCommonState
+              (\st -> st { stMediaBag = MB.deleteMedia fp (stMediaBag st) }))
+  <#> stringParam "filepath" "filename of item to delete"
+  =#> []
+
+
+-- | Delete all items from the media bag.
+empty :: DocumentedFunction PandocError
+empty = defun "empty"
+  ### unPandocLua (modifyCommonState (\st -> st { stMediaBag = mempty }))
+  =#> []
+
+-- | Fill the mediabag with all images in the document that aren't
+-- present yet.
+fill :: DocumentedFunction PandocError
+fill = defun "fill"
+  ### unPandocLua . fillMediaBag
+  <#> parameter peekPandoc "Pandoc" "doc"
+        "document from which to fill the mediabag"
+  =#> functionResult pushPandoc "Pandoc" "modified document"
+  #? ("Fills the mediabag with the images in the given document.\n" <>
+      "An image that cannot be retrieved will be replaced with a Span\n" <>
+      "of class \"image\" that contains the image description.\n" <>
+      "" <>
+      "Images for which the mediabag already contains an item will\n" <>
+      "not be processed again.")
+
+-- | Insert a new item into the media bag.
+insert :: DocumentedFunction PandocError
+insert = defun "insert"
+  ### (\fp mmime contents -> unPandocLua $ do
+          mb <- getMediaBag
+          setMediaBag $ MB.insertMedia fp mmime contents mb
+          return (Lua.NumResults 0))
+  <#> stringParam "filepath" "item file path"
+  <#> opt (textParam "mimetype" "the item's MIME type")
+  <#> parameter Lua.peekLazyByteString "string" "contents" "binary contents"
+  =#> []
+
+-- | Returns iterator values to be used with a Lua @for@ loop.
+items :: DocumentedFunction PandocError
+items = defun "items"
+  ### (do
+          mb <-unPandocLua getMediaBag
+          let pushItem (fp, mimetype, contents) = do
+                Lua.pushString fp
+                Lua.pushText mimetype
+                Lua.pushByteString $ BL.toStrict contents
+                return (Lua.NumResults 3)
+          Lua.pushIterator pushItem (MB.mediaItems mb))
+  =?> "Iterator triple"
+
+-- | Function to lookup a value in the mediabag.
+lookup :: DocumentedFunction PandocError
+lookup = defun "lookup"
+  ### (\fp -> unPandocLua (MB.lookupMedia fp <$> getMediaBag) >>= \case
+          Nothing   -> 1 <$ Lua.pushnil
+          Just item -> 2 <$ do
+            Lua.pushText $ MB.mediaMimeType item
+            Lua.pushLazyByteString $ MB.mediaContents item)
+  <#> stringParam "filepath" "path of item to lookup"
+  =?> "MIME type and contents"
+
+-- | Function listing all mediabag items.
+list :: DocumentedFunction PandocError
+list = defun "list"
+  ### (unPandocLua (MB.mediaDirectory <$> getMediaBag))
+  =#> functionResult (pushPandocList pushEntry) "table" "list of entry triples"
+ where
+  pushEntry :: (FilePath, MimeType, Int) -> LuaE PandocError ()
+  pushEntry (fp, mimeType, contentLength) = do
+    Lua.newtable
+    Lua.pushName "path"   *> Lua.pushString fp              *> Lua.rawset (-3)
+    Lua.pushName "type"   *> Lua.pushText mimeType          *> Lua.rawset (-3)
+    Lua.pushName "length" *> Lua.pushIntegral contentLength *> Lua.rawset (-3)
+
+-- | Lua function to retrieve a new item.
+fetch :: DocumentedFunction PandocError
+fetch = defun "fetch"
+  ### (\src -> do
+          (bs, mimeType) <- unPandocLua $ fetchItem src
+          Lua.pushText $ fromMaybe "" mimeType
+          Lua.pushByteString bs
+          return 2)
+  <#> textParam "src" "URI to fetch"
+  =?> "Returns two string values: the fetched contents and the mimetype."
+
+-- | Extract the mediabag or just a single entry.
+write :: DocumentedFunction PandocError
+write = defun "write"
+  ### (\dir mfp -> do
+          mb <- unPandocLua getMediaBag
+          case mfp of
+            Nothing -> unPandocLua $ mapM_ (writeMedia dir) (MB.mediaItems mb)
+            Just fp -> do
+              case MB.lookupMedia fp mb of
+                Nothing   -> Lua.failLua ("Resource not in mediabag: " <> fp)
+                Just item -> unPandocLua $ do
+                  let triple = ( MB.mediaPath item
+                               , MB.mediaMimeType item
+                               , MB.mediaContents item
+                               )
+                  writeMedia dir triple)
+  <#> stringParam "dir" "path of the target directory"
+  <#> opt (stringParam "fp" "canonical name (relative path) of resource")
+  =#> []
+  #? T.unlines
+     [ "Writes the contents of  mediabag to the given target directory. If"
+     , "`fp` is given, then only the resource with the given name will be"
+     , "extracted. Omitting that parameter means that the whole mediabag"
+     , "gets extracted. An error is thrown if `fp` is given but cannot be"
+     , "found in the mediabag."
+     ]
+  `since` makeVersion [3, 0]
diff --git a/src/Text/Pandoc/Lua/Module/Pandoc.hs b/src/Text/Pandoc/Lua/Module/Pandoc.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/Pandoc.hs
@@ -0,0 +1,317 @@
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE FlexibleContexts  #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications  #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.Pandoc
+   Copyright   : Copyright © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Pandoc module for lua.
+-}
+module Text.Pandoc.Lua.Module.Pandoc
+  ( documentedModule
+  ) where
+
+import Prelude hiding (read)
+import Control.Applicative ((<|>))
+import Control.Monad (forM_, when)
+import Control.Monad.Catch (catch, throwM)
+import Data.Data (Data, dataTypeConstrs, dataTypeOf, showConstr)
+import Data.Default (Default (..))
+import Data.Maybe (fromMaybe)
+import Data.Proxy (Proxy (Proxy))
+import HsLua
+import System.Exit (ExitCode (..))
+import Text.Pandoc.Definition
+import Text.Pandoc.Error (PandocError (..))
+import Text.Pandoc.Format (parseFlavoredFormat)
+import Text.Pandoc.Lua.Orphans ()
+import Text.Pandoc.Lua.Marshal.AST
+import Text.Pandoc.Lua.Marshal.Format (peekFlavoredFormat)
+import Text.Pandoc.Lua.Marshal.Filter (peekFilter)
+import Text.Pandoc.Lua.Marshal.ReaderOptions ( peekReaderOptions
+                                             , pushReaderOptions)
+import Text.Pandoc.Lua.Marshal.Sources (peekSources)
+import Text.Pandoc.Lua.Marshal.WriterOptions ( peekWriterOptions
+                                             , pushWriterOptions)
+import Text.Pandoc.Lua.Module.Utils (sha1)
+import Text.Pandoc.Lua.PandocLua (PandocLua (unPandocLua))
+import Text.Pandoc.Lua.Writer.Classic (runCustom)
+import Text.Pandoc.Options ( ReaderOptions (readerExtensions)
+                           , WriterOptions (writerExtensions) )
+import Text.Pandoc.Process (pipeProcess)
+import Text.Pandoc.Readers (Reader (..), getReader, readers)
+import Text.Pandoc.Sources (toSources)
+import Text.Pandoc.Writers (Writer (..), getWriter, writers)
+
+import qualified HsLua as Lua
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.ByteString.Lazy.Char8 as BSL
+import qualified Data.Set as Set
+import qualified Data.Text as T
+import qualified Text.Pandoc.UTF8 as UTF8
+
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc"
+  , moduleDescription = T.unlines
+    [ "Lua functions for pandoc scripts; includes constructors for"
+    , "document elements, functions to parse text in a given"
+    , "format, and functions to filter and modify a subtree."
+    ]
+  , moduleFields = readersField : writersField :
+                   stringConstants ++ [inlineField, blockField]
+  , moduleOperations = []
+  , moduleFunctions = mconcat
+      [ functions
+      , otherConstructors
+      , blockConstructors
+      , inlineConstructors
+      , metaValueConstructors
+      ]
+  }
+
+-- | Set of input formats accepted by @read@.
+readersField :: Field PandocError
+readersField = Field
+  { fieldName = "readers"
+  , fieldDescription = T.unlines
+    [ "Set of formats that pandoc can parse. All keys in this table can"
+    , "be used as the `format` value in `pandoc.read`."
+    ]
+  , fieldPushValue = pushSet pushText $
+                     Set.fromList (map fst (readers @PandocLua))
+  }
+
+-- | Set of input formats accepted by @write@.
+writersField :: Field PandocError
+writersField = Field
+  { fieldName = "writers"
+  , fieldDescription = T.unlines
+    [ "Set of formats that pandoc can generate. All keys in this table"
+    , "can be used as the `format` value in `pandoc.write`."
+    ]
+  , fieldPushValue = pushSet pushText $
+                     Set.fromList (map fst (writers @PandocLua))
+  }
+
+-- | Inline table field
+inlineField :: Field PandocError
+inlineField = Field
+  { fieldName = "Inline"
+  , fieldDescription = "Inline constructors, nested under 'constructors'."
+  -- the nesting happens for historical reasons and should probably be
+  -- changed.
+  , fieldPushValue = pushWithConstructorsSubtable inlineConstructors
+  }
+
+-- | @Block@ module field
+blockField :: Field PandocError
+blockField = Field
+  { fieldName = "Block"
+  , fieldDescription = "Inline constructors, nested under 'constructors'."
+  -- the nesting happens for historical reasons and should probably be
+  -- changed.
+  , fieldPushValue = pushWithConstructorsSubtable blockConstructors
+  }
+
+pushWithConstructorsSubtable :: [DocumentedFunction PandocError]
+                             -> LuaE PandocError ()
+pushWithConstructorsSubtable constructors = do
+  newtable -- Field table
+  newtable -- constructor table
+  pushName "constructor" *> pushvalue (nth 2) *> rawset (nth 4)
+  forM_ constructors $ \fn -> do
+    pushName (functionName fn)
+    pushDocumentedFunction fn
+    rawset (nth 3)
+  pop 1 -- pop constructor table
+
+otherConstructors :: [DocumentedFunction PandocError]
+otherConstructors =
+  [ mkPandoc
+  , mkMeta
+  , mkAttr
+  , mkAttributeList
+  , mkBlocks
+  , mkCitation
+  , mkCell
+  , mkRow
+  , mkTableHead
+  , mkTableFoot
+  , mkInlines
+  , mkListAttributes
+  , mkSimpleTable
+
+  , defun "ReaderOptions"
+    ### liftPure id
+    <#> parameter peekReaderOptions "ReaderOptions|table" "opts" "reader options"
+    =#> functionResult pushReaderOptions "ReaderOptions" "new object"
+    #? "Creates a new ReaderOptions value."
+
+  , defun "WriterOptions"
+    ### liftPure id
+    <#> parameter peekWriterOptions "WriterOptions|table" "opts"
+          "writer options"
+    =#> functionResult pushWriterOptions "WriterOptions" "new object"
+    #? "Creates a new WriterOptions value."
+  ]
+
+stringConstants :: [Field e]
+stringConstants =
+  let constrs :: forall a. Data a => Proxy a -> [String]
+      constrs _ = map showConstr . dataTypeConstrs . dataTypeOf @a $ undefined
+      nullaryConstructors = mconcat
+        [ constrs (Proxy @ListNumberStyle)
+        , constrs (Proxy @ListNumberDelim)
+        , constrs (Proxy @QuoteType)
+        , constrs (Proxy @MathType)
+        , constrs (Proxy @Alignment)
+        , constrs (Proxy @CitationMode)
+        ]
+      toField s = Field
+        { fieldName = T.pack s
+        , fieldDescription = T.pack s
+        , fieldPushValue = pushString s
+        }
+  in map toField nullaryConstructors
+
+functions :: [DocumentedFunction PandocError]
+functions =
+  [ defun "pipe"
+    ### (\command args input -> do
+            (ec, output) <- Lua.liftIO $ pipeProcess Nothing command args input
+                            `catch` (throwM . PandocIOError "pipe")
+            case ec of
+              ExitSuccess -> 1 <$ Lua.pushLazyByteString output
+              ExitFailure n -> do
+                pushPipeError (PipeError (T.pack command) n output)
+                Lua.error)
+    <#> parameter peekString "string" "command" "path to executable"
+    <#> parameter (peekList peekString) "{string,...}" "args"
+          "list of arguments"
+    <#> parameter peekLazyByteString "string" "input"
+          "input passed to process via stdin"
+    =?> "output string, or error triple"
+
+  , defun "read"
+    ### (\content mformatspec mreaderOptions -> unPandocLua $ do
+            flvrd <- maybe (parseFlavoredFormat "markdown") pure mformatspec
+            let readerOpts = fromMaybe def mreaderOptions
+            getReader flvrd >>= \case
+              (TextReader r, es)       ->
+                 r readerOpts{readerExtensions = es}
+                   (case content of
+                      Left bs       -> toSources $ UTF8.toText bs
+                      Right sources -> sources)
+              (ByteStringReader r, es) ->
+                 case content of
+                   Left bs -> r readerOpts{readerExtensions = es}
+                                (BSL.fromStrict bs)
+                   Right _ -> throwM $ PandocLuaError
+                              "Cannot use bytestring reader with Sources")
+    <#> parameter (\idx -> (Left  <$> peekByteString idx)
+                       <|> (Right <$> peekSources idx))
+          "string|Sources" "content" "text to parse"
+    <#> opt (parameter peekFlavoredFormat "string|table"
+                       "formatspec" "format and extensions")
+    <#> opt (parameter peekReaderOptions "ReaderOptions" "reader_options"
+             "reader options")
+    =#> functionResult pushPandoc "Pandoc" "result document"
+
+  , sha1
+
+  , defun "walk_block"
+    ### walkElement
+    <#> parameter peekBlockFuzzy "Block" "block" "element to traverse"
+    <#> parameter peekFilter "Filter" "lua_filter" "filter functions"
+    =#> functionResult pushBlock "Block" "modified Block"
+
+  , defun "walk_inline"
+    ### walkElement
+    <#> parameter peekInlineFuzzy "Inline" "inline" "element to traverse"
+    <#> parameter peekFilter "Filter" "lua_filter" "filter functions"
+    =#> functionResult pushInline "Inline" "modified Inline"
+
+  , defun "write"
+    ### (\doc mformatspec mwriterOpts -> unPandocLua $ do
+            flvrd <- maybe (parseFlavoredFormat "markdown") pure mformatspec
+            let writerOpts = fromMaybe def mwriterOpts
+            getWriter flvrd >>= \case
+              (TextWriter w, es)      -> Right <$>
+                w writerOpts{ writerExtensions = es } doc
+              (ByteStringWriter w, es) -> Left <$>
+                w writerOpts{ writerExtensions = es } doc)
+    <#> parameter peekPandoc "Pandoc" "doc" "document to convert"
+    <#> opt (parameter peekFlavoredFormat "string|table"
+                       "formatspec" "format and extensions")
+    <#> opt (parameter peekWriterOptions "WriterOptions" "writer_options"
+              "writer options")
+    =#> functionResult (either pushLazyByteString pushText) "string"
+          "result document"
+
+  , defun "write_classic"
+    ### (\doc mwopts -> runCustom (fromMaybe def mwopts) doc)
+    <#> parameter peekPandoc "Pandoc" "doc" "document to convert"
+    <#> opt (parameter peekWriterOptions "WriterOptions" "writer_options"
+              "writer options")
+    =#> functionResult pushText "string" "rendered document"
+    #? (T.unlines
+       [ "Runs a classic custom Lua writer, using the functions defined"
+       , "in the current environment."
+       ])
+  ]
+ where
+  walkElement x f =
+        walkInlineSplicing f x
+    >>= walkInlinesStraight f
+    >>= walkBlockSplicing f
+    >>= walkBlocksStraight f
+
+data PipeError = PipeError
+  { pipeErrorCommand :: T.Text
+  , pipeErrorCode :: Int
+  , pipeErrorOutput :: BL.ByteString
+  }
+
+peekPipeError :: LuaError e => StackIndex -> LuaE e PipeError
+peekPipeError idx =
+  PipeError
+  <$> (Lua.getfield idx "command"    *> Lua.peek (-1) <* Lua.pop 1)
+  <*> (Lua.getfield idx "error_code" *> Lua.peek (-1) <* Lua.pop 1)
+  <*> (Lua.getfield idx "output"     *> Lua.peek (-1) <* Lua.pop 1)
+
+pushPipeError :: LuaError e => Pusher e PipeError
+pushPipeError pipeErr = do
+  pushAsTable [ ("command"    , pushText . pipeErrorCommand)
+              , ("error_code" , pushIntegral . pipeErrorCode)
+              , ("output"     , pushLazyByteString . pipeErrorOutput)
+              ] pipeErr
+  pushPipeErrorMetaTable
+  Lua.setmetatable (nth 2)
+    where
+      pushPipeErrorMetaTable :: LuaError e => LuaE e ()
+      pushPipeErrorMetaTable = do
+        v <- Lua.newmetatable "pandoc pipe error"
+        when v $ do
+          pushName "__tostring"
+          pushHaskellFunction pipeErrorMessage
+          rawset (nth 3)
+
+      pipeErrorMessage :: LuaError e => LuaE e NumResults
+      pipeErrorMessage = do
+        (PipeError cmd errorCode output) <- peekPipeError (nthBottom 1)
+        pushByteString . BSL.toStrict . BSL.concat $
+          [ BSL.pack "Error running "
+          , BSL.pack $ T.unpack cmd
+          , BSL.pack " (error code "
+          , BSL.pack $ show errorCode
+          , BSL.pack "): "
+          , if output == mempty then BSL.pack "<no output>" else output
+          ]
+        return (NumResults 1)
diff --git a/src/Text/Pandoc/Lua/Module/Scaffolding.hs b/src/Text/Pandoc/Lua/Module/Scaffolding.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/Scaffolding.hs
@@ -0,0 +1,52 @@
+{-# LANGUAGE OverloadedStrings #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.Scaffolding
+   Copyright   : Copyright © 2022-2023 Albert Krewinkel, John MacFarlane
+   License     : GNU GPL, version 2 or above
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Scaffolding for custom Writers.
+-}
+module Text.Pandoc.Lua.Module.Scaffolding
+  ( documentedModule
+  ) where
+
+import HsLua
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.Writer.Scaffolding (pushWriterScaffolding)
+import qualified Data.Text as T
+
+-- | The "pandoc.template" module.
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc.scaffolding"
+  , moduleDescription = T.unlines
+    [ "Scaffolding for custom writers."
+    ]
+  , moduleFields = [writerScaffolding]
+  , moduleOperations = []
+  , moduleFunctions = []
+  }
+
+-- | Template module functions.
+writerScaffolding :: Field PandocError
+writerScaffolding = Field
+  { fieldName = "Writer"
+  , fieldDescription = T.unlines
+    [ "An object to be used as a `Writer` function; the construct handles"
+    , "most of the boilerplate, expecting only render functions for all"
+    , "AST elements"
+    ]
+  , fieldPushValue = do
+      pushWriterScaffolding
+      -- pretend that it's a submodule so we get better error messages
+      getfield registryindex loaded
+      pushvalue (nth 2)
+      setfield (nth 2) (submod "Writer")
+      -- same for fields "Block" and "Inline"
+      getfield (nth 2) "Inline" *> setfield (nth 2) (submod "Writer.Inline")
+      getfield (nth 2) "Block" *> setfield (nth 2) (submod "Writer.Block")
+
+      pop 1 -- remove "LOADED_TABLE"
+  }
+ where submod x = moduleName documentedModule <> "." <> x
diff --git a/src/Text/Pandoc/Lua/Module/Structure.hs b/src/Text/Pandoc/Lua/Module/Structure.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/Structure.hs
@@ -0,0 +1,192 @@
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE OverloadedStrings #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.Structure
+   Copyright   : © 2023 Albert Krewinkel
+   License     : GPL-2.0-or-later
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Command line helpers
+-}
+module Text.Pandoc.Lua.Module.Structure
+  ( documentedModule
+  ) where
+
+import Control.Applicative ((<|>), optional)
+import Data.Default (Default (..))
+import Data.Maybe (fromMaybe)
+import HsLua ( DocumentedFunction, LuaError, Module (..), Peeker
+             , (###), (<#>), (=#>), (#?)
+             , defun, functionResult, getfield, isnil, lastly, liftLua
+             , opt, liftPure, parameter , peekBool, peekIntegral
+             , peekFieldRaw, peekText, pop, pushIntegral, top )
+import Text.Pandoc.Chunks ( ChunkedDoc (..), PathTemplate (..)
+                          , tocToList, splitIntoChunks )
+import Text.Pandoc.Definition (Pandoc (..), Block)
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.PandocLua ()
+import Text.Pandoc.Lua.Marshal.AST ( peekBlocksFuzzy, peekPandoc
+                                   , pushBlock, pushBlocks )
+import Text.Pandoc.Lua.Marshal.Chunks
+import Text.Pandoc.Lua.Marshal.WriterOptions ( peekWriterOptions )
+import Text.Pandoc.Options (WriterOptions (writerTOCDepth,
+                                           writerNumberSections))
+import Text.Pandoc.Slides (getSlideLevel, prepSlides)
+import Text.Pandoc.Writers.Shared (toTableOfContents)
+import qualified Data.Text as T
+import qualified Text.Pandoc.Shared as Shared
+
+-- | Push the pandoc.structure module on the Lua stack.
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc.structure"
+  , moduleDescription =
+    "Access to the higher-level document structure, including" <>
+    "hierarchical sections and the table of contents."
+  , moduleFields = []
+  , moduleFunctions =
+      [ make_sections
+      , slide_level
+      , split_into_chunks
+      , table_of_contents
+      ]
+  , moduleOperations = []
+  }
+
+make_sections :: LuaError e => DocumentedFunction e
+make_sections = defun "make_sections"
+  ### (\blks mopts ->
+         let (numSects, baseLevel, mslideLevel) =
+               fromMaybe (defNumSec, Nothing, Nothing) mopts
+             blks' = case mslideLevel of
+                       Just l | l <= 0 -> prepSlides (getSlideLevel blks) blks
+                       Just sl -> prepSlides sl blks
+                       Nothing -> blks
+         in pure $ Shared.makeSections numSects baseLevel blks')
+  <#> parameter peekBodyBlocks "Blocks" "blocks" "document blocks to process"
+  <#> opt (parameter peekOpts "table" "opts" "options")
+  =#> functionResult pushBlocks "list of Blocks"
+        "processed blocks"
+  #? T.unlines
+     [ "Puts [Blocks] into a hierarchical structure: a list of sections"
+     , "(each a Div with class \"section\" and first element a Header)."
+     , ""
+     , "The optional `opts` argument can be a table; two settings are"
+     , "recognized: If `number_sections` is true, a `number` attribute"
+     , "containing the section number will be added to each `Header`. If"
+     , "`base_level` is an integer, then `Header` levels will be"
+     , "reorganized so that there are no gaps, with numbering levels"
+     , "shifted by the given value. Finally, an integer `slide_level`"
+     , "value triggers the creation of slides at that heading level."
+     , ""
+     , "Note that a [WriterOptions][] object can be passed as the opts"
+     , "table; this will set the `number_section` and `slide_level` values"
+     , "to those defined on the command line."
+     ]
+  where
+    defNumSec = False
+    peekOpts idx = do
+      numberSections <- fromMaybe defNumSec <$> do
+        liftLua $ getfield idx "number_sections"
+        optional (peekBool top `lastly` pop 1)
+      baseLevel <- do
+        liftLua $ getfield idx "base_level"
+        optional (peekIntegral top `lastly` pop 1)
+      slideLevel <- do
+        liftLua $ getfield idx "slide_level"
+        optional (peekIntegral top `lastly` pop 1)
+      return (numberSections, baseLevel, slideLevel)
+
+slide_level :: LuaError e => DocumentedFunction e
+slide_level = defun "slide_level"
+  ### liftPure getSlideLevel
+  <#> parameter peekBodyBlocks "Pandoc|Blocks" "blocks" "document body"
+  =#> functionResult pushIntegral "integer" "slide level"
+  #? T.unlines
+  [ "Find level of header that starts slides (defined as the least"
+  , "header level that occurs before a non-header/non-hrule in the"
+  , "blocks)."
+  ]
+
+-- | Split 'Pandoc' into 'Chunk's.
+split_into_chunks :: LuaError e => DocumentedFunction e
+split_into_chunks = defun "split_into_chunks"
+  ### (\doc mopts -> pure $
+          let defOpts = (defPathTmpl, defNumSects, Nothing, defLvl)
+              (pathTempl, numberSect, mbBaseLevel, chunkLevel) =
+                fromMaybe defOpts mopts
+          in splitIntoChunks pathTempl numberSect mbBaseLevel chunkLevel doc)
+  <#> parameter peekPandoc "Pandoc" "doc" "document to split"
+  <#> opt (parameter peekSplitOpts "table" "opts" optionsDescr)
+  =#> functionResult pushChunkedDoc "ChunkedDoc" ""
+  #? T.unlines
+     [ "Converts a `Pandoc` document into a `ChunkedDoc`." ]
+ where
+  defPathTmpl = PathTemplate "chunk-%n"
+  defNumSects = False
+  defLvl = 1
+  peekSplitOpts idx = (,,,)
+    <$> peekFieldRaw ((fmap PathTemplate . peekText) `orDefault` defPathTmpl)
+                     "path_template" idx
+    <*> peekFieldRaw (peekBool `orDefault` defNumSects) "number_sections" idx
+    <*> peekFieldRaw (optional . peekIntegral) "base_heading_level" idx
+    <*> peekFieldRaw (peekIntegral `orDefault` defLvl) "chunk_level" idx
+  orDefault p defaultValue idx' = liftLua (isnil idx') >>= \case
+    True  -> pure defaultValue
+    False -> p idx'
+  optionsDescr = T.unlines
+    [ "The following options are supported:"
+    , ""
+    , "    `path_template`"
+    , "    :   template used to generate the chunks' filepaths"
+    , "        `%n` will be replaced with the chunk number (padded with"
+    , "        leading 0s to 3 digits), `%s` with the section number of"
+    , "        the heading, `%h` with the (stringified) heading text,"
+    , "        `%i` with the section identifier. For example,"
+    , "        `\"section-%s-%i.html\"` might be resolved to"
+    , "        `\"section-1.2-introduction.html\"`."
+    , ""
+    , "        Default is `\"chunk-%n\"` (string)"
+    , ""
+    , "    `number_sections`"
+    , "    :   whether sections should be numbered; default is `false`"
+    , "        (boolean)"
+    , ""
+    , "    `chunk_level`"
+    , "    :   The heading level the document should be split into"
+    , "        chunks. The default is to split at the top-level, i.e.,"
+    , "        `1`. (integer)"
+    , ""
+    , "    `base_heading_level`"
+    , "    :   The base level to be used for numbering. Default is `nil`"
+    , "        (integer|nil)"
+    ]
+
+-- | Generate a table of contents.
+table_of_contents :: DocumentedFunction PandocError
+table_of_contents = defun "table_of_contents"
+  ### (\tocSource mwriterOpts -> pure $
+          let writerOpts = fromMaybe def mwriterOpts
+          in case tocSource of
+               Left blks  -> toTableOfContents writerOpts blks
+               Right tree -> tocToList (writerNumberSections writerOpts)
+                                       (writerTOCDepth writerOpts) tree
+      )
+  <#> parameter peekTocSource "Blocks|Pandoc|ChunkedDoc" "toc_source"
+        "list of command line arguments"
+  <#> opt (parameter peekWriterOptions "WriterOptions" "opts" "options")
+  =#> functionResult pushBlock "Block"
+        "Table of contents as a BulletList object"
+  #? T.unlines
+     [ "Generates a table of contents for the given object." ]
+ where
+  peekTocSource idx =
+    (Left <$> peekBodyBlocks idx) <|>
+    (Right . chunkedTOC <$> peekChunkedDoc idx)
+
+-- | Retrieves the body blocks of a 'Pandoc' object or from a list of
+-- blocks.
+peekBodyBlocks :: LuaError e => Peeker e [Block]
+peekBodyBlocks idx =
+  ((\(Pandoc _ blks) -> blks) <$> peekPandoc idx) <|>
+  peekBlocksFuzzy idx
diff --git a/src/Text/Pandoc/Lua/Module/System.hs b/src/Text/Pandoc/Lua/Module/System.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/System.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.System
+   Copyright   : © 2019-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Pandoc's system Lua module.
+-}
+module Text.Pandoc.Lua.Module.System
+  ( documentedModule
+  ) where
+
+import HsLua
+import HsLua.Module.System
+  (arch, env, getwd, ls, mkdir, os, rmdir, with_env, with_tmpdir, with_wd)
+
+-- | Push the pandoc.system module on the Lua stack.
+documentedModule :: LuaError e => Module e
+documentedModule = Module
+  { moduleName = "pandoc.system"
+  , moduleDescription = "system functions"
+  , moduleFields =
+      [ arch
+      , os
+      ]
+  , moduleFunctions =
+      [ setName "environment" env
+      , setName "get_working_directory" getwd
+      , setName "list_directory" ls
+      , setName "make_directory" mkdir
+      , setName "remove_directory" rmdir
+      , setName "with_environment" with_env
+      , setName "with_temporary_directory" with_tmpdir
+      , setName "with_working_directory" with_wd
+      ]
+  , moduleOperations = []
+  }
diff --git a/src/Text/Pandoc/Lua/Module/Template.hs b/src/Text/Pandoc/Lua/Module/Template.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/Template.hs
@@ -0,0 +1,103 @@
+{-# LANGUAGE OverloadedStrings #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.Template
+   Copyright   : Copyright © 2022-2023 Albert Krewinkel, John MacFarlane
+   License     : GNU GPL, version 2 or above
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+Lua module to handle pandoc templates.
+-}
+module Text.Pandoc.Lua.Module.Template
+  ( documentedModule
+  ) where
+
+import HsLua
+import HsLua.Module.DocLayout (peekDoc, pushDoc)
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.Marshal.AST (peekMeta, pushBlocks, pushInlines)
+import Text.Pandoc.Lua.Marshal.Context (peekContext, pushContext)
+import Text.Pandoc.Lua.Marshal.Template (peekTemplate, pushTemplate)
+import Text.Pandoc.Lua.PandocLua (PandocLua (unPandocLua), liftPandocLua)
+import Text.Pandoc.Writers.Shared (metaToContext')
+import Text.Pandoc.Templates
+  ( compileTemplate, getDefaultTemplate, renderTemplate
+  , runWithPartials, runWithDefaultPartials )
+
+import qualified Data.Text as T
+
+-- | The "pandoc.template" module.
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc.template"
+  , moduleDescription = T.unlines
+    [ "Lua functions for pandoc templates."
+    ]
+  , moduleFields = []
+  , moduleOperations = []
+  , moduleFunctions = functions
+  }
+
+-- | Template module functions.
+functions :: [DocumentedFunction PandocError]
+functions =
+  [ defun "apply"
+     ### liftPure2 renderTemplate
+     <#> parameter peekTemplate "Template" "template" "template to apply"
+     <#> parameter peekContext "table" "context" "variable values"
+     =#> functionResult pushDoc "Doc" "rendered template"
+     #? T.unlines
+     [ "Applies a context with variable assignments to a template,"
+     , "returning the rendered template. The `context` parameter must be a"
+     , "table with variable names as keys and [Doc], string, boolean, or"
+     , "table as values, where the table can be either be a list of the"
+     , "aforementioned types, or a nested context."
+     ]
+
+  , defun "compile"
+     ### (\template mfilepath -> unPandocLua $
+           case mfilepath of
+             Just fp -> runWithPartials (compileTemplate fp template)
+             Nothing -> runWithDefaultPartials
+                        (compileTemplate "templates/default" template))
+     <#> parameter peekText "string" "template" "template string"
+     <#> opt (stringParam "templ_path" "template path")
+     =#> functionResult (either failLua pushTemplate) "pandoc Template"
+           "compiled template"
+
+  , defun "default"
+     ### (\mformat -> unPandocLua $ do
+           let getFORMAT = liftPandocLua $ do
+                 getglobal "FORMAT"
+                 forcePeek $ peekText top `lastly` pop 1
+           format <- maybe getFORMAT pure mformat
+           getDefaultTemplate format)
+     <#> opt (textParam "writer"
+              "writer for which the template should be returned.")
+     =#> functionResult pushText "string"
+           "string representation of the writer's default template"
+
+  , defun "meta_to_context"
+     ### (\meta blockWriterIdx inlineWriterIdx -> unPandocLua $ do
+             let blockWriter blks = liftPandocLua $ do
+                   pushvalue blockWriterIdx
+                   pushBlocks blks
+                   callTrace 1 1
+                   forcePeek $ peekDoc top
+             let inlineWriter blks = liftPandocLua $ do
+                   pushvalue inlineWriterIdx
+                   pushInlines blks
+                   callTrace 1 1
+                   forcePeek $ peekDoc top
+             metaToContext' blockWriter inlineWriter meta)
+     <#> parameter peekMeta "Meta" "meta" "document metadata"
+     <#> parameter pure "function" "blocks_writer"
+           "converter from Blocks to Doc values"
+     <#> parameter pure "function" "inlines_writer"
+           "converter from Inlines to Doc values"
+     =#> functionResult pushContext "table" "template context"
+     #? T.unlines
+     [ "Creates template context from the document's [Meta]{#type-meta}"
+     , "data, using the given functions to convert [Blocks] and [Inlines]"
+     , "to [Doc] values."
+     ]
+  ]
diff --git a/src/Text/Pandoc/Lua/Module/Types.hs b/src/Text/Pandoc/Lua/Module/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/Types.hs
@@ -0,0 +1,42 @@
+{-# LANGUAGE OverloadedStrings #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.Types
+   Copyright   : © 2019-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Pandoc data type constructors.
+-}
+module Text.Pandoc.Lua.Module.Types
+  ( documentedModule
+  ) where
+
+import HsLua ( Module (..), (###), (<#>), (=#>)
+             , defun, functionResult, parameter)
+import HsLua.Module.Version (peekVersionFuzzy, pushVersion)
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Lua.PandocLua ()
+
+-- | Push the pandoc.types module on the Lua stack.
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc.types"
+  , moduleDescription =
+      "Constructors for types that are not part of the pandoc AST."
+  , moduleFields = []
+  , moduleFunctions =
+      [ defun "Version"
+        ### return
+        <#> parameter peekVersionFuzzy "string|integer|{integer,...}|Version"
+              "version_specifier"
+              (mconcat [ "either a version string like `'2.7.3'`, "
+                       , "a single integer like `2`, "
+                       , "list of integers like `{2,7,3}`, "
+                       , "or a Version object"
+                       ])
+        =#> functionResult pushVersion "Version" "A new Version object."
+      ]
+  , moduleOperations = []
+  }
diff --git a/src/Text/Pandoc/Lua/Module/Utils.hs b/src/Text/Pandoc/Lua/Module/Utils.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Module/Utils.hs
@@ -0,0 +1,239 @@
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications    #-}
+{- |
+   Module      : Text.Pandoc.Lua.Module.Utils
+   Copyright   : Copyright © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Utility module for Lua, exposing internal helper functions.
+-}
+module Text.Pandoc.Lua.Module.Utils
+  ( documentedModule
+  , sha1
+  ) where
+
+import Control.Applicative ((<|>))
+import Control.Monad ((<$!>))
+import Data.Data (showConstr, toConstr)
+import Data.Default (def)
+import Data.Maybe (fromMaybe)
+import Data.Version (Version)
+import HsLua as Lua
+import HsLua.Module.Version (peekVersionFuzzy, pushVersion)
+import Text.Pandoc.Citeproc (getReferences, processCitations)
+import Text.Pandoc.Definition
+import Text.Pandoc.Error (PandocError)
+import Text.Pandoc.Filter (applyJSONFilter)
+import Text.Pandoc.Lua.Marshal.AST
+import Text.Pandoc.Lua.Marshal.Reference
+import Text.Pandoc.Lua.PandocLua (PandocLua (unPandocLua))
+
+import qualified Data.Digest.Pure.SHA as SHA
+import qualified Data.ByteString.Lazy as BSL
+import qualified Data.Map as Map
+import qualified Data.Text as T
+import qualified Text.Pandoc.Builder as B
+import qualified Text.Pandoc.Shared as Shared
+import qualified Text.Pandoc.UTF8 as UTF8
+import qualified Text.Pandoc.Writers.Shared as Shared
+
+-- | Push the "pandoc.utils" module to the Lua stack.
+documentedModule :: Module PandocError
+documentedModule = Module
+  { moduleName = "pandoc.utils"
+  , moduleDescription = "pandoc utility functions"
+  , moduleFields = []
+  , moduleOperations = []
+  , moduleFunctions =
+    [ defun "blocks_to_inlines"
+      ### (\blks mSep -> do
+              let sep = maybe Shared.defaultBlocksSeparator B.fromList mSep
+              return $ B.toList (Shared.blocksToInlinesWithSep sep blks))
+      <#> parameter (peekList peekBlock) "list of blocks"
+            "blocks" ""
+      <#> opt (parameter (peekList peekInline) "Inlines" "sep" "")
+      =#> functionResult pushInlines "list of inlines" ""
+
+    , defun "citeproc"
+      ### unPandocLua . processCitations
+      <#> parameter peekPandoc "Pandoc" "doc" "document"
+      =#> functionResult pushPandoc "Pandoc" "processed document"
+      #?  T.unwords
+          [ "Process the citations in the file, replacing them with "
+          , "rendered citations and adding a bibliography. "
+          , "See the manual section on citation rendering for details."
+          ]
+
+    , defun "equals"
+      ### equal
+      <#> parameter pure "AST element" "elem1" ""
+      <#> parameter pure "AST element" "elem2" ""
+      =#> functionResult pushBool "boolean" "true iff elem1 == elem2"
+
+    , defun "make_sections"
+      ### liftPure3 Shared.makeSections
+      <#> parameter peekBool "boolean" "numbering" "add header numbers"
+      <#> parameter (\i -> (Nothing <$ peekNil i) <|> (Just <$!> peekIntegral i))
+                    "integer or nil" "baselevel" ""
+      <#> parameter (peekList peekBlock) "list of blocks"
+            "blocks" "document blocks to process"
+      =#> functionResult pushBlocks "list of Blocks"
+            "processes blocks"
+
+    , defun "normalize_date"
+      ### liftPure Shared.normalizeDate
+      <#> parameter peekText "string" "date" "the date string"
+      =#> functionResult (maybe pushnil pushText) "string or nil"
+            "normalized date, or nil if normalization failed."
+      #? T.unwords
+      [ "Parse a date and convert (if possible) to \"YYYY-MM-DD\" format. We"
+      , "limit years to the range 1601-9999 (ISO 8601 accepts greater than"
+      , "or equal to 1583, but MS Word only accepts dates starting 1601)."
+      , "Returns nil instead of a string if the conversion failed."
+      ]
+
+    , sha1
+
+    , defun "Version"
+      ### liftPure (id @Version)
+      <#> parameter peekVersionFuzzy
+            "version string, list of integers, or integer"
+            "v" "version description"
+      =#> functionResult pushVersion "Version" "new Version object"
+      #? "Creates a Version object."
+
+    , defun "references"
+      ### (unPandocLua . getReferences Nothing)
+      <#> parameter peekPandoc "Pandoc" "doc" "document"
+      =#> functionResult (pushPandocList pushReference) "table"
+            "lift of references"
+      #? mconcat
+         [ "Get references defined inline in the metadata and via an external "
+         , "bibliography.  Only references that are actually cited in the "
+         , "document (either with a genuine citation or with `nocite`) are "
+         , "returned. URL variables are converted to links."
+         ]
+
+    , defun "run_json_filter"
+      ### (\doc filterPath margs -> do
+              args <- case margs of
+                        Just xs -> return xs
+                        Nothing -> do
+                          Lua.getglobal "FORMAT"
+                          (forcePeek ((:[]) <$!> peekString top) <* pop 1)
+              applyJSONFilter def args filterPath doc
+          )
+      <#> parameter peekPandoc "Pandoc" "doc" "input document"
+      <#> parameter peekString "filepath" "filter_path" "path to filter"
+      <#> opt (parameter (peekList peekString) "list of strings"
+               "args" "arguments to pass to the filter")
+      =#> functionResult pushPandoc "Pandoc" "filtered document"
+
+    , defun "stringify"
+      ### stringify
+      <#> parameter pure "AST element" "elem" "some pandoc AST element"
+      =#> functionResult pushText "string" "stringified element"
+
+    , defun "from_simple_table"
+      ### from_simple_table
+      <#> parameter peekSimpleTable "SimpleTable" "simple_tbl" ""
+      =?> "Simple table"
+
+    , defun "to_roman_numeral"
+      ### liftPure Shared.toRomanNumeral
+      <#> parameter (peekIntegral @Int) "integer" "n" "number smaller than 4000"
+      =#> functionResult pushText "string" "roman numeral"
+      #? "Converts a number < 4000 to uppercase roman numeral."
+
+    , defun "to_simple_table"
+      ### to_simple_table
+      <#> parameter peekTable "Block" "tbl" "a table"
+      =#> functionResult pushSimpleTable "SimpleTable" "SimpleTable object"
+      #? "Converts a table into an old/simple table."
+
+    , defun "type"
+      ### (\idx -> getmetafield idx "__name" >>= \case
+              TypeString -> fromMaybe mempty <$> tostring top
+              _ -> ltype idx >>= typename)
+      <#> parameter pure "any" "object" ""
+      =#> functionResult pushByteString "string" "type of the given value"
+    #? ("Pandoc-friendly version of Lua's default `type` function, " <>
+        "returning the type of a value. If the argument has a " <>
+        "string-valued metafield `__name`, then it gives that string. " <>
+        "Otherwise it behaves just like the normal `type` function.")
+    ]
+  }
+
+-- | Documented Lua function to compute the hash of a string.
+sha1 :: DocumentedFunction e
+sha1 = defun "sha1"
+  ### liftPure (SHA.showDigest . SHA.sha1)
+  <#> parameter (fmap BSL.fromStrict . peekByteString) "string" "input" ""
+  =#> functionResult pushString "string" "hexadecimal hash value"
+  #? "Compute the hash of the given string value."
+
+
+-- | Convert pandoc structure to a string with formatting removed.
+-- Footnotes are skipped (since we don't want their contents in link
+-- labels).
+stringify :: LuaError e => StackIndex -> LuaE e T.Text
+stringify idx = forcePeek . retrieving "stringifyable element" $
+  choice
+  [ (fmap Shared.stringify . peekPandoc)
+  , (fmap Shared.stringify . peekInline)
+  , (fmap Shared.stringify . peekBlock)
+  , (fmap Shared.stringify . peekCitation)
+  , (fmap stringifyMetaValue . peekMetaValue)
+  , (fmap (const "") . peekAttr)
+  , (fmap (const "") . peekListAttributes)
+  ] idx
+ where
+  stringifyMetaValue :: MetaValue -> T.Text
+  stringifyMetaValue mv = case mv of
+    MetaBool b   -> T.toLower $ T.pack (show b)
+    MetaString s -> s
+    MetaList xs  -> mconcat $ map stringifyMetaValue xs
+    MetaMap m    -> mconcat $ map (stringifyMetaValue . snd) (Map.toList m)
+    _            -> Shared.stringify mv
+
+-- | Converts an old/simple table into a normal table block element.
+from_simple_table :: SimpleTable -> LuaE PandocError NumResults
+from_simple_table (SimpleTable capt aligns widths head' body) = do
+  pushBlock $ Table
+    nullAttr
+    (Caption Nothing [Plain capt | not (null capt)])
+    (zipWith (\a w -> (a, toColWidth w)) aligns widths)
+    (TableHead nullAttr [blockListToRow head' | not (null head') ])
+    [TableBody nullAttr 0 [] $ map blockListToRow body | not (null body)]
+    (TableFoot nullAttr [])
+  return (NumResults 1)
+  where
+    blockListToRow :: [[Block]] -> Row
+    blockListToRow = Row nullAttr . map (B.simpleCell . B.fromList)
+
+    toColWidth :: Double -> ColWidth
+    toColWidth 0 = ColWidthDefault
+    toColWidth w = ColWidth w
+
+-- | Converts a table into an old/simple table.
+to_simple_table :: Block -> LuaE PandocError SimpleTable
+to_simple_table = \case
+  Table _attr caption specs thead tbodies tfoot -> do
+    let (capt, aligns, widths, headers, rows) =
+          Shared.toLegacyTable caption specs thead tbodies tfoot
+    return $ SimpleTable capt aligns widths headers rows
+  blk -> Lua.failLua $ mconcat
+         [ "Expected Table, got ", showConstr (toConstr blk), "." ]
+
+peekTable :: LuaError e => Peeker e Block
+peekTable idx = peekBlock idx >>= \case
+  t@(Table {}) -> return t
+  b -> Lua.failPeek $ mconcat
+       [ "Expected Table, got "
+       , UTF8.fromString $ showConstr (toConstr b)
+       , "." ]
diff --git a/src/Text/Pandoc/Lua/Orphans.hs b/src/Text/Pandoc/Lua/Orphans.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Orphans.hs
@@ -0,0 +1,115 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{-# LANGUAGE FlexibleInstances    #-}
+{- |
+   Module      : Text.Pandoc.Lua.Orphans
+   Copyright   : © 2012-2023 John MacFarlane
+                 © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+   Stability   : alpha
+
+Orphan instances for Lua's Pushable and Peekable type classes.
+-}
+module Text.Pandoc.Lua.Orphans () where
+
+import Data.Version (Version)
+import HsLua
+import HsLua.Module.Version (peekVersionFuzzy)
+import Text.Pandoc.Definition
+import Text.Pandoc.Lua.Marshal.AST
+import Text.Pandoc.Lua.Marshal.CommonState ()
+import Text.Pandoc.Lua.Marshal.Context ()
+import Text.Pandoc.Lua.Marshal.PandocError()
+import Text.Pandoc.Lua.Marshal.ReaderOptions ()
+import Text.Pandoc.Lua.Marshal.Sources (pushSources)
+import Text.Pandoc.Sources (Sources)
+
+instance Pushable Pandoc where
+  push = pushPandoc
+
+instance Pushable Meta where
+  push = pushMeta
+
+instance Pushable MetaValue where
+  push = pushMetaValue
+
+instance Pushable Block where
+  push = pushBlock
+
+instance {-# OVERLAPPING #-} Pushable [Block] where
+  push = pushBlocks
+
+instance Pushable Alignment where
+  push = pushString . show
+
+instance Pushable CitationMode where
+  push = pushCitationMode
+
+instance Pushable Format where
+  push = pushFormat
+
+instance Pushable ListNumberDelim where
+  push = pushString . show
+
+instance Pushable ListNumberStyle where
+  push = pushString . show
+
+instance Pushable MathType where
+  push = pushMathType
+
+instance Pushable QuoteType where
+  push = pushQuoteType
+
+instance Pushable Cell where
+  push = pushCell
+
+instance Pushable Inline where
+  push = pushInline
+
+instance {-# OVERLAPPING #-} Pushable [Inline] where
+  push = pushInlines
+
+instance Pushable Citation where
+  push = pushCitation
+
+instance Pushable Row where
+  push = pushRow
+
+instance Pushable TableBody where
+  push = pushTableBody
+
+instance Pushable TableFoot where
+  push = pushTableFoot
+
+instance Pushable TableHead where
+  push = pushTableHead
+
+-- These instances exist only for testing. It's a hack to avoid making
+-- the marshalling modules public.
+instance Peekable Inline where
+  safepeek = peekInline
+
+instance Peekable Block where
+  safepeek = peekBlock
+
+instance Peekable Cell where
+  safepeek = peekCell
+
+instance Peekable Meta where
+  safepeek = peekMeta
+
+instance Peekable Pandoc where
+  safepeek = peekPandoc
+
+instance Peekable Row where
+  safepeek = peekRow
+
+instance Peekable Version where
+  safepeek = peekVersionFuzzy
+
+instance {-# OVERLAPPING #-} Peekable Attr where
+  safepeek = peekAttr
+
+instance Pushable Sources where
+  push = pushSources
diff --git a/src/Text/Pandoc/Lua/PandocLua.hs b/src/Text/Pandoc/Lua/PandocLua.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/PandocLua.hs
@@ -0,0 +1,100 @@
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+{- |
+   Module      : Text.Pandoc.Lua.PandocLua
+   Copyright   : © 2020-2023 Albert Krewinkel
+   License     : GPL-2.0-or-later
+   Maintainer  : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
+
+PandocMonad instance which allows execution of Lua operations and which
+uses Lua to handle state.
+-}
+module Text.Pandoc.Lua.PandocLua
+  ( PandocLua (..)
+  , liftPandocLua
+  ) where
+
+import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow)
+import Control.Monad.Except (MonadError (catchError, throwError))
+import Control.Monad.IO.Class (MonadIO)
+import HsLua as Lua
+import Text.Pandoc.Class (PandocMonad (..))
+import Text.Pandoc.Error (PandocError (..))
+import Text.Pandoc.Lua.Marshal.CommonState (peekCommonState, pushCommonState)
+import Text.Pandoc.Lua.Marshal.PandocError (peekPandocError, pushPandocError)
+
+import qualified Control.Monad.Catch as Catch
+import qualified Data.Text as T
+import qualified Text.Pandoc.Class.IO as IO
+
+-- | Type providing access to both, pandoc and Lua operations.
+newtype PandocLua a = PandocLua { unPandocLua :: LuaE PandocError a }
+  deriving
+    ( Applicative
+    , Functor
+    , Monad
+    , MonadCatch
+    , MonadIO
+    , MonadMask
+    , MonadThrow
+    )
+
+-- | Lift a @'Lua'@ operation into the @'PandocLua'@ type.
+liftPandocLua :: LuaE PandocError a -> PandocLua a
+liftPandocLua = PandocLua
+
+instance {-# OVERLAPPING #-} Exposable PandocError (PandocLua NumResults) where
+  partialApply _narg = liftLua . unPandocLua
+
+instance Pushable a => Exposable PandocError (PandocLua a) where
+  partialApply _narg x = 1 <$ (liftLua (unPandocLua x >>= Lua.push))
+
+instance MonadError PandocError PandocLua where
+  catchError = Catch.catch
+  throwError = Catch.throwM
+
+instance PandocMonad PandocLua where
+  lookupEnv = IO.lookupEnv
+  getCurrentTime = IO.getCurrentTime
+  getCurrentTimeZone = IO.getCurrentTimeZone
+  newStdGen = IO.newStdGen
+  newUniqueHash = IO.newUniqueHash
+
+  openURL = IO.openURL
+
+  readFileLazy = IO.readFileLazy
+  readFileStrict = IO.readFileStrict
+  readStdinStrict = IO.readStdinStrict
+
+  glob = IO.glob
+  fileExists = IO.fileExists
+  getDataFileName = IO.getDataFileName
+  getModificationTime = IO.getModificationTime
+
+  getCommonState = PandocLua $ do
+    Lua.getglobal "PANDOC_STATE"
+    forcePeek $ peekCommonState Lua.top
+  putCommonState cst = PandocLua $ do
+    pushCommonState cst
+    Lua.setglobal "PANDOC_STATE"
+
+  logOutput = IO.logOutput
+
+-- | Retrieve a @'PandocError'@ from the Lua stack.
+popPandocError :: LuaE PandocError PandocError
+popPandocError = do
+  errResult <- runPeek $ peekPandocError top `lastly` pop 1
+  case resultToEither errResult of
+    Right x  -> return x
+    Left err -> return $ PandocLuaError (T.pack err)
+
+-- | Conversions between Lua errors and 'PandocError' exceptions.
+instance LuaError PandocError where
+  popException = popPandocError
+  pushException = pushPandocError
+  luaException = PandocLuaError . T.pack
diff --git a/src/Text/Pandoc/Lua/Writer/Classic.hs b/src/Text/Pandoc/Lua/Writer/Classic.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Writer/Classic.hs
@@ -0,0 +1,255 @@
+{-# LANGUAGE CPP                 #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE FlexibleInstances   #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications    #-}
+{- |
+   Module      : Text.Pandoc.Lua.Writer.Classic
+   Copyright   : Copyright (C) 2012-2023 John MacFarlane
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : John MacFarlane <jgm@berkeley.edu>
+   Stability   : alpha
+   Portability : portable
+
+Conversion of Pandoc documents using a \"classic\" custom Lua writer.
+-}
+module Text.Pandoc.Lua.Writer.Classic
+  ( runCustom
+  ) where
+import Control.Applicative (optional)
+import Control.Arrow ((***))
+import Data.List (intersperse)
+import Data.Maybe (fromMaybe)
+import qualified Data.Text as T
+import Data.Text (Text, pack)
+import HsLua as Lua hiding (Operation (Div))
+#if !MIN_VERSION_hslua(2,2,0)
+import HsLua.Aeson (peekViaJSON)
+#endif
+import Text.DocLayout (literal, render)
+import Text.DocTemplates (Context)
+import Text.Pandoc.Definition
+import Text.Pandoc.Lua.Marshal.Attr (pushAttributeList)
+import Text.Pandoc.Lua.Orphans ()
+import Text.Pandoc.Options
+import Text.Pandoc.Templates (renderTemplate)
+import Text.Pandoc.Writers.Shared
+
+-- | List of key-value pairs that is pushed to Lua as AttributeList
+-- userdata.
+newtype AttributeList = AttributeList [(Text, Text)]
+instance Pushable AttributeList where
+  push (AttributeList kvs) = pushAttributeList kvs
+
+attrToMap :: Attr -> AttributeList
+attrToMap (id',classes,keyvals) = AttributeList
+    $ ("id", id')
+    : ("class", T.unwords classes)
+    : keyvals
+
+newtype Stringify a = Stringify a
+
+instance Pushable (Stringify Format) where
+  push (Stringify (Format f)) = Lua.push (T.toLower f)
+
+instance Pushable (Stringify [Inline]) where
+  push (Stringify ils) = Lua.push =<< inlineListToCustom ils
+
+instance Pushable (Stringify [Block]) where
+  push (Stringify blks) = Lua.push =<< blockListToCustom blks
+
+instance Pushable (Stringify MetaValue) where
+  push (Stringify (MetaMap m))       = Lua.push (fmap Stringify m)
+  push (Stringify (MetaList xs))     = Lua.push (map Stringify xs)
+  push (Stringify (MetaBool x))      = Lua.push x
+  push (Stringify (MetaString s))    = Lua.push s
+  push (Stringify (MetaInlines ils)) = Lua.push (Stringify ils)
+  push (Stringify (MetaBlocks bs))   = Lua.push (Stringify bs)
+
+instance Pushable (Stringify Citation) where
+  push (Stringify cit) = pushAsTable
+    [ ("citationId", push . citationId)
+    , ("citationPrefix",  push . Stringify . citationPrefix)
+    , ("citationSuffix",  push . Stringify . citationSuffix)
+    , ("citationMode",    push . citationMode)
+    , ("citationNoteNum", push . citationNoteNum)
+    , ("citationHash",    push . citationHash)
+    ]
+    cit
+
+-- | Key-value pair, pushed as a table with @a@ as the only key and @v@ as the
+-- associated value.
+newtype KeyValue a b = KeyValue (a, b)
+
+instance (Pushable a, Pushable b) => Pushable (KeyValue a b) where
+  push (KeyValue (k, v)) = do
+    Lua.newtable
+    Lua.push k
+    Lua.push v
+    Lua.rawset (Lua.nth 3)
+
+-- | Convert Pandoc to custom markup using a classic Lua writer.
+runCustom :: LuaError e
+          => WriterOptions
+          -> Pandoc
+          -> LuaE e Text
+runCustom opts doc@(Pandoc meta _) = do
+  (body, context) <- docToCustom opts doc
+  -- convert metavalues to a template context (variables)
+  metaContext <- metaToContext opts
+                   (fmap (literal . pack) . blockListToCustom)
+                   (fmap (literal . pack) . inlineListToCustom)
+                   meta
+  -- merge contexts from metadata and variables
+  let renderContext = context <> metaContext
+  return $ case writerTemplate opts of
+    Nothing  -> body
+    Just tpl -> render Nothing $
+                renderTemplate tpl $ setField "body" body renderContext
+
+-- | Converts a Pandoc value to custom markup using a classic Lua writer.
+docToCustom :: forall e. LuaError e
+            => WriterOptions -> Pandoc -> LuaE e (Text, Context Text)
+docToCustom opts (Pandoc (Meta metamap) blocks) = do
+  body <- blockListToCustom blocks
+  -- invoke doesn't work with multiple return values, so we have to call
+  -- `Doc` manually.
+  Lua.getglobal "Doc"                 -- function
+  push body                           -- argument 1
+  push (fmap Stringify  metamap)      -- argument 2
+  push (writerVariables opts)         -- argument 3
+  call 3 2
+  rendered  <- peek (nth 2)       -- first return value
+  context <- forcePeek . optional $ peekViaJSON top  -- snd return value
+  return (rendered, fromMaybe mempty context)
+
+
+-- | Convert Pandoc block element to Custom.
+blockToCustom :: forall e. LuaError e
+              => Block         -- ^ Block element
+              -> LuaE e String
+
+blockToCustom (Plain inlines) = invoke "Plain" (Stringify inlines)
+
+blockToCustom (Para [Image attr txt (src,tit)]) =
+  invoke "CaptionedImage" src tit (Stringify txt) (attrToMap attr)
+
+blockToCustom (Para inlines) = invoke "Para" (Stringify inlines)
+
+blockToCustom (LineBlock linesList) =
+  invoke "LineBlock" (map (Stringify) linesList)
+
+blockToCustom (RawBlock format str) =
+  invoke "RawBlock" (Stringify format) str
+
+blockToCustom HorizontalRule = invoke "HorizontalRule"
+
+blockToCustom (Header level attr inlines) =
+  invoke "Header" level (Stringify inlines) (attrToMap attr)
+
+blockToCustom (CodeBlock attr str) =
+  invoke "CodeBlock" str (attrToMap attr)
+
+blockToCustom (BlockQuote blocks) =
+  invoke "BlockQuote" (Stringify blocks)
+
+blockToCustom (Figure attr (Caption _ cbody) content) =
+  invoke "Figure"
+    (Stringify cbody)
+    (Stringify content)
+    (attrToMap attr)
+
+blockToCustom (Table _ blkCapt specs thead tbody tfoot) =
+  let (capt, aligns, widths, headers, rows) = toLegacyTable blkCapt specs thead tbody tfoot
+      aligns' = map show aligns
+      capt' = Stringify capt
+      headers' = map (Stringify) headers
+      rows' = map (map (Stringify)) rows
+  in invoke "Table" capt' aligns' widths headers' rows'
+
+blockToCustom (BulletList items) =
+  invoke "BulletList" (map (Stringify) items)
+
+blockToCustom (OrderedList (num,sty,delim) items) =
+  invoke "OrderedList" (map (Stringify) items) num (show sty) (show delim)
+
+blockToCustom (DefinitionList items) =
+  invoke "DefinitionList"
+               (map (KeyValue . (Stringify *** map (Stringify))) items)
+
+blockToCustom (Div attr items) =
+  invoke "Div" (Stringify items) (attrToMap attr)
+
+-- | Convert list of Pandoc block elements to Custom.
+blockListToCustom :: forall e. LuaError e
+                  => [Block]       -- ^ List of block elements
+                  -> LuaE e String
+blockListToCustom xs = do
+  blocksep <- invoke "Blocksep"
+  bs <- mapM blockToCustom xs
+  return $ mconcat $ intersperse blocksep bs
+
+-- | Convert list of Pandoc inline elements to Custom.
+inlineListToCustom :: forall e. LuaError e => [Inline] -> LuaE e String
+inlineListToCustom lst = do
+  xs <- mapM (inlineToCustom @e) lst
+  return $ mconcat xs
+
+-- | Convert Pandoc inline element to Custom.
+inlineToCustom :: forall e. LuaError e => Inline -> LuaE e String
+
+inlineToCustom (Str str) = invoke "Str" str
+
+inlineToCustom Space = invoke "Space"
+
+inlineToCustom SoftBreak = invoke "SoftBreak"
+
+inlineToCustom (Emph lst) = invoke "Emph" (Stringify lst)
+
+inlineToCustom (Underline lst) = invoke "Underline" (Stringify lst)
+
+inlineToCustom (Strong lst) = invoke "Strong" (Stringify lst)
+
+inlineToCustom (Strikeout lst) = invoke "Strikeout" (Stringify lst)
+
+inlineToCustom (Superscript lst) = invoke "Superscript" (Stringify lst)
+
+inlineToCustom (Subscript lst) = invoke "Subscript" (Stringify lst)
+
+inlineToCustom (SmallCaps lst) = invoke "SmallCaps" (Stringify lst)
+
+inlineToCustom (Quoted SingleQuote lst) =
+  invoke "SingleQuoted" (Stringify lst)
+
+inlineToCustom (Quoted DoubleQuote lst) =
+  invoke "DoubleQuoted" (Stringify lst)
+
+inlineToCustom (Cite cs lst) =
+  invoke "Cite" (Stringify lst) (map (Stringify) cs)
+
+inlineToCustom (Code attr str) =
+  invoke "Code" str (attrToMap attr)
+
+inlineToCustom (Math DisplayMath str) =
+  invoke "DisplayMath" str
+
+inlineToCustom (Math InlineMath str) =
+  invoke "InlineMath" str
+
+inlineToCustom (RawInline format str) =
+  invoke "RawInline" (Stringify format) str
+
+inlineToCustom LineBreak = invoke "LineBreak"
+
+inlineToCustom (Link attr txt (src,tit)) =
+  invoke "Link" (Stringify txt) src tit (attrToMap attr)
+
+inlineToCustom (Image attr alt (src,tit)) =
+  invoke "Image" (Stringify alt) src tit (attrToMap attr)
+
+inlineToCustom (Note contents) = invoke "Note" (Stringify contents)
+
+inlineToCustom (Span attr items) =
+  invoke "Span" (Stringify items) (attrToMap attr)
diff --git a/src/Text/Pandoc/Lua/Writer/Scaffolding.hs b/src/Text/Pandoc/Lua/Writer/Scaffolding.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Lua/Writer/Scaffolding.hs
@@ -0,0 +1,311 @@
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{- |
+   Module      : Text.Pandoc.Lua.Writer.Scaffolding
+   Copyright   : © 2022-2023 Albert Krewinkel
+   License     : GPL-2.0-or-later
+   Maintainer  : Albert Krewinkel <pandoc@tarleb.com>
+
+Conversion of Pandoc documents using a custom Lua writer.
+-}
+module Text.Pandoc.Lua.Writer.Scaffolding
+  ( pushWriterScaffolding
+  ) where
+
+import Control.Monad ((<$!>), void)
+import Data.ByteString (ByteString)
+import Data.Data (dataTypeConstrs, dataTypeOf, showConstr, toConstr)
+import Data.Default (def)
+import Data.List (intersperse)
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import Data.String (IsString (fromString))
+import HsLua
+import HsLua.Module.DocLayout (peekDoc, pushDoc)
+import Text.DocLayout (Doc, blankline, render)
+import Text.DocTemplates (Context)
+import Text.Pandoc.Definition
+import Text.Pandoc.Error (PandocError (..))
+import Text.Pandoc.Options (WriterOptions (..), WrapOption(..))
+import Text.Pandoc.Lua.PandocLua ()
+import Text.Pandoc.Lua.Marshal.AST
+import Text.Pandoc.Lua.Marshal.Context (peekContext)
+import Text.Pandoc.Lua.Marshal.WriterOptions ( peekWriterOptions
+                                             , pushWriterOptions)
+import Text.Pandoc.Templates (renderTemplate)
+import Text.Pandoc.Writers.Shared (metaToContext, setField)
+import qualified Data.Text as T
+import qualified Text.Pandoc.UTF8 as UTF8
+
+-- | Convert Pandoc to custom markup.
+pushWriterScaffolding :: LuaE PandocError NumResults
+pushWriterScaffolding = do
+  newtable
+    *> pushWriterMT *> setmetatable (nth 2)
+  writer <- toWriterTable top
+  addField "Blocks"  $ pushDocumentedFunction (blocksFn writer)
+  addField "Inlines" $ pushDocumentedFunction (inlinesFn writer)
+  addField "Block"   $ newtable *> pushBlockMT  writer *> setmetatable (nth 2)
+  addField "Inline"  $ newtable *> pushInlineMT writer *> setmetatable (nth 2)
+  addField "Pandoc"  $ pushDocumentedFunction $ lambda
+    ### (\(Pandoc _ blks) -> do
+            pushWriterTable writer
+            getfield' top "Blocks"
+            pushBlocks blks
+            callTrace 1 1
+            pure (NumResults 1))
+    <#> parameter peekPandoc "Pandoc" "doc" ""
+    =?> "rendered doc"
+  freeWriter writer
+  return 1
+ where
+  blocksFn w = lambda
+    ### (\blocks msep -> blockListToCustom w msep blocks)
+    <#> parameter peekBlocks "Blocks" "blocks" ""
+    <#> opt (parameter peekDocFuzzy "Doc" "sep" "")
+    =#> functionResult pushDoc "Doc" ""
+  inlinesFn w = lambda
+    ### inlineListToCustom w
+    <#> parameter peekInlines "Inlines" "inlines" ""
+    =#> functionResult pushDoc "Doc" ""
+  pushBlockMT writer = do
+    newtable
+    addField "__call" $ pushDocumentedFunction $ lambda
+      ### blockToCustom
+      <#> parameter peekWriter "table" "writer" ""
+      <#> parameter peekBlockFuzzy "Block" "block" ""
+      =#> functionResult pushDoc "Doc" "rendered blocks"
+    addField "__index" $
+      -- lookup missing fields in the main Writer table
+      pushWriterTable writer
+  pushInlineMT writer = do
+    newtable
+    addField "__call" $ pushDocumentedFunction $ lambda
+      ### inlineToCustom
+      <#> parameter peekWriter "table" "writer" ""
+      <#> parameter peekInlineFuzzy "Inline" "inline" ""
+      =#> functionResult pushDoc "Doc" "rendered inline"
+    addField "__index" $ do
+      -- lookup missing fields in the main Writer table
+      pushWriterTable writer
+
+pushWriterMT :: LuaE PandocError ()
+pushWriterMT = do
+  newtable
+  addField "__call" $ pushDocumentedFunction $ lambda
+    ### (\writer doc mopts -> runWriter writer doc mopts)
+    <#> parameter peekWriter "table" "writer" ""
+    <#> parameter peekPandoc "Pandoc" "doc" ""
+    <#> opt (parameter peekWriterOptions "WriterOptions" "opts" "")
+    =#> functionResult pushText "string" "rendered document"
+  addField "__index" . pushDocumentedFunction $ lambda
+    ### (\_writer key -> handleMissingField key)
+    <#> parameter pure "table"  "writer" ""
+    <#> parameter (liftLua . tostring') "string" "key" ""
+    =#> functionResult (const pushnil) "string" ""
+
+
+addField :: LuaError e => Name -> LuaE e a -> LuaE e ()
+addField name action = do
+  pushName name
+  action
+  rawset (nth 3)
+
+getfield' :: LuaError e => StackIndex -> Name -> LuaE e HsLua.Type
+getfield' idx name = do
+  aidx <- absindex idx
+  pushName name
+  rawget aidx >>= \case
+    TypeNil -> pop 1 *> getfield aidx name
+    ty      -> pure ty
+
+-- | A writer table is just an absolute stack index.
+newtype WriterTable = WriterTable Reference
+
+toWriterTable :: LuaError e => StackIndex -> LuaE e WriterTable
+toWriterTable idx = WriterTable <$!> do
+  pushvalue idx
+  ref registryindex
+
+peekWriter :: LuaError e => Peeker e WriterTable
+peekWriter = liftLua . toWriterTable
+
+pushWriterTable :: LuaError e => Pusher e WriterTable
+pushWriterTable (WriterTable wref) = void $ getref registryindex wref
+
+writerOptionsField :: Name
+writerOptionsField = "Pandoc Writer WriterOptions"
+
+freeWriter :: WriterTable -> LuaE e ()
+freeWriter (WriterTable wref) = unref registryindex wref
+
+pushOpts :: LuaE PandocError ()
+pushOpts = void $ getfield' registryindex writerOptionsField
+
+runWriter :: WriterTable -> Pandoc -> Maybe WriterOptions
+          -> LuaE PandocError Text
+runWriter writer doc@(Pandoc meta _blks) mopts = do
+  let opts = fromMaybe def mopts
+  pushWriterOptions opts *>
+    setfield registryindex writerOptionsField
+
+  (body, mcontext) <- runPeek (pandocToCustom writer doc) >>= force . \case
+    Failure msg contexts -> Failure (cleanupTrace msg) contexts
+    s -> s
+
+  -- convert metavalues to a template context (variables)
+  defaultContext <- metaToContext opts
+                    (blockListToCustom writer Nothing)
+                    (inlineListToCustom writer)
+                    meta
+  let context = setField "body" body
+              $ fromMaybe defaultContext mcontext
+
+  let colwidth = if writerWrapText opts == WrapAuto
+                    then Just $ writerColumns opts
+                    else Nothing
+
+  return $ render colwidth $
+    case writerTemplate opts of
+       Nothing  -> body
+       Just tpl -> renderTemplate tpl context
+
+-- | Keep exactly one traceback and clean it up. This wouldn't be
+-- necessary if the @pcallTrace@ function would do nothing whenever the
+-- error already included a trace, but that would require some bigger
+-- changes; removing the additional traces in this post-process step is
+-- much easier (for now).
+cleanupTrace :: ByteString -> ByteString
+cleanupTrace msg = UTF8.fromText . T.intercalate "\n" $
+  let tmsg = T.lines $ UTF8.toText msg
+      traceStart = (== "stack traceback:")
+  in case break traceStart tmsg of
+        (x, t:traces) -> (x <>) . (t:) $
+                         let (firstTrace, rest) = break traceStart traces
+                             isPeekContext = ("\twhile " `T.isPrefixOf`)
+                             isUnknownCFn = (== "\t[C]: in ?")
+                         in filter (not . isUnknownCFn) firstTrace <>
+                            filter isPeekContext rest
+        _ -> tmsg
+
+-- | Pushes the field in the writer table.
+getWriterField :: LuaError e
+               => WriterTable -> Name -> LuaE e HsLua.Type
+getWriterField writer name = do
+  pushWriterTable writer
+  getfield' top name <* remove (nth 2)
+
+-- | Looks up @Writer.subtable.field@; tries @Writer.field@ as a fallback if the
+-- subtable field is @nil@.
+getNestedWriterField :: LuaError e
+                     => WriterTable -> Name -> Name -> LuaE e HsLua.Type
+getNestedWriterField writer subtable field = do
+  pushWriterTable writer
+  getfield' top subtable >>= \case
+    TypeNil -> TypeNil <$ remove (nth 2) -- remove Writer table
+    _       -> getfield' top field
+               -- remove Writer and subtable
+               <* remove (nth 3) <* remove (nth 2)
+
+pandocToCustom :: WriterTable -> Pandoc
+               -> Peek PandocError (Doc Text, Maybe (Context Text))
+pandocToCustom writer doc = withContext "rendering Pandoc" $ do
+  callStatus <- liftLua $ do
+    getWriterField writer "Pandoc"
+    pushPandoc doc
+    pushOpts
+    pcallTrace 2 2
+  case callStatus of
+    OK -> ((,) <$> peekDocFuzzy (nth 2) <*> orNil peekContext top)
+          `lastly` pop 2
+    _  -> failPeek =<< liftLua (tostring' top)
+
+blockToCustom :: WriterTable -> Block -> LuaE PandocError (Doc Text)
+blockToCustom writer blk = forcePeek $ renderBlock writer blk
+
+renderBlock :: WriterTable -> Block -> Peek PandocError (Doc Text)
+renderBlock writer blk = do
+  let constrName = fromString . showConstr . toConstr $ blk
+  withContext ("rendering Block `" <> constrName <> "`") $
+    liftLua (getNestedWriterField writer "Block" constrName) >>= \case
+      TypeNil -> failPeek =<< typeMismatchMessage "function or Doc" top
+      _       -> callOrDoc (pushBlock blk)
+
+inlineToCustom :: WriterTable -> Inline -> LuaE PandocError (Doc Text)
+inlineToCustom writer inln = forcePeek $ renderInline writer inln
+
+renderInline :: WriterTable -> Inline -> Peek PandocError (Doc Text)
+renderInline writer inln = do
+  let constrName = fromString . showConstr . toConstr $ inln
+  withContext ("rendering Inline `" <> constrName <> "`") $ do
+    liftLua (getNestedWriterField writer "Inline" constrName) >>= \case
+      TypeNil -> failPeek =<< typeMismatchMessage "function or Doc" top
+      _       -> callOrDoc (pushInline inln)
+
+-- | If the value at the top of the stack can be called as a function,
+-- then push the element and writer options to the stack and call it;
+-- otherwise treat it as a plain Doc value
+callOrDoc :: LuaE PandocError ()
+          -> Peek PandocError (Doc Text)
+callOrDoc pushElement = do
+  liftLua (ltype top) >>= \case
+    TypeFunction -> peekCall
+    _            ->
+      liftLua (getmetafield top "__call") >>= \case
+        TypeNil -> peekDocFuzzy top
+        _       -> liftLua (pop 1) *> peekCall
+ where
+   peekCall :: Peek PandocError (Doc Text)
+   peekCall =
+     liftLua (pushElement *> pushOpts *> pcallTrace 2 1) >>= \case
+       OK -> peekDocFuzzy top
+       _  -> failPeek =<< liftLua (tostring' top)
+
+blockListToCustom :: WriterTable -> Maybe (Doc Text) -> [Block]
+                  -> LuaE PandocError (Doc Text)
+blockListToCustom writer msep blocks = forcePeek $
+  renderBlockList writer msep blocks
+
+inlineListToCustom :: WriterTable -> [Inline] -> LuaE PandocError (Doc Text)
+inlineListToCustom writer inlines = forcePeek $
+  renderInlineList writer inlines
+
+renderBlockList :: WriterTable -> Maybe (Doc Text) -> [Block]
+                -> Peek PandocError (Doc Text)
+renderBlockList writer msep blocks = withContext "rendering Blocks" $ do
+  let addSeps = intersperse $ fromMaybe blankline msep
+  mconcat . addSeps <$> mapM (renderBlock writer) blocks
+
+renderInlineList :: WriterTable -> [Inline] -> Peek PandocError (Doc Text)
+renderInlineList writer inlines = withContext "rendering Inlines" $ do
+  mconcat <$> mapM (renderInline writer) inlines
+
+orNil :: Peeker e a -> Peeker e (Maybe a)
+orNil p idx = liftLua (ltype idx) >>= \case
+  TypeNil  -> pure Nothing
+  TypeNone -> pure Nothing
+  _        -> Just <$> p idx
+
+peekDocFuzzy :: LuaError e => Peeker e (Doc Text)
+peekDocFuzzy idx = liftLua (ltype idx) >>= \case
+  TypeTable -> mconcat <$!> peekList peekDoc idx
+  _         -> peekDoc idx
+
+handleMissingField :: LuaError e => ByteString -> LuaE e ()
+handleMissingField key' =
+  let key = UTF8.toString key'
+      blockNames  = map (fromString . show) . dataTypeConstrs . dataTypeOf
+                      $ HorizontalRule
+      inlineNames = map (fromString . show) . dataTypeConstrs . dataTypeOf
+                      $ Space
+      mtypeName = case () of
+       _ | key `elem` blockNames  -> Just "Block"
+       _ | key `elem` inlineNames -> Just "Inline"
+       _                          -> Nothing
+  in case mtypeName of
+       Just typeName  -> failLua $
+                         "No render function for " <> typeName <> " value " <>
+                         "'" <> key <> "';\ndefine a function `Writer." <>
+                         typeName <> "." <> key <> "` that returns " <>
+                         "a string or Doc."
+       _ -> pure ()
diff --git a/test/Tests/Lua.hs b/test/Tests/Lua.hs
new file mode 100644
--- /dev/null
+++ b/test/Tests/Lua.hs
@@ -0,0 +1,246 @@
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications    #-}
+{- |
+   Module      : Tests.Lua
+   Copyright   : © 2017-2023 Albert Krewinkel
+   License     : GNU GPL, version 2 or above
+
+   Maintainer  : Albert Krewinkel <albert@zeitkraut.de>
+   Stability   : alpha
+   Portability : portable
+
+Unit and integration tests for pandoc's Lua subsystem.
+-}
+module Tests.Lua ( runLuaTest, tests ) where
+
+import HsLua as Lua hiding (Operation (Div), error)
+import System.FilePath ((</>))
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit ((@=?), Assertion, HasCallStack, assertEqual, testCase)
+import Text.Pandoc.Arbitrary ()
+import Text.Pandoc.Builder (bulletList, definitionList, displayMath, divWith,
+                            doc, doubleQuoted, emph, header, lineBlock,
+                            linebreak, math, orderedList, para, plain, rawBlock,
+                            singleQuoted, space, str, strong,
+                            HasMeta (setMeta))
+import Text.Pandoc.Class (runIOorExplode, setUserDataDir)
+import Text.Pandoc.Definition (Attr, Block (BlockQuote, Div, Para), Pandoc,
+                               Inline (Emph, Str), pandocTypesVersion)
+import Text.Pandoc.Error (PandocError (PandocLuaError))
+import Text.Pandoc.Lua (Global (..), applyFilter, runLua, setGlobals)
+import Text.Pandoc.Options (def)
+import Text.Pandoc.Version (pandocVersionText)
+
+import qualified Control.Monad.Catch as Catch
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+
+tests :: [TestTree]
+tests =
+  [ testCase "macro expansion via filter" $
+    assertFilterConversion "a '{{helloworld}}' string is expanded"
+      "strmacro.lua"
+      (doc . para $ str "{{helloworld}}")
+      (doc . para . emph $ str "Hello, World")
+
+  , testCase "convert all plains to paras" $
+    assertFilterConversion "plains become para"
+      "plain-to-para.lua"
+      (doc $ bulletList [plain (str "alfa"), plain (str "bravo")])
+      (doc $ bulletList [para (str "alfa"), para (str "bravo")])
+
+  , testCase "convert display math to inline math" $
+    assertFilterConversion "display math becomes inline math"
+      "math.lua"
+      (doc $ para (displayMath "5+5"))
+      (doc $ para (math "5+5"))
+
+  , testCase "make hello world document" $
+    assertFilterConversion "Document contains 'Hello, World!'"
+      "hello-world-doc.lua"
+      (doc . para $ str "Hey!" <> linebreak <> str "What's up?")
+      (doc . para $ str "Hello," <> space <> str "World!")
+
+  , testCase "implicit doc filter" $
+    assertFilterConversion "Document contains 'Hello, World!'"
+      "implicit-doc-filter.lua"
+      (doc . plain $ linebreak)
+      (doc . para $ str "Hello," <> space <> str "World!")
+
+  , testCase "parse raw markdown blocks" $
+    assertFilterConversion "raw markdown block is converted"
+      "markdown-reader.lua"
+      (doc $ rawBlock "markdown" "*charly* **delta**")
+      (doc . para $ emph "charly" <> space <> strong "delta")
+
+  , testCase "allow shorthand functions for quote types" $
+    assertFilterConversion "single quoted becomes double quoted string"
+      "single-to-double-quoted.lua"
+      (doc . para . singleQuoted $ str "simple")
+      (doc . para . doubleQuoted $ str "simple")
+
+  , testCase "Count inlines via metatable catch-all" $
+    assertFilterConversion "filtering with metatable catch-all failed"
+      "metatable-catch-all.lua"
+      (doc . para $ "four words, three spaces")
+      (doc . para $ str "7")
+
+  , testCase "Count blocks via Block-specific catch-all" $
+    assertFilterConversion "filtering with Block catch-all failed"
+      "block-count.lua"
+      (doc $ para "one" <> para "two")
+      (doc $ para "2")
+
+  , testCase "Smart constructors" $
+    assertFilterConversion "smart constructors returned a wrong result"
+      "smart-constructors.lua"
+      (doc $ para "")
+      (doc $ mconcat
+       [ bulletList [para "Hello", para "World"]
+       , definitionList [("foo", [para "placeholder"])]
+       , lineBlock ["Moin", "Welt"]
+       , orderedList [plain "one", plain "two"]
+       ])
+
+  , testCase "Convert header upper case" $
+    assertFilterConversion "converting header to upper case failed"
+      "uppercase-header.lua"
+      (doc $ header 1 "les états-unis" <> para "text")
+      (doc $ header 1 "LES ÉTATS-UNIS" <> para "text")
+
+  , testCase "Attribute lists are convenient to use" $
+    let kv_before = [("one", "1"), ("two", "2"), ("three", "3")]
+        kv_after  = [("one", "eins"), ("three", "3"), ("five", "5")]
+    in assertFilterConversion "Attr doesn't behave as expected"
+      "attr-test.lua"
+      (doc $ divWith ("", [], kv_before) (para "nil"))
+      (doc $ divWith ("", [], kv_after) (para "nil"))
+
+  , testCase "Filter list of inlines" $
+      assertFilterConversion "List of inlines"
+      "inlines-filter.lua"
+      (doc $ para ("Hello," <> linebreak <> "World! Wassup?"))
+      (doc $ para "Hello, World! Wassup?")
+
+  , testCase "Filter list of blocks" $
+      assertFilterConversion "List of blocks"
+      "blocks-filter.lua"
+      (doc $ para "one." <> para "two." <> para "three.")
+      (doc $ plain "3")
+
+  , testCase "Filter Meta" $
+    let setMetaBefore = setMeta "old" ("old" :: T.Text)
+                      . setMeta "bool" False
+        setMetaAfter  = setMeta "new" ("new" :: T.Text)
+                      . setMeta "bool" True
+    in assertFilterConversion "Meta filtering"
+      "meta.lua"
+      (setMetaBefore . doc $ mempty)
+      (setMetaAfter . doc $ mempty)
+
+  , testCase "Script filename is set" $
+    assertFilterConversion "unexpected script name"
+      "script-name.lua"
+      (doc $ para "ignored")
+      (doc $ para (str $ T.pack $ "lua" </> "script-name.lua"))
+
+  , testCase "Pandoc version is set" . runLuaTest $ do
+      Lua.getglobal "PANDOC_VERSION"
+      Lua.liftIO .
+        assertEqual "pandoc version is wrong" (TE.encodeUtf8 pandocVersionText)
+        =<< Lua.tostring' Lua.top
+
+  , testCase "Pandoc types version is set" . runLuaTest $ do
+      Lua.getglobal "PANDOC_API_VERSION"
+      Lua.liftIO . assertEqual "pandoc-types version is wrong" pandocTypesVersion
+        =<< Lua.peek Lua.top
+
+  , testCase "require file" $
+    assertFilterConversion "requiring file failed"
+      "require-file.lua"
+      (doc $ para "ignored")
+      (doc $ para (str . T.pack $ "lua" </> "require-file.lua"))
+
+  , testCase "Allow singleton inline in constructors" . runLuaTest $ do
+      Lua.liftIO . assertEqual "Not the expected Emph"
+        (Emph [Str "test"]) =<< do
+        Lua.OK <- Lua.dostring "return pandoc.Emph"
+        Lua.push @Inline (Str "test")
+        Lua.call 1 1
+        Lua.peek @Inline top
+      Lua.liftIO . assertEqual "Unexpected element"
+        (Para [Str "test"]) =<< do
+        Lua.getglobal' "pandoc.Para"
+        Lua.pushString "test"
+        Lua.call 1 1
+        Lua.peek @Block top
+      Lua.liftIO . assertEqual "Unexptected element"
+        (BlockQuote [Para [Str "foo"]]) =<< (
+        do
+          Lua.getglobal' "pandoc.BlockQuote"
+          Lua.push (Para [Str "foo"])
+          _ <- Lua.call 1 1
+          Lua.peek @Block Lua.top
+        )
+
+  , testCase "Elements with Attr have `attr` accessor" . runLuaTest $ do
+      Lua.push (Div ("hi", ["moin"], [])
+                [Para [Str "ignored"]])
+      Lua.getfield Lua.top "attr"
+      Lua.liftIO . assertEqual "no accessor" (("hi", ["moin"], []) :: Attr)
+        =<< Lua.peek @Attr Lua.top
+
+  , testCase "module `pandoc.system` is present" . runLuaTest $ do
+      Lua.getglobal' "pandoc.system"
+      ty <- Lua.ltype Lua.top
+      Lua.liftIO $ assertEqual "module should be a table" Lua.TypeTable ty
+
+  , testGroup "global modules"
+    [ testCase "module 'lpeg' is loaded into a global" . runLuaTest $ do
+        s <- Lua.dostring "assert(type(lpeg)=='table')"
+        Lua.liftIO $ Lua.OK @=? s
+
+    , testCase "module 're' is loaded into a global" . runLuaTest $ do
+        s <- Lua.dostring "assert(type(re)=='table')"
+        Lua.liftIO $ Lua.OK @=? s
+
+    , testCase "module 'lpeg' is available via `require`" . runLuaTest $ do
+        s <- Lua.dostring
+              "package.path = ''; package.cpath = ''; require 'lpeg'"
+        Lua.liftIO $ Lua.OK @=? s
+
+    , testCase "module 're' is available via `require`" . runLuaTest $ do
+        s <- Lua.dostring
+               "package.path = ''; package.cpath = ''; require 're'"
+        Lua.liftIO $ Lua.OK @=? s
+    ]
+
+  , testCase "informative error messages" . runLuaTest $ do
+      Lua.pushboolean True
+      -- Lua.newtable
+      eitherPandoc <- Catch.try (peek @Pandoc Lua.top)
+      case eitherPandoc of
+        Left (PandocLuaError msg) -> do
+          let expectedMsg = "Pandoc expected, got boolean\n"
+                <> "\twhile retrieving Pandoc"
+          Lua.liftIO $ assertEqual "unexpected error message" expectedMsg msg
+        Left e -> error ("Expected a Lua error, but got " <> show e)
+        Right _ -> error "Getting a Pandoc element from a bool should fail."
+  ]
+
+assertFilterConversion :: String -> FilePath -> Pandoc -> Pandoc -> Assertion
+assertFilterConversion msg filterPath docIn expectedDoc = do
+  actualDoc <- runIOorExplode $ do
+    setUserDataDir (Just "../data")
+    applyFilter def ["HTML"] ("lua" </> filterPath) docIn
+  assertEqual msg expectedDoc actualDoc
+
+runLuaTest :: HasCallStack => Lua.LuaE PandocError a -> IO a
+runLuaTest op = runIOorExplode $ do
+  res <- runLua $ do
+    setGlobals [ PANDOC_WRITER_OPTIONS def ]
+    op
+  case res of
+    Left e -> error (show e)
+    Right x -> return x
diff --git a/test/Tests/Lua/Module.hs b/test/Tests/Lua/Module.hs
new file mode 100644
--- /dev/null
+++ b/test/Tests/Lua/Module.hs
@@ -0,0 +1,45 @@
+{- |
+Module      : Tests.Lua.Module
+Copyright   : © 2019-2023 Albert Krewinkel
+License     : GNU GPL, version 2 or above
+
+Maintainer  : Albert Krewinkel <albert@zeitkraut.de>
+Stability   : alpha
+Portability : portable
+
+Lua module tests
+-}
+module Tests.Lua.Module (tests) where
+
+import System.FilePath ((</>))
+import Test.Tasty (TestName, TestTree)
+import Test.Tasty.Lua (testLuaFile)
+
+import Tests.Lua (runLuaTest)
+
+tests :: [TestTree]
+tests =
+  [ testPandocLua "pandoc"
+                  ("lua" </> "module" </> "pandoc.lua")
+  , testPandocLua "pandoc.List"
+                  ("lua" </> "module" </> "pandoc-list.lua")
+  , testPandocLua "pandoc.format"
+                  ("lua" </> "module" </> "pandoc-format.lua")
+  , testPandocLua "pandoc.mediabag"
+                  ("lua" </> "module" </> "pandoc-mediabag.lua")
+  , testPandocLua "pandoc.path"
+                  ("lua" </> "module" </> "pandoc-path.lua")
+  , testPandocLua "pandoc.structure"
+                  ("lua" </> "module" </> "pandoc-structure.lua")
+  , testPandocLua "pandoc.template"
+                  ("lua" </> "module" </> "pandoc-template.lua")
+  , testPandocLua "pandoc.types"
+                  ("lua" </> "module" </> "pandoc-types.lua")
+  , testPandocLua "pandoc.utils"
+                  ("lua" </> "module" </> "pandoc-utils.lua")
+  , testPandocLua "globals"
+                  ("lua" </> "module" </> "globals.lua")
+  ]
+
+testPandocLua :: TestName -> FilePath -> TestTree
+testPandocLua = testLuaFile runLuaTest
diff --git a/test/Tests/Lua/Reader.hs b/test/Tests/Lua/Reader.hs
new file mode 100644
--- /dev/null
+++ b/test/Tests/Lua/Reader.hs
@@ -0,0 +1,36 @@
+{-# LANGUAGE LambdaCase        #-}
+{- |
+Module      : Tests.Lua.Reader
+Copyright   : © 2022-2023 Albert Krewinkel
+License     : GPL-2.0-or-later
+Maintainer  : Albert Krewinkel <pandoc@tarleb.com>
+
+Tests for custom Lua readers.
+-}
+module Tests.Lua.Reader (tests) where
+
+import Control.Arrow ((>>>))
+import Data.Char (chr)
+import Data.Default (Default (def))
+import Text.Pandoc.Class (runIOorExplode)
+import Text.Pandoc.Lua (loadCustom)
+import Text.Pandoc.Readers (Reader (ByteStringReader))
+import Text.Pandoc.Scripting (customReader)
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit ((@?=), testCase)
+
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.Text as T
+import qualified Text.Pandoc.Builder as B
+
+tests :: [TestTree]
+tests =
+  [ testCase "read binary to code block" $ do
+    input <- BL.readFile "bytestring.bin"
+    doc <- runIOorExplode $
+      loadCustom "bytestring-reader.lua" >>= (customReader >>> \case
+        Just (ByteStringReader f) -> f def input
+        _                         -> error "Expected a bytestring reader")
+    let bytes = mconcat $ map (B.str . T.singleton . chr) [0..255]
+    doc @?= B.doc (B.plain bytes)
+  ]
diff --git a/test/Tests/Lua/Writer.hs b/test/Tests/Lua/Writer.hs
new file mode 100644
--- /dev/null
+++ b/test/Tests/Lua/Writer.hs
@@ -0,0 +1,94 @@
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE OverloadedStrings #-}
+{- |
+Module      : Tests.Lua.Writer
+Copyright   : © 2019-2023 Albert Krewinkel
+License     : GNU GPL, version 2 or above
+Maintainer  : Albert Krewinkel <albert@zeitkraut.de>
+
+Tests for custom Lua writers.
+-}
+module Tests.Lua.Writer (tests) where
+
+import Data.Default (Default (def))
+import Data.Maybe (fromMaybe)
+import Text.Pandoc.Class (runIOorExplode, readFileStrict)
+import Text.Pandoc.Extensions (Extension (..), extensionsFromList)
+import Text.Pandoc.Format (ExtensionsDiff (..), FlavoredFormat (..),
+                           applyExtensionsDiff)
+import Text.Pandoc.Lua (loadCustom)
+import Text.Pandoc.Options (WriterOptions (..))
+import Text.Pandoc.Readers (readNative)
+import Text.Pandoc.Scripting (CustomComponents (..))
+import Text.Pandoc.Writers (Writer (ByteStringWriter, TextWriter))
+import Test.Tasty (TestTree)
+import Test.Tasty.Golden (goldenVsString)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+import qualified Data.ByteString.Lazy as BL
+import qualified Text.Pandoc.Builder as B
+import qualified Text.Pandoc.UTF8 as UTF8
+
+tests :: [TestTree]
+tests =
+  [ goldenVsString "default testsuite"
+    "writer.custom"
+    (runIOorExplode $ do
+        source <- UTF8.toText <$> readFileStrict "testsuite.native"
+        doc <- readNative def source
+        txt <- customWriter <$> loadCustom "sample.lua" >>= \case
+          Just (TextWriter f) -> f def doc
+          _                   -> error "Expected a text writer"
+        pure $ BL.fromStrict (UTF8.fromText txt))
+
+  , goldenVsString "tables testsuite"
+    "tables.custom"
+    (runIOorExplode $ do
+        source <- UTF8.toText <$> readFileStrict "tables.native"
+        doc <- readNative def source
+        txt <- writeCustom "sample.lua" >>= \case
+          (TextWriter f, _, _) -> f def doc
+          _            -> error "Expected a text writer"
+        pure $ BL.fromStrict (UTF8.fromText txt))
+
+  , goldenVsString "bytestring writer"
+    "bytestring.bin"
+    (runIOorExplode $
+        writeCustom "bytestring.lua" >>= \case
+          (ByteStringWriter f, _, _) -> f def mempty
+          _                       -> error "Expected a bytestring writer")
+
+  , goldenVsString "template"
+    "writer-template.out.txt"
+    (runIOorExplode $ do
+        (_, _, template) <- writeCustom "writer-template.lua"
+        pure . BL.fromStrict . UTF8.fromText $ fromMaybe "" template)
+
+  , testCase "preset extensions" $ do
+      let format = FlavoredFormat "extensions.lua" mempty
+      result <- runIOorExplode $ writeCustom "extensions.lua" >>= \case
+          (TextWriter write, extsConf, _) -> do
+            exts <- applyExtensionsDiff extsConf format
+            write def{writerExtensions = exts} (B.doc mempty)
+          _                        -> error "Expected a text writer"
+      result @?= "smart extension is enabled;\ncitations extension is disabled\n"
+  , testCase "modified extensions" $ do
+      let ediff = ExtensionsDiff
+            { extsToEnable = extensionsFromList [Ext_citations]
+            , extsToDisable = mempty
+            }
+      let format = FlavoredFormat "extensions.lua" ediff
+      result <- runIOorExplode $ writeCustom "extensions.lua" >>= \case
+          (TextWriter write, extsConf, _) -> do
+            exts <- applyExtensionsDiff extsConf format
+            write def{writerExtensions = exts} (B.doc mempty)
+          _                        -> error "Expected a text writer"
+      result @?= "smart extension is enabled;\ncitations extension is enabled\n"
+  ]
+ where
+  writeCustom fp = do
+    components <- loadCustom fp
+    let exts = fromMaybe mempty (customExtensions components)
+    case customWriter components of
+      Nothing -> error "Expected a writer to be defined"
+      Just w  -> return (w, exts, customTemplate components)
diff --git a/test/bytestring-reader.lua b/test/bytestring-reader.lua
new file mode 100644
--- /dev/null
+++ b/test/bytestring-reader.lua
@@ -0,0 +1,7 @@
+function ByteStringReader (input, opts)
+  local chars = pandoc.List{}
+  for i = 1, #input do
+    chars:insert(utf8.char(input:byte(i,i)))
+  end
+  return pandoc.Pandoc(pandoc.Plain(pandoc.Str(table.concat(chars))))
+end
diff --git a/test/bytestring.bin b/test/bytestring.bin
new file mode 100644
Binary files /dev/null and b/test/bytestring.bin differ
diff --git a/test/bytestring.lua b/test/bytestring.lua
new file mode 100644
--- /dev/null
+++ b/test/bytestring.lua
@@ -0,0 +1,7 @@
+function ByteStringWriter (doc, opts)
+  local buffer = {}
+  for i=0, 255 do
+    table.insert(buffer, string.char(i))
+  end
+  return table.concat(buffer, '')
+end
diff --git a/test/extensions.lua b/test/extensions.lua
new file mode 100644
--- /dev/null
+++ b/test/extensions.lua
@@ -0,0 +1,12 @@
+function Writer (doc, opts)
+  local output = 'smart extension is %s;\ncitations extension is %s\n'
+  local status = function (ext)
+    return opts.extensions:includes(ext) and 'enabled' or 'disabled'
+  end
+  return output:format(status('smart'), status('citations'))
+end
+
+Extensions = {
+  smart = true,
+  citations = false,
+}
diff --git a/test/lua/attr-test.lua b/test/lua/attr-test.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/attr-test.lua
@@ -0,0 +1,6 @@
+function Div (div)
+  div.attributes.five = ("%d"):format(div.attributes.two + div.attributes.three)
+  div.attributes.two = nil
+  div.attributes.one = "eins"
+  return div
+end
diff --git a/test/lua/block-count.lua b/test/lua/block-count.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/block-count.lua
@@ -0,0 +1,11 @@
+local num_blocks = 0
+
+function Block(el)
+  num_blocks = num_blocks + 1
+end
+
+function Pandoc(blocks, meta)
+  return pandoc.Pandoc {
+    pandoc.Para{pandoc.Str(num_blocks)}
+  }
+end
diff --git a/test/lua/blocks-filter.lua b/test/lua/blocks-filter.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/blocks-filter.lua
@@ -0,0 +1,8 @@
+function Blocks (blks)
+  -- verify that this looks like a `pandoc.List`
+  if not blks.find or not blks.map or not blks.filter then
+    error("table doesn't seem to be an instance of pandoc.List")
+  end
+  -- return plain block containing the number of elements in the list
+  return {pandoc.Plain {pandoc.Str(tostring(#blks))}}
+end
diff --git a/test/lua/hello-world-doc.lua b/test/lua/hello-world-doc.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/hello-world-doc.lua
@@ -0,0 +1,10 @@
+return {
+  {
+    Pandoc = function(doc)
+      local meta = {}
+      local hello = { pandoc.Str "Hello,", pandoc.Space(), pandoc.Str "World!" }
+      local blocks = { pandoc.Para(hello) }
+      return pandoc.Pandoc(blocks, meta)
+    end
+  }
+}
diff --git a/test/lua/implicit-doc-filter.lua b/test/lua/implicit-doc-filter.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/implicit-doc-filter.lua
@@ -0,0 +1,6 @@
+function Pandoc (doc)
+  local meta = {}
+  local hello = { pandoc.Str "Hello,", pandoc.Space(), pandoc.Str "World!" }
+  local blocks = { pandoc.Para(hello) }
+  return pandoc.Pandoc(blocks, meta)
+end
diff --git a/test/lua/inlines-filter.lua b/test/lua/inlines-filter.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/inlines-filter.lua
@@ -0,0 +1,19 @@
+function isWorldAfterSpace (fst, snd)
+  return fst and fst.t == 'LineBreak'
+   and snd and snd.t == 'Str' and snd.text == 'World!'
+end
+
+function Inlines (inlns)
+  -- verify that this looks like a `pandoc.List`
+  if not inlns.find or not inlns.map or not inlns.filter then
+    error("table doesn't seem to be an instance of pandoc.List")
+  end
+
+  -- Remove spaces before string "World"
+  for i = #inlns-1,1,-1 do
+    if isWorldAfterSpace(inlns[i], inlns[i+1]) then
+      inlns[i] = pandoc.Space()
+    end
+  end
+  return inlns
+end
diff --git a/test/lua/markdown-reader.lua b/test/lua/markdown-reader.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/markdown-reader.lua
@@ -0,0 +1,12 @@
+return {
+  {
+    RawBlock = function (elem)
+      if elem.format == "markdown" then
+        local pd = pandoc.read(elem.text, "markdown")
+        return pd.blocks[1]
+      else
+        return elem
+      end
+    end,
+  }
+}
diff --git a/test/lua/math.lua b/test/lua/math.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/math.lua
@@ -0,0 +1,10 @@
+return {
+  {
+    Math = function (elem)
+      if elem.mathtype == "DisplayMath" then
+        elem.mathtype = "InlineMath"
+      end
+      return elem
+    end,
+  }
+}
diff --git a/test/lua/meta.lua b/test/lua/meta.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/meta.lua
@@ -0,0 +1,6 @@
+function Meta (meta)
+  meta.old = nil
+  meta.new = "new"
+  meta.bool = (meta.bool == false)
+  return meta
+end
diff --git a/test/lua/metatable-catch-all.lua b/test/lua/metatable-catch-all.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/metatable-catch-all.lua
@@ -0,0 +1,20 @@
+local num_inlines = 0
+
+function catch_all(el)
+  if el.tag and pandoc.Inline.constructor[el.tag] then
+    num_inlines = num_inlines + 1
+  end
+end
+
+function Pandoc(blocks, meta)
+  return pandoc.Pandoc {
+    pandoc.Para{pandoc.Str(num_inlines)}
+  }
+end
+
+return {
+  setmetatable(
+    {Pandoc = Pandoc},
+    {__index = function(_) return catch_all end}
+  )
+}
diff --git a/test/lua/module/globals.lua b/test/lua/module/globals.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/globals.lua
@@ -0,0 +1,108 @@
+local tasty = require 'tasty'
+
+local test = tasty.test_case
+local group = tasty.test_group
+local assert = tasty.assert
+
+-- These tests exist mainly to catch changes to the JSON representation of
+-- WriterOptions and its components. UPDATE THE DOCS if anything changes.
+return {
+  group 'PANDOC_WRITER_OPTIONS' {
+    test('cite_method', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.cite_method), 'string')
+    end),
+    test('columns', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.columns), 'number')
+    end),
+    test('dpi', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.dpi), 'number')
+    end),
+    test('email_obfuscation', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.email_obfuscation), 'string')
+    end),
+    test('split_level', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.split_level), 'number')
+    end),
+    test('epub_fonts', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.epub_fonts), 'table')
+    end),
+    test('epub_metadata', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.epub_metadata), 'nil')
+    end),
+    test('epub_subdirectory', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.epub_subdirectory), 'string')
+    end),
+    test('extensions', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.extensions), 'table')
+      for _, v in ipairs(PANDOC_WRITER_OPTIONS.extensions) do
+        assert.are_equal(type(v), 'string')
+      end
+    end),
+    test('highlight_style', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.highlight_style), 'table')
+    end),
+    test('html_math_method', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.html_math_method), 'string')
+    end),
+    test('html_q_tags', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.html_q_tags), 'boolean')
+    end),
+    test('identifier_prefix', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.identifier_prefix), 'string')
+    end),
+    test('incremental', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.incremental), 'boolean')
+    end),
+    test('listings', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.listings), 'boolean')
+    end),
+    test('number_offset', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.number_offset), 'table')
+      for _, v in ipairs(PANDOC_WRITER_OPTIONS.number_offset) do
+        assert.are_equal(type(v), 'number')
+      end
+    end),
+    test('number_sections', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.number_sections), 'boolean')
+    end),
+    test('prefer_ascii', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.prefer_ascii), 'boolean')
+    end),
+    test('reference_doc', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.reference_doc), 'nil')
+    end),
+    test('reference_links', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.reference_links), 'boolean')
+    end),
+    test('reference_location', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.reference_location), 'string')
+    end),
+    test('section_divs', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.section_divs), 'boolean')
+    end),
+    test('setext_headers', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.setext_headers), 'boolean')
+    end),
+    test('slide_level', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.slide_level), 'nil')
+    end),
+    test('tab_stop', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.tab_stop), 'number')
+    end),
+    test('table_of_contents', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.table_of_contents), 'boolean')
+    end),
+    test('toc_depth', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.toc_depth), 'number')
+    end),
+    test('top_level_division', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.top_level_division), 'string')
+    end),
+    test('variables', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.variables), 'table')
+    end),
+    test('wrap_text', function ()
+      assert.are_equal(type(PANDOC_WRITER_OPTIONS.wrap_text), 'string')
+    end),
+  }
+}
diff --git a/test/lua/module/pandoc-format.lua b/test/lua/module/pandoc-format.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc-format.lua
@@ -0,0 +1,50 @@
+local tasty = require 'tasty'
+
+local test = tasty.test_case
+local group = tasty.test_group
+local assert = tasty.assert
+
+local format = require 'pandoc.format'
+
+return {
+  group 'default_extensions' {
+    test('docx', function ()
+      local docx_default_exts = {
+        'auto_identifiers',
+      }
+      assert.are_same(format.default_extensions('docx'), docx_default_exts)
+    end),
+  },
+
+  group 'all_extensions' {
+    test('docx', function ()
+      local docx_default_exts = {
+        'ascii_identifiers',
+        'auto_identifiers',
+        'citations',
+        'east_asian_line_breaks',
+        'empty_paragraphs',
+        'gfm_auto_identifiers',
+        'native_numbering',
+        'styles',
+      }
+      assert.are_same(format.all_extensions('docx'), docx_default_exts)
+    end),
+  },
+
+  group 'extensions' {
+    test('org', function ()
+      local org_default_exts = {
+        ascii_identifiers = false,
+        auto_identifiers = true,
+        citations = true,
+        east_asian_line_breaks = false,
+        fancy_lists = false,
+        gfm_auto_identifiers = false,
+        smart = false,
+        task_lists = true,
+      }
+      assert.are_same(format.extensions 'org', org_default_exts)
+    end),
+  },
+}
diff --git a/test/lua/module/pandoc-list.lua b/test/lua/module/pandoc-list.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc-list.lua
@@ -0,0 +1,160 @@
+local tasty = require 'tasty'
+local List = require 'pandoc.List'
+
+local assert = tasty.assert
+local test = tasty.test_case
+local group = tasty.test_group
+
+return {
+  group 'List as function' {
+    test('equivalent to List:new', function (x)
+      local new = List:new {'ramen'}
+      local list = List {'ramen'}
+      assert.are_same(new, list)
+      assert.are_equal(getmetatable(new), getmetatable(list))
+    end)
+  },
+
+  group 'clone' {
+    test('changing the clone does not affect original', function ()
+      local orig = List:new {23, 42}
+      local copy = orig:clone()
+      copy[1] = 5
+      assert.are_same({23, 42}, orig)
+      assert.are_same({5, 42}, copy)
+    end),
+    test('result is a list', function ()
+      local orig = List:new {23, 42}
+      assert.are_equal(List, getmetatable(orig:clone()))
+    end),
+  },
+
+  group 'extend' {
+    test('extends list with other list', function ()
+      local primes = List:new {2, 3, 5, 7}
+      primes:extend {11, 13, 17}
+      assert.are_same({2, 3, 5, 7, 11, 13, 17}, primes)
+    end)
+  },
+
+  group 'filter' {
+    test('keep elements for which property is truthy', function ()
+      local is_small_prime = function (x)
+        return List.includes({2, 3, 5, 7}, x)
+      end
+      local numbers = List:new {4, 7, 2, 9, 5, 11}
+      assert.are_same({7, 2, 5}, numbers:filter(is_small_prime))
+    end),
+  },
+
+  group 'find' {
+    test('returns element and index if found', function ()
+      local list = List:new {5, 23, 71}
+      local elem, idx = list:find(71)
+      assert.are_same(71, elem)
+      assert.are_same(3, idx)
+    end),
+    test('respects start index', function ()
+      local list = List:new {19, 23, 29, 71}
+      assert.are_equal(23, list:find(23, 1))
+      assert.are_equal(23, list:find(23, 2))
+      assert.is_nil(list:find(23, 3))
+    end),
+    test('returns nil if element not found', function ()
+      assert.is_nil((List:new {18, 20, 22, 0, 24}):find('0'))
+    end),
+  },
+
+  group 'find_if' {
+    test('returns element and index if found', function ()
+      local perm_prime = List:new {2, 3, 5, 7, 11, 13, 17, 31, 37, 71}
+      local elem, idx = perm_prime:find_if(function (x) return x >= 10 end)
+      assert.are_same(11, elem)
+      assert.are_same(5, idx)
+    end),
+    test('returns nil if element not found', function ()
+      local is_null = function (n) return List.includes({23,35,46,59}, n) end
+      assert.is_nil((List:new {18, 20, 22, 24, 27}):find_if(is_null))
+    end),
+  },
+
+  group 'includes' {
+    test('finds elements in list', function ()
+      local lst = List:new {'one', 'two', 'three'}
+      assert.is_truthy(lst:includes('one'))
+      assert.is_truthy(lst:includes('two'))
+      assert.is_truthy(lst:includes('three'))
+      assert.is_falsy(lst:includes('four'))
+    end)
+  },
+
+  group 'insert' {
+    test('insert value at end of list.', function ()
+      local count_norsk = List {'en', 'to', 'tre'}
+      count_norsk:insert('fire')
+      assert.are_same({'en', 'to', 'tre', 'fire'}, count_norsk)
+    end),
+    test('insert value in the middle of list.', function ()
+      local count_norsk = List {'fem', 'syv'}
+      count_norsk:insert(2, 'seks')
+      assert.are_same({'fem', 'seks', 'syv'}, count_norsk)
+    end)
+  },
+
+  group 'map' {
+    test('applies function to elements', function ()
+      local primes = List:new {2, 3, 5, 7}
+      local squares = primes:map(function (x) return x^2 end)
+      assert.are_same({4, 9, 25, 49}, squares)
+    end),
+    test('leaves original list unchanged', function ()
+      local primes = List:new {2, 3, 5, 7}
+      local squares = primes:map(function (x) return x^2 end)
+      assert.are_same({2, 3, 5, 7}, primes)
+    end)
+  },
+
+  group 'new' {
+    test('make table usable as list', function ()
+      local test = List:new{1, 1, 2, 3, 5}
+      assert.are_same(
+        {1, 1, 4, 9, 25},
+        test:map(function (x) return x^2 end)
+      )
+    end),
+    test('return empty list if no argument is given', function ()
+       assert.are_same({}, List:new())
+    end),
+    test('metatable of result is pandoc.List', function ()
+      local test = List:new{5}
+      assert.are_equal(List, getmetatable(test))
+    end)
+  },
+
+  group 'remove' {
+    test('remove value at end of list.', function ()
+      local understand = List {'jeg', 'forstår', 'ikke'}
+      local norsk_not = understand:remove()
+      assert.are_same({'jeg', 'forstår'}, understand)
+      assert.are_equal('ikke', norsk_not)
+    end),
+    test('remove value at beginning of list.', function ()
+      local count_norsk = List {'en', 'to', 'tre'}
+      count_norsk:remove(1)
+      assert.are_same({'to', 'tre'}, count_norsk)
+    end)
+  },
+
+  group 'sort' {
+    test('sort numeric list', function ()
+      local numbers = List {71, 5, -1, 42, 23, 0, 1}
+      numbers:sort()
+      assert.are_same({-1, 0, 1, 5, 23, 42, 71}, numbers)
+    end),
+    test('reverse-sort numeric', function ()
+      local numbers = List {71, 5, -1, 42, 23, 0, 1}
+      numbers:sort(function (x, y) return x > y end)
+      assert.are_same({71, 42, 23, 5, 1, 0, -1}, numbers)
+    end)
+  },
+}
diff --git a/test/lua/module/pandoc-mediabag.lua b/test/lua/module/pandoc-mediabag.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc-mediabag.lua
@@ -0,0 +1,72 @@
+local tasty = require 'tasty'
+
+local test = tasty.test_case
+local group = tasty.test_group
+local assert = tasty.assert
+
+local mediabag = require 'pandoc.mediabag'
+
+return {
+  group 'insert' {
+    test('insert adds an item to the mediabag', function ()
+      local fp = "media/hello.txt"
+      local mt = "text/plain"
+      local contents = "Hello, World!"
+      assert.are_same(mediabag.list(), {})
+      mediabag.insert(fp, mt, contents)
+      assert.are_same(
+        mediabag.list(),
+        {{['path'] = fp, ['type'] = mt, ['length'] = 13}}
+      )
+      mediabag.empty() -- clean up
+    end),
+    test('is idempotent', function ()
+      local fp = "media/hello.txt"
+      local mt = "text/plain"
+      local contents = "Hello, World!"
+      mediabag.insert(fp, mt, contents)
+      mediabag.insert(fp, mt, contents)
+      assert.are_same(
+        mediabag.list(),
+        {{['path'] = fp, ['type'] = mt, ['length'] = 13}}
+      )
+      mediabag.empty() -- clean up
+    end),
+  },
+
+  group 'delete' {
+    test('removes an item', function ()
+      assert.are_same(mediabag.list(), {})
+      mediabag.insert('test.html', 'text/html', '<aside>Who cares?</aside>')
+      mediabag.insert('test.css', 'text/plain', 'aside { color: red; }')
+      assert.are_equal(#mediabag.list(), 2)
+      mediabag.delete('test.html')
+      assert.are_same(
+        mediabag.list(),
+        {{['path'] = 'test.css', ['type'] = 'text/plain', ['length'] = 21}}
+      )
+      mediabag.empty() -- clean up
+    end),
+  },
+
+  group 'items' {
+    test('iterates over all items', function ()
+      local input_items = {
+        ['test.html'] = {'text/html', '<aside>Really?</aside>'},
+        ['test.css'] = {'text/plain', 'aside { color: red; }'},
+        ['test.js'] = {'application/javascript', 'alert("HI MOM!")'}
+      }
+      -- fill mediabag
+      for name, v in pairs(input_items) do
+        mediabag.insert(name, v[1], v[2])
+      end
+
+      local seen_items = {}
+      for fp, mt, c in mediabag.items() do
+        seen_items[fp] = {mt, c}
+      end
+      assert.are_same(seen_items, input_items)
+      mediabag.empty() -- clean up
+    end)
+  }
+}
diff --git a/test/lua/module/pandoc-path.lua b/test/lua/module/pandoc-path.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc-path.lua
@@ -0,0 +1,44 @@
+local tasty = require 'tasty'
+local path = require 'pandoc.path'
+
+local assert = tasty.assert
+local test = tasty.test_case
+local group = tasty.test_group
+
+return {
+  group 'path separator' {
+    test('is string', function ()
+      assert.are_same(type(path.separator), 'string')
+    end),
+    test('is slash or backslash', function ()
+      assert.is_truthy(path.separator:match '^[/\\]$')
+    end),
+  },
+  group 'search path separator' {
+    test('is string', function ()
+      assert.are_same(type(path.search_path_separator), 'string')
+    end),
+    test('is colon or semicolon', function ()
+      assert.is_truthy(path.search_path_separator:match '^[:;]$')
+    end)
+  },
+  group 'module' {
+    test('check function existence', function ()
+      local functions = {
+        'directory',
+        'filename',
+        'is_absolute',
+        'is_relative',
+        'join',
+        'make_relative',
+        'normalize',
+        'split',
+        'split_extension',
+        'split_search_path',
+      }
+      for _, f in ipairs(functions) do
+        assert.are_equal(type(path[f]), 'function')
+      end
+    end)
+  }
+}
diff --git a/test/lua/module/pandoc-structure.lua b/test/lua/module/pandoc-structure.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc-structure.lua
@@ -0,0 +1,139 @@
+local tasty = require 'tasty'
+local structure = require 'pandoc.structure'
+local path = require 'pandoc.path'
+local system = require 'pandoc.system'
+
+local assert = tasty.assert
+local test = tasty.test_case
+local group = tasty.test_group
+
+return {
+  test('is table', function ()
+    assert.are_equal(type(structure), 'table')
+  end),
+
+  group 'make_sections' {
+    test('sanity check', function ()
+      local blks = {
+        pandoc.Header(1, {pandoc.Str 'First'}),
+        pandoc.Header(2, {pandoc.Str 'Second'}),
+        pandoc.Header(2, {pandoc.Str 'Third'}),
+      }
+      local opts = PANDOC_WRITER_OPTIONS
+      local hblks = structure.make_sections(blks, opts)
+      assert.are_equal('Div', hblks[1].t)
+      assert.are_equal('Header', hblks[1].content[1].t)
+    end),
+    test('respects number_sections', function ()
+      local blks = {
+        pandoc.Header(1, {pandoc.Str 'First'}),
+        pandoc.Para 'Vestibulum convallis, lorem a tempus semper.'
+      }
+      local hblks = structure.make_sections(blks, {number_sections = true})
+      assert.are_equal('Div', hblks[1].t)
+      assert.are_equal('Header', hblks[1].content[1].t)
+      assert.are_equal('1', hblks[1].content[1].attributes['number'])
+    end),
+    test('respects base_level', function ()
+      local blks = {
+        pandoc.Header(1, {pandoc.Str 'First'}),
+        pandoc.Para 'Curabitur lacinia pulvinar nibh.',
+        pandoc.Header(3, {pandoc.Str 'First'}), -- Skipping level 2
+      }
+      local opts = {
+        number_sections = true,
+        base_level = 1
+      }
+      local hblks = structure.make_sections(blks, opts)
+      assert.are_equal('Div', hblks[1].t)
+      assert.are_equal('Header', hblks[1].content[1].t)
+      assert.are_equal('1',   hblks[1].content[1].attributes['number'])
+      assert.are_equal('1.1', hblks[1].content[3].attributes['number'])
+    end)
+  },
+
+  group 'split_into_chunks' {
+    test('is function', function ()
+      assert.are_equal(type(structure.split_into_chunks), 'function')
+    end),
+    test('returns a chunked doc', function ()
+      assert.are_equal(
+        pandoc.utils.type(structure.split_into_chunks(pandoc.Pandoc{})),
+        'pandoc.ChunkedDoc'
+      )
+    end),
+  },
+
+  group 'table_of_contents' {
+    test('is function', function ()
+      assert.are_equal(type(structure.table_of_contents), 'function')
+    end),
+    test('returns a bullet list', function ()
+      assert.are_equal(
+        pandoc.utils.type(structure.table_of_contents{}),
+        'Block'
+      )
+      assert.are_equal(
+        structure.table_of_contents{}.t,
+        'BulletList'
+      )
+    end),
+    test('returns a toc for a list of blocks', function ()
+      local body = pandoc.Blocks{
+        pandoc.Header(1, 'First'),
+        pandoc.Para('A sentence placed below the first structure.'),
+        pandoc.Header(2, 'Subsection'),
+        pandoc.Para('Mauris ac felis vel velit tristique imperdiet.'),
+        pandoc.Header(1, 'Second'),
+        pandoc.Para('Integer placerat tristique nisl.')
+      }
+      assert.are_equal(
+        structure.table_of_contents(body),
+        pandoc.BulletList{
+          {pandoc.Plain('First'),
+           pandoc.BulletList{{pandoc.Plain 'Subsection'}}
+          },
+          {pandoc.Plain('Second')}
+        }
+      )
+    end),
+    test('returns a toc for a chunked doc', function ()
+      local doc = pandoc.Pandoc {
+        pandoc.Header(1, 'First', {id='first'}),
+        pandoc.Para('A sentence placed below the first structure.'),
+        pandoc.Header(2, 'Subsection', {id='subsection'}),
+        pandoc.Para('Mauris ac felis vel velit tristique imperdiet.'),
+        pandoc.Header(1, 'Second', {id='second'}),
+        pandoc.Para('Integer placerat tristique nisl.')
+      }
+      local chunked = structure.split_into_chunks(doc, {chunk_level = 2})
+      assert.are_equal(
+        structure.table_of_contents(chunked),
+        pandoc.BulletList{
+          {pandoc.Plain({pandoc.Link('First', 'chunk-001', '', {id='toc-first'})}),
+           pandoc.BulletList{{pandoc.Plain({pandoc.Link('Subsection', 'chunk-002', '', {id='toc-subsection'})})}}
+          },
+          {pandoc.Plain({pandoc.Link('Second', 'chunk-003', '', {id='toc-second'})})}
+        }
+      )
+    end),
+    test('respects toc-depth option', function ()
+      local doc = pandoc.Pandoc {
+        pandoc.Header(1, 'First', {id='first'}),
+        pandoc.Para('A sentence placed below the first structure.'),
+        pandoc.Header(2, 'Subsection', {id='subsection'}),
+        pandoc.Para('Mauris ac felis vel velit tristique imperdiet.'),
+        pandoc.Header(1, 'Second', {id='second'}),
+        pandoc.Para('Integer placerat tristique nisl.')
+      }
+      local chunked = structure.split_into_chunks(doc)
+      assert.are_equal(
+        structure.table_of_contents(chunked, {toc_depth = 1}),
+        pandoc.BulletList{
+          {pandoc.Plain({pandoc.Link('First', 'chunk-001', '', {id='toc-first'})})},
+          {pandoc.Plain({pandoc.Link('Second', 'chunk-002', '', {id='toc-second'})})}
+        }
+      )
+    end),
+  },
+}
diff --git a/test/lua/module/pandoc-template.lua b/test/lua/module/pandoc-template.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc-template.lua
@@ -0,0 +1,92 @@
+local tasty = require 'tasty'
+local template = require 'pandoc.template'
+
+local assert = tasty.assert
+local test = tasty.test_case
+local group = tasty.test_group
+
+return {
+  test('is table', function ()
+    assert.are_equal(type(template), 'table')
+  end),
+  group 'default' {
+    test('is function', function ()
+      assert.are_equal(type(template.default), 'function')
+    end),
+    test('returns a string for known format', function ()
+      assert.are_equal(
+        pandoc.utils.type(template.default 'json'),
+        'string'
+      )
+      assert.are_equal(
+        pandoc.utils.type(template.default 'markdown'),
+        'string'
+      )
+    end),
+    test('fails on unknown format', function ()
+      local success, msg = pcall(function ()
+          return pandoc.utils.type(template.default 'nosuchformat')
+      end)
+      assert.is_falsy(success)
+    end),
+  },
+  group 'compile' {
+    test('is function', function ()
+      assert.are_equal(type(template.compile), 'function')
+    end),
+    test('returns a Template', function ()
+      assert.are_equal(
+        pandoc.utils.type(template.compile('$title$')),
+        'pandoc Template'
+      )
+    end),
+    test('returns a Template', function ()
+      local templ_path = pandoc.path.join{'lua', 'module', 'default.test'}
+      assert.are_equal(
+        pandoc.utils.type(template.compile('${ partial() }', templ_path)),
+        'pandoc Template'
+      )
+    end),
+    test('fails if template has non-existing partial', function ()
+      assert.error_matches(
+        function () return template.compile('${ nosuchpartial() }') end,
+        'Could not find data file'
+      )
+    end),
+    test('works with default template that uses partials', function ()
+      local jats_template = template.default 'jats'
+      assert.are_equal(type(jats_template), 'string')
+      assert.are_equal(
+        pandoc.utils.type(template.compile(jats_template)),
+        'pandoc Template'
+      )
+    end),
+  },
+  group 'apply' {
+    test('is function', function ()
+      assert.are_equal(type(template.apply), 'function')
+    end),
+    test('returns a Doc value', function ()
+      local tmpl = template.compile('placeholder')
+      assert.are_equal(
+        pandoc.utils.type(template.apply(tmpl, {})),
+        'Doc'
+      )
+    end),
+    test('applies the given context', function ()
+      local tmpl = template.compile('song: $title$')
+      local context = {title = 'Along Comes Mary'}
+      assert.are_equal(
+        template.apply(tmpl, context):render(),
+        'song: Along Comes Mary'
+      )
+    end),
+    test('accepts string as template', function ()
+      local context = {number = '2'}
+      assert.are_equal(
+        template.apply('Song $number$', context):render(),
+        'Song 2'
+      )
+    end)
+  },
+}
diff --git a/test/lua/module/pandoc-types.lua b/test/lua/module/pandoc-types.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc-types.lua
@@ -0,0 +1,86 @@
+local tasty = require 'tasty'
+local types = require 'pandoc.types'
+local Version = types.Version
+
+local assert = tasty.assert
+local test = tasty.test_case
+local group = tasty.test_group
+
+return {
+  group 'Version' {
+
+    group 'constructor' {
+      test('has type `userdata`', function ()
+        assert.are_same(type(Version {2}), 'userdata')
+      end),
+      test('accepts list of integers', function ()
+        assert.are_same(type(Version {2, 7, 3}), 'userdata')
+      end),
+      test('accepts a single integer', function ()
+        assert.are_same(Version(5), Version {5})
+      end),
+      test('accepts version as string', function ()
+        assert.are_same(
+          Version '4.45.1',
+          Version {4, 45, 1}
+        )
+      end),
+      test('non-version string is rejected', function ()
+        local success, msg = pcall(function () Version '11friends' end)
+        assert.is_falsy(success)
+        assert.is_truthy(tostring(msg):match('11friends'))
+      end)
+    },
+
+    group 'comparison' {
+      test('smaller (equal) than', function ()
+        assert.is_truthy(Version {2, 58, 3} < Version {2, 58, 4})
+        assert.is_falsy(Version {2, 60, 1} < Version {2, 59, 2})
+        assert.is_truthy(Version {0, 14, 3} < Version {0, 14, 3, 1})
+        assert.is_truthy(Version {3, 58, 3} <= Version {4})
+        assert.is_truthy(Version {0, 14, 3} <= Version {0, 14, 3, 1})
+      end),
+      test('larger (equal) than', function ()
+        assert.is_truthy(Version{2,58,3} > Version {2, 57, 4})
+        assert.is_truthy(Version{2,58,3} > Version {2, 58, 2})
+        assert.is_truthy(Version {0, 8} >= Version {0, 8})
+        assert.is_falsy(Version {0, 8} >= Version {0, 8, 2})
+      end),
+      test('equality', function ()
+        assert.is_truthy(Version '8.8', Version {8, 8})
+      end),
+      test('second argument can be a version string', function ()
+        assert.is_truthy(Version '8' < '9.1')
+        assert.is_falsy(Version '8.8' < '8.7')
+      end),
+    },
+
+    group 'conversion to string' {
+      test('converting from and to string is a noop', function ()
+        local version_string = '1.19.4'
+        assert.are_equal(tostring(Version(version_string)), version_string)
+      end)
+    },
+
+    group 'convenience functions' {
+      test('throws error if version is too old', function ()
+        local actual = Version {2, 8}
+        local expected = Version {2, 9}
+        assert.error_matches(
+          function () actual:must_be_at_least(expected) end,
+          'expected version 2.9 or newer, got 2.8'
+        )
+      end),
+      test('does nothing if expected version is older than actual', function ()
+        local actual = Version '2.9'
+        local expected = Version '2.8'
+        actual:must_be_at_least(expected)
+      end),
+      test('does nothing if expected version equals to actual', function ()
+        local actual = Version '2.8'
+        local expected = Version '2.8'
+        actual:must_be_at_least(expected)
+      end)
+    }
+  }
+}
diff --git a/test/lua/module/pandoc-utils.lua b/test/lua/module/pandoc-utils.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc-utils.lua
@@ -0,0 +1,333 @@
+local tasty = require 'tasty'
+local utils = require 'pandoc.utils'
+
+local assert = tasty.assert
+local test = tasty.test_case
+local group = tasty.test_group
+
+return {
+  group 'blocks_to_inlines' {
+    test('default separator', function ()
+      local blocks = {
+        pandoc.Para { pandoc.Str 'Paragraph1' },
+        pandoc.Para { pandoc.Emph { pandoc.Str 'Paragraph2' } }
+      }
+      local expected = {
+        pandoc.Str 'Paragraph1',
+        pandoc.LineBreak(),
+        pandoc.Emph { pandoc.Str 'Paragraph2' }
+      }
+      assert.are_same(
+        expected,
+        utils.blocks_to_inlines(blocks)
+      )
+    end),
+    test('custom separator', function ()
+      local blocks = {
+        pandoc.Para{ pandoc.Str 'Paragraph1' },
+        pandoc.Para{ pandoc.Emph 'Paragraph2' }
+      }
+      local expected = {
+        pandoc.Str 'Paragraph1',
+        pandoc.LineBreak(),
+        pandoc.Emph { pandoc.Str 'Paragraph2' }
+      }
+      assert.are_same(
+        expected,
+        utils.blocks_to_inlines(blocks, { pandoc.LineBreak() })
+      )
+    end)
+  },
+
+  group 'equals' {
+    test('compares Pandoc elements', function ()
+      assert.is_truthy(
+        utils.equals(pandoc.Pandoc{'foo'}, pandoc.Pandoc{'foo'})
+      )
+    end),
+    test('compares Block elements', function ()
+      assert.is_truthy(
+        utils.equals(pandoc.Plain{'foo'}, pandoc.Plain{'foo'})
+      )
+      assert.is_falsy(
+        utils.equals(pandoc.Para{'foo'}, pandoc.Plain{'foo'})
+      )
+    end),
+    test('compares Inline elements', function ()
+      assert.is_truthy(
+        utils.equals(pandoc.Emph{'foo'}, pandoc.Emph{'foo'})
+      )
+      assert.is_falsy(
+        utils.equals(pandoc.Emph{'foo'}, pandoc.Strong{'foo'})
+      )
+    end),
+    test('compares Inline with Block elements', function ()
+      assert.is_falsy(
+        utils.equals(pandoc.Emph{'foo'}, pandoc.Plain{'foo'})
+      )
+      assert.is_falsy(
+        utils.equals(pandoc.Para{'foo'}, pandoc.Strong{'foo'})
+      )
+    end),
+    test('compares Pandoc with Block elements', function ()
+      assert.is_falsy(
+        utils.equals(pandoc.Pandoc{'foo'}, pandoc.Plain{'foo'})
+      )
+      assert.is_falsy(
+        utils.equals(pandoc.Para{'foo'}, pandoc.Pandoc{'foo'})
+      )
+    end),
+  },
+
+  group 'make_sections' {
+    test('sanity check', function ()
+      local blks = {
+        pandoc.Header(1, {pandoc.Str 'First'}),
+        pandoc.Header(2, {pandoc.Str 'Second'}),
+        pandoc.Header(2, {pandoc.Str 'Third'}),
+      }
+      local hblks = utils.make_sections(true, 1, blks)
+      assert.are_equal('Div', hblks[1].t)
+      assert.are_equal('Header', hblks[1].content[1].t)
+      assert.are_equal('1', hblks[1].content[1].attributes['number'])
+    end)
+  },
+
+  group 'normalize_date' {
+    test('09 Nov 1989', function ()
+      assert.are_equal('1989-11-09', utils.normalize_date '09 Nov 1989')
+    end),
+    test('12/31/2017', function ()
+      assert.are_equal('2017-12-31', utils.normalize_date '12/31/2017')
+    end),
+  },
+
+  group 'references' {
+    test('gets references from doc', function ()
+      local ref = {
+        ['author'] = {
+          {given = 'Max', family = 'Mustermann'}
+        },
+        ['container-title'] = pandoc.Inlines('JOSS'),
+        ['id']     = 'test',
+        ['issued'] = {['date-parts'] = {{2021}}},
+        ['title']  = pandoc.Inlines{
+          pandoc.Quoted('DoubleQuote', 'Interesting'),
+          pandoc.Space(),
+          'work'
+        },
+        ['type']   = 'article-journal',
+      }
+      local nocite = pandoc.Cite(
+        '@test',
+        {pandoc.Citation('test', 'NormalCitation')}
+      )
+      local doc = pandoc.Pandoc({}, {nocite = nocite, references = {ref}})
+      assert.are_same({ref}, pandoc.utils.references(doc))
+    end)
+  },
+
+  group 'sha1' {
+    test('hashing', function ()
+      local ref_hash = '0a0a9f2a6772942557ab5355d76af442f8f65e01'
+      assert.are_equal(ref_hash, utils.sha1 'Hello, World!')
+    end)
+  },
+
+  group 'stringify' {
+    test('Inline', function ()
+      local inline = pandoc.Emph{
+        pandoc.Str 'Cogito',
+        pandoc.Space(),
+        pandoc.Str 'ergo',
+        pandoc.Space(),
+        pandoc.Str 'sum.',
+      }
+      assert.are_equal('Cogito ergo sum.', utils.stringify(inline))
+    end),
+    test('Block', function ()
+      local block = pandoc.Para{
+        pandoc.Str 'Make',
+        pandoc.Space(),
+        pandoc.Str 'it',
+        pandoc.Space(),
+        pandoc.Str 'so.',
+      }
+      assert.are_equal('Make it so.', utils.stringify(block))
+    end),
+    test('boolean', function ()
+      assert.are_equal('true', utils.stringify(true))
+      assert.are_equal('false', utils.stringify(false))
+    end),
+    test('number', function ()
+      assert.are_equal('5', utils.stringify(5))
+      assert.are_equal('23.23', utils.stringify(23.23))
+    end),
+    test('Attr', function ()
+      local attr = pandoc.Attr('foo', {'bar'}, {a = 'b'})
+      assert.are_equal('', utils.stringify(attr))
+    end),
+    test('List', function ()
+      local list = pandoc.List{pandoc.Str 'a', pandoc.Blocks('b')}
+      assert.are_equal('ab', utils.stringify(list))
+    end),
+    test('Blocks', function ()
+      local blocks = pandoc.Blocks{pandoc.Para 'a', pandoc.Header(1, 'b')}
+      assert.are_equal('ab', utils.stringify(blocks))
+    end),
+    test('Inlines', function ()
+      local inlines = pandoc.Inlines{pandoc.Str 'a', pandoc.Subscript('b')}
+      assert.are_equal('ab', utils.stringify(inlines))
+    end),
+    test('Meta', function ()
+      local meta = pandoc.Meta{
+        a = pandoc.Inlines 'funny and ',
+        b = 'good movie',
+        c = pandoc.List{pandoc.Inlines{pandoc.Str '!'}}
+      }
+      assert.are_equal('funny and good movie!', utils.stringify(meta))
+    end),
+  },
+
+  group 'to_roman_numeral' {
+    test('convertes number', function ()
+      assert.are_equal('MDCCCLXXXVIII', utils.to_roman_numeral(1888))
+    end),
+    test('fails on non-convertible argument', function ()
+      assert.is_falsy(pcall(utils.to_roman_numeral, 'not a number'))
+    end)
+  },
+
+  group 'type' {
+    test('nil', function ()
+      assert.are_equal(utils.type(nil), 'nil')
+    end),
+    test('boolean', function ()
+      assert.are_equal(utils.type(true), 'boolean')
+      assert.are_equal(utils.type(false), 'boolean')
+    end),
+    test('number', function ()
+      assert.are_equal(utils.type(5), 'number')
+      assert.are_equal(utils.type(-3.02), 'number')
+    end),
+    test('string', function ()
+      assert.are_equal(utils.type(''), 'string')
+      assert.are_equal(utils.type('asdf'), 'string')
+    end),
+    test('plain table', function ()
+      assert.are_equal(utils.type({}), 'table')
+    end),
+    test('List', function ()
+      assert.are_equal(utils.type(pandoc.List{}), 'List')
+    end),
+    test('Inline', function ()
+      assert.are_equal(utils.type(pandoc.Str 'a'), 'Inline')
+      assert.are_equal(utils.type(pandoc.Emph 'emphasized'), 'Inline')
+    end),
+    test('Inlines', function ()
+      assert.are_equal(utils.type(pandoc.Inlines{pandoc.Str 'a'}), 'Inlines')
+      assert.are_equal(utils.type(pandoc.Inlines{pandoc.Emph 'b'}), 'Inlines')
+    end),
+    test('Blocks', function ()
+      assert.are_equal(utils.type(pandoc.Para 'a'), 'Block')
+      assert.are_equal(utils.type(pandoc.CodeBlock 'true'), 'Block')
+    end),
+    test('Inlines', function ()
+      assert.are_equal(utils.type(pandoc.Blocks{'a'}), 'Blocks')
+      assert.are_equal(utils.type(pandoc.Blocks{pandoc.CodeBlock 'b'}), 'Blocks')
+    end),
+  },
+
+  group 'to_simple_table' {
+    test('convertes Table', function ()
+      function simple_cell (blocks)
+        return {
+          attr = pandoc.Attr(),
+          alignment = "AlignDefault",
+          contents = blocks,
+          col_span = 1,
+          row_span = 1,
+        }
+      end
+      local tbl = pandoc.Table(
+        {long = {pandoc.Plain {
+                   pandoc.Str "the", pandoc.Space(), pandoc.Str "caption"}}},
+        {{pandoc.AlignDefault, nil}},
+        pandoc.TableHead{pandoc.Row{simple_cell{pandoc.Plain "head1"}}},
+        {{
+            attr = pandoc.Attr(),
+            body = {pandoc.Row{simple_cell{pandoc.Plain "cell1"}}},
+            head = {},
+            row_head_columns = 0
+        }},
+        pandoc.TableFoot(),
+        pandoc.Attr()
+      )
+      local stbl = utils.to_simple_table(tbl)
+      assert.are_equal('SimpleTable', stbl.t)
+      assert.are_equal('head1', utils.stringify(stbl.headers[1]))
+      assert.are_equal('cell1', utils.stringify(stbl.rows[1][1]))
+      assert.are_equal('the caption', utils.stringify(pandoc.Span(stbl.caption)))
+    end),
+    test('fails on para', function ()
+      assert.is_falsy(pcall(utils.to_simple_table, pandoc.Para "nope"))
+    end),
+  },
+  group 'from_simple_table' {
+    test('converts SimpleTable to Table', function ()
+      local caption = {pandoc.Str "Overview"}
+      local aligns = {pandoc.AlignDefault, pandoc.AlignDefault}
+      local widths = {0, 0} -- let pandoc determine col widths
+      local headers = {
+        {pandoc.Plain "Language"},
+        {pandoc.Plain "Typing"}
+      }
+      local rows = {
+        {{pandoc.Plain "Haskell"}, {pandoc.Plain "static"}},
+        {{pandoc.Plain "Lua"}, {pandoc.Plain "Dynamic"}},
+      }
+      local simple_table = pandoc.SimpleTable(
+        caption,
+        aligns,
+        widths,
+        headers,
+        rows
+      )
+      local tbl = utils.from_simple_table(simple_table)
+      assert.are_equal("Table", tbl.t)
+      assert.are_same(
+        {pandoc.Plain(caption)},
+        tbl.caption.long
+      )
+      -- reversible
+      assert.are_same(simple_table, utils.to_simple_table(tbl))
+    end),
+    test('empty caption', function ()
+      local simple_table = pandoc.SimpleTable(
+        {},
+        {pandoc.AlignDefault},
+        {0},
+        {{pandoc.Plain 'a'}},
+        {{{pandoc.Plain 'b'}}}
+      )
+      local tbl = utils.from_simple_table(simple_table)
+      assert.are_equal(
+        pandoc.Blocks{},
+        tbl.caption.long
+      )
+      assert.is_nil(tbl.caption.short)
+    end),
+    test('empty body', function ()
+      local simple_table = pandoc.SimpleTable(
+        pandoc.Inlines('a nice caption'),
+        {pandoc.AlignDefault},
+        {0},
+        {{pandoc.Plain 'a'}},
+        {}
+      )
+      local tbl = utils.from_simple_table(simple_table)
+      tbl.bodies:map(print)
+      assert.are_same(pandoc.List(), tbl.bodies)
+    end),
+  }
+}
diff --git a/test/lua/module/pandoc.lua b/test/lua/module/pandoc.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/module/pandoc.lua
@@ -0,0 +1,423 @@
+local tasty = require 'tasty'
+
+local test = tasty.test_case
+local group = tasty.test_group
+local assert = tasty.assert
+
+function os_is_windows ()
+  return package.config:sub(1,1) == '\\'
+end
+
+-- Constructor behavior is tested in the hslua-pandoc-types module, so
+-- we just make sure the functions are present.
+return {
+  group 'Constructors' {
+    group 'Misc' {
+      test('pandoc.Attr is a function', function ()
+        assert.are_equal(type(pandoc.Attr), 'function')
+      end),
+      test('pandoc.AttributeList is a function', function ()
+        assert.are_equal(type(pandoc.AttributeList), 'function')
+      end),
+      test('pandoc.Blocks is a function', function ()
+        assert.are_equal(type(pandoc.Blocks), 'function')
+      end),
+      test('pandoc.Citation is a function', function ()
+        assert.are_equal(type(pandoc.Citation), 'function')
+      end),
+      test('pandoc.Inlines is a function', function ()
+        assert.are_equal(type(pandoc.Inlines), 'function')
+      end),
+      test('pandoc.SimpleTable is a function', function ()
+        assert.are_equal(type(pandoc.SimpleTable), 'function')
+      end),
+      test('pandoc.Meta is a function', function ()
+        assert.are_equal(type(pandoc.Meta), 'function')
+      end),
+      test('pandoc.Pandoc is a function', function ()
+        assert.are_equal(type(pandoc.Pandoc), 'function')
+      end),
+    },
+    group "Inline elements" {
+      test('pandoc.AttributeList is a function', function ()
+        assert.are_equal(type(pandoc.Cite), 'function')
+      end),
+      test('pandoc.AttributeList is a function', function ()
+        assert.are_equal(type(pandoc.Code), 'function')
+      end),
+      test('pandoc.Emph is a function', function ()
+        assert.are_equal(type(pandoc.Emph), 'function')
+      end),
+      test('pandoc.Image is a function', function ()
+        assert.are_equal(type(pandoc.Image), 'function')
+      end),
+      test('pandoc.Link is a function', function ()
+        assert.are_equal(type(pandoc.Link), 'function')
+      end),
+      test('pandoc.Math is a function', function ()
+        assert.are_equal(type(pandoc.Math), 'function')
+      end),
+      test('pandoc.Note is a function', function ()
+        assert.are_equal(type(pandoc.Note), 'function')
+      end),
+      test('pandoc.Quoted is a function', function ()
+        assert.are_equal(type(pandoc.Quoted), 'function')
+      end),
+      test('pandoc.SmallCaps is a function', function ()
+        assert.are_equal(type(pandoc.SmallCaps), 'function')
+      end),
+      test('pandoc.SoftBreak is a function', function ()
+        assert.are_equal(type(pandoc.SoftBreak), 'function')
+      end),
+      test('pandoc.Span is a function', function ()
+        assert.are_equal(type(pandoc.Span), 'function')
+      end),
+      test('pandoc.Str is a function', function ()
+        assert.are_equal(type(pandoc.Str), 'function')
+      end),
+      test('pandoc.Strikeout is a function', function ()
+      assert.are_equal(type(pandoc.Strikeout), 'function')
+      end),
+      test('pandoc.Strong is a function', function ()
+        assert.are_equal(type(pandoc.Strong), 'function')
+      end),
+      test('pandoc.Subscript is a function', function ()
+        assert.are_equal(type(pandoc.Subscript), 'function')
+      end),
+      test('pandoc.Superscript is a function', function ()
+        assert.are_equal(type(pandoc.Superscript), 'function')
+      end),
+      test('pandoc.Underline is a function', function ()
+        assert.are_equal(type(pandoc.Underline), 'function')
+      end),
+    },
+    group "Block elements" {
+      test('pandoc.BlockQuote is a function', function ()
+        assert.are_equal(type(pandoc.BlockQuote), 'function')
+      end),
+      test('pandoc.BulletList is a function', function ()
+        assert.are_equal(type(pandoc.BulletList), 'function')
+      end),
+      test('pandoc.CodeBlock is a function', function ()
+        assert.are_equal(type(pandoc.CodeBlock), 'function')
+      end),
+      test('pandoc.DefinitionList is a function', function ()
+        assert.are_equal(type(pandoc.DefinitionList), 'function')
+      end),
+      test('pandoc.Div is a function', function ()
+        assert.are_equal(type(pandoc.Div), 'function')
+      end),
+      test('pandoc.Header is a function', function ()
+        assert.are_equal(type(pandoc.Header), 'function')
+      end),
+      test('pandoc.LineBlock is a function', function ()
+        assert.are_equal(type(pandoc.LineBlock), 'function')
+      end),
+      test('pandoc.Figure is a function', function ()
+        assert.are_equal(type(pandoc.Figure), 'function')
+      end),
+      test('pandoc.OrderedList is a function', function ()
+        assert.are_equal(type(pandoc.OrderedList), 'function')
+      end),
+      test('pandoc.Para is a function', function ()
+        assert.are_equal(type(pandoc.Para), 'function')
+      end),
+      test('pandoc.Plain is a function', function ()
+        assert.are_equal(type(pandoc.Plain), 'function')
+      end),
+      test('pandoc.RawBlock is a function', function ()
+        assert.are_equal(type(pandoc.Plain), 'function')
+      end),
+      test('pandoc.Table is a function', function ()
+        assert.are_equal(type(pandoc.Table), 'function')
+      end),
+    }
+  },
+  group 'MetaValue elements' {
+    test('MetaList elements behave like lists', function ()
+      local metalist = pandoc.MetaList{}
+      assert.are_equal(type(metalist.insert), 'function')
+      assert.are_equal(type(metalist.remove), 'function')
+    end),
+    test('`tag` is an alias for `t``', function ()
+      assert.are_equal((pandoc.MetaList{}).tag, (pandoc.MetaList{}).t)
+      assert.are_equal((pandoc.MetaMap{}).tag, (pandoc.MetaMap{}).t)
+      assert.are_equal((pandoc.MetaInlines{}).tag, (pandoc.MetaInlines{}).t)
+      assert.are_equal((pandoc.MetaBlocks{}).tag, (pandoc.MetaBlocks{}).t)
+    end),
+  },
+  group 'Meta' {
+    test('inline list is treated as MetaInlines', function ()
+      local meta = pandoc.Pandoc({}, {test = {pandoc.Emph 'check'}}).meta
+      assert.are_same(meta.test, {pandoc.Emph{pandoc.Str 'check'}})
+    end),
+    test('inline element is treated as MetaInlines singleton', function ()
+      local meta = pandoc.Pandoc({}, {test = pandoc.Emph 'check'}).meta
+      assert.are_same(meta.test, {pandoc.Emph{pandoc.Str 'check'}})
+    end),
+    test('block list is treated as MetaBlocks', function ()
+      local meta = pandoc.Pandoc({}, {test = {pandoc.Plain 'check'}}).meta
+      assert.are_same(meta.test, {pandoc.Plain{pandoc.Str 'check'}})
+    end),
+    test('block element is treated as MetaBlocks singleton', function ()
+      local meta = pandoc.Pandoc({}, {test = pandoc.Plain 'check'}).meta
+      assert.are_same(meta.test, {pandoc.Plain{pandoc.Str 'check'}})
+    end),
+  },
+  group 'Other types' {
+    group 'ReaderOptions' {
+      test('returns a userdata value', function ()
+        local opts = pandoc.ReaderOptions {}
+        assert.are_equal(type(opts), 'userdata')
+      end),
+      test('can construct from table', function ()
+        local opts = pandoc.ReaderOptions {columns = 66}
+        assert.are_equal(opts.columns, 66)
+      end),
+      test('can construct from other ReaderOptions value', function ()
+        local orig = pandoc.ReaderOptions{columns = 65}
+        local copy = pandoc.ReaderOptions(orig)
+        for k, v in pairs(orig) do
+          assert.are_same(copy[k], v)
+        end
+        assert.are_equal(copy.columns, 65)
+      end),
+    },
+  },
+
+  group 'clone' {
+    test('clones Attr', function ()
+      local attr = pandoc.Attr('test', {'my-class'}, {foo = 'bar'})
+      local cloned = attr:clone()
+      attr.identifier = ''
+      attr.classes = {}
+      attr.attributes = {}
+      assert.are_same(cloned.identifier, 'test')
+      assert.are_same(cloned.classes, {'my-class'})
+      assert.are_same(cloned.attributes.foo, 'bar')
+    end),
+    test('clones ListAttributes', function ()
+      local la = pandoc.ListAttributes(2, pandoc.DefaultStyle, pandoc.Period)
+      local cloned = la:clone()
+      la.start = 9
+      assert.are_same(cloned.start, 2)
+    end),
+    test('clones Para', function ()
+      local para = pandoc.Para {pandoc.Str 'Hello'}
+      local cloned = para:clone()
+      para.content[1].text = 'bye'
+      assert.are_same(cloned, pandoc.Para {pandoc.Str 'Hello'})
+    end),
+    test('clones Str', function ()
+      local str = pandoc.Str 'Hello'
+      local cloned = str:clone()
+      str.text = 'bye'
+      assert.are_same(cloned.text, 'Hello')
+    end),
+    test('clones Citation', function ()
+      local cite = pandoc.Citation('leibniz', pandoc.AuthorInText)
+      local cloned = cite:clone()
+      cite.id = 'newton'
+      assert.are_same(cloned.id, 'leibniz')
+      assert.are_same(cite.id, 'newton')
+      assert.are_same(cite.mode, cloned.mode)
+    end),
+  },
+
+  group 'pipe' {
+    test('external string processing', function ()
+      if os_is_windows() then
+        local pipe_result = pandoc.pipe('find', {'hi'}, 'hi')
+        assert.are_equal('hi', pipe_result:match '%a+')
+      else
+        local pipe_result = pandoc.pipe('tr', {'a', 'b'}, 'abc')
+        assert.are_equal('bbc', pipe_result:match '%a+')
+      end
+    end),
+    test('failing pipe', function ()
+      if os_is_windows() then
+        local success, err = pcall(pandoc.pipe, 'find', {'/a'}, 'hi')
+        assert.is_falsy(success)
+        assert.are_equal('find', err.command)
+        assert.is_truthy(err.error_code ~= 0)
+      else
+        local success, err = pcall(pandoc.pipe, 'false', {}, 'abc')
+        assert.is_falsy(success)
+        assert.are_equal('false', err.command)
+        assert.are_equal(1, err.error_code)
+        assert.are_equal('', err.output)
+      end
+    end)
+  },
+
+  group 'read' {
+    test('Markdown', function ()
+      local valid_markdown = '*Hello*, World!\n'
+      local expected = pandoc.Pandoc({
+          pandoc.Para {
+            pandoc.Emph { pandoc.Str 'Hello' },
+            pandoc.Str ',',
+            pandoc.Space(),
+            pandoc.Str 'World!'
+          }
+      })
+      assert.are_same(expected, pandoc.read(valid_markdown))
+    end),
+    test('unsupported extension', function ()
+      assert.error_matches(
+        function () pandoc.read('foo', 'gfm+empty_paragraphs') end,
+        'The extension empty_paragraphs is not supported for gfm'
+      )
+    end),
+    test('read with other indented code classes', function()
+      local indented_code = '    return true'
+      local expected = pandoc.Pandoc({
+          pandoc.CodeBlock('return true', {class='foo'})
+      })
+      assert.are_same(
+        expected,
+        pandoc.read(indented_code, 'markdown', {indented_code_classes={'foo'}})
+      )
+    end),
+    test('can read epub', function ()
+      local epub = io.open('lua/module/tiny.epub', 'rb')
+      local blocks = pandoc.read(epub:read'a', 'epub').blocks
+      assert.are_equal(
+        blocks[#blocks],
+        pandoc.Para { pandoc.Emph 'EPUB' }
+      )
+    end),
+    test('failing read', function ()
+      assert.error_matches(
+        function () pandoc.read('foo', 'nosuchreader') end,
+        'Unknown input format nosuchreader'
+      )
+    end),
+    group 'extensions' {
+      test('string spec', function ()
+        local doc = pandoc.read('"vice versa"', 'markdown-smart')
+        assert.are_equal(doc, pandoc.Pandoc{pandoc.Para '"vice versa"'})
+      end),
+      test('unsupported extension', function ()
+        assert.error_matches(
+          function () pandoc.read('foo', 'gfm+empty_paragraphs') end,
+          'The extension empty_paragraphs is not supported for gfm'
+        )
+      end),
+      test('unknown extension', function ()
+        local format_spec = { format = 'markdown', extensions = {'nope'}}
+        assert.error_matches(
+          function () pandoc.read('x', format_spec) end,
+          'The extension nope is not supported for markdown'
+        )
+      end),
+      test('fails on invalid extension', function ()
+        local format_spec = { format = 'markdown', extensions = {'nope'}}
+        assert.error_matches(
+          function () pandoc.read('nu-uh', format_spec) end,
+          'The extension nope is not supported for markdown'
+        )
+      end),
+    },
+  },
+
+  group 'walk_block' {
+    test('block walking order', function ()
+     local acc = {}
+     local nested_nums = pandoc.Div {
+       pandoc.Para{pandoc.Str'1'},
+       pandoc.Div{
+         pandoc.Para{pandoc.Str'2'},
+         pandoc.Para{pandoc.Str'3'}
+       },
+       pandoc.Para{pandoc.Str'4'}
+     }
+     pandoc.walk_block(
+       nested_nums,
+       {Para = function (p) table.insert(acc, p.content[1].text) end}
+     )
+     assert.are_equal('1234', table.concat(acc))
+    end)
+  },
+
+  group 'walk_inline' {
+    test('inline walking order', function ()
+      local acc = {}
+      local nested_nums = pandoc.Span {
+        pandoc.Str'1',
+        pandoc.Emph {
+          pandoc.Str'2',
+          pandoc.Str'3'
+        },
+        pandoc.Str'4'
+      }
+      pandoc.walk_inline(
+        nested_nums,
+        {Str = function (s) table.insert(acc, s.text) end}
+      )
+      assert.are_equal('1234', table.concat(acc))
+    end)
+  },
+
+  group 'write' {
+    test('string spec', function ()
+      local doc = pandoc.Pandoc{pandoc.Quoted('DoubleQuote', 'vice versa')}
+      local plain = pandoc.write(doc, 'plain+smart')
+      assert.are_equal(plain, '"vice versa"\n')
+    end),
+    test('table format spec with extensions list', function ()
+      local doc = pandoc.Pandoc{pandoc.Quoted('DoubleQuote', 'vice versa')}
+      local format_spec = { format = 'plain', extensions = {'smart'}}
+      local plain = pandoc.write(doc, format_spec)
+      assert.are_equal(plain, '"vice versa"\n')
+    end),
+    test('table format spec with `enable`/`disable` diff', function ()
+      local diff = {
+        enable = {'smart'}
+      }
+      local doc = pandoc.Pandoc{pandoc.Quoted('DoubleQuote', 'vice versa')}
+      local format_spec = { format = 'plain', extensions = diff}
+      local plain = pandoc.write(doc, format_spec)
+      assert.are_equal(plain, '"vice versa"\n')
+    end),
+    test('table format spec with set-like diff', function ()
+      local diff = {
+        smart = true,
+        auto_identifiers = false
+      }
+      local doc = pandoc.Pandoc{pandoc.Quoted('DoubleQuote', 'vice versa')}
+      local format_spec = { format = 'plain', extensions = diff}
+      local plain = pandoc.write(doc, format_spec)
+      assert.are_equal(plain, '"vice versa"\n')
+    end),
+    test('fails on invalid extension', function ()
+      local doc = pandoc.Pandoc{'nope'}
+      local format_spec = { format = 'plain', extensions = {'nope'}}
+      assert.error_matches(
+        function () pandoc.write(doc, format_spec) end,
+        'The extension nope is not supported for plain'
+      )
+    end),
+  },
+
+  group 'Marshal' {
+    group 'Inlines' {
+      test('Strings are broken into words', function ()
+        assert.are_equal(
+          pandoc.Emph 'Nice, init?',
+          pandoc.Emph{pandoc.Str 'Nice,', pandoc.Space(), pandoc.Str 'init?'}
+        )
+      end)
+    },
+    group 'Blocks' {
+      test('Strings are broken into words and wrapped in Plain', function ()
+        assert.are_equal(
+          pandoc.Div{
+            pandoc.Plain{pandoc.Str 'Nice,', pandoc.Space(), pandoc.Str 'init?'}
+          },
+          pandoc.Div{'Nice, init?'}
+        )
+      end)
+    }
+  }
+}
diff --git a/test/lua/module/partial.test b/test/lua/module/partial.test
new file mode 100644
--- /dev/null
+++ b/test/lua/module/partial.test
diff --git a/test/lua/module/tiny.epub b/test/lua/module/tiny.epub
new file mode 100644
Binary files /dev/null and b/test/lua/module/tiny.epub differ
diff --git a/test/lua/plain-to-para.lua b/test/lua/plain-to-para.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/plain-to-para.lua
@@ -0,0 +1,6 @@
+return {
+  { Plain = function (elem)
+      return pandoc.Para(elem.content)
+  end,
+  }
+}
diff --git a/test/lua/require-file.lua b/test/lua/require-file.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/require-file.lua
@@ -0,0 +1,2 @@
+package.path = package.path .. ';lua/?.lua'
+require 'script-name'
diff --git a/test/lua/script-name.lua b/test/lua/script-name.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/script-name.lua
@@ -0,0 +1,3 @@
+function Para (_)
+  return pandoc.Para{pandoc.Str(PANDOC_SCRIPT_FILE)}
+end
diff --git a/test/lua/single-to-double-quoted.lua b/test/lua/single-to-double-quoted.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/single-to-double-quoted.lua
@@ -0,0 +1,10 @@
+return {
+  {
+    Quoted = function (elem)
+      if elem.quotetype == "SingleQuote" then
+        elem.quotetype = "DoubleQuote"
+      end
+      return elem
+    end,
+  }
+}
diff --git a/test/lua/smallcaps-title.lua b/test/lua/smallcaps-title.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/smallcaps-title.lua
@@ -0,0 +1,12 @@
+return {
+  {
+    Meta = function(meta)
+      -- The call to `MetaInlines` is redundant and used for testing purposes
+      -- only. The explicit use of a MetaValue constructor is only useful when
+      -- used with an empty table: `MetaInlines{}` is read differently than
+      -- `MetaBlocks{}`.
+      meta.title = pandoc.MetaInlines{pandoc.SmallCaps(meta.title)}
+      return meta
+    end
+  }
+}
diff --git a/test/lua/smart-constructors.lua b/test/lua/smart-constructors.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/smart-constructors.lua
@@ -0,0 +1,10 @@
+-- Test that constructors are "smart" in that they autoconvert
+-- types where sensible.
+function Para (_)
+  return {
+    pandoc.BulletList{pandoc.Para "Hello", pandoc.Para "World"},
+    pandoc.DefinitionList{{"foo", pandoc.Para "placeholder"}},
+    pandoc.LineBlock{"Moin", "Welt"},
+    pandoc.OrderedList{pandoc.Plain{pandoc.Str "one"}, pandoc.Plain "two"}
+  }
+end
diff --git a/test/lua/strmacro.lua b/test/lua/strmacro.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/strmacro.lua
@@ -0,0 +1,11 @@
+return {
+  {
+    Str = function (elem)
+      if elem.text == "{{helloworld}}" then
+        return pandoc.Emph {pandoc.Str "Hello, World"}
+      else
+        return elem
+      end
+    end,
+  }
+}
diff --git a/test/lua/undiv.lua b/test/lua/undiv.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/undiv.lua
@@ -0,0 +1,3 @@
+function Div(el)
+  return el.content
+end
diff --git a/test/lua/uppercase-header.lua b/test/lua/uppercase-header.lua
new file mode 100644
--- /dev/null
+++ b/test/lua/uppercase-header.lua
@@ -0,0 +1,9 @@
+local text = require 'text'
+
+local function str_to_uppercase (s)
+  return pandoc.Str(text.upper(s.text))
+end
+
+function Header (el)
+  return pandoc.walk_block(el, {Str = str_to_uppercase})
+end
diff --git a/test/sample.lua b/test/sample.lua
new file mode 100644
--- /dev/null
+++ b/test/sample.lua
@@ -0,0 +1,369 @@
+-- This is a sample custom writer for pandoc.  It produces output
+-- that is very similar to that of pandoc's HTML writer.
+-- There is one new feature: code blocks marked with class 'dot'
+-- are piped through graphviz and images are included in the HTML
+-- output using 'data:' URLs. The image format can be controlled
+-- via the `image_format` metadata field.
+--
+-- Invoke with: pandoc -t sample.lua
+--
+-- Note:  you need not have lua installed on your system to use this
+-- custom writer.  However, if you do have lua installed, you can
+-- use it to test changes to the script.  'lua sample.lua' will
+-- produce informative error messages if your code contains
+-- syntax errors.
+
+function Writer (doc, opts)
+  PANDOC_DOCUMENT = doc
+  PANDOC_WRITER_OPTIONS = opts
+  loadfile(PANDOC_SCRIPT_FILE)()
+  return pandoc.write_classic(doc, opts)
+end
+
+local pipe = pandoc.pipe
+local stringify = (require 'pandoc.utils').stringify
+
+-- Choose the image format based on the value of the
+-- `image_format` environment variable.
+local image_format = os.getenv 'image_format' or 'png'
+local image_mime_type = ({
+    jpeg = 'image/jpeg',
+    jpg = 'image/jpeg',
+    gif = 'image/gif',
+    png = 'image/png',
+    svg = 'image/svg+xml',
+  })[image_format]
+  or error('unsupported image format `' .. image_format .. '`')
+
+-- Character escaping
+local function escape(s, in_attribute)
+  return s:gsub('[<>&"\']',
+    function(x)
+      if x == '<' then
+        return '&lt;'
+      elseif x == '>' then
+        return '&gt;'
+      elseif x == '&' then
+        return '&amp;'
+      elseif in_attribute and x == '"' then
+        return '&quot;'
+      elseif in_attribute and x == "'" then
+        return '&#39;'
+      else
+        return x
+      end
+    end)
+end
+
+-- Helper function to convert an attributes table into
+-- a string that can be put into HTML tags.
+local function attributes(attr)
+  local attr_table = {}
+  for x,y in pairs(attr) do
+    if y and y ~= '' then
+      table.insert(attr_table, ' ' .. x .. '="' .. escape(y,true) .. '"')
+    end
+  end
+  return table.concat(attr_table)
+end
+
+-- Table to store footnotes, so they can be included at the end.
+local notes = {}
+
+-- Blocksep is used to separate block elements.
+function Blocksep()
+  return '\n\n'
+end
+
+-- This function is called once for the whole document. Parameters:
+-- body is a string, metadata is a table, variables is a table.
+-- This gives you a fragment.  You could use the metadata table to
+-- fill variables in a custom lua template.  Or, pass `--template=...`
+-- to pandoc, and pandoc will do the template processing as usual.
+function Doc(body, metadata, variables)
+  local buffer = {}
+  local function add(s)
+    table.insert(buffer, s)
+  end
+  add(body)
+  if #notes > 0 then
+    add('<ol class="footnotes">')
+    for _,note in pairs(notes) do
+      add(note)
+    end
+    add('</ol>')
+  end
+  return table.concat(buffer,'\n') .. '\n'
+end
+
+-- The functions that follow render corresponding pandoc elements.
+-- s is always a string, attr is always a table of attributes, and
+-- items is always an array of strings (the items in a list).
+-- Comments indicate the types of other variables.
+
+function Str(s)
+  return escape(s)
+end
+
+function Space()
+  return ' '
+end
+
+function SoftBreak()
+  return '\n'
+end
+
+function LineBreak()
+  return '<br/>'
+end
+
+function Emph(s)
+  return '<em>' .. s .. '</em>'
+end
+
+function Strong(s)
+  return '<strong>' .. s .. '</strong>'
+end
+
+function Subscript(s)
+  return '<sub>' .. s .. '</sub>'
+end
+
+function Superscript(s)
+  return '<sup>' .. s .. '</sup>'
+end
+
+function SmallCaps(s)
+  return '<span style="font-variant: small-caps;">' .. s .. '</span>'
+end
+
+function Strikeout(s)
+  return '<del>' .. s .. '</del>'
+end
+
+function Link(s, tgt, tit, attr)
+  return '<a href="' .. escape(tgt,true) .. '" title="' ..
+         escape(tit,true) .. '"' .. attributes(attr) .. '>' .. s .. '</a>'
+end
+
+function Image(s, src, tit, attr)
+  return '<img src="' .. escape(src,true) .. '" title="' ..
+         escape(tit,true) .. '"/>'
+end
+
+function Code(s, attr)
+  return '<code' .. attributes(attr) .. '>' .. escape(s) .. '</code>'
+end
+
+function InlineMath(s)
+  return '\\(' .. escape(s) .. '\\)'
+end
+
+function DisplayMath(s)
+  return '\\[' .. escape(s) .. '\\]'
+end
+
+function SingleQuoted(s)
+  return '&lsquo;' .. s .. '&rsquo;'
+end
+
+function DoubleQuoted(s)
+  return '&ldquo;' .. s .. '&rdquo;'
+end
+
+function Note(s)
+  local num = #notes + 1
+  -- insert the back reference right before the final closing tag.
+  s = string.gsub(s,
+          '(.*)</', '%1 <a href="#fnref' .. num ..  '">&#8617;</a></')
+  -- add a list item with the note to the note table.
+  table.insert(notes, '<li id="fn' .. num .. '">' .. s .. '</li>')
+  -- return the footnote reference, linked to the note.
+  return '<a id="fnref' .. num .. '" href="#fn' .. num ..
+            '"><sup>' .. num .. '</sup></a>'
+end
+
+function Span(s, attr)
+  return '<span' .. attributes(attr) .. '>' .. s .. '</span>'
+end
+
+function RawInline(format, str)
+  if format == 'html' then
+    return str
+  else
+    return ''
+  end
+end
+
+function Cite(s, cs)
+  local ids = {}
+  for _,cit in ipairs(cs) do
+    table.insert(ids, cit.citationId)
+  end
+  return '<span class="cite" data-citation-ids="' .. table.concat(ids, ',') ..
+    '">' .. s .. '</span>'
+end
+
+function Plain(s)
+  return s
+end
+
+function Para(s)
+  return '<p>' .. s .. '</p>'
+end
+
+-- lev is an integer, the header level.
+function Header(lev, s, attr)
+  return '<h' .. lev .. attributes(attr) ..  '>' .. s .. '</h' .. lev .. '>'
+end
+
+function BlockQuote(s)
+  return '<blockquote>\n' .. s .. '\n</blockquote>'
+end
+
+function HorizontalRule()
+  return "<hr/>"
+end
+
+function LineBlock(ls)
+  return '<div style="white-space: pre-line;">' .. table.concat(ls, '\n') ..
+         '</div>'
+end
+
+function CodeBlock(s, attr)
+  -- If code block has class 'dot', pipe the contents through dot
+  -- and base64, and include the base64-encoded png as a data: URL.
+  if attr.class and string.match(' ' .. attr.class .. ' ',' dot ') then
+    local img = pipe('base64', {}, pipe('dot', {'-T' .. image_format}, s))
+    return '<img src="data:' .. image_mime_type .. ';base64,' .. img .. '"/>'
+  -- otherwise treat as code (one could pipe through a highlighter)
+  else
+    return '<pre><code' .. attributes(attr) .. '>' .. escape(s) ..
+           '</code></pre>'
+  end
+end
+
+function BulletList(items)
+  local buffer = {}
+  for _, item in pairs(items) do
+    table.insert(buffer, '<li>' .. item .. '</li>')
+  end
+  return '<ul>\n' .. table.concat(buffer, '\n') .. '\n</ul>'
+end
+
+function OrderedList(items)
+  local buffer = {}
+  for _, item in pairs(items) do
+    table.insert(buffer, '<li>' .. item .. '</li>')
+  end
+  return '<ol>\n' .. table.concat(buffer, '\n') .. '\n</ol>'
+end
+
+function DefinitionList(items)
+  local buffer = {}
+  for _,item in pairs(items) do
+    local k, v = next(item)
+    table.insert(buffer, '<dt>' .. k .. '</dt>\n<dd>' ..
+                   table.concat(v, '</dd>\n<dd>') .. '</dd>')
+  end
+  return '<dl>\n' .. table.concat(buffer, '\n') .. '\n</dl>'
+end
+
+-- Convert pandoc alignment to something HTML can use.
+-- align is AlignLeft, AlignRight, AlignCenter, or AlignDefault.
+local function html_align(align)
+  if align == 'AlignLeft' then
+    return 'left'
+  elseif align == 'AlignRight' then
+    return 'right'
+  elseif align == 'AlignCenter' then
+    return 'center'
+  else
+    return 'left'
+  end
+end
+
+function CaptionedImage(src, tit, caption, attr)
+  if #caption == 0 then
+    return '<p><img src="' .. escape(src,true) .. '" id="' .. attr.id ..
+      '"/></p>'
+  else
+    local ecaption = escape(caption)
+    return '<figure>\n<img src="' .. escape(src,true) ..
+        '" id="' .. attr.id .. '" alt="' .. ecaption  .. '"/>' ..
+        '<figcaption>' .. ecaption .. '</figcaption>\n</figure>'
+  end
+end
+
+function Figure(caption, contents, attr)
+  return '<figure' .. attributes(attr) .. '>\n' .. contents ..
+    '\n<figcaption>' .. caption .. '</figcaption>\n' ..
+    '</figure>'
+end
+
+-- Caption is a string, aligns is an array of strings,
+-- widths is an array of floats, headers is an array of
+-- strings, rows is an array of arrays of strings.
+function Table(caption, aligns, widths, headers, rows)
+  local buffer = {}
+  local function add(s)
+    table.insert(buffer, s)
+  end
+  add('<table>')
+  if caption ~= '' then
+    add('<caption>' .. escape(caption) .. '</caption>')
+  end
+  if widths and widths[1] ~= 0 then
+    for _, w in pairs(widths) do
+      add('<col width="' .. string.format('%.0f%%', w * 100) .. '" />')
+    end
+  end
+  local header_row = {}
+  local empty_header = true
+  for i, h in pairs(headers) do
+    local align = html_align(aligns[i])
+    table.insert(header_row,'<th align="' .. align .. '">' .. h .. '</th>')
+    empty_header = empty_header and h == ''
+  end
+  if not empty_header then
+    add('<tr class="header">')
+    for _,h in pairs(header_row) do
+      add(h)
+    end
+    add('</tr>')
+  end
+  local class = 'even'
+  for _, row in pairs(rows) do
+    class = (class == 'even' and 'odd') or 'even'
+    add('<tr class="' .. class .. '">')
+    for i,c in pairs(row) do
+      add('<td align="' .. html_align(aligns[i]) .. '">' .. c .. '</td>')
+    end
+    add('</tr>')
+  end
+  add('</table>')
+  return table.concat(buffer,'\n')
+end
+
+function RawBlock(format, str)
+  if format == 'html' then
+    return str
+  else
+    return ''
+  end
+end
+
+function Div(s, attr)
+  return '<div' .. attributes(attr) .. '>\n' .. s .. '</div>'
+end
+
+-- The following code will produce runtime warnings when you haven't defined
+-- all of the functions you need for the custom writer, so it's useful
+-- to include when you're working on a writer.
+local meta = {}
+meta.__index =
+  function(_, key)
+    io.stderr:write(string.format("WARNING: Undefined function '%s'\n",key))
+    return function() return '' end
+  end
+setmetatable(_G, meta)
diff --git a/test/tables.custom b/test/tables.custom
new file mode 100644
--- /dev/null
+++ b/test/tables.custom
@@ -0,0 +1,200 @@
+<p>Simple table with caption:</p>
+
+<table>
+<caption>Demonstration of simple table syntax.</caption>
+<tr class="header">
+<th align="right">Right</th>
+<th align="left">Left</th>
+<th align="center">Center</th>
+<th align="left">Default</th>
+</tr>
+<tr class="odd">
+<td align="right">12</td>
+<td align="left">12</td>
+<td align="center">12</td>
+<td align="left">12</td>
+</tr>
+<tr class="even">
+<td align="right">123</td>
+<td align="left">123</td>
+<td align="center">123</td>
+<td align="left">123</td>
+</tr>
+<tr class="odd">
+<td align="right">1</td>
+<td align="left">1</td>
+<td align="center">1</td>
+<td align="left">1</td>
+</tr>
+</table>
+
+<p>Simple table without caption:</p>
+
+<table>
+<tr class="header">
+<th align="right">Right</th>
+<th align="left">Left</th>
+<th align="center">Center</th>
+<th align="left">Default</th>
+</tr>
+<tr class="odd">
+<td align="right">12</td>
+<td align="left">12</td>
+<td align="center">12</td>
+<td align="left">12</td>
+</tr>
+<tr class="even">
+<td align="right">123</td>
+<td align="left">123</td>
+<td align="center">123</td>
+<td align="left">123</td>
+</tr>
+<tr class="odd">
+<td align="right">1</td>
+<td align="left">1</td>
+<td align="center">1</td>
+<td align="left">1</td>
+</tr>
+</table>
+
+<p>Simple table indented two spaces:</p>
+
+<table>
+<caption>Demonstration of simple table syntax.</caption>
+<tr class="header">
+<th align="right">Right</th>
+<th align="left">Left</th>
+<th align="center">Center</th>
+<th align="left">Default</th>
+</tr>
+<tr class="odd">
+<td align="right">12</td>
+<td align="left">12</td>
+<td align="center">12</td>
+<td align="left">12</td>
+</tr>
+<tr class="even">
+<td align="right">123</td>
+<td align="left">123</td>
+<td align="center">123</td>
+<td align="left">123</td>
+</tr>
+<tr class="odd">
+<td align="right">1</td>
+<td align="left">1</td>
+<td align="center">1</td>
+<td align="left">1</td>
+</tr>
+</table>
+
+<p>Multiline table with caption:</p>
+
+<table>
+<caption>Here’s the caption.
+It may span multiple lines.</caption>
+<col width="15%" />
+<col width="14%" />
+<col width="16%" />
+<col width="35%" />
+<tr class="header">
+<th align="center">Centered
+Header</th>
+<th align="left">Left
+Aligned</th>
+<th align="right">Right
+Aligned</th>
+<th align="left">Default aligned</th>
+</tr>
+<tr class="odd">
+<td align="center">First</td>
+<td align="left">row</td>
+<td align="right">12.0</td>
+<td align="left">Example of a row that spans
+multiple lines.</td>
+</tr>
+<tr class="even">
+<td align="center">Second</td>
+<td align="left">row</td>
+<td align="right">5.0</td>
+<td align="left">Here’s another one. Note
+the blank line between rows.</td>
+</tr>
+</table>
+
+<p>Multiline table without caption:</p>
+
+<table>
+<col width="15%" />
+<col width="14%" />
+<col width="16%" />
+<col width="35%" />
+<tr class="header">
+<th align="center">Centered
+Header</th>
+<th align="left">Left
+Aligned</th>
+<th align="right">Right
+Aligned</th>
+<th align="left">Default aligned</th>
+</tr>
+<tr class="odd">
+<td align="center">First</td>
+<td align="left">row</td>
+<td align="right">12.0</td>
+<td align="left">Example of a row that spans
+multiple lines.</td>
+</tr>
+<tr class="even">
+<td align="center">Second</td>
+<td align="left">row</td>
+<td align="right">5.0</td>
+<td align="left">Here’s another one. Note
+the blank line between rows.</td>
+</tr>
+</table>
+
+<p>Table without column headers:</p>
+
+<table>
+<tr class="odd">
+<td align="right">12</td>
+<td align="left">12</td>
+<td align="center">12</td>
+<td align="right">12</td>
+</tr>
+<tr class="even">
+<td align="right">123</td>
+<td align="left">123</td>
+<td align="center">123</td>
+<td align="right">123</td>
+</tr>
+<tr class="odd">
+<td align="right">1</td>
+<td align="left">1</td>
+<td align="center">1</td>
+<td align="right">1</td>
+</tr>
+</table>
+
+<p>Multiline table without column headers:</p>
+
+<table>
+<col width="15%" />
+<col width="14%" />
+<col width="16%" />
+<col width="35%" />
+<tr class="odd">
+<td align="center">First</td>
+<td align="left">row</td>
+<td align="right">12.0</td>
+<td align="left">Example of a row that spans
+multiple lines.</td>
+</tr>
+<tr class="even">
+<td align="center">Second</td>
+<td align="left">row</td>
+<td align="right">5.0</td>
+<td align="left">Here’s another one. Note
+the blank line between rows.</td>
+</tr>
+</table>
diff --git a/test/tables.native b/test/tables.native
new file mode 100644
--- /dev/null
+++ b/test/tables.native
@@ -0,0 +1,964 @@
+[ Para
+    [ Str "Simple"
+    , Space
+    , Str "table"
+    , Space
+    , Str "with"
+    , Space
+    , Str "caption:"
+    ]
+, Table
+    ( "" , [] , [] )
+    (Caption
+       Nothing
+       [ Plain
+           [ Str "Demonstration"
+           , Space
+           , Str "of"
+           , Space
+           , Str "simple"
+           , Space
+           , Str "table"
+           , Space
+           , Str "syntax."
+           ]
+       ])
+    [ ( AlignRight , ColWidthDefault )
+    , ( AlignLeft , ColWidthDefault )
+    , ( AlignCenter , ColWidthDefault )
+    , ( AlignDefault , ColWidthDefault )
+    ]
+    (TableHead
+       ( "" , [] , [] )
+       [ Row
+           ( "" , [] , [] )
+           [ Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Right" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Left" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Center" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Default" ] ]
+           ]
+       ])
+    [ TableBody
+        ( "" , [] , [] )
+        (RowHeadColumns 0)
+        []
+        [ Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            ]
+        ]
+    ]
+    (TableFoot ( "" , [] , [] ) [])
+, Para
+    [ Str "Simple"
+    , Space
+    , Str "table"
+    , Space
+    , Str "without"
+    , Space
+    , Str "caption:"
+    ]
+, Table
+    ( "" , [] , [] )
+    (Caption Nothing [])
+    [ ( AlignRight , ColWidthDefault )
+    , ( AlignLeft , ColWidthDefault )
+    , ( AlignCenter , ColWidthDefault )
+    , ( AlignDefault , ColWidthDefault )
+    ]
+    (TableHead
+       ( "" , [] , [] )
+       [ Row
+           ( "" , [] , [] )
+           [ Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Right" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Left" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Center" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Default" ] ]
+           ]
+       ])
+    [ TableBody
+        ( "" , [] , [] )
+        (RowHeadColumns 0)
+        []
+        [ Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            ]
+        ]
+    ]
+    (TableFoot ( "" , [] , [] ) [])
+, Para
+    [ Str "Simple"
+    , Space
+    , Str "table"
+    , Space
+    , Str "indented"
+    , Space
+    , Str "two"
+    , Space
+    , Str "spaces:"
+    ]
+, Table
+    ( "" , [] , [] )
+    (Caption
+       Nothing
+       [ Plain
+           [ Str "Demonstration"
+           , Space
+           , Str "of"
+           , Space
+           , Str "simple"
+           , Space
+           , Str "table"
+           , Space
+           , Str "syntax."
+           ]
+       ])
+    [ ( AlignRight , ColWidthDefault )
+    , ( AlignLeft , ColWidthDefault )
+    , ( AlignCenter , ColWidthDefault )
+    , ( AlignDefault , ColWidthDefault )
+    ]
+    (TableHead
+       ( "" , [] , [] )
+       [ Row
+           ( "" , [] , [] )
+           [ Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Right" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Left" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Center" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Default" ] ]
+           ]
+       ])
+    [ TableBody
+        ( "" , [] , [] )
+        (RowHeadColumns 0)
+        []
+        [ Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            ]
+        ]
+    ]
+    (TableFoot ( "" , [] , [] ) [])
+, Para
+    [ Str "Multiline"
+    , Space
+    , Str "table"
+    , Space
+    , Str "with"
+    , Space
+    , Str "caption:"
+    ]
+, Table
+    ( "" , [] , [] )
+    (Caption
+       Nothing
+       [ Plain
+           [ Str "Here\8217s"
+           , Space
+           , Str "the"
+           , Space
+           , Str "caption."
+           , SoftBreak
+           , Str "It"
+           , Space
+           , Str "may"
+           , Space
+           , Str "span"
+           , Space
+           , Str "multiple"
+           , Space
+           , Str "lines."
+           ]
+       ])
+    [ ( AlignCenter , ColWidth 0.15 )
+    , ( AlignLeft , ColWidth 0.1375 )
+    , ( AlignRight , ColWidth 0.1625 )
+    , ( AlignLeft , ColWidth 0.35 )
+    ]
+    (TableHead
+       ( "" , [] , [] )
+       [ Row
+           ( "" , [] , [] )
+           [ Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Centered" , SoftBreak , Str "Header" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Left" , SoftBreak , Str "Aligned" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Right" , SoftBreak , Str "Aligned" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Default" , Space , Str "aligned" ] ]
+           ]
+       ])
+    [ TableBody
+        ( "" , [] , [] )
+        (RowHeadColumns 0)
+        []
+        [ Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "First" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "row" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12.0" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain
+                    [ Str "Example"
+                    , Space
+                    , Str "of"
+                    , Space
+                    , Str "a"
+                    , Space
+                    , Str "row"
+                    , Space
+                    , Str "that"
+                    , Space
+                    , Str "spans"
+                    , SoftBreak
+                    , Str "multiple"
+                    , Space
+                    , Str "lines."
+                    ]
+                ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "Second" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "row" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "5.0" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain
+                    [ Str "Here\8217s"
+                    , Space
+                    , Str "another"
+                    , Space
+                    , Str "one."
+                    , Space
+                    , Str "Note"
+                    , SoftBreak
+                    , Str "the"
+                    , Space
+                    , Str "blank"
+                    , Space
+                    , Str "line"
+                    , Space
+                    , Str "between"
+                    , Space
+                    , Str "rows."
+                    ]
+                ]
+            ]
+        ]
+    ]
+    (TableFoot ( "" , [] , [] ) [])
+, Para
+    [ Str "Multiline"
+    , Space
+    , Str "table"
+    , Space
+    , Str "without"
+    , Space
+    , Str "caption:"
+    ]
+, Table
+    ( "" , [] , [] )
+    (Caption Nothing [])
+    [ ( AlignCenter , ColWidth 0.15 )
+    , ( AlignLeft , ColWidth 0.1375 )
+    , ( AlignRight , ColWidth 0.1625 )
+    , ( AlignLeft , ColWidth 0.35 )
+    ]
+    (TableHead
+       ( "" , [] , [] )
+       [ Row
+           ( "" , [] , [] )
+           [ Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Centered" , SoftBreak , Str "Header" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Left" , SoftBreak , Str "Aligned" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Right" , SoftBreak , Str "Aligned" ] ]
+           , Cell
+               ( "" , [] , [] )
+               AlignDefault
+               (RowSpan 1)
+               (ColSpan 1)
+               [ Plain [ Str "Default" , Space , Str "aligned" ] ]
+           ]
+       ])
+    [ TableBody
+        ( "" , [] , [] )
+        (RowHeadColumns 0)
+        []
+        [ Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "First" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "row" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12.0" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain
+                    [ Str "Example"
+                    , Space
+                    , Str "of"
+                    , Space
+                    , Str "a"
+                    , Space
+                    , Str "row"
+                    , Space
+                    , Str "that"
+                    , Space
+                    , Str "spans"
+                    , SoftBreak
+                    , Str "multiple"
+                    , Space
+                    , Str "lines."
+                    ]
+                ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "Second" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "row" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "5.0" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain
+                    [ Str "Here\8217s"
+                    , Space
+                    , Str "another"
+                    , Space
+                    , Str "one."
+                    , Space
+                    , Str "Note"
+                    , SoftBreak
+                    , Str "the"
+                    , Space
+                    , Str "blank"
+                    , Space
+                    , Str "line"
+                    , Space
+                    , Str "between"
+                    , Space
+                    , Str "rows."
+                    ]
+                ]
+            ]
+        ]
+    ]
+    (TableFoot ( "" , [] , [] ) [])
+, Para
+    [ Str "Table"
+    , Space
+    , Str "without"
+    , Space
+    , Str "column"
+    , Space
+    , Str "headers:"
+    ]
+, Table
+    ( "" , [] , [] )
+    (Caption Nothing [])
+    [ ( AlignRight , ColWidthDefault )
+    , ( AlignLeft , ColWidthDefault )
+    , ( AlignCenter , ColWidthDefault )
+    , ( AlignRight , ColWidthDefault )
+    ]
+    (TableHead ( "" , [] , [] ) [])
+    [ TableBody
+        ( "" , [] , [] )
+        (RowHeadColumns 0)
+        []
+        [ Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12" ] ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "123" ] ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "1" ] ]
+            ]
+        ]
+    ]
+    (TableFoot ( "" , [] , [] ) [])
+, Para
+    [ Str "Multiline"
+    , Space
+    , Str "table"
+    , Space
+    , Str "without"
+    , Space
+    , Str "column"
+    , Space
+    , Str "headers:"
+    ]
+, Table
+    ( "" , [] , [] )
+    (Caption Nothing [])
+    [ ( AlignCenter , ColWidth 0.15 )
+    , ( AlignLeft , ColWidth 0.1375 )
+    , ( AlignRight , ColWidth 0.1625 )
+    , ( AlignDefault , ColWidth 0.35 )
+    ]
+    (TableHead ( "" , [] , [] ) [])
+    [ TableBody
+        ( "" , [] , [] )
+        (RowHeadColumns 0)
+        []
+        [ Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "First" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "row" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "12.0" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain
+                    [ Str "Example"
+                    , Space
+                    , Str "of"
+                    , Space
+                    , Str "a"
+                    , Space
+                    , Str "row"
+                    , Space
+                    , Str "that"
+                    , Space
+                    , Str "spans"
+                    , SoftBreak
+                    , Str "multiple"
+                    , Space
+                    , Str "lines."
+                    ]
+                ]
+            ]
+        , Row
+            ( "" , [] , [] )
+            [ Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "Second" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "row" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain [ Str "5.0" ] ]
+            , Cell
+                ( "" , [] , [] )
+                AlignDefault
+                (RowSpan 1)
+                (ColSpan 1)
+                [ Plain
+                    [ Str "Here\8217s"
+                    , Space
+                    , Str "another"
+                    , Space
+                    , Str "one."
+                    , Space
+                    , Str "Note"
+                    , SoftBreak
+                    , Str "the"
+                    , Space
+                    , Str "blank"
+                    , Space
+                    , Str "line"
+                    , Space
+                    , Str "between"
+                    , Space
+                    , Str "rows."
+                    ]
+                ]
+            ]
+        ]
+    ]
+    (TableFoot ( "" , [] , [] ) [])
+]
diff --git a/test/test-pandoc-lua-engine.hs b/test/test-pandoc-lua-engine.hs
new file mode 100644
--- /dev/null
+++ b/test/test-pandoc-lua-engine.hs
@@ -0,0 +1,18 @@
+module Main (main) where
+import Test.Tasty (TestTree, defaultMain, testGroup)
+import qualified Tests.Lua
+import qualified Tests.Lua.Module
+import qualified Tests.Lua.Reader
+import qualified Tests.Lua.Writer
+import System.Directory (withCurrentDirectory)
+
+main :: IO ()
+main = withCurrentDirectory "test" $ defaultMain tests
+
+tests :: TestTree
+tests = testGroup "pandoc Lua engine"
+  [ testGroup "Lua filters" Tests.Lua.tests
+  , testGroup "Lua modules" Tests.Lua.Module.tests
+  , testGroup "Custom writers" Tests.Lua.Writer.tests
+  , testGroup "Custom readers" Tests.Lua.Reader.tests
+  ]
diff --git a/test/testsuite.native b/test/testsuite.native
new file mode 100644
--- /dev/null
+++ b/test/testsuite.native
@@ -0,0 +1,2238 @@
+Pandoc
+  Meta
+    { unMeta =
+        fromList
+          [ ( "author"
+            , MetaList
+                [ MetaInlines [ Str "John" , Space , Str "MacFarlane" ]
+                , MetaInlines [ Str "Anonymous" ]
+                ]
+            )
+          , ( "date"
+            , MetaInlines
+                [ Str "July" , Space , Str "17," , Space , Str "2006" ]
+            )
+          , ( "title"
+            , MetaInlines
+                [ Str "Pandoc"
+                , Space
+                , Str "Test"
+                , Space
+                , Str "Suite"
+                ]
+            )
+          ]
+    }
+  [ Para
+      [ Str "This"
+      , Space
+      , Str "is"
+      , Space
+      , Str "a"
+      , Space
+      , Str "set"
+      , Space
+      , Str "of"
+      , Space
+      , Str "tests"
+      , Space
+      , Str "for"
+      , Space
+      , Str "pandoc."
+      , Space
+      , Str "Most"
+      , Space
+      , Str "of"
+      , Space
+      , Str "them"
+      , Space
+      , Str "are"
+      , Space
+      , Str "adapted"
+      , Space
+      , Str "from"
+      , SoftBreak
+      , Str "John"
+      , Space
+      , Str "Gruber\8217s"
+      , Space
+      , Str "markdown"
+      , Space
+      , Str "test"
+      , Space
+      , Str "suite."
+      ]
+  , HorizontalRule
+  , Header 1 ( "headers" , [] , [] ) [ Str "Headers" ]
+  , Header
+      2
+      ( "level-2-with-an-embedded-link" , [] , [] )
+      [ Str "Level"
+      , Space
+      , Str "2"
+      , Space
+      , Str "with"
+      , Space
+      , Str "an"
+      , Space
+      , Link
+          ( "" , [] , [] )
+          [ Str "embedded" , Space , Str "link" ]
+          ( "/url" , "" )
+      ]
+  , Header
+      3
+      ( "level-3-with-emphasis" , [] , [] )
+      [ Str "Level"
+      , Space
+      , Str "3"
+      , Space
+      , Str "with"
+      , Space
+      , Emph [ Str "emphasis" ]
+      ]
+  , Header
+      4 ( "level-4" , [] , [] ) [ Str "Level" , Space , Str "4" ]
+  , Header
+      5 ( "level-5" , [] , [] ) [ Str "Level" , Space , Str "5" ]
+  , Header
+      1 ( "level-1" , [] , [] ) [ Str "Level" , Space , Str "1" ]
+  , Header
+      2
+      ( "level-2-with-emphasis" , [] , [] )
+      [ Str "Level"
+      , Space
+      , Str "2"
+      , Space
+      , Str "with"
+      , Space
+      , Emph [ Str "emphasis" ]
+      ]
+  , Header
+      3 ( "level-3" , [] , [] ) [ Str "Level" , Space , Str "3" ]
+  , Para
+      [ Str "with"
+      , Space
+      , Str "no"
+      , Space
+      , Str "blank"
+      , Space
+      , Str "line"
+      ]
+  , Header
+      2 ( "level-2" , [] , [] ) [ Str "Level" , Space , Str "2" ]
+  , Para
+      [ Str "with"
+      , Space
+      , Str "no"
+      , Space
+      , Str "blank"
+      , Space
+      , Str "line"
+      ]
+  , HorizontalRule
+  , Header 1 ( "paragraphs" , [] , [] ) [ Str "Paragraphs" ]
+  , Para
+      [ Str "Here\8217s"
+      , Space
+      , Str "a"
+      , Space
+      , Str "regular"
+      , Space
+      , Str "paragraph."
+      ]
+  , Para
+      [ Str "In"
+      , Space
+      , Str "Markdown"
+      , Space
+      , Str "1.0.0"
+      , Space
+      , Str "and"
+      , Space
+      , Str "earlier."
+      , Space
+      , Str "Version"
+      , SoftBreak
+      , Str "8."
+      , Space
+      , Str "This"
+      , Space
+      , Str "line"
+      , Space
+      , Str "turns"
+      , Space
+      , Str "into"
+      , Space
+      , Str "a"
+      , Space
+      , Str "list"
+      , Space
+      , Str "item."
+      , SoftBreak
+      , Str "Because"
+      , Space
+      , Str "a"
+      , Space
+      , Str "hard-wrapped"
+      , Space
+      , Str "line"
+      , Space
+      , Str "in"
+      , Space
+      , Str "the"
+      , SoftBreak
+      , Str "middle"
+      , Space
+      , Str "of"
+      , Space
+      , Str "a"
+      , Space
+      , Str "paragraph"
+      , Space
+      , Str "looked"
+      , Space
+      , Str "like"
+      , Space
+      , Str "a"
+      , SoftBreak
+      , Str "list"
+      , Space
+      , Str "item."
+      ]
+  , Para
+      [ Str "Here\8217s"
+      , Space
+      , Str "one"
+      , Space
+      , Str "with"
+      , Space
+      , Str "a"
+      , Space
+      , Str "bullet."
+      , SoftBreak
+      , Str "*"
+      , Space
+      , Str "criminey."
+      ]
+  , Para
+      [ Str "There"
+      , Space
+      , Str "should"
+      , Space
+      , Str "be"
+      , Space
+      , Str "a"
+      , Space
+      , Str "hard"
+      , Space
+      , Str "line"
+      , Space
+      , Str "break"
+      , LineBreak
+      , Str "here."
+      ]
+  , HorizontalRule
+  , Header
+      1
+      ( "block-quotes" , [] , [] )
+      [ Str "Block" , Space , Str "Quotes" ]
+  , Para [ Str "E-mail" , Space , Str "style:" ]
+  , BlockQuote
+      [ Para
+          [ Str "This"
+          , Space
+          , Str "is"
+          , Space
+          , Str "a"
+          , Space
+          , Str "block"
+          , Space
+          , Str "quote."
+          , SoftBreak
+          , Str "It"
+          , Space
+          , Str "is"
+          , Space
+          , Str "pretty"
+          , Space
+          , Str "short."
+          ]
+      ]
+  , BlockQuote
+      [ Para
+          [ Str "Code"
+          , Space
+          , Str "in"
+          , Space
+          , Str "a"
+          , Space
+          , Str "block"
+          , Space
+          , Str "quote:"
+          ]
+      , CodeBlock
+          ( "" , [] , [] ) "sub status {\n    print \"working\";\n}"
+      , Para [ Str "A" , Space , Str "list:" ]
+      , OrderedList
+          ( 1 , Decimal , Period )
+          [ [ Plain [ Str "item" , Space , Str "one" ] ]
+          , [ Plain [ Str "item" , Space , Str "two" ] ]
+          ]
+      , Para
+          [ Str "Nested"
+          , Space
+          , Str "block"
+          , Space
+          , Str "quotes:"
+          ]
+      , BlockQuote [ Para [ Str "nested" ] ]
+      , BlockQuote [ Para [ Str "nested" ] ]
+      ]
+  , Para
+      [ Str "This"
+      , Space
+      , Str "should"
+      , Space
+      , Str "not"
+      , Space
+      , Str "be"
+      , Space
+      , Str "a"
+      , Space
+      , Str "block"
+      , Space
+      , Str "quote:"
+      , Space
+      , Str "2"
+      , SoftBreak
+      , Str ">"
+      , Space
+      , Str "1."
+      ]
+  , Para
+      [ Str "And"
+      , Space
+      , Str "a"
+      , Space
+      , Str "following"
+      , Space
+      , Str "paragraph."
+      ]
+  , HorizontalRule
+  , Header
+      1
+      ( "code-blocks" , [] , [] )
+      [ Str "Code" , Space , Str "Blocks" ]
+  , Para [ Str "Code:" ]
+  , CodeBlock
+      ( "" , [] , [] )
+      "---- (should be four hyphens)\n\nsub status {\n    print \"working\";\n}\n\nthis code block is indented by one tab"
+  , Para [ Str "And:" ]
+  , CodeBlock
+      ( "" , [] , [] )
+      "    this code block is indented by two tabs\n\nThese should not be escaped:  \\$ \\\\ \\> \\[ \\{"
+  , HorizontalRule
+  , Header 1 ( "lists" , [] , [] ) [ Str "Lists" ]
+  , Header 2 ( "unordered" , [] , [] ) [ Str "Unordered" ]
+  , Para [ Str "Asterisks" , Space , Str "tight:" ]
+  , BulletList
+      [ [ Plain [ Str "asterisk" , Space , Str "1" ] ]
+      , [ Plain [ Str "asterisk" , Space , Str "2" ] ]
+      , [ Plain [ Str "asterisk" , Space , Str "3" ] ]
+      ]
+  , Para [ Str "Asterisks" , Space , Str "loose:" ]
+  , BulletList
+      [ [ Para [ Str "asterisk" , Space , Str "1" ] ]
+      , [ Para [ Str "asterisk" , Space , Str "2" ] ]
+      , [ Para [ Str "asterisk" , Space , Str "3" ] ]
+      ]
+  , Para [ Str "Pluses" , Space , Str "tight:" ]
+  , BulletList
+      [ [ Plain [ Str "Plus" , Space , Str "1" ] ]
+      , [ Plain [ Str "Plus" , Space , Str "2" ] ]
+      , [ Plain [ Str "Plus" , Space , Str "3" ] ]
+      ]
+  , Para [ Str "Pluses" , Space , Str "loose:" ]
+  , BulletList
+      [ [ Para [ Str "Plus" , Space , Str "1" ] ]
+      , [ Para [ Str "Plus" , Space , Str "2" ] ]
+      , [ Para [ Str "Plus" , Space , Str "3" ] ]
+      ]
+  , Para [ Str "Minuses" , Space , Str "tight:" ]
+  , BulletList
+      [ [ Plain [ Str "Minus" , Space , Str "1" ] ]
+      , [ Plain [ Str "Minus" , Space , Str "2" ] ]
+      , [ Plain [ Str "Minus" , Space , Str "3" ] ]
+      ]
+  , Para [ Str "Minuses" , Space , Str "loose:" ]
+  , BulletList
+      [ [ Para [ Str "Minus" , Space , Str "1" ] ]
+      , [ Para [ Str "Minus" , Space , Str "2" ] ]
+      , [ Para [ Str "Minus" , Space , Str "3" ] ]
+      ]
+  , Header 2 ( "ordered" , [] , [] ) [ Str "Ordered" ]
+  , Para [ Str "Tight:" ]
+  , OrderedList
+      ( 1 , Decimal , Period )
+      [ [ Plain [ Str "First" ] ]
+      , [ Plain [ Str "Second" ] ]
+      , [ Plain [ Str "Third" ] ]
+      ]
+  , Para [ Str "and:" ]
+  , OrderedList
+      ( 1 , Decimal , Period )
+      [ [ Plain [ Str "One" ] ]
+      , [ Plain [ Str "Two" ] ]
+      , [ Plain [ Str "Three" ] ]
+      ]
+  , Para
+      [ Str "Loose" , Space , Str "using" , Space , Str "tabs:" ]
+  , OrderedList
+      ( 1 , Decimal , Period )
+      [ [ Para [ Str "First" ] ]
+      , [ Para [ Str "Second" ] ]
+      , [ Para [ Str "Third" ] ]
+      ]
+  , Para
+      [ Str "and" , Space , Str "using" , Space , Str "spaces:" ]
+  , OrderedList
+      ( 1 , Decimal , Period )
+      [ [ Para [ Str "One" ] ]
+      , [ Para [ Str "Two" ] ]
+      , [ Para [ Str "Three" ] ]
+      ]
+  , Para [ Str "Multiple" , Space , Str "paragraphs:" ]
+  , OrderedList
+      ( 1 , Decimal , Period )
+      [ [ Para
+            [ Str "Item"
+            , Space
+            , Str "1,"
+            , Space
+            , Str "graf"
+            , Space
+            , Str "one."
+            ]
+        , Para
+            [ Str "Item"
+            , Space
+            , Str "1."
+            , Space
+            , Str "graf"
+            , Space
+            , Str "two."
+            , Space
+            , Str "The"
+            , Space
+            , Str "quick"
+            , Space
+            , Str "brown"
+            , Space
+            , Str "fox"
+            , Space
+            , Str "jumped"
+            , Space
+            , Str "over"
+            , Space
+            , Str "the"
+            , Space
+            , Str "lazy"
+            , Space
+            , Str "dog\8217s"
+            , SoftBreak
+            , Str "back."
+            ]
+        ]
+      , [ Para [ Str "Item" , Space , Str "2." ] ]
+      , [ Para [ Str "Item" , Space , Str "3." ] ]
+      ]
+  , Header 2 ( "nested" , [] , [] ) [ Str "Nested" ]
+  , BulletList
+      [ [ Plain [ Str "Tab" ]
+        , BulletList
+            [ [ Plain [ Str "Tab" ]
+              , BulletList [ [ Plain [ Str "Tab" ] ] ]
+              ]
+            ]
+        ]
+      ]
+  , Para [ Str "Here\8217s" , Space , Str "another:" ]
+  , OrderedList
+      ( 1 , Decimal , Period )
+      [ [ Plain [ Str "First" ] ]
+      , [ Plain [ Str "Second:" ]
+        , BulletList
+            [ [ Plain [ Str "Fee" ] ]
+            , [ Plain [ Str "Fie" ] ]
+            , [ Plain [ Str "Foe" ] ]
+            ]
+        ]
+      , [ Plain [ Str "Third" ] ]
+      ]
+  , Para
+      [ Str "Same"
+      , Space
+      , Str "thing"
+      , Space
+      , Str "but"
+      , Space
+      , Str "with"
+      , Space
+      , Str "paragraphs:"
+      ]
+  , OrderedList
+      ( 1 , Decimal , Period )
+      [ [ Para [ Str "First" ] ]
+      , [ Para [ Str "Second:" ]
+        , BulletList
+            [ [ Plain [ Str "Fee" ] ]
+            , [ Plain [ Str "Fie" ] ]
+            , [ Plain [ Str "Foe" ] ]
+            ]
+        ]
+      , [ Para [ Str "Third" ] ]
+      ]
+  , Header
+      2
+      ( "tabs-and-spaces" , [] , [] )
+      [ Str "Tabs" , Space , Str "and" , Space , Str "spaces" ]
+  , BulletList
+      [ [ Para
+            [ Str "this"
+            , Space
+            , Str "is"
+            , Space
+            , Str "a"
+            , Space
+            , Str "list"
+            , Space
+            , Str "item"
+            , SoftBreak
+            , Str "indented"
+            , Space
+            , Str "with"
+            , Space
+            , Str "tabs"
+            ]
+        ]
+      , [ Para
+            [ Str "this"
+            , Space
+            , Str "is"
+            , Space
+            , Str "a"
+            , Space
+            , Str "list"
+            , Space
+            , Str "item"
+            , SoftBreak
+            , Str "indented"
+            , Space
+            , Str "with"
+            , Space
+            , Str "spaces"
+            ]
+        , BulletList
+            [ [ Para
+                  [ Str "this"
+                  , Space
+                  , Str "is"
+                  , Space
+                  , Str "an"
+                  , Space
+                  , Str "example"
+                  , Space
+                  , Str "list"
+                  , Space
+                  , Str "item"
+                  , SoftBreak
+                  , Str "indented"
+                  , Space
+                  , Str "with"
+                  , Space
+                  , Str "tabs"
+                  ]
+              ]
+            , [ Para
+                  [ Str "this"
+                  , Space
+                  , Str "is"
+                  , Space
+                  , Str "an"
+                  , Space
+                  , Str "example"
+                  , Space
+                  , Str "list"
+                  , Space
+                  , Str "item"
+                  , SoftBreak
+                  , Str "indented"
+                  , Space
+                  , Str "with"
+                  , Space
+                  , Str "spaces"
+                  ]
+              ]
+            ]
+        ]
+      ]
+  , Header
+      2
+      ( "fancy-list-markers" , [] , [] )
+      [ Str "Fancy" , Space , Str "list" , Space , Str "markers" ]
+  , OrderedList
+      ( 2 , Decimal , TwoParens )
+      [ [ Para
+            [ Str "begins" , Space , Str "with" , Space , Str "2" ]
+        ]
+      , [ Para [ Str "and" , Space , Str "now" , Space , Str "3" ]
+        , Para
+            [ Str "with"
+            , Space
+            , Str "a"
+            , Space
+            , Str "continuation"
+            ]
+        , OrderedList
+            ( 4 , LowerRoman , Period )
+            [ [ Plain
+                  [ Str "sublist"
+                  , Space
+                  , Str "with"
+                  , Space
+                  , Str "roman"
+                  , Space
+                  , Str "numerals,"
+                  , SoftBreak
+                  , Str "starting"
+                  , Space
+                  , Str "with"
+                  , Space
+                  , Str "4"
+                  ]
+              ]
+            , [ Plain [ Str "more" , Space , Str "items" ]
+              , OrderedList
+                  ( 1 , UpperAlpha , TwoParens )
+                  [ [ Plain [ Str "a" , Space , Str "subsublist" ] ]
+                  , [ Plain [ Str "a" , Space , Str "subsublist" ] ]
+                  ]
+              ]
+            ]
+        ]
+      ]
+  , Para [ Str "Nesting:" ]
+  , OrderedList
+      ( 1 , UpperAlpha , Period )
+      [ [ Plain [ Str "Upper" , Space , Str "Alpha" ]
+        , OrderedList
+            ( 1 , UpperRoman , Period )
+            [ [ Plain [ Str "Upper" , Space , Str "Roman." ]
+              , OrderedList
+                  ( 6 , Decimal , TwoParens )
+                  [ [ Plain
+                        [ Str "Decimal"
+                        , Space
+                        , Str "start"
+                        , Space
+                        , Str "with"
+                        , Space
+                        , Str "6"
+                        ]
+                    , OrderedList
+                        ( 3 , LowerAlpha , OneParen )
+                        [ [ Plain
+                              [ Str "Lower"
+                              , Space
+                              , Str "alpha"
+                              , Space
+                              , Str "with"
+                              , Space
+                              , Str "paren"
+                              ]
+                          ]
+                        ]
+                    ]
+                  ]
+              ]
+            ]
+        ]
+      ]
+  , Para [ Str "Autonumbering:" ]
+  , OrderedList
+      ( 1 , DefaultStyle , DefaultDelim )
+      [ [ Plain [ Str "Autonumber." ] ]
+      , [ Plain [ Str "More." ]
+        , OrderedList
+            ( 1 , DefaultStyle , DefaultDelim )
+            [ [ Plain [ Str "Nested." ] ] ]
+        ]
+      ]
+  , Para
+      [ Str "Should"
+      , Space
+      , Str "not"
+      , Space
+      , Str "be"
+      , Space
+      , Str "a"
+      , Space
+      , Str "list"
+      , Space
+      , Str "item:"
+      ]
+  , Para [ Str "M.A.\160\&2007" ]
+  , Para [ Str "B." , Space , Str "Williams" ]
+  , HorizontalRule
+  , Header
+      1
+      ( "definition-lists" , [] , [] )
+      [ Str "Definition" , Space , Str "Lists" ]
+  , Para
+      [ Str "Tight"
+      , Space
+      , Str "using"
+      , Space
+      , Str "spaces:"
+      ]
+  , DefinitionList
+      [ ( [ Str "apple" ]
+        , [ [ Plain [ Str "red" , Space , Str "fruit" ] ] ]
+        )
+      , ( [ Str "orange" ]
+        , [ [ Plain [ Str "orange" , Space , Str "fruit" ] ] ]
+        )
+      , ( [ Str "banana" ]
+        , [ [ Plain [ Str "yellow" , Space , Str "fruit" ] ] ]
+        )
+      ]
+  , Para
+      [ Str "Tight" , Space , Str "using" , Space , Str "tabs:" ]
+  , DefinitionList
+      [ ( [ Str "apple" ]
+        , [ [ Plain [ Str "red" , Space , Str "fruit" ] ] ]
+        )
+      , ( [ Str "orange" ]
+        , [ [ Plain [ Str "orange" , Space , Str "fruit" ] ] ]
+        )
+      , ( [ Str "banana" ]
+        , [ [ Plain [ Str "yellow" , Space , Str "fruit" ] ] ]
+        )
+      ]
+  , Para [ Str "Loose:" ]
+  , DefinitionList
+      [ ( [ Str "apple" ]
+        , [ [ Para [ Str "red" , Space , Str "fruit" ] ] ]
+        )
+      , ( [ Str "orange" ]
+        , [ [ Para [ Str "orange" , Space , Str "fruit" ] ] ]
+        )
+      , ( [ Str "banana" ]
+        , [ [ Para [ Str "yellow" , Space , Str "fruit" ] ] ]
+        )
+      ]
+  , Para
+      [ Str "Multiple"
+      , Space
+      , Str "blocks"
+      , Space
+      , Str "with"
+      , Space
+      , Str "italics:"
+      ]
+  , DefinitionList
+      [ ( [ Emph [ Str "apple" ] ]
+        , [ [ Para [ Str "red" , Space , Str "fruit" ]
+            , Para
+                [ Str "contains"
+                , Space
+                , Str "seeds,"
+                , SoftBreak
+                , Str "crisp,"
+                , Space
+                , Str "pleasant"
+                , Space
+                , Str "to"
+                , Space
+                , Str "taste"
+                ]
+            ]
+          ]
+        )
+      , ( [ Emph [ Str "orange" ] ]
+        , [ [ Para [ Str "orange" , Space , Str "fruit" ]
+            , CodeBlock ( "" , [] , [] ) "{ orange code block }"
+            , BlockQuote
+                [ Para
+                    [ Str "orange"
+                    , Space
+                    , Str "block"
+                    , Space
+                    , Str "quote"
+                    ]
+                ]
+            ]
+          ]
+        )
+      ]
+  , Para
+      [ Str "Multiple"
+      , Space
+      , Str "definitions,"
+      , Space
+      , Str "tight:"
+      ]
+  , DefinitionList
+      [ ( [ Str "apple" ]
+        , [ [ Plain [ Str "red" , Space , Str "fruit" ] ]
+          , [ Plain [ Str "computer" ] ]
+          ]
+        )
+      , ( [ Str "orange" ]
+        , [ [ Plain [ Str "orange" , Space , Str "fruit" ] ]
+          , [ Plain [ Str "bank" ] ]
+          ]
+        )
+      ]
+  , Para
+      [ Str "Multiple"
+      , Space
+      , Str "definitions,"
+      , Space
+      , Str "loose:"
+      ]
+  , DefinitionList
+      [ ( [ Str "apple" ]
+        , [ [ Para [ Str "red" , Space , Str "fruit" ] ]
+          , [ Para [ Str "computer" ] ]
+          ]
+        )
+      , ( [ Str "orange" ]
+        , [ [ Para [ Str "orange" , Space , Str "fruit" ] ]
+          , [ Para [ Str "bank" ] ]
+          ]
+        )
+      ]
+  , Para
+      [ Str "Blank"
+      , Space
+      , Str "line"
+      , Space
+      , Str "after"
+      , Space
+      , Str "term,"
+      , Space
+      , Str "indented"
+      , Space
+      , Str "marker,"
+      , Space
+      , Str "alternate"
+      , Space
+      , Str "markers:"
+      ]
+  , DefinitionList
+      [ ( [ Str "apple" ]
+        , [ [ Para [ Str "red" , Space , Str "fruit" ] ]
+          , [ Para [ Str "computer" ] ]
+          ]
+        )
+      , ( [ Str "orange" ]
+        , [ [ Para [ Str "orange" , Space , Str "fruit" ]
+            , OrderedList
+                ( 1 , Decimal , Period )
+                [ [ Plain [ Str "sublist" ] ]
+                , [ Plain [ Str "sublist" ] ]
+                ]
+            ]
+          ]
+        )
+      ]
+  , Header
+      1
+      ( "html-blocks" , [] , [] )
+      [ Str "HTML" , Space , Str "Blocks" ]
+  , Para
+      [ Str "Simple"
+      , Space
+      , Str "block"
+      , Space
+      , Str "on"
+      , Space
+      , Str "one"
+      , Space
+      , Str "line:"
+      ]
+  , Div ( "" , [] , [] ) [ Plain [ Str "foo" ] ]
+  , Para
+      [ Str "And"
+      , Space
+      , Str "nested"
+      , Space
+      , Str "without"
+      , Space
+      , Str "indentation:"
+      ]
+  , Div
+      ( "" , [] , [] )
+      [ Div
+          ( "" , [] , [] )
+          [ Div ( "" , [] , [] ) [ Para [ Str "foo" ] ] ]
+      , Div ( "" , [] , [] ) [ Plain [ Str "bar" ] ]
+      ]
+  , Para
+      [ Str "Interpreted"
+      , Space
+      , Str "markdown"
+      , Space
+      , Str "in"
+      , Space
+      , Str "a"
+      , Space
+      , Str "table:"
+      ]
+  , RawBlock (Format "html") "<table>"
+  , RawBlock (Format "html") "<tr>"
+  , RawBlock (Format "html") "<td>"
+  , Plain
+      [ Str "This"
+      , Space
+      , Str "is"
+      , Space
+      , Emph [ Str "emphasized" ]
+      ]
+  , RawBlock (Format "html") "</td>"
+  , RawBlock (Format "html") "<td>"
+  , Plain
+      [ Str "And"
+      , Space
+      , Str "this"
+      , Space
+      , Str "is"
+      , Space
+      , Strong [ Str "strong" ]
+      ]
+  , RawBlock (Format "html") "</td>"
+  , RawBlock (Format "html") "</tr>"
+  , RawBlock (Format "html") "</table>"
+  , RawBlock
+      (Format "html")
+      "<script type=\"text/javascript\">document.write('This *should not* be interpreted as markdown');</script>"
+  , Para
+      [ Str "Here\8217s"
+      , Space
+      , Str "a"
+      , Space
+      , Str "simple"
+      , Space
+      , Str "block:"
+      ]
+  , Div ( "" , [] , [] ) [ Para [ Str "foo" ] ]
+  , Para
+      [ Str "This"
+      , Space
+      , Str "should"
+      , Space
+      , Str "be"
+      , Space
+      , Str "a"
+      , Space
+      , Str "code"
+      , Space
+      , Str "block,"
+      , Space
+      , Str "though:"
+      ]
+  , CodeBlock ( "" , [] , [] ) "<div>\n    foo\n</div>"
+  , Para
+      [ Str "As" , Space , Str "should" , Space , Str "this:" ]
+  , CodeBlock ( "" , [] , [] ) "<div>foo</div>"
+  , Para [ Str "Now," , Space , Str "nested:" ]
+  , Div
+      ( "" , [] , [] )
+      [ Div
+          ( "" , [] , [] )
+          [ Div ( "" , [] , [] ) [ Plain [ Str "foo" ] ] ]
+      ]
+  , Para
+      [ Str "This"
+      , Space
+      , Str "should"
+      , Space
+      , Str "just"
+      , Space
+      , Str "be"
+      , Space
+      , Str "an"
+      , Space
+      , Str "HTML"
+      , Space
+      , Str "comment:"
+      ]
+  , RawBlock (Format "html") "<!-- Comment -->"
+  , Para [ Str "Multiline:" ]
+  , RawBlock (Format "html") "<!--\nBlah\nBlah\n-->"
+  , RawBlock
+      (Format "html") "<!--\n    This is another comment.\n-->"
+  , Para [ Str "Code" , Space , Str "block:" ]
+  , CodeBlock ( "" , [] , [] ) "<!-- Comment -->"
+  , Para
+      [ Str "Just"
+      , Space
+      , Str "plain"
+      , Space
+      , Str "comment,"
+      , Space
+      , Str "with"
+      , Space
+      , Str "trailing"
+      , Space
+      , Str "spaces"
+      , Space
+      , Str "on"
+      , Space
+      , Str "the"
+      , Space
+      , Str "line:"
+      ]
+  , RawBlock (Format "html") "<!-- foo -->"
+  , Para [ Str "Code:" ]
+  , CodeBlock ( "" , [] , [] ) "<hr />"
+  , Para [ Str "Hr\8217s:" ]
+  , RawBlock (Format "html") "<hr>"
+  , RawBlock (Format "html") "<hr />"
+  , RawBlock (Format "html") "<hr />"
+  , RawBlock (Format "html") "<hr>"
+  , RawBlock (Format "html") "<hr />"
+  , RawBlock (Format "html") "<hr />"
+  , RawBlock (Format "html") "<hr class=\"foo\" id=\"bar\" />"
+  , RawBlock (Format "html") "<hr class=\"foo\" id=\"bar\" />"
+  , RawBlock (Format "html") "<hr class=\"foo\" id=\"bar\">"
+  , HorizontalRule
+  , Header
+      1
+      ( "inline-markup" , [] , [] )
+      [ Str "Inline" , Space , Str "Markup" ]
+  , Para
+      [ Str "This"
+      , Space
+      , Str "is"
+      , Space
+      , Emph [ Str "emphasized" ]
+      , Str ","
+      , Space
+      , Str "and"
+      , Space
+      , Str "so"
+      , Space
+      , Emph [ Str "is" , Space , Str "this" ]
+      , Str "."
+      ]
+  , Para
+      [ Str "This"
+      , Space
+      , Str "is"
+      , Space
+      , Strong [ Str "strong" ]
+      , Str ","
+      , Space
+      , Str "and"
+      , Space
+      , Str "so"
+      , Space
+      , Strong [ Str "is" , Space , Str "this" ]
+      , Str "."
+      ]
+  , Para
+      [ Str "An"
+      , Space
+      , Emph
+          [ Link
+              ( "" , [] , [] )
+              [ Str "emphasized" , Space , Str "link" ]
+              ( "/url" , "" )
+          ]
+      , Str "."
+      ]
+  , Para
+      [ Strong
+          [ Emph
+              [ Str "This"
+              , Space
+              , Str "is"
+              , Space
+              , Str "strong"
+              , Space
+              , Str "and"
+              , Space
+              , Str "em."
+              ]
+          ]
+      ]
+  , Para
+      [ Str "So"
+      , Space
+      , Str "is"
+      , Space
+      , Strong [ Emph [ Str "this" ] ]
+      , Space
+      , Str "word."
+      ]
+  , Para
+      [ Strong
+          [ Emph
+              [ Str "This"
+              , Space
+              , Str "is"
+              , Space
+              , Str "strong"
+              , Space
+              , Str "and"
+              , Space
+              , Str "em."
+              ]
+          ]
+      ]
+  , Para
+      [ Str "So"
+      , Space
+      , Str "is"
+      , Space
+      , Strong [ Emph [ Str "this" ] ]
+      , Space
+      , Str "word."
+      ]
+  , Para
+      [ Str "This"
+      , Space
+      , Str "is"
+      , Space
+      , Str "code:"
+      , Space
+      , Code ( "" , [] , [] ) ">"
+      , Str ","
+      , Space
+      , Code ( "" , [] , [] ) "$"
+      , Str ","
+      , Space
+      , Code ( "" , [] , [] ) "\\"
+      , Str ","
+      , Space
+      , Code ( "" , [] , [] ) "\\$"
+      , Str ","
+      , Space
+      , Code ( "" , [] , [] ) "<html>"
+      , Str "."
+      ]
+  , Para
+      [ Strikeout
+          [ Str "This"
+          , Space
+          , Str "is"
+          , Space
+          , Emph [ Str "strikeout" ]
+          , Str "."
+          ]
+      ]
+  , Para
+      [ Str "Superscripts:"
+      , Space
+      , Str "a"
+      , Superscript [ Str "bc" ]
+      , Str "d"
+      , Space
+      , Str "a"
+      , Superscript [ Emph [ Str "hello" ] ]
+      , Space
+      , Str "a"
+      , Superscript [ Str "hello\160there" ]
+      , Str "."
+      ]
+  , Para
+      [ Str "Subscripts:"
+      , Space
+      , Str "H"
+      , Subscript [ Str "2" ]
+      , Str "O,"
+      , Space
+      , Str "H"
+      , Subscript [ Str "23" ]
+      , Str "O,"
+      , Space
+      , Str "H"
+      , Subscript [ Str "many\160of\160them" ]
+      , Str "O."
+      ]
+  , Para
+      [ Str "These"
+      , Space
+      , Str "should"
+      , Space
+      , Str "not"
+      , Space
+      , Str "be"
+      , Space
+      , Str "superscripts"
+      , Space
+      , Str "or"
+      , Space
+      , Str "subscripts,"
+      , SoftBreak
+      , Str "because"
+      , Space
+      , Str "of"
+      , Space
+      , Str "the"
+      , Space
+      , Str "unescaped"
+      , Space
+      , Str "spaces:"
+      , Space
+      , Str "a^b"
+      , Space
+      , Str "c^d,"
+      , Space
+      , Str "a~b"
+      , Space
+      , Str "c~d."
+      ]
+  , HorizontalRule
+  , Header
+      1
+      ( "smart-quotes-ellipses-dashes" , [] , [] )
+      [ Str "Smart"
+      , Space
+      , Str "quotes,"
+      , Space
+      , Str "ellipses,"
+      , Space
+      , Str "dashes"
+      ]
+  , Para
+      [ Quoted DoubleQuote [ Str "Hello," ]
+      , Space
+      , Str "said"
+      , Space
+      , Str "the"
+      , Space
+      , Str "spider."
+      , Space
+      , Quoted
+          DoubleQuote
+          [ Quoted SingleQuote [ Str "Shelob" ]
+          , Space
+          , Str "is"
+          , Space
+          , Str "my"
+          , Space
+          , Str "name."
+          ]
+      ]
+  , Para
+      [ Quoted SingleQuote [ Str "A" ]
+      , Str ","
+      , Space
+      , Quoted SingleQuote [ Str "B" ]
+      , Str ","
+      , Space
+      , Str "and"
+      , Space
+      , Quoted SingleQuote [ Str "C" ]
+      , Space
+      , Str "are"
+      , Space
+      , Str "letters."
+      ]
+  , Para
+      [ Quoted SingleQuote [ Str "Oak," ]
+      , Space
+      , Quoted SingleQuote [ Str "elm," ]
+      , Space
+      , Str "and"
+      , Space
+      , Quoted SingleQuote [ Str "beech" ]
+      , Space
+      , Str "are"
+      , Space
+      , Str "names"
+      , Space
+      , Str "of"
+      , Space
+      , Str "trees."
+      , SoftBreak
+      , Str "So"
+      , Space
+      , Str "is"
+      , Space
+      , Quoted SingleQuote [ Str "pine." ]
+      ]
+  , Para
+      [ Quoted
+          SingleQuote
+          [ Str "He"
+          , Space
+          , Str "said,"
+          , Space
+          , Quoted
+              DoubleQuote
+              [ Str "I"
+              , Space
+              , Str "want"
+              , Space
+              , Str "to"
+              , Space
+              , Str "go."
+              ]
+          ]
+      , Space
+      , Str "Were"
+      , Space
+      , Str "you"
+      , Space
+      , Str "alive"
+      , Space
+      , Str "in"
+      , Space
+      , Str "the"
+      , SoftBreak
+      , Str "70\8217s?"
+      ]
+  , Para
+      [ Str "Here"
+      , Space
+      , Str "is"
+      , Space
+      , Str "some"
+      , Space
+      , Str "quoted"
+      , Space
+      , Quoted SingleQuote [ Code ( "" , [] , [] ) "code" ]
+      , Space
+      , Str "and"
+      , Space
+      , Str "a"
+      , Space
+      , Quoted
+          DoubleQuote
+          [ Link
+              ( "" , [] , [] )
+              [ Str "quoted" , Space , Str "link" ]
+              ( "http://example.com/?foo=1&bar=2" , "" )
+          ]
+      , Str "."
+      ]
+  , Para
+      [ Str "Some"
+      , Space
+      , Str "dashes:"
+      , Space
+      , Str "one\8212two"
+      , Space
+      , Str "\8212"
+      , Space
+      , Str "three\8212four"
+      , Space
+      , Str "\8212"
+      , Space
+      , Str "five."
+      ]
+  , Para
+      [ Str "Dashes"
+      , Space
+      , Str "between"
+      , Space
+      , Str "numbers:"
+      , Space
+      , Str "5\8211\&7,"
+      , Space
+      , Str "255\8211\&66,"
+      , Space
+      , Str "1987\8211\&1999."
+      ]
+  , Para [ Str "Ellipses\8230and\8230and\8230." ]
+  , HorizontalRule
+  , Header 1 ( "latex" , [] , [] ) [ Str "LaTeX" ]
+  , BulletList
+      [ [ Plain
+            [ RawInline (Format "tex") "\\cite[22-23]{smith.1899}" ]
+        ]
+      , [ Plain [ Math InlineMath "2+2=4" ] ]
+      , [ Plain [ Math InlineMath "x \\in y" ] ]
+      , [ Plain [ Math InlineMath "\\alpha \\wedge \\omega" ] ]
+      , [ Plain [ Math InlineMath "223" ] ]
+      , [ Plain [ Math InlineMath "p" , Str "-Tree" ] ]
+      , [ Plain
+            [ Str "Here\8217s"
+            , Space
+            , Str "some"
+            , Space
+            , Str "display"
+            , Space
+            , Str "math:"
+            , SoftBreak
+            , Math
+                DisplayMath
+                "\\frac{d}{dx}f(x)=\\lim_{h\\to 0}\\frac{f(x+h)-f(x)}{h}"
+            ]
+        ]
+      , [ Plain
+            [ Str "Here\8217s"
+            , Space
+            , Str "one"
+            , Space
+            , Str "that"
+            , Space
+            , Str "has"
+            , Space
+            , Str "a"
+            , Space
+            , Str "line"
+            , Space
+            , Str "break"
+            , Space
+            , Str "in"
+            , Space
+            , Str "it:"
+            , Space
+            , Math InlineMath "\\alpha + \\omega \\times x^2"
+            , Str "."
+            ]
+        ]
+      ]
+  , Para
+      [ Str "These"
+      , Space
+      , Str "shouldn\8217t"
+      , Space
+      , Str "be"
+      , Space
+      , Str "math:"
+      ]
+  , BulletList
+      [ [ Plain
+            [ Str "To"
+            , Space
+            , Str "get"
+            , Space
+            , Str "the"
+            , Space
+            , Str "famous"
+            , Space
+            , Str "equation,"
+            , Space
+            , Str "write"
+            , Space
+            , Code ( "" , [] , [] ) "$e = mc^2$"
+            , Str "."
+            ]
+        ]
+      , [ Plain
+            [ Str "$22,000"
+            , Space
+            , Str "is"
+            , Space
+            , Str "a"
+            , Space
+            , Emph [ Str "lot" ]
+            , Space
+            , Str "of"
+            , Space
+            , Str "money."
+            , Space
+            , Str "So"
+            , Space
+            , Str "is"
+            , Space
+            , Str "$34,000."
+            , SoftBreak
+            , Str "(It"
+            , Space
+            , Str "worked"
+            , Space
+            , Str "if"
+            , Space
+            , Quoted DoubleQuote [ Str "lot" ]
+            , Space
+            , Str "is"
+            , Space
+            , Str "emphasized.)"
+            ]
+        ]
+      , [ Plain
+            [ Str "Shoes"
+            , Space
+            , Str "($20)"
+            , Space
+            , Str "and"
+            , Space
+            , Str "socks"
+            , Space
+            , Str "($5)."
+            ]
+        ]
+      , [ Plain
+            [ Str "Escaped"
+            , Space
+            , Code ( "" , [] , [] ) "$"
+            , Str ":"
+            , Space
+            , Str "$73"
+            , Space
+            , Emph
+                [ Str "this"
+                , Space
+                , Str "should"
+                , Space
+                , Str "be"
+                , Space
+                , Str "emphasized"
+                ]
+            , Space
+            , Str "23$."
+            ]
+        ]
+      ]
+  , Para
+      [ Str "Here\8217s"
+      , Space
+      , Str "a"
+      , Space
+      , Str "LaTeX"
+      , Space
+      , Str "table:"
+      ]
+  , RawBlock
+      (Format "tex")
+      "\\begin{tabular}{|l|l|}\\hline\nAnimal & Number \\\\ \\hline\nDog    & 2      \\\\\nCat    & 1      \\\\ \\hline\n\\end{tabular}"
+  , HorizontalRule
+  , Header
+      1
+      ( "special-characters" , [] , [] )
+      [ Str "Special" , Space , Str "Characters" ]
+  , Para
+      [ Str "Here"
+      , Space
+      , Str "is"
+      , Space
+      , Str "some"
+      , Space
+      , Str "unicode:"
+      ]
+  , BulletList
+      [ [ Plain
+            [ Str "I" , Space , Str "hat:" , Space , Str "\206" ]
+        ]
+      , [ Plain
+            [ Str "o" , Space , Str "umlaut:" , Space , Str "\246" ]
+        ]
+      , [ Plain [ Str "section:" , Space , Str "\167" ] ]
+      , [ Plain
+            [ Str "set"
+            , Space
+            , Str "membership:"
+            , Space
+            , Str "\8712"
+            ]
+        ]
+      , [ Plain [ Str "copyright:" , Space , Str "\169" ] ]
+      ]
+  , Para
+      [ Str "AT&T"
+      , Space
+      , Str "has"
+      , Space
+      , Str "an"
+      , Space
+      , Str "ampersand"
+      , Space
+      , Str "in"
+      , Space
+      , Str "their"
+      , Space
+      , Str "name."
+      ]
+  , Para
+      [ Str "AT&T"
+      , Space
+      , Str "is"
+      , Space
+      , Str "another"
+      , Space
+      , Str "way"
+      , Space
+      , Str "to"
+      , Space
+      , Str "write"
+      , Space
+      , Str "it."
+      ]
+  , Para
+      [ Str "This" , Space , Str "&" , Space , Str "that." ]
+  , Para [ Str "4" , Space , Str "<" , Space , Str "5." ]
+  , Para [ Str "6" , Space , Str ">" , Space , Str "5." ]
+  , Para [ Str "Backslash:" , Space , Str "\\" ]
+  , Para [ Str "Backtick:" , Space , Str "`" ]
+  , Para [ Str "Asterisk:" , Space , Str "*" ]
+  , Para [ Str "Underscore:" , Space , Str "_" ]
+  , Para
+      [ Str "Left" , Space , Str "brace:" , Space , Str "{" ]
+  , Para
+      [ Str "Right" , Space , Str "brace:" , Space , Str "}" ]
+  , Para
+      [ Str "Left" , Space , Str "bracket:" , Space , Str "[" ]
+  , Para
+      [ Str "Right" , Space , Str "bracket:" , Space , Str "]" ]
+  , Para
+      [ Str "Left" , Space , Str "paren:" , Space , Str "(" ]
+  , Para
+      [ Str "Right" , Space , Str "paren:" , Space , Str ")" ]
+  , Para [ Str "Greater-than:" , Space , Str ">" ]
+  , Para [ Str "Hash:" , Space , Str "#" ]
+  , Para [ Str "Period:" , Space , Str "." ]
+  , Para [ Str "Bang:" , Space , Str "!" ]
+  , Para [ Str "Plus:" , Space , Str "+" ]
+  , Para [ Str "Minus:" , Space , Str "-" ]
+  , HorizontalRule
+  , Header 1 ( "links" , [] , [] ) [ Str "Links" ]
+  , Header 2 ( "explicit" , [] , [] ) [ Str "Explicit" ]
+  , Para
+      [ Str "Just"
+      , Space
+      , Str "a"
+      , Space
+      , Link ( "" , [] , [] ) [ Str "URL" ] ( "/url/" , "" )
+      , Str "."
+      ]
+  , Para
+      [ Link
+          ( "" , [] , [] )
+          [ Str "URL" , Space , Str "and" , Space , Str "title" ]
+          ( "/url/" , "title" )
+      , Str "."
+      ]
+  , Para
+      [ Link
+          ( "" , [] , [] )
+          [ Str "URL" , Space , Str "and" , Space , Str "title" ]
+          ( "/url/" , "title preceded by two spaces" )
+      , Str "."
+      ]
+  , Para
+      [ Link
+          ( "" , [] , [] )
+          [ Str "URL" , Space , Str "and" , Space , Str "title" ]
+          ( "/url/" , "title preceded by a tab" )
+      , Str "."
+      ]
+  , Para
+      [ Link
+          ( "" , [] , [] )
+          [ Str "URL" , Space , Str "and" , Space , Str "title" ]
+          ( "/url/" , "title with \"quotes\" in it" )
+      ]
+  , Para
+      [ Link
+          ( "" , [] , [] )
+          [ Str "URL" , Space , Str "and" , Space , Str "title" ]
+          ( "/url/" , "title with single quotes" )
+      ]
+  , Para
+      [ Link
+          ( "" , [] , [] )
+          [ Str "with_underscore" ]
+          ( "/url/with_underscore" , "" )
+      ]
+  , Para
+      [ Link
+          ( "" , [] , [] )
+          [ Str "Email" , Space , Str "link" ]
+          ( "mailto:nobody@nowhere.net" , "" )
+      ]
+  , Para
+      [ Link ( "" , [] , [] ) [ Str "Empty" ] ( "" , "" )
+      , Str "."
+      ]
+  , Header 2 ( "reference" , [] , [] ) [ Str "Reference" ]
+  , Para
+      [ Str "Foo"
+      , Space
+      , Link ( "" , [] , [] ) [ Str "bar" ] ( "/url/" , "" )
+      , Str "."
+      ]
+  , Para
+      [ Str "With"
+      , Space
+      , Link
+          ( "" , [] , [] )
+          [ Str "embedded" , Space , Str "[brackets]" ]
+          ( "/url/" , "" )
+      , Str "."
+      ]
+  , Para
+      [ Link ( "" , [] , [] ) [ Str "b" ] ( "/url/" , "" )
+      , Space
+      , Str "by"
+      , Space
+      , Str "itself"
+      , Space
+      , Str "should"
+      , Space
+      , Str "be"
+      , Space
+      , Str "a"
+      , Space
+      , Str "link."
+      ]
+  , Para
+      [ Str "Indented"
+      , Space
+      , Link ( "" , [] , [] ) [ Str "once" ] ( "/url" , "" )
+      , Str "."
+      ]
+  , Para
+      [ Str "Indented"
+      , Space
+      , Link ( "" , [] , [] ) [ Str "twice" ] ( "/url" , "" )
+      , Str "."
+      ]
+  , Para
+      [ Str "Indented"
+      , Space
+      , Link ( "" , [] , [] ) [ Str "thrice" ] ( "/url" , "" )
+      , Str "."
+      ]
+  , Para
+      [ Str "This"
+      , Space
+      , Str "should"
+      , Space
+      , Str "[not][]"
+      , Space
+      , Str "be"
+      , Space
+      , Str "a"
+      , Space
+      , Str "link."
+      ]
+  , CodeBlock ( "" , [] , [] ) "[not]: /url"
+  , Para
+      [ Str "Foo"
+      , Space
+      , Link
+          ( "" , [] , [] )
+          [ Str "bar" ]
+          ( "/url/" , "Title with \"quotes\" inside" )
+      , Str "."
+      ]
+  , Para
+      [ Str "Foo"
+      , Space
+      , Link
+          ( "" , [] , [] )
+          [ Str "biz" ]
+          ( "/url/" , "Title with \"quote\" inside" )
+      , Str "."
+      ]
+  , Header
+      2
+      ( "with-ampersands" , [] , [] )
+      [ Str "With" , Space , Str "ampersands" ]
+  , Para
+      [ Str "Here\8217s"
+      , Space
+      , Str "a"
+      , Space
+      , Link
+          ( "" , [] , [] )
+          [ Str "link"
+          , Space
+          , Str "with"
+          , Space
+          , Str "an"
+          , Space
+          , Str "ampersand"
+          , Space
+          , Str "in"
+          , Space
+          , Str "the"
+          , Space
+          , Str "URL"
+          ]
+          ( "http://example.com/?foo=1&bar=2" , "" )
+      , Str "."
+      ]
+  , Para
+      [ Str "Here\8217s"
+      , Space
+      , Str "a"
+      , Space
+      , Str "link"
+      , Space
+      , Str "with"
+      , Space
+      , Str "an"
+      , Space
+      , Str "amersand"
+      , Space
+      , Str "in"
+      , Space
+      , Str "the"
+      , Space
+      , Str "link"
+      , Space
+      , Str "text:"
+      , Space
+      , Link
+          ( "" , [] , [] )
+          [ Str "AT&T" ]
+          ( "http://att.com/" , "AT&T" )
+      , Str "."
+      ]
+  , Para
+      [ Str "Here\8217s"
+      , Space
+      , Str "an"
+      , Space
+      , Link
+          ( "" , [] , [] )
+          [ Str "inline" , Space , Str "link" ]
+          ( "/script?foo=1&bar=2" , "" )
+      , Str "."
+      ]
+  , Para
+      [ Str "Here\8217s"
+      , Space
+      , Str "an"
+      , Space
+      , Link
+          ( "" , [] , [] )
+          [ Str "inline"
+          , Space
+          , Str "link"
+          , Space
+          , Str "in"
+          , Space
+          , Str "pointy"
+          , Space
+          , Str "braces"
+          ]
+          ( "/script?foo=1&bar=2" , "" )
+      , Str "."
+      ]
+  , Header 2 ( "autolinks" , [] , [] ) [ Str "Autolinks" ]
+  , Para
+      [ Str "With"
+      , Space
+      , Str "an"
+      , Space
+      , Str "ampersand:"
+      , Space
+      , Link
+          ( "" , [ "uri" ] , [] )
+          [ Str "http://example.com/?foo=1&bar=2" ]
+          ( "http://example.com/?foo=1&bar=2" , "" )
+      ]
+  , BulletList
+      [ [ Plain
+            [ Str "In" , Space , Str "a" , Space , Str "list?" ]
+        ]
+      , [ Plain
+            [ Link
+                ( "" , [ "uri" ] , [] )
+                [ Str "http://example.com/" ]
+                ( "http://example.com/" , "" )
+            ]
+        ]
+      , [ Plain [ Str "It" , Space , Str "should." ] ]
+      ]
+  , Para
+      [ Str "An"
+      , Space
+      , Str "e-mail"
+      , Space
+      , Str "address:"
+      , Space
+      , Link
+          ( "" , [ "email" ] , [] )
+          [ Str "nobody@nowhere.net" ]
+          ( "mailto:nobody@nowhere.net" , "" )
+      ]
+  , BlockQuote
+      [ Para
+          [ Str "Blockquoted:"
+          , Space
+          , Link
+              ( "" , [ "uri" ] , [] )
+              [ Str "http://example.com/" ]
+              ( "http://example.com/" , "" )
+          ]
+      ]
+  , Para
+      [ Str "Auto-links"
+      , Space
+      , Str "should"
+      , Space
+      , Str "not"
+      , Space
+      , Str "occur"
+      , Space
+      , Str "here:"
+      , Space
+      , Code ( "" , [] , [] ) "<http://example.com/>"
+      ]
+  , CodeBlock
+      ( "" , [] , [] ) "or here: <http://example.com/>"
+  , HorizontalRule
+  , Header 1 ( "images" , [] , [] ) [ Str "Images" ]
+  , Para
+      [ Str "From"
+      , Space
+      , Quoted
+          DoubleQuote
+          [ Str "Voyage"
+          , Space
+          , Str "dans"
+          , Space
+          , Str "la"
+          , Space
+          , Str "Lune"
+          ]
+      , Space
+      , Str "by"
+      , Space
+      , Str "Georges"
+      , Space
+      , Str "Melies"
+      , Space
+      , Str "(1902):"
+      ]
+  , Figure
+      ( "" , [] , [] )
+      (Caption Nothing [ Plain [ Str "lalune" ] ])
+      [ Plain
+          [ Image
+              ( "" , [] , [] )
+              [ Str "lalune" ]
+              ( "lalune.jpg" , "Voyage dans la Lune" )
+          ]
+      ]
+  , Para
+      [ Str "Here"
+      , Space
+      , Str "is"
+      , Space
+      , Str "a"
+      , Space
+      , Str "movie"
+      , Space
+      , Image
+          ( "" , [] , [] ) [ Str "movie" ] ( "movie.jpg" , "" )
+      , Space
+      , Str "icon."
+      ]
+  , HorizontalRule
+  , Header 1 ( "footnotes" , [] , [] ) [ Str "Footnotes" ]
+  , Para
+      [ Str "Here"
+      , Space
+      , Str "is"
+      , Space
+      , Str "a"
+      , Space
+      , Str "footnote"
+      , Space
+      , Str "reference,"
+      , Note
+          [ Para
+              [ Str "Here"
+              , Space
+              , Str "is"
+              , Space
+              , Str "the"
+              , Space
+              , Str "footnote."
+              , Space
+              , Str "It"
+              , Space
+              , Str "can"
+              , Space
+              , Str "go"
+              , Space
+              , Str "anywhere"
+              , Space
+              , Str "after"
+              , Space
+              , Str "the"
+              , Space
+              , Str "footnote"
+              , SoftBreak
+              , Str "reference."
+              , Space
+              , Str "It"
+              , Space
+              , Str "need"
+              , Space
+              , Str "not"
+              , Space
+              , Str "be"
+              , Space
+              , Str "placed"
+              , Space
+              , Str "at"
+              , Space
+              , Str "the"
+              , Space
+              , Str "end"
+              , Space
+              , Str "of"
+              , Space
+              , Str "the"
+              , Space
+              , Str "document."
+              ]
+          ]
+      , Space
+      , Str "and"
+      , Space
+      , Str "another."
+      , Note
+          [ Para
+              [ Str "Here\8217s"
+              , Space
+              , Str "the"
+              , Space
+              , Str "long"
+              , Space
+              , Str "note."
+              , Space
+              , Str "This"
+              , Space
+              , Str "one"
+              , Space
+              , Str "contains"
+              , Space
+              , Str "multiple"
+              , SoftBreak
+              , Str "blocks."
+              ]
+          , Para
+              [ Str "Subsequent"
+              , Space
+              , Str "blocks"
+              , Space
+              , Str "are"
+              , Space
+              , Str "indented"
+              , Space
+              , Str "to"
+              , Space
+              , Str "show"
+              , Space
+              , Str "that"
+              , Space
+              , Str "they"
+              , Space
+              , Str "belong"
+              , Space
+              , Str "to"
+              , Space
+              , Str "the"
+              , SoftBreak
+              , Str "footnote"
+              , Space
+              , Str "(as"
+              , Space
+              , Str "with"
+              , Space
+              , Str "list"
+              , Space
+              , Str "items)."
+              ]
+          , CodeBlock ( "" , [] , [] ) "  { <code> }"
+          , Para
+              [ Str "If"
+              , Space
+              , Str "you"
+              , Space
+              , Str "want,"
+              , Space
+              , Str "you"
+              , Space
+              , Str "can"
+              , Space
+              , Str "indent"
+              , Space
+              , Str "every"
+              , Space
+              , Str "line,"
+              , Space
+              , Str "but"
+              , Space
+              , Str "you"
+              , Space
+              , Str "can"
+              , Space
+              , Str "also"
+              , Space
+              , Str "be"
+              , SoftBreak
+              , Str "lazy"
+              , Space
+              , Str "and"
+              , Space
+              , Str "just"
+              , Space
+              , Str "indent"
+              , Space
+              , Str "the"
+              , Space
+              , Str "first"
+              , Space
+              , Str "line"
+              , Space
+              , Str "of"
+              , Space
+              , Str "each"
+              , Space
+              , Str "block."
+              ]
+          ]
+      , SoftBreak
+      , Str "This"
+      , Space
+      , Str "should"
+      , Space
+      , Emph [ Str "not" ]
+      , Space
+      , Str "be"
+      , Space
+      , Str "a"
+      , Space
+      , Str "footnote"
+      , Space
+      , Str "reference,"
+      , Space
+      , Str "because"
+      , Space
+      , Str "it"
+      , SoftBreak
+      , Str "contains"
+      , Space
+      , Str "a"
+      , Space
+      , Str "space.[^my"
+      , Space
+      , Str "note]"
+      , Space
+      , Str "Here"
+      , Space
+      , Str "is"
+      , Space
+      , Str "an"
+      , Space
+      , Str "inline"
+      , Space
+      , Str "note."
+      , Note
+          [ Para
+              [ Str "This"
+              , SoftBreak
+              , Str "is"
+              , Space
+              , Emph [ Str "easier" ]
+              , Space
+              , Str "to"
+              , Space
+              , Str "type."
+              , Space
+              , Str "Inline"
+              , Space
+              , Str "notes"
+              , Space
+              , Str "may"
+              , Space
+              , Str "contain"
+              , SoftBreak
+              , Link
+                  ( "" , [] , [] )
+                  [ Str "links" ]
+                  ( "http://google.com" , "" )
+              , Space
+              , Str "and"
+              , Space
+              , Code ( "" , [] , [] ) "]"
+              , Space
+              , Str "verbatim"
+              , Space
+              , Str "characters,"
+              , SoftBreak
+              , Str "as"
+              , Space
+              , Str "well"
+              , Space
+              , Str "as"
+              , Space
+              , Str "[bracketed"
+              , Space
+              , Str "text]."
+              ]
+          ]
+      ]
+  , BlockQuote
+      [ Para
+          [ Str "Notes"
+          , Space
+          , Str "can"
+          , Space
+          , Str "go"
+          , Space
+          , Str "in"
+          , Space
+          , Str "quotes."
+          , Note [ Para [ Str "In" , Space , Str "quote." ] ]
+          ]
+      ]
+  , OrderedList
+      ( 1 , Decimal , Period )
+      [ [ Plain
+            [ Str "And"
+            , Space
+            , Str "in"
+            , Space
+            , Str "list"
+            , Space
+            , Str "items."
+            , Note [ Para [ Str "In" , Space , Str "list." ] ]
+            ]
+        ]
+      ]
+  , Para
+      [ Str "This"
+      , Space
+      , Str "paragraph"
+      , Space
+      , Str "should"
+      , Space
+      , Str "not"
+      , Space
+      , Str "be"
+      , Space
+      , Str "part"
+      , Space
+      , Str "of"
+      , Space
+      , Str "the"
+      , Space
+      , Str "note,"
+      , Space
+      , Str "as"
+      , Space
+      , Str "it"
+      , Space
+      , Str "is"
+      , Space
+      , Str "not"
+      , Space
+      , Str "indented."
+      ]
+  ]
diff --git a/test/writer-template.lua b/test/writer-template.lua
new file mode 100644
--- /dev/null
+++ b/test/writer-template.lua
@@ -0,0 +1,7 @@
+function Writer (doc, opts)
+  return pandoc.write(doc, 'gfm', opts)
+end
+
+function Template ()
+  return '<!-- start -->\n$body$\n<!-- stop -->\n'
+end
diff --git a/test/writer-template.out.txt b/test/writer-template.out.txt
new file mode 100644
--- /dev/null
+++ b/test/writer-template.out.txt
@@ -0,0 +1,3 @@
+<!-- start -->
+$body$
+<!-- stop -->
diff --git a/test/writer.custom b/test/writer.custom
new file mode 100644
--- /dev/null
+++ b/test/writer.custom
@@ -0,0 +1,782 @@
+<p>This is a set of tests for pandoc. Most of them are adapted from
+John Gruber’s markdown test suite.</p>
+
+<hr/>
+
+<h1 id="headers">Headers</h1>
+
+<h2 id="level-2-with-an-embedded-link">Level 2 with an <a href="/url" title="">embedded link</a></h2>
+
+<h3 id="level-3-with-emphasis">Level 3 with <em>emphasis</em></h3>
+
+<h4 id="level-4">Level 4</h4>
+
+<h5 id="level-5">Level 5</h5>
+
+<h1 id="level-1">Level 1</h1>
+
+<h2 id="level-2-with-emphasis">Level 2 with <em>emphasis</em></h2>
+
+<h3 id="level-3">Level 3</h3>
+
+<p>with no blank line</p>
+
+<h2 id="level-2">Level 2</h2>
+
+<p>with no blank line</p>
+
+<hr/>
+
+<h1 id="paragraphs">Paragraphs</h1>
+
+<p>Here’s a regular paragraph.</p>
+
+<p>In Markdown 1.0.0 and earlier. Version
+8. This line turns into a list item.
+Because a hard-wrapped line in the
+middle of a paragraph looked like a
+list item.</p>
+
+<p>Here’s one with a bullet.
+* criminey.</p>
+
+<p>There should be a hard line break<br/>here.</p>
+
+<hr/>
+
+<h1 id="block-quotes">Block Quotes</h1>
+
+<p>E-mail style:</p>
+
+<blockquote>
+<p>This is a block quote.
+It is pretty short.</p>
+</blockquote>
+
+<blockquote>
+<p>Code in a block quote:</p>
+
+<pre><code>sub status {
+    print "working";
+}</code></pre>
+
+<p>A list:</p>
+
+<ol>
+<li>item one</li>
+<li>item two</li>
+</ol>
+
+<p>Nested block quotes:</p>
+
+<blockquote>
+<p>nested</p>
+</blockquote>
+
+<blockquote>
+<p>nested</p>
+</blockquote>
+</blockquote>
+
+<p>This should not be a block quote: 2
+&gt; 1.</p>
+
+<p>And a following paragraph.</p>
+
+<hr/>
+
+<h1 id="code-blocks">Code Blocks</h1>
+
+<p>Code:</p>
+
+<pre><code>---- (should be four hyphens)
+
+sub status {
+    print "working";
+}
+
+this code block is indented by one tab</code></pre>
+
+<p>And:</p>
+
+<pre><code>    this code block is indented by two tabs
+
+These should not be escaped:  \$ \\ \&gt; \[ \{</code></pre>
+
+<hr/>
+
+<h1 id="lists">Lists</h1>
+
+<h2 id="unordered">Unordered</h2>
+
+<p>Asterisks tight:</p>
+
+<ul>
+<li>asterisk 1</li>
+<li>asterisk 2</li>
+<li>asterisk 3</li>
+</ul>
+
+<p>Asterisks loose:</p>
+
+<ul>
+<li><p>asterisk 1</p></li>
+<li><p>asterisk 2</p></li>
+<li><p>asterisk 3</p></li>
+</ul>
+
+<p>Pluses tight:</p>
+
+<ul>
+<li>Plus 1</li>
+<li>Plus 2</li>
+<li>Plus 3</li>
+</ul>
+
+<p>Pluses loose:</p>
+
+<ul>
+<li><p>Plus 1</p></li>
+<li><p>Plus 2</p></li>
+<li><p>Plus 3</p></li>
+</ul>
+
+<p>Minuses tight:</p>
+
+<ul>
+<li>Minus 1</li>
+<li>Minus 2</li>
+<li>Minus 3</li>
+</ul>
+
+<p>Minuses loose:</p>
+
+<ul>
+<li><p>Minus 1</p></li>
+<li><p>Minus 2</p></li>
+<li><p>Minus 3</p></li>
+</ul>
+
+<h2 id="ordered">Ordered</h2>
+
+<p>Tight:</p>
+
+<ol>
+<li>First</li>
+<li>Second</li>
+<li>Third</li>
+</ol>
+
+<p>and:</p>
+
+<ol>
+<li>One</li>
+<li>Two</li>
+<li>Three</li>
+</ol>
+
+<p>Loose using tabs:</p>
+
+<ol>
+<li><p>First</p></li>
+<li><p>Second</p></li>
+<li><p>Third</p></li>
+</ol>
+
+<p>and using spaces:</p>
+
+<ol>
+<li><p>One</p></li>
+<li><p>Two</p></li>
+<li><p>Three</p></li>
+</ol>
+
+<p>Multiple paragraphs:</p>
+
+<ol>
+<li><p>Item 1, graf one.</p>
+
+<p>Item 1. graf two. The quick brown fox jumped over the lazy dog’s
+back.</p></li>
+<li><p>Item 2.</p></li>
+<li><p>Item 3.</p></li>
+</ol>
+
+<h2 id="nested">Nested</h2>
+
+<ul>
+<li>Tab
+
+<ul>
+<li>Tab
+
+<ul>
+<li>Tab</li>
+</ul></li>
+</ul></li>
+</ul>
+
+<p>Here’s another:</p>
+
+<ol>
+<li>First</li>
+<li>Second:
+
+<ul>
+<li>Fee</li>
+<li>Fie</li>
+<li>Foe</li>
+</ul></li>
+<li>Third</li>
+</ol>
+
+<p>Same thing but with paragraphs:</p>
+
+<ol>
+<li><p>First</p></li>
+<li><p>Second:</p>
+
+<ul>
+<li>Fee</li>
+<li>Fie</li>
+<li>Foe</li>
+</ul></li>
+<li><p>Third</p></li>
+</ol>
+
+<h2 id="tabs-and-spaces">Tabs and spaces</h2>
+
+<ul>
+<li><p>this is a list item
+indented with tabs</p></li>
+<li><p>this is a list item
+indented with spaces</p>
+
+<ul>
+<li><p>this is an example list item
+indented with tabs</p></li>
+<li><p>this is an example list item
+indented with spaces</p></li>
+</ul></li>
+</ul>
+
+<h2 id="fancy-list-markers">Fancy list markers</h2>
+
+<ol>
+<li><p>begins with 2</p></li>
+<li><p>and now 3</p>
+
+<p>with a continuation</p>
+
+<ol>
+<li>sublist with roman numerals,
+starting with 4</li>
+<li>more items
+
+<ol>
+<li>a subsublist</li>
+<li>a subsublist</li>
+</ol></li>
+</ol></li>
+</ol>
+
+<p>Nesting:</p>
+
+<ol>
+<li>Upper Alpha
+
+<ol>
+<li>Upper Roman.
+
+<ol>
+<li>Decimal start with 6
+
+<ol>
+<li>Lower alpha with paren</li>
+</ol></li>
+</ol></li>
+</ol></li>
+</ol>
+
+<p>Autonumbering:</p>
+
+<ol>
+<li>Autonumber.</li>
+<li>More.
+
+<ol>
+<li>Nested.</li>
+</ol></li>
+</ol>
+
+<p>Should not be a list item:</p>
+
+<p>M.A. 2007</p>
+
+<p>B. Williams</p>
+
+<hr/>
+
+<h1 id="definition-lists">Definition Lists</h1>
+
+<p>Tight using spaces:</p>
+
+<dl>
+<dt>apple</dt>
+<dd>red fruit</dd>
+<dt>orange</dt>
+<dd>orange fruit</dd>
+<dt>banana</dt>
+<dd>yellow fruit</dd>
+</dl>
+
+<p>Tight using tabs:</p>
+
+<dl>
+<dt>apple</dt>
+<dd>red fruit</dd>
+<dt>orange</dt>
+<dd>orange fruit</dd>
+<dt>banana</dt>
+<dd>yellow fruit</dd>
+</dl>
+
+<p>Loose:</p>
+
+<dl>
+<dt>apple</dt>
+<dd><p>red fruit</p></dd>
+<dt>orange</dt>
+<dd><p>orange fruit</p></dd>
+<dt>banana</dt>
+<dd><p>yellow fruit</p></dd>
+</dl>
+
+<p>Multiple blocks with italics:</p>
+
+<dl>
+<dt><em>apple</em></dt>
+<dd><p>red fruit</p>
+
+<p>contains seeds,
+crisp, pleasant to taste</p></dd>
+<dt><em>orange</em></dt>
+<dd><p>orange fruit</p>
+
+<pre><code>{ orange code block }</code></pre>
+
+<blockquote>
+<p>orange block quote</p>
+</blockquote></dd>
+</dl>
+
+<p>Multiple definitions, tight:</p>
+
+<dl>
+<dt>apple</dt>
+<dd>red fruit</dd>
+<dd>computer</dd>
+<dt>orange</dt>
+<dd>orange fruit</dd>
+<dd>bank</dd>
+</dl>
+
+<p>Multiple definitions, loose:</p>
+
+<dl>
+<dt>apple</dt>
+<dd><p>red fruit</p></dd>
+<dd><p>computer</p></dd>
+<dt>orange</dt>
+<dd><p>orange fruit</p></dd>
+<dd><p>bank</p></dd>
+</dl>
+
+<p>Blank line after term, indented marker, alternate markers:</p>
+
+<dl>
+<dt>apple</dt>
+<dd><p>red fruit</p></dd>
+<dd><p>computer</p></dd>
+<dt>orange</dt>
+<dd><p>orange fruit</p>
+
+<ol>
+<li>sublist</li>
+<li>sublist</li>
+</ol></dd>
+</dl>
+
+<h1 id="html-blocks">HTML Blocks</h1>
+
+<p>Simple block on one line:</p>
+
+<div>
+foo</div>
+
+<p>And nested without indentation:</p>
+
+<div>
+<div>
+<div>
+<p>foo</p></div></div>
+
+<div>
+bar</div></div>
+
+<p>Interpreted markdown in a table:</p>
+
+<table>
+
+<tr>
+
+<td>
+
+This is <em>emphasized</em>
+
+</td>
+
+<td>
+
+And this is <strong>strong</strong>
+
+</td>
+
+</tr>
+
+</table>
+
+<script type="text/javascript">document.write('This *should not* be interpreted as markdown');</script>
+
+<p>Here’s a simple block:</p>
+
+<div>
+<p>foo</p></div>
+
+<p>This should be a code block, though:</p>
+
+<pre><code>&lt;div&gt;
+    foo
+&lt;/div&gt;</code></pre>
+
+<p>As should this:</p>
+
+<pre><code>&lt;div&gt;foo&lt;/div&gt;</code></pre>
+
+<p>Now, nested:</p>
+
+<div>
+<div>
+<div>
+foo</div></div></div>
+
+<p>This should just be an HTML comment:</p>
+
+<!-- Comment -->
+
+<p>Multiline:</p>
+
+<!--
+Blah
+Blah
+-->
+
+<!--
+    This is another comment.
+-->
+
+<p>Code block:</p>
+
+<pre><code>&lt;!-- Comment --&gt;</code></pre>
+
+<p>Just plain comment, with trailing spaces on the line:</p>
+
+<!-- foo -->
+
+<p>Code:</p>
+
+<pre><code>&lt;hr /&gt;</code></pre>
+
+<p>Hr’s:</p>
+
+<hr>
+
+<hr />
+
+<hr />
+
+<hr>
+
+<hr />
+
+<hr />
+
+<hr class="foo" id="bar" />
+
+<hr class="foo" id="bar" />
+
+<hr class="foo" id="bar">
+
+<hr/>
+
+<h1 id="inline-markup">Inline Markup</h1>
+
+<p>This is <em>emphasized</em>, and so <em>is this</em>.</p>
+
+<p>This is <strong>strong</strong>, and so <strong>is this</strong>.</p>
+
+<p>An <em><a href="/url" title="">emphasized link</a></em>.</p>
+
+<p><strong><em>This is strong and em.</em></strong></p>
+
+<p>So is <strong><em>this</em></strong> word.</p>
+
+<p><strong><em>This is strong and em.</em></strong></p>
+
+<p>So is <strong><em>this</em></strong> word.</p>
+
+<p>This is code: <code>&gt;</code>, <code>$</code>, <code>\</code>, <code>\$</code>, <code>&lt;html&gt;</code>.</p>
+
+<p><del>This is <em>strikeout</em>.</del></p>
+
+<p>Superscripts: a<sup>bc</sup>d a<sup><em>hello</em></sup> a<sup>hello there</sup>.</p>
+
+<p>Subscripts: H<sub>2</sub>O, H<sub>23</sub>O, H<sub>many of them</sub>O.</p>
+
+<p>These should not be superscripts or subscripts,
+because of the unescaped spaces: a^b c^d, a~b c~d.</p>
+
+<hr/>
+
+<h1 id="smart-quotes-ellipses-dashes">Smart quotes, ellipses, dashes</h1>
+
+<p>&ldquo;Hello,&rdquo; said the spider. &ldquo;&lsquo;Shelob&rsquo; is my name.&rdquo;</p>
+
+<p>&lsquo;A&rsquo;, &lsquo;B&rsquo;, and &lsquo;C&rsquo; are letters.</p>
+
+<p>&lsquo;Oak,&rsquo; &lsquo;elm,&rsquo; and &lsquo;beech&rsquo; are names of trees.
+So is &lsquo;pine.&rsquo;</p>
+
+<p>&lsquo;He said, &ldquo;I want to go.&rdquo;&rsquo; Were you alive in the
+70’s?</p>
+
+<p>Here is some quoted &lsquo;<code>code</code>&rsquo; and a &ldquo;<a href="http://example.com/?foo=1&amp;bar=2" title="">quoted link</a>&rdquo;.</p>
+
+<p>Some dashes: one—two — three—four — five.</p>
+
+<p>Dashes between numbers: 5–7, 255–66, 1987–1999.</p>
+
+<p>Ellipses…and…and….</p>
+
+<hr/>
+
+<h1 id="latex">LaTeX</h1>
+
+<ul>
+<li></li>
+<li>\(2+2=4\)</li>
+<li>\(x \in y\)</li>
+<li>\(\alpha \wedge \omega\)</li>
+<li>\(223\)</li>
+<li>\(p\)-Tree</li>
+<li>Here’s some display math:
+\[\frac{d}{dx}f(x)=\lim_{h\to 0}\frac{f(x+h)-f(x)}{h}\]</li>
+<li>Here’s one that has a line break in it: \(\alpha + \omega \times x^2\).</li>
+</ul>
+
+<p>These shouldn’t be math:</p>
+
+<ul>
+<li>To get the famous equation, write <code>$e = mc^2$</code>.</li>
+<li>$22,000 is a <em>lot</em> of money. So is $34,000.
+(It worked if &ldquo;lot&rdquo; is emphasized.)</li>
+<li>Shoes ($20) and socks ($5).</li>
+<li>Escaped <code>$</code>: $73 <em>this should be emphasized</em> 23$.</li>
+</ul>
+
+<p>Here’s a LaTeX table:</p>
+
+
+
+<hr/>
+
+<h1 id="special-characters">Special Characters</h1>
+
+<p>Here is some unicode:</p>
+
+<ul>
+<li>I hat: Î</li>
+<li>o umlaut: ö</li>
+<li>section: §</li>
+<li>set membership: ∈</li>
+<li>copyright: ©</li>
+</ul>
+
+<p>AT&amp;T has an ampersand in their name.</p>
+
+<p>AT&amp;T is another way to write it.</p>
+
+<p>This &amp; that.</p>
+
+<p>4 &lt; 5.</p>
+
+<p>6 &gt; 5.</p>
+
+<p>Backslash: \</p>
+
+<p>Backtick: `</p>
+
+<p>Asterisk: *</p>
+
+<p>Underscore: _</p>
+
+<p>Left brace: {</p>
+
+<p>Right brace: }</p>
+
+<p>Left bracket: [</p>
+
+<p>Right bracket: ]</p>
+
+<p>Left paren: (</p>
+
+<p>Right paren: )</p>
+
+<p>Greater-than: &gt;</p>
+
+<p>Hash: #</p>
+
+<p>Period: .</p>
+
+<p>Bang: !</p>
+
+<p>Plus: +</p>
+
+<p>Minus: -</p>
+
+<hr/>
+
+<h1 id="links">Links</h1>
+
+<h2 id="explicit">Explicit</h2>
+
+<p>Just a <a href="/url/" title="">URL</a>.</p>
+
+<p><a href="/url/" title="title">URL and title</a>.</p>
+
+<p><a href="/url/" title="title preceded by two spaces">URL and title</a>.</p>
+
+<p><a href="/url/" title="title preceded by a tab">URL and title</a>.</p>
+
+<p><a href="/url/" title="title with &quot;quotes&quot; in it">URL and title</a></p>
+
+<p><a href="/url/" title="title with single quotes">URL and title</a></p>
+
+<p><a href="/url/with_underscore" title="">with_underscore</a></p>
+
+<p><a href="mailto:nobody@nowhere.net" title="">Email link</a></p>
+
+<p><a href="" title="">Empty</a>.</p>
+
+<h2 id="reference">Reference</h2>
+
+<p>Foo <a href="/url/" title="">bar</a>.</p>
+
+<p>With <a href="/url/" title="">embedded [brackets]</a>.</p>
+
+<p><a href="/url/" title="">b</a> by itself should be a link.</p>
+
+<p>Indented <a href="/url" title="">once</a>.</p>
+
+<p>Indented <a href="/url" title="">twice</a>.</p>
+
+<p>Indented <a href="/url" title="">thrice</a>.</p>
+
+<p>This should [not][] be a link.</p>
+
+<pre><code>[not]: /url</code></pre>
+
+<p>Foo <a href="/url/" title="Title with &quot;quotes&quot; inside">bar</a>.</p>
+
+<p>Foo <a href="/url/" title="Title with &quot;quote&quot; inside">biz</a>.</p>
+
+<h2 id="with-ampersands">With ampersands</h2>
+
+<p>Here’s a <a href="http://example.com/?foo=1&amp;bar=2" title="">link with an ampersand in the URL</a>.</p>
+
+<p>Here’s a link with an amersand in the link text: <a href="http://att.com/" title="AT&amp;T">AT&amp;T</a>.</p>
+
+<p>Here’s an <a href="/script?foo=1&amp;bar=2" title="">inline link</a>.</p>
+
+<p>Here’s an <a href="/script?foo=1&amp;bar=2" title="">inline link in pointy braces</a>.</p>
+
+<h2 id="autolinks">Autolinks</h2>
+
+<p>With an ampersand: <a href="http://example.com/?foo=1&amp;bar=2" title="" class="uri">http://example.com/?foo=1&amp;bar=2</a></p>
+
+<ul>
+<li>In a list?</li>
+<li><a href="http://example.com/" title="" class="uri">http://example.com/</a></li>
+<li>It should.</li>
+</ul>
+
+<p>An e-mail address: <a href="mailto:nobody@nowhere.net" title="" class="email">nobody@nowhere.net</a></p>
+
+<blockquote>
+<p>Blockquoted: <a href="http://example.com/" title="" class="uri">http://example.com/</a></p>
+</blockquote>
+
+<p>Auto-links should not occur here: <code>&lt;http://example.com/&gt;</code></p>
+
+<pre><code>or here: &lt;http://example.com/&gt;</code></pre>
+
+<hr/>
+
+<h1 id="images">Images</h1>
+
+<p>From &ldquo;Voyage dans la Lune&rdquo; by Georges Melies (1902):</p>
+
+<figure>
+<img src="lalune.jpg" title="Voyage dans la Lune"/>
+<figcaption>lalune</figcaption>
+</figure>
+
+<p>Here is a movie <img src="movie.jpg" title=""/> icon.</p>
+
+<hr/>
+
+<h1 id="footnotes">Footnotes</h1>
+
+<p>Here is a footnote reference,<a id="fnref1" href="#fn1"><sup>1</sup></a> and another.<a id="fnref2" href="#fn2"><sup>2</sup></a>
+This should <em>not</em> be a footnote reference, because it
+contains a space.[^my note] Here is an inline note.<a id="fnref3" href="#fn3"><sup>3</sup></a></p>
+
+<blockquote>
+<p>Notes can go in quotes.<a id="fnref4" href="#fn4"><sup>4</sup></a></p>
+</blockquote>
+
+<ol>
+<li>And in list items.<a id="fnref5" href="#fn5"><sup>5</sup></a></li>
+</ol>
+
+<p>This paragraph should not be part of the note, as it is not indented.</p>
+<ol class="footnotes">
+<li id="fn1"><p>Here is the footnote. It can go anywhere after the footnote
+reference. It need not be placed at the end of the document. <a href="#fnref1">&#8617;</a></p></li>
+<li id="fn2"><p>Here’s the long note. This one contains multiple
+blocks.</p>
+
+<p>Subsequent blocks are indented to show that they belong to the
+footnote (as with list items).</p>
+
+<pre><code>  { &lt;code&gt; }</code></pre>
+
+<p>If you want, you can indent every line, but you can also be
+lazy and just indent the first line of each block. <a href="#fnref2">&#8617;</a></p></li>
+<li id="fn3"><p>This
+is <em>easier</em> to type. Inline notes may contain
+<a href="http://google.com" title="">links</a> and <code>]</code> verbatim characters,
+as well as [bracketed text]. <a href="#fnref3">&#8617;</a></p></li>
+<li id="fn4"><p>In quote. <a href="#fnref4">&#8617;</a></p></li>
+<li id="fn5"><p>In list. <a href="#fnref5">&#8617;</a></p></li>
+</ol>
