Skip to main content

Lua filters

Quarto documents can be transformed with Pandoc-style Lua filters. List filters in the document (or project) metadata:

---
title: My document
filters:
  - my-filter.lua
---

Element filters

A filter file defines functions named after the AST elements it wants to transform. Each function receives the element and may return a replacement (or nil to keep the element unchanged):

function Str(el)
  return pandoc.Str(el.text:upper())
end

function Header(el)
  el.level = el.level + 1
  return el
end

Whole-document handlers: Meta and Pandoc

Beyond element functions, a filter can define document-level handlers:

  • function Meta(meta) receives the document metadata after the element traversal (so it can observe anything element functions computed). Return the (possibly modified) meta, or nil to leave metadata unchanged.
  • function Pandoc(doc) receives the whole document (doc.blocks, doc.meta) last of all, and may return a replacement document. (Doc is accepted as a deprecated alias.)
function Meta(meta)
  meta.subtitle = 'Rendered ' .. os.date('%Y-%m-%d')
  return meta
end

function Pandoc(doc)
  doc.blocks:insert(pandoc.HorizontalRule())
  return doc
end

With the default (typewise) traversal the order is: element functions, then Meta, then Pandoc. Setting traverse = 'topdown' reverses it: Pandoc first, then Meta, then a top-down element walk. Element functions also traverse metadata values — a Str function will visit the words of your title as well as your document body, exactly as in Pandoc.

Two ways to organize a filter

Everything above defines handlers as global functions. A filter may instead return a table of them, which keeps the handlers out of the global namespace:

return {
  Str = function(el)
    return pandoc.Str(el.text:upper())
  end,
}

Both forms are equivalent, and both work in Pandoc. Set traverse as a key of the table rather than as a global when you use this form:

return {
  traverse = 'topdown',
  Div = function(el) ... end,
}
ImportantA returned table replaces the globals

If a script both defines global handlers and returns a table, only the returned table runs — the globals are ignored entirely. This follows Pandoc.

It also means an empty returned table silently disables every global handler in the file, and that an accidental return nil at the end of a script is an error rather than a no-op (Q-11-6). To use your global functions, end the script without returning anything at all.

Several passes in a fixed order

Returning a list of handler tables runs each one as a separate pass over the whole document, in order. Each pass sees the previous pass’s output:

return {
  -- pass 1: mark the elements of interest
  { Div = function(el) ... end },
  -- pass 2: act on what pass 1 produced
  { Span = function(el) ... end },
}

Each entry carries its own traverse setting; a traverse on one entry does not affect the others.

Reach for this when one transformation has to finish everywhere before another begins. Within a single filter table you cannot get that ordering by rearranging the handlers — the traversal order is fixed by the document structure, not by the order you wrote them in. (A Pandoc handler calling doc:walk{...} can also sequence passes by hand; a returned list is the declarative way to say the same thing.)

When a handler never runs

Handlers are dispatched by name, so a misspelled one is simply never looked up. Quarto warns rather than leaving you to wonder (Q-11-7):

Warning [Q-11-7]: Lua filter 'my-filter.lua' defines a handler 'Strs',
which is not an element type Quarto knows about, so it will never run.
Did you mean 'Str'?

If you keep helper functions on a returned filter table, they will be warned about too. Make them local instead:

local function shout(text)
  return text:upper()
end

return {
  Str = function(el) return pandoc.Str(shout(el.text)) end,
}

How metadata looks from Lua

Metadata values appear as natural Lua values, following modern Pandoc’s conventions:

metadata value Lua value
string parsed as markdown (the default in front matter) Inlines (or Blocks)
literal string (!str) string
boolean boolean
list pandoc.List
mapping table

Quarto deliberately diverges from Pandoc in a few places, in favor of its richer configuration model:

  • Numbers stay numbers. count: 3 reads as the Lua number 3 (Pandoc renders it as the string '3'). In return, a number or boolean is accepted wherever an Inline is expected and reads as tostring would print it: pandoc.Strong(meta.count) works, where Pandoc would reject pandoc.Strong(3). Block positions stay strict.
  • Null reads as nil. A key with an explicit YAML ~ looks absent from Lua; deleting a metadata key is meta.key = nil, and writing an explicit null is quarto.config.null().
  • Values Quarto tracks specially — !path, !glob, !expr tagged strings — appear as small typed values with a .value string property, and survive a filter round-trip losslessly.

