How it works
markdown-contract parses a document once and runs everything — structure checks, content checks, cross-cutting rules, and the typed model — off that single parse.
One parse
Section titled “One parse”Each document is parsed with remark/mdast (GitHub-flavored markdown plus YAML frontmatter), extended with an in-house dialect for two Obsidian conventions many docs corpora already use:
- line-terminal
^block-idanchors, which make sections, tables, and blocks addressable, [[wikilinks]]and![[transclusions]], with|aliasand#fragmentparts recognized.
The parse is projected into a position-carrying section tree: every
section, table, list, and code block knows its source line. That is what lets
every finding land as path:line instead of an opaque JSON pointer.
Three cooperating planes
Section titled “Three cooperating planes”A contract declares three kinds of expectations, and each is handled by the mechanism that is actually good at it:
- Structure — a small tree grammar over sections and block kinds:
sections,section,optional,oneOf, andgap, nested to any depth, withorderandallowUnknownset per level. This covers the one axis a schema language cannot express: these sections, in this order, with room for extras. - Content — Zod at every leaf. The frontmatter is a
plain Zod schema; inside sections, a small leaf vocabulary (
table,list,code,maxWords) compiles to Zod checks over the projected blocks, with raw Zod as the escape hatch for anything richer. - Rules — named checks for what neither plane covers:
ruleattaches to a section,docRulesees the whole document (so a frontmatter field can gate a body section), andrequires/forbidsdeclare text constraints without writing a function at all.
The split is deliberate. Schema languages and tree grammars can each express things the other cannot, so markdown-contract never forces one to fake the other — the same “grammar + datatype library + rules” shape the XML structured-authoring world (RELAX NG, Schematron) ran on for decades, applied to markdown. The working doctrine: kind and presence are structure; data shape is content.
One finding shape
Section titled “One finding shape”Every mechanism reports the same record:
{ id: "structure/section-missing", level: "error", path: "D-9001.md", pos: { line: 8, col: 1 }, message: "required section 'Decision' is missing" }Rule ids are namespaced by plane, severity (error / warn / report) is
contract data, and the CLI renders findings as human text, raw JSON, or SARIF
2.1.0 for code scanning. Findings sort deterministically — by line, then
column, then plane — so two runs over the same input always report the same
thing in the same order.
The finding record and its rule ids are catalogued in the findings reference; the output formats and flags are in the CLI reference.
Validation and consumption are the same contract
Section titled “Validation and consumption are the same contract”A compiled contract has two doors (both in the API reference):
contract.validate(src, { path })returns{ findings, doc?, tree }and never throws — the shape CI wants.contract.read(src, { path })returns the typedDocor throws aContractErrorcarrying the findings — the shape a consumer wants.
The Doc is a navigable typed view of the document (its full surface is the
model reference): doc.frontmatter is typed by the
frontmatter schema; doc.body reaches each declared section by its heading
(doc.body.Summary, or doc.body.section("Summary") for dynamic names);
tables read back as iterable typed-row collections; anchors resolve with
doc.byAnchor(id). Because the model is derived from the contract, checking
and consuming can never drift apart.
Two smaller conveniences fall out of the same design:
- Repeatable sections. A heading that legitimately recurs — a changelog’s
## Release, a log’s## Entry— is declaredrepeatable(with optionalmin/maxbounds) and reads back as a positional array, while an undeclared duplicate heading is still an error. - No contract required to navigate.
parse()alone yields the positioned tree, with helpers for finding sections, filtering blocks, and recovering verbatim table cells — see the read examples.
From one document to a corpus
Section titled “From one document to a corpus”Everything above is one document against one contract. The corpus runner
scales it to a tree: a config maps include/exclude globs to contracts
(first match wins), the runner walks the files, validates each against its
contract, and aggregates the findings into one report with an exit code. The
CLI is a thin shell over exactly this call — anything it does, your own code
can do in-process with runCorpus.
For where each of these pieces lives in the codebase — and how the CLI, runner, and engine relate — see Architecture.