Skip to content

Validation & diagnostics

This is the engine companion to How Keystone checks your book. That page is the author's mental model; this one is the mechanism — how the checks are implemented, and how a core forker adds their own. If you're only writing a book, you want the author page. If you're writing a hook, the callout vocabulary below is the one it sends over the wire; the rest is Lua and not yours.

One path for filter diagnostics

Every diagnostic from Keystone's Lua filters and handlers goes through one library, .pandoc/filters/lib/errors.lua — no filter writes to stderr or exits the process on its own. That single owner is why the Lua messages are uniform: the WARN:/ERROR: prefix, the element-context suffix, and clean-fatal behavior are decided in one place.

Three worlds raise diagnostics, and each owns its own: the Lua filters here, the shell resolvers through .pandoc/diagnostics.sh, and Pandoc itself. The first two are separate implementations of one convention — same prefixes, same severities, same stderr — because they are separate runtimes, not because they disagree.

Pandoc's are its own, and .pandoc/progress.sh swaps its level prefix for ours on the way past — [WARNING] to WARN:, [ERROR] to ERROR: — so a message reads the same whichever world raised it. Only the prefix changes; severity and wording stay Pandoc's, and KEYSTONE_PROGRESS=verbose bypasses the swap to show the stream as Pandoc wrote it.

Pandoc logs no [ERROR] record in practice: it prints a failure as bare prose, after which publish.sh closes with an ERROR: line naming the pass — the main build or the pre-scan. The stage is named by the ✗ line above it. See strict mode for how each world fails.

Two functions, two severities:

  • warn(callout) — print, notify the strict-mode sink, and return. The caller continues with a fallback.
  • fatal(callout) — print, then exit the process cleanly. No Lua traceback into filter source reaches the author; fatal calls os.exit before Pandoc's own error handler can append one.

fatal is for author- and config-facing failures — a bad value, an undeclared mark. A bare error() is reserved for internal-invariant bugs (a handler that returned the wrong shape), where a traceback is the right tool. Handlers are pure author-content surface: they always report, never error() or io.stderr directly — so every message stays uniform and the fatal path stays traceback-free.

Declared, not written

A diagnostic is a table of callouts, not a string. The caller names the pieces; the lib assembles them:

errors.fatal({
  problem = "papersize '" .. size .. "' is not a page size Keystone names",
  choices = pagesizes.names(),
  remedy = "For any other trim, give the dimensions to geometry instead,"
    .. " e.g. geometry: paperwidth=5in,paperheight=5in",
})
ERROR: papersize 'a4plus' is not a page size Keystone names
  Valid: a0, a1, a2, a3, a4, a5, a6, b0, b1, b2, b3, b4, b5, b6, c0, c1, c2, c3,
         c4, c5, c6, ansia, ansib, ansic, ansid, ansie, letter, legal, executive
  For any other trim, give the dimensions to geometry instead, e.g. geometry:
  paperwidth=5in,paperheight=5in
  See https://keystone.knight-owl.dev/errors/unknown-papersize/
Callout Holds Casing
problem what went wrong fragment, lowercase
offenders the items that caused it, one per line theirs
verbatim text reproduced from elsewhere, behind a quote bar theirs
because the rule the problem breaks sentence
effect the consequence the problem had sentence
choices the values that would have been accepted values
remedy what to do about it sentence
see one URL worth following; renders as See <url> —
describe the element-context thunk bind sets; Lua only —

problem is the only one a call must carry; the rest are named when there is something to say. Naming a key that is not on the list, or giving one a value of the wrong shape, raises rather than dropping the piece — a misspelled remedies would otherwise vanish from the message with nothing to show for it, and a diagnostic that quietly loses half of itself is worse than one that stops.

Each callout, shown

One example apiece, rendered by the lib. describe is not here — it takes a Lua thunk rather than text, and Element context below is its own section.

problem

The fragment that reads on from the label, so it opens lowercase and ends in no terminator.

errors.fatal({ problem = "'brochure' is not a target this project defines" })
ERROR: 'brochure' is not a target this project defines

offenders

The items that caused it, one per line, keeping the gutter every other line has. A string carrying newlines splits the same way.

errors.fatal({
  problem = "three metadata keys are the engine's to set",
  offenders = { "documentclass", "geometry", "papersize" },
})
ERROR: three metadata keys are the engine's to set
  documentclass
  geometry
  papersize

verbatim

Text Keystone reports for rather than writes — Pandoc's diagnostic, a loader's message, the reason an operating system gave. Reproduced as it arrived, never reflowed and never re-cased. The bar gives those lines a left edge of their own, so a caret still lands under the token it marks.

