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 citation, 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/lib/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/lib/progress.sh swaps its level prefix for ours on the way past — [WARNING] to
WARN: — so a warning 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. progress.sh holds that prose back for
its caller, which quotes it under an ERROR: line naming the pass — the main build, the
EPUB expand pass, or the font collection. Every step progress.sh runs works the same way — the lint, an import's
conversion, the move into artifacts/ — and its caller closes a failed run with progress_fail. The progress report
marks the stage it stopped in ✗; KEYSTONE_PROGRESS=verbose streams the prose raw instead. A filter's fatal is held
the same way and printed under the ✗ as the build's last word, with no frame after it: it names the cause already. 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, a missing required field. A bare error() is reserved
for internal-invariant bugs (a handler that returned the wrong shape), where a traceback is the right tool. A handler
reports a problem in the author's input through report, never error() or io.stderr — 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 quoted rather than composed, 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-citation 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
Citing the element 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 quoted rather than composed — Pandoc's diagnostic, a loader's message, the reason an operating system gave, the markup a diagnostic cites. Printed as given: 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.
A message the author can act on cites its page where the manual has one. A check only an engine change can trip needs none: its reader is editing the engine.
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 — fontsize '13pt' is not a size this set allows — 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/lib/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.
Citing the element, not a line
A diagnostic about a div, span or heading locates it by quoting its markup, not by line number:
WARN: shortcut 'aside' rejects type 'todo'
│ ::: {.aside type="todo"}
│ A callout whose type is not one of the d…
It takes one of the values listed.
The default 'note' stands in.
Valid: example, note, tip, warning
See https://keystone.knight-owl.dev/errors/invalid-value/
.pandoc/filters/lib/citation.lua writes the element's Attr back as Markdown over the text it opens with: a breadcrumb
to search for. The errors lib prints the lines as verbatim text.
A shortcut use is quoted as written. Expansion stamps it with its citation before rewriting it into its handler's element, and the dispatcher takes the stamp off before the handler runs:
-- in the dispatcher, per element: `stamp` is the citation taken off it, or nil
local report = errors.bind(function() return citation.describe(el, stamp) end)
-- a handler then calls report.warn{…} / report.fatal{…}
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 lint reads the source itself, so its findings carry file:line:column — see
Lint findings.)
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 and heading class
must be one of:
- a shortcut name (system or your own), or
- a short Pandoc-native allowlist (
smallcaps,underline,ul,mark,unlisted,unnumbered).
Any class matching neither warns, naming the class. A close match from the vocabulary — Levenshtein distance ≤ 2, ties
broken lexicographically — is offered as a closest-match hint. A ks- class is refused rather than warned of: expansion
writes those, and a manuscript reaching a handler past its shortcut would skip the contract the shortcut enforces. Its
remedy names the shortcut fronting the handler, 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. - When the shortcuts compile, every shortcut routing to a handler must guarantee each required attribute — via a
default, aconstorrequired: true. One that would let a required attribute through unset is a fatal misconfiguration, caught before any chapter is read: the system shortcuts' at image build, a book's own as it compiles. The guarantee extends to the shortcuts a template uses, and no path bypasses it — a handler requiring an attribute is reached only through a shortcut.
The split keeps handlers free of presence checks: aside needs a type, and its interface defaults it to note.
Accepted values
What an attribute takes is declared the same way, and judged before a handler runs:
- Each handler declares, per attribute it can't render as free text, its domain on its returned table:
domain = { type = { "example", "note", "tip", "warning" } }. - Every shortcut fronting the handler declares, on each entry routing to such an attribute, an
acceptswithin the domain, checked at image build; a shortcut built on it inherits that, or narrows it with anacceptsof its own. - A value is judged where it is written: a
default, aconstor a template's value as the shortcuts compile, fatal; a use's as it expands, warning or fatal.
So every value reaching a handler is one it renders, and the handler checks none: a value outside its domain is an
engine bug, raised with error() when the handler looks the value up through handler-contracts. What a handler
reports is what no shortcut attribute carries — dropcap's opening paragraph, an image's own width.
Strict mode
KEYSTONE_WARNINGS_AS_ERRORS turns every warning fatal;
How settings are applied lists the values it takes. Each
world that emits warnings fails differently:
- Shell resolvers (
diagnostics.sh) fail fast — under strict mode the firstwarnstops the build immediately. - The lint runs with
--strict, so its warnings fail the lint phase, before Pandoc reads the book. - Pandoc runs with
--fail-if-warnings, so its own warnings fail the pass they occur in, at that pass's end. - Lua filters are the one world that aggregates. They run as separate Lua states — the shortcut compile, the EPUB
expand pass, the main pass, 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.
Two elements raising the same problem write the same line to the sink, so strict mode lists each distinct warning once; the log above it keeps every one.
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 image = find_image(el.content)
if not image then
report.warn({
problem = "mydiv: no image in the div",
effect = "The div renders as plain content.",
remedy = "Put a Markdown image inside it.",
})
return nil
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 citation is attached automatically — don't repeat the class or a location in your message. Name the problem, and the offending value where there is one.
- Declare anything you can't run without in
required_attributes, and what each attribute takes indomain, rather than hand-rolling a presence or value check — the interface then guarantees both, and the messages are consistent with every other shortcut's.
Match the wording of the existing handlers: problem opens with the lower-case handler name, and because carries the
rule behind it.
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.