Writing a handler
Handlers explains what a handler is — a directory of per-format functions the dispatcher routes divs, spans and headings to. This page builds one end to end, from an empty directory to real output in your book.
Before you start, make sure you actually need a handler. Most new constructs are shortcuts — compositions of existing handlers that need no new code and no rebuild. See Customizing for that boundary and Writing your own shortcuts for how. Reach for a handler only when the behavior can't be composed from the ones you have.
A handler is an engine primitive; authors reach it through a shortcut, not by naming the handler class directly. The
shortcut is the stable, author-facing name (::: aside, not ::: ks-aside): the manuscript depends on it, not on the
handler's class or attributes, so you can change the implementation later without editing prose. Its interface also
enforces the handler's attribute contract at build time. That's the pattern the shipped handlers follow, and the one to
follow for your own. So a handler is really two artifacts, and this guide builds both: the handler, then the shortcut
that fronts it.
This is real engine work: every shipped handler lives in your project under .pandoc/filters/divs/, so at each step
below you can open the named one and read the same pattern in production.
The loop
You'll rebuild the engine and build a book to see each change:
make image # rebuild the engine with your handler
make publish # build your book against it
There's no separate test command — you verify by building and looking at the output. Keep a small manuscript with your new construct handy and rerun these two commands as you go.
We'll build a callout — a titled note box — across five steps: a minimal handler, styling, a validated attribute, the
shortcut that fronts it, then the checks that make it production-ready.
Step 1 — the smallest handler that works
Create the directory. The directory name names the handler: divs/callout/ handles the ks-callout class, which
the callout shortcut expands to.
.pandoc/filters/divs/callout/
└── handler.lua
handler.lua returns a table keyed by element kind (div here), mapping format keys to functions. Each function
receives the element and a report handle for diagnostics (used from Step 3 — omit it when a hook doesn't need it, as
the shipped handlers do), and returns AST built through the engine's AST library (KAST), never Pandoc's
constructors directly.
local kast = ks_require("ast")
-- PDF: wrap the content in a LaTeX environment. kast.latex.blocks serializes
-- the div's child blocks to a LaTeX string; kast.latex.env wraps them.
local function latex(el)
local body = kast.latex.blocks(el.content)
return kast.RawBlock("latex", kast.latex.env("quote", body))
end
-- EPUB/HTML: tag the element with a class and hand it back. Pandoc serializes
-- it; the class is the styling hook (Step 2 gives it rules).
local function html(el)
el.classes = { "callout" }
return el
end
return {
div = {
latex = latex,
html = html,
epub = html,
},
}
Two things to notice, because they're the whole model:
- The return shape differs by format. PDF returns a
RawBlockof raw LaTeX — you own the markup. EPUB mutates and returns the element — Pandoc owns the HTML, you just add the class. (Theepubkey reuses thehtmlfunction — EPUB and standalone HTML are distinct format keys, so point both at one function, or usedefault.) That markup-vs-tag split is the common shape, though not universal:pagebreak(below) emits raw output for every format. - You implement only the formats you serve. There's no
docx/odthere, so in those formats the div passes through unchanged. You can target them — adocx/odthook can emit raw word-processor markup or restructure content, just like any format (the shippedpagebreakdoes exactly this). The only thing that needs extra tooling is adding a new named reference-doc style; see Format integration and Customizing.
Rebuild with make image, then make publish. A manuscript reaches your handler only through the shortcut that fronts
it, so to see this step's output, add its fragment now — .pandoc/system-shortcuts/callout.yaml holding callout: with
class: ks-callout and forms: [div] — and give it an interface in Step 4. The shipped pagebreak handler is this
shape at its simplest — fixed output, no attributes.
Step 2 — add styling
Step 1 leaned on LaTeX's built-in quote environment and an unstyled class. Now give the callout its own look. Two
optional files, discovered automatically by the dispatcher — no wiring code:
.pandoc/filters/divs/callout/
├── handler.lua
├── macros.tex # injected into the LaTeX preamble (PDF)
└── style.css # linked into the EPUB stylesheet
Define your own LaTeX environment in macros.tex:
% A boxed note with a rule above and below.
\newenvironment{callout}{%
\par\medskip\hrule\medskip%
}{%
\par\medskip\hrule\medskip%
}
The build reads macros.tex and injects it into the document preamble before your content renders, so the latex hook
can just emit \begin{callout}… and rely on the command being defined. Point the hook at your environment instead of
the built-in quote:
local function latex(el)
local body = kast.latex.blocks(el.content)
return kast.RawBlock("latex", kast.latex.env("callout", body))
end
Style the EPUB class in style.css:
.callout {
border-top: 1px solid;
border-bottom: 1px solid;
padding: 0.5em 0;
}
The shipped quote handler is this pattern — a keystonequote environment in macros.tex, a .quote rule in
style.css, and a latex hook that wraps content in the environment. Open it next to yours.
A handler that ships only
macros.texorstyle.css— styling, no logic — still has a hook per form its fragment declares, one that changes nothing, ascontainer's does. Those two files cover PDF and EPUB; DOCX/ODT styling works through a reference document instead (see Customizing).
Step 3 — accept an attribute
Let the author pick a tone. Read attributes off el.attributes, and declare the values the handler renders as the
attribute's domain, built from the table it renders through:
local collections = ks_require("collections")
local contracts = ks_require("handler-contracts")
local TONES = {
note = "callout-note",
warning = "callout-warning",
}
local function html(el)
local tone = el.attributes["tone"]
el.classes = { "callout", contracts.rendering(TONES, tone, "callout", "tone") }
return el
end
return {
div = {
latex = latex,
html = html,
epub = html,
},
required_attributes = { "tone" },
domain = { tone = collections.sorted_keys(TONES) },
}
domain— what each attribute takes: a list of values, as here, or any form Handlers lists.required_attributes— the attributes the handler can't run without.
The dispatcher enforces neither — the shortcut that fronts the handler does (Step 4). It guarantees each required
attribute and refuses a value outside the domain where the author wrote it, so the hook checks no value.
contracts.rendering looks the tone up, and raises for one the table lacks: an engine bug, since the shortcut refused
it first. Until Step 4's fragment guarantees tone and accepts the domain, the image build refuses the handler, so
rebuild after Step 4. The shipped vspace handler shows the whole pattern — required_attributes = { "size" } and
a domain of named sizes and lengths.
An attribute the handler doesn't require arrives absent when the author leaves it out, unless the fragment gives it a
default — which also stands in for a value the shortcut refuses. Without one, check for nil before the lookup, as
the shipped align does.
What no shortcut attribute carries, the hook checks itself, through the report handle — the shipped dropcap warns of
a paragraph that doesn't open with text:
report.warn(msg)surfaces a diagnostic and keeps building (and is promoted to a hard error under strict mode);report.fatal(msg)stops the build cleanly. The engine cites the author's markup under the message for you. Never write toio.stderror callerror()from a hook for the author's input — see Validation & diagnostics.- Returning
nildeclines the element: it passes through untouched. Returning the element (or a new one) replaces it.
Step 4 — front it with a shortcut
The second artifact is the shortcut that fronts the handler: a fragment beside the system shortcuts, named after the
shortcut it defines — .pandoc/system-shortcuts/callout.yaml:
callout:
class: ks-callout
forms: [div]
interface:
tone:
bind: class.tone
accepts: ks-callout@tone
required: true
Now authors write ::: {.callout tone="warning"}, and the shortcut expands to ks-callout, your handler's own class. A
fragment is the one place a ks- class may be written, which is how the shortcut shares the plain name — see
Handlers. forms declares the forms the shortcut renders — div, span, heading — which shortcuts
built on it inherit; the image build checks your handler has a hook for each. Because the handler declares tone in
required_attributes, the interface must guarantee it — here with required: true (a default would also do). Where a
handler requires an attribute, its fragment's interface is what supplies it — vspace.yaml's required: true for
size, aside.yaml's default: note for type. Each of the fragment's entries routing to an attribute with a domain
declares an accepts: the domain itself, named ks-<handler>@<attribute>, or a list or rule within it. Shortcuts built
on the fragment inherit it.
The fragment is part of the engine: make image compiles every fragment and fails on any diagnostic, a warning
included, so a misconfiguration surfaces before a book builds rather than as a crash inside your hook. A book's own
shortcuts.yaml then builds on callout like any system shortcut. See
Writing your own shortcuts for the full interface syntax (bindings, defaults,
required fields, templates).
Step 5 — verify it
There's no test harness in the template, so verification is building and reading the output — the same
make image && make publish loop, run against a manuscript that exercises every branch:
- Each format you implemented — build a PDF and an EPUB and look at both. The return shapes differ, so a handler correct in one can be wrong in the other.
- Each attribute value, including one outside the domain — confirm the build reports it where it's used, naming the shortcut.
- Each input a hook reports on — confirm the
report.warnfires and the element degrades the way you intended. - A format you didn't implement — confirm the content passes through cleanly rather than vanishing.
Your hooks are ordinary Lua functions over KAST tables — handler.div.latex(el, report) returns data you can inspect.
If you want automated tests, you can drive them directly with whatever Lua test tooling you prefer; the engine doesn't
prescribe one.
Checklist
mkdir .pandoc/filters/divs/<name>/— the handler's class isks-<name>.handler.luareturns a per-format hook table; implement only the formats you serve, build AST through KAST.- Add
macros.tex(PDF) and/orstyle.css(EPUB) if the handler needs styling. - Declare
required_attributesfor any attribute the handler can't run without, adomainfor each it can't render as free text, andtakes_content = falseif it renders from attributes alone; report on content withreport. - Front the handler with a fragment in
.pandoc/system-shortcuts/that declares itsformsand whose interface guarantees each required attribute and accepts each domain — this is how authors reach it and how the contract is enforced. make image, then build your book in every format you implemented and read the output.
Keep each handler to a single concern; when a second axis of control appears, it's a new handler composed via a shortcut, not another attribute.