Writing a Pandoc Lua filter

A filter is a Lua table whose keys are AST element constructors — CodeBlock, Para, Header, Image, Link, Str, Span, Div, Note, … — and whose values are handler functions. Each handler returns a replacement AST node, or nil to leave the input alone.

For where filters fit in Emanote’s pipeline and how to enable them in frontmatter, see Pandoc Lua Filters.

Two phases, one shape

The same Lua-table shape works in both phases. What changes is what’s allowed:

  • Parse-time filters run with FORMAT == "markdown" and rewrite the model. They can’t do IO — io, os, require, dofile, pandoc.pipe, pandoc.system, etc. are runtime-banned. Good for cheap AST rewrites that should affect Backlinks, tags, titles, Full-text search, table structure.
  • Render-time filters run with FORMAT == "html" and can do IO. Good for shelling out to engines, reading the notebook layer, or anything that should not run when Emanote is only after metadata.

Either phase can match any element type and either phase can produce any AST output. The error-reporting protocol below also works identically in both.

Hello-world

This page loads lua-filters/hello.lua — bundled in Emanote’s default layer so any notebook can opt into it. It matches CodeBlock elements whose first class is hello and turns each line of the body into a bullet greeting:

  • 👋 Hello, world!
  • 👋 Hello, Pandoc!
  • 👋 Hello, Lua filters!

Source:

-- Hello-world render-time Lua filter for Emanote.
--
-- Handles `hello` fenced code blocks and demonstrates Emanote's
-- in-place error protocol via the injected `emanote.error_block`
-- helper (see `docs/extend/lua-filters/writing-filters.md`).
--
-- Happy path -> a greeting; sad path -> a protocol-shaped error block
-- that (a) shows inline as a red banner on the live server, and
-- (b) makes `emanote gen` abort the build unless the notebook opts
-- out via `--allow-broken-lua-filters` (the docs site does).

local function on_hello(block)
  if block.classes[1] ~= 'hello' then
    return nil
  end
  local err = block.text:match('^ERROR:%s*(.*)')
  if err then
    return emanote.error_block{
      title = 'hello.lua error',
      message = err,
    }
  end
  -- Each non-empty line is one greeting; the body becomes a bullet list.
  local items = {}
  for line in block.text:gmatch('[^\n]+') do
    table.insert(items, { pandoc.Plain {
      pandoc.Str('👋 Hello, '),
      pandoc.Strong { pandoc.Str(line) },
      pandoc.Str('!'),
    } })
  end
  return pandoc.BulletList(items)
end

return {
  { CodeBlock = on_hello },
}

Reporting errors to the reader

Lua gives several tools that look like reasonable ways to flag “this input was malformed”. They land in very different places — verified empirically against the rendered output of this page:

ToolFilter pipelineOther inputs on the pageReader seesemanote gen
error('msg')Aborts on the offending inputNot transformed; ship as rawTop-of-page banner; failing input stays rawAborts
emanote.error_block{...}ContinuesTransform normallyInline red banner exactly where the input wasAborts
warn(...) / pandoc.log.warn(...)ContinuesTransform normallyWhatever the handler returns; warning to stderrDoes not abort
  • error() is right when the filter file itself is misconfigured (missing required metadata, a typo in the table, etc.) — nothing on the page is going to work.
  • warn / pandoc.log.warn are for filter-author diagnostics during development. The output goes to stderr ([WARNING] Scripting warning at …); the reader sees nothing. Never use them as the reader-facing error surface.
  • emanote.error_block is the right answer for recoverable per-element errors. Five cetz blocks on a page with a typo in the second: error() leaves a top banner + four raw code blocks; emanote.error_block leaves four working diagrams + one inline banner.

emanote.error_block

Emanote injects an emanote global into every filter’s Lua chunk — parse-time and render-time. The helper builds the marker Div so filters don’t carry near-identical copies of it:

return emanote.error_block{
  title = 'cetz error',           -- bolded title
  message = engine_stderr,        -- first CodeBlock child; what `emanote gen` recovers
  source = block.text,            -- optional: original failing source
  source_class = 'cetz',          -- optional: syntax class on the source CodeBlock
}

The returned Div carries the marker class emanote:error:lua-filter. The static-build walks the post-filter AST for that class and aborts unless the notebook opts out via --allow-broken-lua-filters — which the docs site does, which is why the sad-path demo below ships in the deployed static site.

Live sad-path demo

hello.lua error

typst stderr says "unclosed delimiter at line 7"

The block above triggers the sad path. On the live server, the rest of the page renders normally. In a emanote gen run without the opt-out flag, the build aborts with typst stderr says ... in the failure message.

A fuller example — slides.lua

See A Markdown Presentation about Lua Filters for a real-world render-time filter: it rewrites :::slides Div elements into a navigable Reveal.js presentation. Div matching, pandoc.RawBlock / pandoc.RawInline for raw HTML, frontmatter metadata via doc.meta — plus the embedded source.