errors.fatal({
  problem = "the shortcut definitions could not be read",
  verbatim = captured,  -- the loader's own message, lines and carets and all
})
ERROR: the shortcut definitions could not be read
  │ shortcuts.yaml:14: mapping values are not allowed in this context
  │   quote: inner: '\u201c'
  │                 ^

because

errors.fatal({
  problem = "papersize 'a5' and geometry both set the page",
  because = "Only one of the two can reach the page.",
})
ERROR: papersize 'a5' and geometry both set the page
  Only one of the two can reach the page.

effect

Belongs to warn far more often than to fatal: a build that carried on has a fallback to name. A fatal takes it where the stop itself left something to say — a strict build writes no artifact, so yesterday's is still the newest one in artifacts/.

errors.warn({
  problem = "font family 'garmond' is not one the image carries",
  effect = "The book sets in the target's own serif.",
})
WARN: font family 'garmond' is not one the image carries
  The book sets in the target's own serif.

choices

The label and the hang under it are the lib's; a caller passes the set.

errors.fatal({
  problem = "'midnight' is not a code theme Keystone ships",
  choices = { "breezedark", "espresso", "haddock", "kate", "monochrome" },
})
ERROR: 'midnight' is not a code theme Keystone ships
  Valid: breezedark, espresso, haddock, kate, monochrome

remedy

errors.fatal({
  problem = "papersize 'a5' and geometry both set the page",
  remedy = "Clear papersize to size the page in geometry, or drop the geometry options.",
})
ERROR: papersize 'a5' and geometry both set the page
  Clear papersize to size the page in geometry, or drop the geometry options.

see

Never wrapped, so a reader can copy the line — which is why a line break in one is shown rather than acted on, as any other control character is.

A diagnostic the manual has a page for carries that page's URL, anchored to the section that names the message where a page covers several. The message keeps its own remedy: most authors fix it from the terminal, and the page is for the one who wants the longer account.

errors.fatal({
  problem = "1 equation(s) cannot be converted for epub output",
  see = "https://keystone.knight-owl.dev/errors/unconvertible-math/",
})
ERROR: 1 equation(s) cannot be converted for epub output
  See https://keystone.knight-owl.dev/errors/unconvertible-math/

How they read

Which of because and effect a sentence belongs to is settled by reading it back: the problem happened BECAUSE this, or the problem happened, and here is its EFFECT. A sentence that parses neither way is in the wrong callout, or is two sentences.

A value a message names is wrapped in single quotes — unknown family 'garmond' — so an empty or space-padded one is visible rather than swallowed by the sentence around it. A path or a URL at the end of a sentence reads better bare. Lua's %q is not this: it quotes with doubles, and a message that reaches for it disagrees with every other one an author sees.

Both severities take the same callouts — warn where the build carries on, fatal where it stops:

errors.warn({
  problem = "user font '" .. key .. "' is missing css",
  effect = "The font is skipped.",
})

Presentation is the lib's: the label, the order, the two-space gutter, the word that introduces a value set, and the wrap to 80 columns. The width is fixed rather than read from ${COLUMNS}, so a diagnostic prints the same text everywhere.

The frame is the lib's too. No control character in a diagnostic can move the cursor or start a line of its own. A callout that prints its lines as given shows one — ^M for a carriage return, ^[ for an escape; a wrapped callout treats the ones that are whitespace as the word breaks they resemble. Tab is left as it arrived either way, since it moves along the line and cannot leave it. It is a question about the bytes, so it holds for Pandoc's words, a hook's and the engine's alike.

Line breaks in the source carry no meaning. Split a sentence across .. wherever it reads best, or repeat a flag in shell, and the lib reassembles it. Line control belongs to offenders and verbatim, which print their lines as given.

.pandoc/diagnostics.sh takes the same callouts as --problem, --because and so on, and renders them identically. Repeating a flag appends, except --see, which carries one URL and refuses a repeat.

A plain string still works: report.warn("…") is read as { problem = "…" }. Reach for it where a problem is the whole message — the prose is yours, and Keystone neither owns nor polices it. Keystone's own call sites declare callouts wherever there is more than a problem to name.

Element context: quoting the element, not a line

A diagnostic locates the offending element by quoting it, not by line number:

WARN: aside: unknown type 'todo'
  (in .aside "A callout whose type is not one of the d…")
  See https://keystone.knight-owl.dev/errors/invalid-value/

That trailing (in …) suffix is built by kast.inspect.describe(el), which snapshots the element's classes, identifier, and a leading-text snippet (capped, cut on a UTF-8 boundary). The errors lib renders that as a CSS-like selector plus the snippet.

