Skip to content

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 RawBlock of raw LaTeX — you own the markup. EPUB mutates and returns the element — Pandoc owns the HTML, you just add the class. (The epub key reuses the html function — EPUB and standalone HTML are distinct format keys, so point both at one function, or use default.) 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/odt here, so in those formats the div passes through unchanged. You can target them — a docx/odt hook can emit raw word-processor markup or restructure content, just like any format (the shipped pagebreak does 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.tex or style.css — styling, no logic — still has a hook per form its fragment declares, one that changes nothing, as container'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 to io.stderr or call error() from a hook for the author's input — see Validation & diagnostics.
  • Returning nil declines 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.warn fires 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

  1. mkdir .pandoc/filters/divs/<name>/ — the handler's class is ks-<name>.
  2. handler.lua returns a per-format hook table; implement only the formats you serve, build AST through KAST.
  3. Add macros.tex (PDF) and/or style.css (EPUB) if the handler needs styling.
  4. Declare required_attributes for any attribute the handler can't run without, a domain for each it can't render as free text, and takes_content = false if it renders from attributes alone; report on content with report.
  5. Front the handler with a fragment in .pandoc/system-shortcuts/ that declares its forms and whose interface guarantees each required attribute and accepts each domain — this is how authors reach it and how the contract is enforced.
  6. 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.