Besides the meta argument of a Meta or Pandoc handler, every filter can read the document metadata directly through quarto.metadata.get("a.b") and the meta global, in the same shapes. The lmd page describes both.

Constructing configuration values: quarto.config

The quarto.config table mirrors Quarto’s YAML tag system, so a filter can create exactly the value a tag would have produced:

Lua YAML equivalent
quarto.config.str('**text**') key: !str "**text**" (literal string)
quarto.config.md('**text**') key: !md "**text**" (parsed markdown)
quarto.config.path('style.css') key: !path style.css
quarto.config.glob('posts/*.qmd') key: !glob posts/*.qmd
quarto.config.expr('Sys.time()') key: !expr Sys.time()
quarto.config.null() key: ~

For example, to add a stylesheet path from a filter:

function Meta(meta)
  meta.css = quarto.config.path('extra.css')
  return meta
end

Equation numbers: the quarto-eq-number attribute

A labelled display equation ($$…$$ {#eq-name}) reaches your filters as a Span whose identifier is the label and whose attributes carry the equation’s number under the reserved key quarto-eq-number. Quarto writes the number into the page only after your filters have run, in the form the selected math renderer understands (\tag{N} for MathJax and KaTeX, for example). So a filter can change what is displayed by editing the attribute, without touching the TeX:

function Span(el)
  local n = el.attributes["quarto-eq-number"]
  if n then
    el.attributes["quarto-eq-number"] = "S" .. n   -- shows as (S1), (S2), …
    return el
  end
end

Delete the attribute (el.attributes["quarto-eq-number"] = nil) to leave an equation unnumbered. The attribute exists only once cross-references have been resolved, so list the filter after the quarto entry in filters:; a filter listed before it runs too early to see the number.

Parsing strings: pandoc.read

pandoc.read(text, format) parses a string into a document, so a filter or an {lmd} cell can turn text into nodes. Three formats are available, and pandoc.readers lists them:

  • "markdown" (the default, also spelled "qmd") reads Quarto Markdown, exactly as a document body is read.
  • "commonmark" reads plain CommonMark.
  • "json" reads a document in Pandoc’s JSON format.

Quarto Markdown is strict on purpose: braces are reserved for attributes, $ starts math, a bare [...] is a span, and quotes must balance. That suits text written as a document, and it is wrong for text that never was: descriptions from a JSON schema, a database or a CMS. Such prose may contain {env:VAR}, $HOME or ["a", "b"], which the "markdown" reader rejects or rewrites. Read that kind of text with "commonmark":

local blocks = pandoc.read(entry.description, "commonmark").blocks

CommonMark has emphasis, links, code and lists, but no attributes, divs, math or tables. Raw HTML in the text becomes raw html nodes, as in pandoc.

Format extensions ("commonmark+smart") are accepted and ignored, for every format.

Warnings about the text point into the string itself, labelled with the line of the call, for example doc.qmd[pandoc.read, line 14]. This covers the reader’s own warnings and warnings a later step raises about the nodes you returned.

Tables, definition lists and figures built from Lua

Quarto’s markdown has shorthand for three structures: a ::: {.list-table} div becomes a table, a ::: {.definition-list} div becomes a definition list, and a paragraph holding nothing but an image with alt text becomes a figure. The reader rewrites those shapes as it parses, so by the time a filter runs, the document holds Table, DefinitionList and Figure nodes, never the shorthand.

The same rewrite is applied to markdown and JSON that pandoc.read returns, and to what an {lmd} cell returns, so a cell that builds pandoc.Div({ rows }, pandoc.Attr("", {"list-table"})) from nodes gets the same Table the markdown would have. A list-table div with the wrong shape is reported as Q-2-35, pointing at the cell that built it.

It is not applied to what a filter function returns. A filter that wants a table returns a pandoc.Table (or the result of pandoc.read); a .list-table div returned from a Div handler stays a div, as it does in pandoc and in Quarto 1.

Attaching scripts and stylesheets: quarto.doc.add_html_dependency

A filter that needs JavaScript or CSS in the rendered page registers an HTML dependency. Quarto copies the files into the output and adds the <script> and <link> tags for you:

function Image(img)
  quarto.doc.add_html_dependency({
    name = 'zoom',
    version = '1.2.0',
    scripts = { 'zoom.js' },
    stylesheets = { 'zoom.css' },
  })
  return img
end

Paths are resolved relative to the filter’s own directory, so an extension can ship its assets alongside its .lua file and reference them by bare name. An entry may also be written as a table with a path field — { path = 'src/zoom.js' } — which is equivalent to the bare string. The copied file always keeps the base name of its path.

Dependencies are deduplicated by name, so it is safe — and intended — to call add_html_dependency unconditionally from a handler that runs many times. The example above fires once per image; only the first registration does anything, and the rest are silently ignored.

Only name is required. Because the dependency only affects HTML output, guard the call when your filter also targets other formats:

if quarto.doc.is_format('html:js') then
  quarto.doc.add_html_dependency({ name = 'zoom', scripts = { 'zoom.js' } })
end

Versions

The optional version keeps releases of the same dependency apart on disk. Assets land under site_libs/libs/{name}/, with a version adding one level:

_site/site_libs/libs/zoom/1.2.0/zoom.js   # with version = '1.2.0'
_site/site_libs/libs/zoom/zoom.js         # without

Declaring a version costs nothing and is worth doing for any dependency you expect to update. Within a single document the first registration still wins, so registering one name at two different versions reports a warning — a page cannot sensibly load two copies of the same library.

Fields not yet supported

Quarto 1 accepts several other fields on a dependency: meta, links, resources, serviceworkers, and head. Quarto 2 does not implement them yet, and warns once per dependency that names one, so a filter carried over from Quarto 1 tells you what was dropped rather than failing. Any other field is a typo and raises an error.

Where the filter is running

A filter often needs to know which file it is processing and where the project is, for example to build a link from the current page to a file at the project root. Quarto 2 provides the same values Quarto 1 did:

quarto.doc.input_file        -- absolute path of the file being rendered
quarto.doc.output_file       -- absolute path its output will be written to
quarto.project.directory     -- project root (nil outside a project)
quarto.project.output_directory  -- resolved output directory (nil outside a project)
quarto.project.offset        -- relative path from this file's directory back
                             -- to the project root: "." or "../..", forward
                             -- slashes (nil outside a project)
quarto.project.profile       -- pandoc.List of active profile names, possibly empty

So a page at guides/intro.qmd links to appendix.qmd at the root with:

local href = quarto.project.offset .. "/appendix.html"

PANDOC_STATE.input_files (a one-element list holding input_file) and PANDOC_STATE.output_file are set too, for filters written against pandoc’s API. The other PANDOC_STATE fields have no equivalent here and are nil.

Environment variables and profiles

os.getenv sees the environment the render started with, plus the variables your project’s _environment files define (a real environment variable always wins over a file). QUARTO_PROFILE is the active profile list, so a filter sees --profile drafts the same way it sees QUARTO_PROFILE=drafts:

if os.getenv("QUARTO_PROFILE") == "drafts" then ... end
-- or, without string handling:
if quarto.project.profile:includes("drafts") then ... end

The environment is read once, before any filter runs, and every filter and shortcode in the render sees the same values. The ?env:NAME shortcode reads the same snapshot, so it and a filter’s os.getenv can never disagree.

Checking versions

quarto.version is the version of Quarto you are running, as a pandoc Version object. It compares with strings and with other versions, prints with tostring, and still works with table.concat:

if quarto.version < "0.36" then
  quarto.log.warning("this filter needs Quarto 0.36 or newer")
end
print(tostring(quarto.version))           -- "0.36.0"
print(table.concat(quarto.version, "."))  -- "0.36.0"
quarto.version:must_be_at_least("0.36")   -- raises if older

pandoc.types.Version builds one from a string, a number or a list of integers, and PANDOC_VERSION / PANDOC_API_VERSION are Version objects as well. Quarto 2 releases are numbered 0.x for now, so a filter that must tell the two apart can test quarto.version < "1": true here, false on Quarto 1.

Failing a render

Three ways to stop, from gentlest to bluntest:

  • quarto.error(message, element) reports an error diagnostic pointing at the source location of element. The document fails; the rest of the project still renders.
  • error(message) fails the document with a plain Lua error. The rest of the project still renders and the summary lists the failure.
  • os.exit(code) also fails the document. Quarto 1 filters use it because there pandoc was a separate process; in Quarto 2 the filter runs inside the renderer, so os.exit is intercepted rather than ending the process. The failure message names the filter and the code; the remaining documents render, errors on them are still reported, and the render as a whole exits non-zero. os.exit(0) counts as a failure too, since no output was produced.

Prefer quarto.error in new code: it is the only one that points the author at a location in their document.