Skip to content

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 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. 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.tex or style.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 to io.stderr or call error() from a hook — see Validation & diagnostics.
  • Returning nil declines 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.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/<class-name>/ — the directory name is the class.
  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; validate optional ones with report.
  5. Front the handler with a shortcut in shortcuts.yaml whose interface guarantees each required attribute — this is how it ships 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.