Why quote the element instead of pointing at file:line? Source positions reach the document tree only through Pandoc's sourcepos extension, which the commonmark/gfm readers support but the markdown reader — the one the whole fenced-div and shortcut system rests on — does not. Quoting the element is correct with no reader switch, and it covers every handler. (The one place a real line number appears today is Pandoc's own unclosed-div warning, whose message body Keystone leaves as-is — see Finding the offender.)

The context is bound lazily: the describe walk runs only if a diagnostic actually fires, so the happy path pays nothing.

-- in the dispatcher, per element
local report = errors.bind(function() return kast.inspect.describe(el) end)
-- a handler then calls report.warn{…} / report.fatal{…}

The closed vocabulary

Keystone's class set is closed: there is no author CSS channel, so a class that resolves to nothing is a typo, not an extension point. shortcuts.lua holds the full vocabulary, so it owns the check — every div/span class must resolve against one of:

  • a handler class (bare or ks--prefixed),
  • a shortcut name (system or user), or
  • a short Pandoc-native allowlist (smallcaps, underline, ul, mark, unlisted).

Any class matching none of them warns, naming the class. A close match from the combined vocabulary — Levenshtein distance ≤ 2, ties broken lexicographically — is offered as a closest-match hint. The name is canonicalized (its ks- prefix stripped) before matching, so a broken private class steers to the public name, which is the stable surface.

Classless elements (an id-only ::: {#refs}) are skipped — citeproc fills those with csl-* classes after the filters run, so flagging them would be wrong.

Required fields

Whether a field is required is declared in the shortcut interface, not in handler code:

  • An interface entry may declare required: true. At expansion, a required field with no author value and no default is fatal — the field is the whole point of the construct (vspace.size, set.mark).
  • Each handler declares the attributes it cannot run without on its returned table: required_attributes = { "size" }. This is the handler's contract.
  • At load time, shortcuts.lua checks that every shortcut routing to a handler guarantees each required attribute — via a default or required: true. A shortcut that would let a required attribute through unset is a fatal misconfiguration, caught before any book builds. The guarantee extends transitively to shortcut bodies; only a bare ks-* handler used directly in the manuscript bypasses it (the documented escape hatch).

The split keeps handlers free of fatal presence checks — they assume the shortcut layer supplied what's required and validate only value validity (aside rejects an unknown type). The required-field guarantee lives in the interface, where aside.type defaults to note; a handler keeps only a soft guard for the bare-ks-* bypass path — aside warns no type given rather than crash.

Strict mode

KEYSTONE_WARNINGS_AS_ERRORS (accepted truthy: true, 1, yes, on, in any case) turns every warning fatal. Three worlds emit warnings, and each fails differently:

  • Shell resolvers (diagnostics.sh) fail fast — under strict mode the first warn stops the build immediately.
  • Pandoc runs with --fail-if-warnings, so its own reader warnings fail the pass they occur in.
  • Lua filters are the one world that aggregates. They run as separate Lua states — the main pass, the EPUB pre-scan, a standalone font-path invocation — so an in-process counter can't see them all. Instead publish.sh exports a sink file path, each state's errors lib appends its warnings there, and publish.sh checks the sink after the build. That's report-all-then-fail: one run surfaces every filter warning at once rather than dying on the first.

Whichever world trips, artifacts are promoted on success only — the build writes to a staging path and moves into artifacts/ only after a clean build with an empty sink, so a strict failure never leaves a half-built file behind.

EPUB diagnostics can print twice

EPUB builds run a pre-scan pass before the main build, and the filters run in both. A filter-level warning therefore prints once per pass. The sink de-duplicates before reporting, so strict mode lists each distinct warning once.

Adding a diagnostic to your handler

When you add a handler, it receives the element and a bound report handle:

local function my_div(el, report)
  local width = el.attributes["width"]
  if width and not valid(width) then
    report.warn({
      problem = "mydiv: unknown width '" .. width .. "'",
      choices = { "narrow", "text", "wide" },
      effect = "The fallback width is used.",
    })
    -- fall through to the fallback
  end
  -- …emit output…
end
  • Use report.warn when you have a fallback and the book can still build; use report.fatal when proceeding would produce broken output.
  • The element context is attached automatically — don't repeat the class or a location in your message. Name the problem and the offending value.
  • Declare anything you can't run without in required_attributes on the returned table, rather than hand-rolling a presence check — the interface then guarantees it, and the message is consistent with every other required field.

Match the wording of the existing handlers: a lower-case handler name and the bad value quoted in problem, the accepted set in choices when it is small, and the rule behind it in because.

Nothing else is yours to decide. The label, the order the callouts print in, the two-space gutter, and where a line breaks all belong to the lib — so a handler you add reads like the ones that ship, without being told how.