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;fatalcallsos.exitbefore 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 nodefaultis 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.luachecks that every shortcut routing to a handler guarantees each required attribute — via adefaultorrequired: 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 bareks-*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 firstwarnstops 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.shexports a sink file path, each state's errors lib appends its warnings there, andpublish.shchecks 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.warnwhen you have a fallback and the book can still build; usereport.fatalwhen 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_attributeson 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.