Skip to content

Handlers

A handler is the engine code behind a div, span or heading class. When you write ::: dialog, the dialog shortcut expands to the handler's own class, ks-dialog, and the dispatcher routes it to the dialog handler, which emits the right output for the current format. Every class a handler answers is reached that way, through the system shortcut that fronts it.

Underneath, Pandoc offers a primitive but powerful model — a document tree you reshape with Lua. The handler layer is where Keystone turns that model into something authors never touch.

The element model

Pandoc gives every div and span three distinct fields — not one flat map. Handlers read them deliberately:

Field Markdown Example
Identifier #value or id=value ::: {.figure #fig-1}
Classes .name ::: {.figure .wide}
Attributes key=value ::: {.figure width=50%}

#fig-1 and id=fig-1 both set the identifier, never an attribute — so a handler that needs the id reads the identifier field, not the attributes. It's the same ::: {…} model your markup already uses.

Single dispatch

The dispatcher auto-discovers handlers — each subdirectory filters/divs/<name>/ handles the class ks-<name>, which shortcut expansion writes, at most one per element. That handler owns the element, and the element's other classes reach it untouched.

There is deliberately no handler aggregation — Keystone never runs two handlers on one element. Stacked handlers would compose in class order: {.font .align} and {.align .font} would differ silently, and nobody reads class order as meaningful. An element naming two shortcuts stops the build.

To combine behaviors you nest instead — each level dispatches to one handler, and the composition is visible in the source. In practice you don't write the nesting by hand; you define a shortcut that composes the handlers and use that. This is why composition lives in shortcuts.yaml, not in the handlers.

What a handler sees

A handler sees the document as it stands at its own level: Pandoc reaches spans before divs, and inner divs before outer ones, so everything inside an element has been through its own handler first.

A handler therefore decides by what each element is — its type, how many, and where — and owns its output. Pandoc's writers decide what renders: a paragraph is a paragraph whatever it holds, and an HTML comment is a block like any other.

Anatomy of a handler

Each handler is a directory under filters/divs/:

filters/divs/<name>/
├── handler.lua     (required) — returns a table of per-format functions
├── macros.tex      (optional) — LaTeX preamble for PDF
├── style.css       (optional) — CSS for EPUB/HTML
└── README.md       (optional) — usage notes

The build reads macros.tex (PDF) and style.css (EPUB) directly, so these two files style PDF and EPUB. DOCX/ODT styling works through a reference document instead — see Customizing.

handler.lua returns a table keyed by element kind — div, span and header for fenced divs, bracketed spans and headings, plus global (intercept a Pandoc element type such as Figure). Each maps format keys (latex, epub, html, docx, odt, or default) to a function that produces output for that format:

return {
  div = {
    latex = function(el, report) --[[ emit LaTeX ]] end,
    epub  = function(el, report) --[[ emit CSS-classed HTML ]] end,
    default = function(el, report) --[[ everything else ]] end,
  },
}

Each function receives the element and a report handle — call report.warn(…) / report.fatal(…) to surface a diagnostic about the author's input (never io.stderr or error() directly). See Validation & diagnostics.

A handler need only implement the formats it supports; an unhandled format passes the element through unchanged. A directory that ships only macros.tex or style.css (style, no logic) still has a hook per form its fragment declares, one that changes nothing, as container's does: div = { default = function() end }.

Beside the hooks, the table may declare:

  • required_attributes — the attributes the handler can't run without.
  • domain — what each attribute takes: a list of values; a rule with its options, one an author's accepts takes ({ integer = { min = 2, max = 4 } }, "css-length"); or a family the font registry holds, one of its sizes, or a mark the book declares ("font-family", "font-size", "mark"). A "font-family" attribute's fonts are embedded in EPUB and given their CSS, as font's are. An attribute with no domain takes any text.
  • takes_content = false — for a handler that renders from attributes alone, such as pagebreak and vspace: content written into its element is then refused rather than dropped.

Validation & diagnostics says how the shortcut layer holds each to the handler.

For a step-by-step build of a real handler — filling in these function bodies, wiring the style files, and declaring its attributes — see Writing a handler.

The design rule

Each handler owns exactly one concern — alignment, font selection, figure sizing. When a second axis of control shows up, it belongs in its own handler, composed via a shortcut, not bolted on as another attribute. That's what keeps each handler implementable across every format. See Format integration for how the per-format functions actually emit output, and Customizing to add your own.

The set of handlers is deliberately small. Keystone started with more and was pruned down to the few that genuinely do distinct things; everything author-facing is a shortcut composed from those. It's a RISC mindset — a lean instruction set, with the richness living in how you combine it.