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
endWhole-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, ornilto leave metadata unchanged.function Pandoc(doc)receives the whole document (doc.blocks,doc.meta) last of all, and may return a replacement document. (Docis 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
endWith 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,
}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: 3reads as the Lua number3(Pandoc renders it as the string'3'). In return, a number or boolean is accepted wherever an Inline is expected and reads astostringwould print it:pandoc.Strong(meta.count)works, where Pandoc would rejectpandoc.Strong(3). Block positions stay strict. - Null reads as
nil. A key with an explicit YAML~looks absent from Lua; deleting a metadata key ismeta.key = nil, and writing an explicit null isquarto.config.null(). - Values Quarto tracks specially —
!path,!glob,!exprtagged strings — appear as small typed values with a.valuestring 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
endEquation 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
endDelete 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").blocksCommonMark 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
endPaths 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' } })
endVersions
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 emptySo 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 ... endThe 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 olderpandoc.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 ofelement. 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, soos.exitis 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.