Quillmark Markdown Specification¶
Status: Authoritative specification Base: CommonMark 0.31.2 Implementation:
crates/core/src/document/
Quillmark Markdown is a strict superset of CommonMark with one declared deviation. It layers a structured-data system (the card-yaml format) on top of ordinary markdown, and selects a small, stable set of GFM extensions. This document is the authoritative syntax standard.
1. Superset Statement¶
Every valid CommonMark 0.31.2 document parses to the same block / inline
structure under this spec, except for the deviations declared in §6.2
(raw HTML), §3.2 (a column-zero ~~~ block with a blank line above it is a
card-yaml block, not an ordinary fenced code block, whatever its info string;
an indented ~~~ is not a card-yaml opener). Additionally, this spec defines:
- Structured data: card-yaml blocks (§3).
- Extensions: strikethrough, pipe tables, and
<u>for underline (§6.1).
A document containing no card-yaml blocks is ordinary CommonMark, parsed as such.
2. The card-yaml Format¶
The card-yaml format isolates structured data from markdown prose.
A document is a sequence of blocks. Each block is one card-yaml block followed by its prose body:
- Root block: the first block, identified purely by position. Its
$quillmetadata declares the quill that renders the document. - Subsequent blocks: zero or more cards. Each declares a composable structured record.
- Prose body: the markdown content between one block's closing fence and the next block's opening fence (or EOF).
2.1 Worked Example¶
~~~
$quill: example@0.1.0
$kind: main
from: "bob"
to: "alice"
~~~
This is the primary document container body text.
~~~
$kind: endorsement
from: "charlie"
role: "reviewer"
clearance: "alpha"
~~~
I have reviewed the contents and officially endorse this flight plan.
The first block is the root block (by position); its $quill entry binds
the document to the example quill at version 0.1.0. The second block is a
card whose $kind is endorsement. The text after each closing ~~~ fence
is that block's prose body.
3. card-yaml Blocks¶
3.1 Structural Rules¶
A card-yaml block has three parts, in order:
- Opening fence:
~~~(three tildes; see §3.2). Any info string is accepted on input and dropped on emit. - YAML payload: a standard YAML mapping containing both system
metadata (
$-prefixed reserved keys; see §3.3) and the block's data fields (see §3.4). - Closing fence: exactly
~~~(see §3.2).
The prose body begins immediately after the closing ~~~ fence and runs to
the next opening fence or EOF.
3.2 Delimiter and Info String¶
- Delimiter. Blocks open and close with a run of tildes. The canonical
fence is exactly three tildes (
~~~), andtoMarkdown(§9) always emits three. An opener of four or more tildes is accepted (non-canonical) and re-emits as~~~; its closing fence must be at least as long as the opener, per CommonMark's fenced-code-block rule. - Info string. The info string is not read. A card-yaml opener is any
~~~satisfying the rules below, whether it is bare,~~~card-yaml,~~~yaml, or~~~rust. The canonical form carries no info string, andtoMarkdown(§9) always emits that bare form, so an info string is dropped on round-trip. - Escape hatch. Because every column-zero
~~~block is a card-yaml block, write a literal fenced code block in prose with a backtick fence (```), including a YAML one (```yaml). Tildes offer no escape: neither a longer run nor a language info string opens a code block. A tilde-fenced code block reaches the YAML parser as a payload, so it fails under §10 unless it happens to be a well-formed card. - Indentation. Both fences are at column zero: no leading spaces.
An indented opener (1–3 spaces) is not a card-yaml opener: it is
delegated to CommonMark as an ordinary fenced code block, exactly like an
opener that fails the blank-line rule below. The closing
~~~must also be at column zero: the payload between the fences is YAML, where indentation is structural, so an indented~~~is payload (e.g. a line of a block scalar), never a closer. (This deliberately tightens CommonMark's closing-fence rule, which tolerates 1–3 leading spaces: that leniency exists for indented openers and list contexts, neither of which applies to card-yaml blocks, and honouring it would let a tilde fence inside a|block-scalar value silently truncate the block.) - Line endings.
\nand\r\nare equally accepted. - Blank-line rule. A blank line is required immediately above every
~~~opener, except when the opener is the very first line of the document. A~~~line without a blank line above it is not a card-yaml opener: it is treated as an ordinary CommonMark fenced code block.
3.2.1 --- Is CommonMark's¶
A card-yaml block is fenced with ~~~, at every position. A --- line is
CommonMark's throughout — a thematic break or a setext-heading underline —
so a document opening with --- front matter has no root block and fails
with MissingQuill (§10), whose message names the fence to write instead.
--- front matter is what broader-internet YAML conventions train an author
(or an LLM) to reach for, so the miss is worth one specific diagnostic rather
than a parse rule: a tolerance would be root-only, since composable cards can
have no --- form, and half a rule is harder to learn than none.
3.3 System Metadata ($)¶
A block's YAML payload may contain $-prefixed reserved keys that carry
system metadata. The set is closed: only $quill, $kind, $ext,
and $seed are accepted. Any other $-prefixed key is a parse error. These
keys are ordinary YAML: they are read by the same YAML parser that handles
the rest of the payload, but they are extracted from the user field
set after parsing; they are not part of the data model's field map (§3.4).
In the typed model, the $ entries live as typed variants of the
unified payload-item list (PayloadItem::Quill, PayloadItem::Kind, and
PayloadItem::Meta keyed by MetaKey::Ext / MetaKey::Seed), interleaved in
source order with user fields and YAML comments. They are surfaced through typed
accessors: card.quill(), card.kind(), card.ext(),
card.seed(): which return Option<…>. On a successfully parsed document the root
card always returns Some(_) for both quill() and kind() (with
kind() == "main"); composable cards return None for quill() and
Some(_) for kind() (any value other than "main"). The root's
$kind: main is synthesised when omitted in source (see §3.3 rules),
so the typed-accessor invariant holds regardless of whether the
author wrote the line.
$quill: <name>@<version>: binds the document to a quill (see §3.5 for the version-selector forms). The root block (the first block) must declare it; no other block may. The value is parsed into a typed quill reference as the block is read.$kind: <value>: identifies a card's kind. The value is name-validated at parse time and must match[a-z_][a-z0-9_]*. The kindmainis reserved for the document root: the root block's$kindismainby position. An explicit$kind: mainis accepted (round-trips byte-equal); omitting it is also accepted and synthesised at parse time. A non-main$kindon the root is a parse error. No composable card may declare$kind: main.$ext: <mapping>: an opaque, optional mapping reserved for out-of-band extension data (UI editor state, agent annotations, …). Required to be a YAML mapping (object); scalars and sequences are rejected. Contents are carried verbatim through Markdown and storage DTO round-trips, and never appear in the plate JSON consumed by backends. Bespoke consumers namespace their state inside the map; e.g.$ext.editor.title, the canonical slot for a per-card display name (an editor-side rename). An empty$ext: {}is preserved as a distinct, explicit declaration.-
$seed: <mapping>: an optional mapping keyed by composable card-kind, present on the root block only; a composable block carrying$seedis a parse error, exactly like$quill. Each entry is a sparse overlay: the user fields (plus an optional reserved$bodystring) that a newly-added card of that kind starts with, layered over the quill's schema-example:seed (overlay › example › absent). Required to be a YAML mapping; scalars and sequences are rejected. Like$extit carries verbatim through Markdown and storage DTO round-trips and never appears in the plate JSON consumed by backends; unlike$extthe seeding layer interprets it. Overlays are validated advisorily byQuill::validateand never gate render. An empty$seed: {}is preserved. -
$metadata entries may appear at any position within the payload, and may be interleaved with data fields. The emitter preserves source order (see §9); newly constructed metadata that does not have a source-order is emitted in the canonical key order$quill,$kind,$ext,$seed. - A duplicate
$keywithin a single block is a parse error (a YAML mapping cannot carry two entries under the same key). - An unknown
$key(anything outside{quill, kind, ext, seed}) is a parse error. A consumer needing a per-card key of its own carries it in$extunder its own namespace, uninterpreted and unguaranteed. - An invalid
$quillreference is a parse error. - A
$-prefixed key whose value type is wrong for the key (e.g. a sequence under$quill, a scalar under$ext) is a parse error. - YAML comments on
$lines. Inline trailing comments ($quill: foo # bound at build) and adjacent own-line comments round-trip through the unified payload-item list: the same mechanism that preserves comments on data fields (§3.4). Both flavors survive parse → emit → parse.
3.4 Data Payload¶
User-defined fields sit in the same YAML payload as the $ metadata keys
(§3.3); after metadata extraction, the remaining mapping entries are the
data payload.
- Field names. Every field name matches
/^[A-Za-z_][A-Za-z0-9_]*$/. The pattern excludes$, so a data field name can never collide with any$-prefixed system key. Lowercase is the canonical, recommended convention, but uppercase ASCII letters are accepted and preserved verbatim; case is significant, sotitleandTitleare distinct fields. - Whitespace-only payload. A block whose payload (after metadata extraction) is only whitespace yields an empty field set.
- YAML comments. Both own-line comments (
# …on their own line) and inline comments (field: value # note) are supported on data fields and round-trip throughtoMarkdown. Comments inside nested YAML values (arrays, maps) are also preserved: the pre-scan captures each nested comment with a structural path and the emitter re-injects it at the matching position. - The
!must_filltag.!must_fillmarks a data field as a placeholder awaiting user input and round-trips through emit. It is whatQuillConfig::blueprintstamps into every obliged cell: the canonical authoring placeholder, and a marker that survives into a rendered document is surfaced byQuill::validateas the non-fatalvalidation::must_fillwarning (it never gates render). It applies both to a top-level field and to a leaf nested inside an object or an array element (e.g.addr.street,recipients[0].name); nested markers are recorded on the value tree and survive markdown, live-wire, and storage round-trips.!must_fillmay be applied to scalars (string, integer, float, bool, null) and sequences; it is rejected on a mapping (tag the leaves, not the container).!must_fillmay not be applied to a$metadata key. The marker is preserved only in block style under a data field:key: !must_fillat any depth. A marker written inside a flow collection ({…}/[…]), on a bare sequence element (- !must_fill), or nested inside a$metadata value ($seed,$ext) cannot be round-tripped and is reported with aparse::fill_marker_unsupported_positionwarning (the value is kept, the marker is not); markers under YAML anchors/merge keys are likewise not preserved.!must_fillis the only fill tag: every other custom tag,!include,!env, and the former!fillspelling: is dropped with aparse::unsupported_yaml_tagwarning; the scalar value is kept but the tag does not round-trip.
3.5 Version Selectors¶
The $quill value is <name>@<version>, where <version> is one
of:
| Form | Meaning |
|---|---|
name@2.1.0 |
exact version |
name@2.1 |
latest 2.1.x |
name@2 |
latest 2.x.x |
name@latest |
latest overall (explicit) |
name |
latest overall (default: @version omitted) |
Quill names match /^[a-z_][a-z0-9_]*$/. The selector is a pin, not a
resolver: this spec fixes the surface syntax accepted on the $quill line,
and matching a partial selector against a set of installed versions belongs to
a layer above the engine.
4. Block Detection¶
A single detector runs over the line stream. A ~~~ line, whatever its info
string, opens a card-yaml block iff all of the following hold:
D0: Column zero. The ~~~ opener has no leading spaces.
D1: Blank line above. The ~~~ line is line 1 of the document, or the
line immediately above it is blank.
D2: Closing fence. A matching ~~~ line at column zero appears
later in the document. An indented ~~~ line is payload (§3.2), never a
closer.
A ~~~ line that fails D0 (an indented opener) or D1 is not a card-yaml
opener; it is delegated to CommonMark, where an indented ~~~ is still a
valid fenced code block.
YAML content between recognised fence markers is opaque to detection: a
~~~ line inside an open block is part of that block's payload, not a new
opener (though the canonical payload never produces such a line). In
particular, an indented ~~~ inside the payload; e.g. a tilde code fence
embedded in a | block-scalar value: is payload by the column-zero closer
rule (D2). A column-zero ~~~ can never be block-scalar content (YAML
requires scalar content to be indented past its key), so the closer is
unambiguous.
Failure of D0, D1, or D2 delegates the ~~~ line to CommonMark (an unclosed
~~~ opener becomes a code block to EOF, with a non-fatal unclosed-fence
warning). A document with no closed root block fails with MissingQuill
(§10).
4.1 Worked Example¶
~~~
$quill: resume@1.0.0
$kind: main
title: CV
~~~
Main body text.
***
A thematic break in prose stays a thematic break.
~~~
$kind: profile
name: "Alice"
~~~
Profile body.
The first ~~~ is the root block (line 1, D1 satisfied). The second opens a
profile card (blank line above). The *** is an ordinary CommonMark
thematic break: card-yaml does not reserve any thematic-break syntax.
5. Data Model¶
Parsing yields the Document model: a root block plus zero or more composable
cards, in document order. System metadata rides on the closed set of
$-prefixed keys (§3.3); user payload fields sit flat alongside them and cannot
collide, because field names exclude the $ sigil.
- Root-block fields and card-field names may collide freely; each card is its own scope.
- Body text is preserved verbatim: whitespace, line endings, and inline CommonMark are untouched by the splitter.
How the engine serialises this model onto the wire for backends (the plate JSON) is an engine concern, outside this markdown standard.
6. Markdown Content¶
Body regions (the root body and every card body) are rendered as CommonMark 0.31.2 with the extensions and deviations below.
6.1 Extensions¶
| Feature | Syntax | Notes |
|---|---|---|
| Strikethrough | ~~text~~ |
GFM rules: word-bounded delimiter runs only. |
| Pipe tables | GFM pipe-table syntax with alignment rows | Supports :---, :---:, ---: alignment. |
| Underline (HTML) | <u>text</u> |
The one allowlisted HTML tag (see §6.2). The only syntax for underline; handles intraword and arbitrary-range cases. |
6.2 Declared Deviation from CommonMark¶
Raw HTML is accepted syntactically but produces no output, except
<u>…</u> which renders as underline. The parser recognises HTML per
CommonMark §4.6 / §6.11, discards every event, and re-emits only the
<u> wrapper. Rationale: Typst has no HTML renderer, and arbitrary
passthrough would create an injection vector for downstream
HTML-producing tooling; <u> is the one exception because no
CommonMark-native syntax covers underline.
No other syntax deviates from CommonMark. Delimiter-run semantics for *,
_, **, __, and ~~ follow CommonMark and GFM exactly: in particular,
__text__ renders as strong emphasis, identical to **text**.
6.3 Limited or Out of Scope¶
The following are parsed where CommonMark or pulldown-cmark already handles them, but produce limited or no Quillmark-specific output; fuller support may come in a future revision:
- Images (
): parsed into animageisland carrying{url, alt}, which stores, round-trips to markdown, and reaches an editor — but no backend typesets one. The Typst backend draws nothing for it and warns underbackend::declined_construct.srcnames no space: a document is portable across the versions its$quillselector admits and declares every other thing it references, so a path into one quill's file tree is not a binding a document may take. - Math (
$…$,$$…$$), footnotes, task lists, definition lists: not supported. In markdown body text$is literal; inside a~~~card-yaml payload$is reserved as the prefix for system-metadata keys (§3.3). - HTML comments: accepted syntactically, not rendered (see §6.2).
<br>,<br/>,<br />: follow the raw-HTML rule (non-rendering); authors use CommonMark-native hard breaks (trailing two spaces plus newline, or trailing\\plus newline).
7. Input Normalization¶
Before CommonMark parsing, each body region is normalized:
- Line-ending canonicalization.
\r\nand bare\rsequences are converted to\n. YAML scalars receive this treatment from the YAML parser itself; the body region does not, so this step ensures both layers agree. Authors editing on Windows or pasting from sources that emit CR-bearing line terminators otherwise leave bare\rbytes in the body, which some backends render as visible garbage. - Bidi control stripping. Remove U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069. These invisible characters can desynchronize delimiter runs when copy-pasted from bidi-aware sources.
- Line-separator spacing. Replace U+000B (LINE TABULATION), U+000C (FORM FEED), U+0085 (NEXT LINE), U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) with a single U+0020 space. CommonMark reads none of them as a line ending, but a backend lexer may, in which case the text after one is read as a block marker the author never wrote and two in a row split the paragraph. All five are Unicode whitespace, so a space keeps the words they part apart.
- HTML comment fence repair. If
-->is followed by non-whitespace text on the same line, insert a newline after-->so the trailing text reaches the paragraph parser instead of being consumed by the CommonMark HTML-block rule (type 2).
Normalization is applied identically to the root body and every card body. It is not applied to YAML payload values.
8. Limits¶
Conforming parsers MUST enforce these limits and MUST surface a parse error when any is exceeded:
| Limit | Value |
|---|---|
| Document size | 10 MiB |
| YAML payload size per block | 1 MiB |
| Field count per block | 1000 |
| Card count per document | 1000 |
A conforming parser MUST also bound YAML nesting depth, at whatever depth its YAML parser accepts, so that deeply nested input is refused rather than exhausting the stack. The depth itself is the parser's to choose.
Markdown block nesting depth (100) is enforced at import time by the
markdown→content parser (Document::parse); the Typst backend re-checks
at render as a backstop for content built without importing.
9. Emission Contract¶
toMarkdown always emits the canonical form of every block:
That is: a bare ~~~ opener, the YAML payload (typed $ system
metadata, user data fields, and YAML comments interleaved in source
order), and a ~~~ closer. The root block must declare $quill;
canonical emission also writes $kind: main on the root, synthesising
it when the input omitted the line (see §3.3). Composable cards must
declare $kind: <kind>. A document round-trips to this canonical
shape: fence markers and YAML quoting are normalised, and an opener's info
string re-emits as bare ~~~.
!must_fill tags and YAML comments
(own-line and inline, including those adjacent to $ lines) survive the
round-trip.
Empty containers. An empty mapping emits as key: {} and an empty
sequence as key: [], at every nesting level and under $ext / $seed
alike. Neither collapses to a bare key:, which reads back as null.
Programmatically constructed metadata that does not have a source-order
emits in the canonical key order $quill, $kind, $ext, $seed: the
typed mutators (set_quill / set_kind / set_ext / set_seed)
insert at these positions.
9.1 Canonical Idempotence¶
A document in canonical form round-trips byte-equal under both
toMarkdown ∘ fromMarkdown and fromStored ∘ toStored:
toMarkdown(fromMarkdown(canonical)) == canonical: the canonical form is a parse-emit fixed point.toMarkdown(fromMarkdown(arbitrary)) == toMarkdown(fromMarkdown( toMarkdown(fromMarkdown(arbitrary)))): at most one round-trip canonicalises any valid input; further round-trips are no-ops.toStored(fromStored(toStored(x))) == toStored(x)for any in-memoryDocument x: JSON serialization is byte-deterministic within a schema version.- The Markdown and JSON forms agree:
toMarkdown(fromStored(toStored(x))) == toMarkdown(x)for everyDocument xproduced byfromMarkdown(arbitrary). The two persistence formats canonicalise to the same in-memory model.
Arbitrary (non-canonical) input parses successfully when it satisfies §1–8
and converges to the canonical form on the first emit. Type fidelity (a
quoted "42" survives as a string, an unquoted 42 survives as an
integer) is preserved, along with the source positions of $ metadata
keys and YAML comments; fence-marker length and quoting style are not.
The canonical form is what consumers should content-hash, content-address,
or compare for equality.
10. Errors¶
Parse errors include:
- The document has no recognised root block (
MissingQuill). This covers an unclosed root fence: an unclosed~~~opener is delegated to CommonMark (§4) rather than erroring on its own, but with no closed root block the document still fails here. When the document does open with a~~~declaring$quill, the message names that opener's line and the missing closer (and the failed closer line, for a~~run or an indented~~~) rather than the generic shape. When it opens with---front matter declaring$quill, the message names the~~~fence to write instead (§3.2.1). - The root block missing its
$quillentry. - The root block declaring a non-
main$kind(an omitted$kindon the root is accepted and synthesised; only an explicit non-mainvalue is rejected). - A composable (non-root) block declaring
$quill, or declaring$kind: main(which is reserved for the document root). - A duplicate
$keywithin a single block (caught by the YAML parser as a duplicate mapping key). - An unknown
$keyoutside the closed set{quill, kind, ext, seed}. - An invalid
$quillreference. - A
$metadata key whose value type is incompatible with the key. - A data-field name failing
/^[A-Za-z_][A-Za-z0-9_]*$/. - Invalid YAML inside any block payload.
- Any §8 limit exceeded.
11. References¶
- CommonMark 0.31.2
- GitHub Flavored Markdown: pipe tables and strikethrough.