Writing a handler
Handlers explains what a handler is — a directory of per-format functions the dispatcher routes fenced divs and spans 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 — and its interface 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 is the class: divs/callout/
handles the callout class.
.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. To smoke-test before you've built
a shortcut, write the bare class ::: callout in your manuscript — with no
shortcut yet, it reaches the handler directly. Once Step 4 adds a callout
shortcut, that name routes through it instead; addressing the handler directly
then means the reserved ::: ks-callout. 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 — can return an empty table. 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 use the
report handle to reject bad input instead of failing silently or calling
error() yourself.
local TONES = {
note = "callout-note",
warning = "callout-warning",
}
local function html(el, report)
local tone = el.attributes["tone"]
local cls = TONES[tone]
if not cls then
report.warn("callout: unknown tone '" .. tostring(tone) .. "'")
return nil -- decline; the div passes through unstyled
end
el.classes = { "callout", cls }
return el
end
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 appends element context (class, id) to the message for you. Never write toio.stderror callerror()from a hook — see Validation & diagnostics.- Returning
nildeclines the element: it passes through untouched. Returning the element (or a new one) replaces it.
If tone is mandatory — the handler can't run without it — declare it on the
returned table:
return {
div = {
latex = latex,
html = html,
epub = html,
},
required_attributes = { "tone" },
}
The dispatcher doesn't enforce this list — the shortcut that fronts the handler
does (Step 4). Reached through its shortcut, a declared attribute is guaranteed
present, so the hook validates only the value. The soft guard above still earns
its place: it covers direct use of the handler class — the reserved
::: ks-callout, or the bare ::: callout before a shortcut fronts it — where
nothing guarantees the attribute. The shipped vspace handler shows the whole pattern — it declares
required_attributes = { "size" }, guards the missing case, and validates the
value. See Validation & diagnostics.
Step 4 — front it with a shortcut
The second artifact is the shortcut that fronts the handler. Add it to
shortcuts.yaml at your project root:
callout:
class: ks-callout
interface:
tone:
bind: class.tone
required: true
Now authors write ::: {.callout tone="warning"} and the shortcut routes it to
your handler. (ks-callout is the reserved handler name for your callout class;
the ks- prefix always addresses the handler directly, so the shortcut can share
the plain name — see Handlers.) Because the handler declares tone
in required_attributes, the interface must guarantee it — here with
required: true (a default would also do). Keystone checks this when it loads
your shortcuts and fails the build if a handler-required attribute could arrive
empty, so a misconfiguration surfaces before the book builds rather than as a
crash inside your hook. Where a handler requires an attribute, its shortcut's
interface is what supplies it — vspace.yaml's required: true for size,
aside.yaml's default: note for type.
shortcuts.yaml isn't engine code, so this needs no make image — just rebuild
the book. See Writing your own shortcuts for
the full interface syntax (bindings, defaults, required fields, body injection).
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 you expect to be rejected — 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/<class-name>/— the directory name is the class.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; validate optional ones withreport. - Front the handler with a shortcut in
shortcuts.yamlwhose interface guarantees each required attribute — this is how it ships 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.