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 two declared deviations (§6.2). 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 two deviations declared in §6.2:
raw HTML, and a column-zero ~~~ block with a blank line above it, which is a
card-yaml block rather than a fenced code block whatever its info string (§3.2;
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). - Tables: a GFM pipe table, or an HTML table in a subset that spells layout and merged cells (§6.8).
- The
qm-*carrier: inline font runs, anchors and island tokens, spelled as custom HTML elements (§6.4).
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:
~~~, at least as long as the opener (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: after the root, one whose text reads as a mapping fails for naming no$kind(§3.3). - 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, $id,
$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,
PayloadItem::Id, 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.id(), 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
kind() returns the declared 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. Every block after the root is a composable card and must declare$kind: one that names none is a parse error (§10). Whether a kind names anything a quill declares is the schema's question, not the parser's.$id: <card-id>: names a composable card across writes. A card id is 1 to 64 of[A-Za-z0-9_-], opening with a letter or digit, and unique in the document. The root block may not declare it: the root ismain. A value that is not a string is read as its JSON text, so$id: 7is the id7and$id: 0x1Fthe id31: quote an id YAML reads as a number. A null$id($id:,~) is no$id. A card that omits$idis minted one:cand five base32 letters, the first of a fixed sequence that no card holds, so equal markdown parses to equal ids. A card spelling an id outside the grammar, or one an earlier card holds, is minted one in its place, with aparse::card_id_remintedwarning at the card. Like$extit never appears in the plate JSON.$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 (overlay › absent, the bodyoverlay › empty). 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,$id,$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, id, ext, seed}) is a parse error. - 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.
- Booleans. A plain scalar is a boolean only when it spells
trueorfalse, in any letter case. YAML 1.1'sy,n,yes,no,onandoff, in any case, are strings. - 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 reads each nested comment's container and slot from the YAML parser's event stream, and the emitter re-injects it there. The YAML's structure places a comment, whatever its indentation: a sequence written at its key's column, or a comment indented less than the block it sits in, keeps its slot. - Indentation decides only where the structure leaves a choice. After a
collection's last child, a comment indented past the key holding the
collection is inside it, and so is one at or past the first key or dash of
a sequence item's collection; any other belongs to the collection around
it. A comment indented under
key: [],key: {}or a barekey:is inside that empty value, and so is one under a bare key whose tag reads as null,key: !!nullorkey: !custom. Underkey: !!strorkey: !the value is an empty string, and the comment follows the entry. - A comment inside a flow collection or on a multi-line scalar's line is the trailer of the entry holding the value, or follows it when one already trails it. A trailer on a sequence item's dash line is the item's, though the line holds the item's first key.
- A mapping holding a merge (
<<) reads its own keys, then each key the merge brings that it does not already hold, andtoMarkdownwrites them in that order. A comment at an own key stays with that key, so one between a merge and the next own key sits with that key, ahead of the merged keys. One ahead of a merge or on its line sits ahead of the keys the merge brings, one inside its value keeps its slot among them, and one closing the mapping after its merge follows them. A comment inside a merged key the mapping already holds sits ahead of where that key would. A sequence item whose mapping holds nothing but merges bringing no key emits as{}, and the comments inside it follow the item. - Tags. A YAML tag on any node (
!include,!env, a core!!str) is dropped with aparse::unsupported_yaml_tagwarning: on a block value, a sequence element, a key, or a node inside a flow collection. The value is kept, and the tag does not round-trip. The warning carries the node's rootedpath(main.addr.street,main.to[0]), or none under a$key, whose value has no document address. A core tag (!!str 5) takes effect as the value is read before it drops.
3.5 Version Selectors¶
The $quill value is <name> or <name>@<version>, where <name> is a quill
name or <collection>.<name>, and <version> is one of:
| Form | Meaning |
|---|---|
name@2.1.0 |
exact version |
name@2.1 |
any 2.1.x |
name |
any version (@version omitted) |
Each version segment is ASCII digits. name@2, name@latest and name@ fail
as parse::invalid_quill_reference: a lone major is no selector, and a
reference with no selector omits the @.
Quill and collection names match /^[a-z][a-z0-9_]*$/, at most 64
characters each. 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 Quillmark.
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.
Outside a card-yaml block, the detector reads ordinary fenced code blocks as
CommonMark does, containers included: a ~~~ line inside one is code, not an
opener. A fenced code block inside a list item or block quote closes when its
container does, without a closing fence and without the unclosed-fence warning.
A column-zero line ends every container, so such a block never covers a
column-zero ~~~ below it; only a code block opened at the top level does.
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 Quillmark serialises this model onto the wire for backends (the plate JSON) lies 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. |
| HTML tables | <table>, <thead>, <tbody>, <tr>, <th>, <td> |
A subset carrying column widths, placement, cell alignment and merged cells (§6.8). |
| Underline (HTML) | <u>text</u> |
Allowlisted HTML (see §6.2). The only syntax for underline; handles intraword and arbitrary-range cases. |
| Task lists | - [ ] open, - [x] done |
GFM rules: the marker opens a list item's first paragraph. A list item's checked. |
| Footnotes | text[^1] and a [^1]: note definition |
GFM rules. A footnote island at the reference, holding the note (§6.5). |
| Math | $tex$ inline, $$tex$$ display |
pulldown-cmark's math rules. A math island holding the TeX (§6.6). |
6.2 Declared Deviations from CommonMark¶
Raw HTML produces no output of its own, except an inline <u>…</u>, which
renders as underline, an inline <br>, which is a hard break, an HTML block
opening <table>, which §6.8 reads as a table, and the qm-* carrier §6.4
defines. The parser recognises HTML per CommonMark §4.6 / §6.6 and discards
the HTML itself. What else an HTML block holds depends on its type:
| HTML block (CommonMark §4.6) | What imports |
|---|---|
Type 6 opening <table> |
Each table it holds, as §6.8 reads it. |
Type 6 or 7 holding only tag lines, a qm-* tag among them |
Nothing of its own; its carrier tags read as §6.4 reads them. |
Any other type 6 or 7: a tag line such as <div>, <center>, <details> or <span> tight against markdown |
Nothing: the block drops whole, to the next blank line, as CommonMark runs it, a carrier tag in it included. |
Types 1–5: <pre>, <script>, <style> or <textarea>; a comment; a processing instruction; a declaration; CDATA |
Nothing: the block drops whole. Text after a comment's --> on its last line imports as a line of its own, unless it opens a fence or a type 1–5 block it does not close on that line. |
A line holding only open and close qm-* tags is a tag line wherever
it stands, in the containers CommonMark puts it in, a lazy
continuation line's included. In a paragraph or a list item's text it ends the
text above it, and the text under it is a paragraph of its own, read as
CommonMark reads it there. In an HTML block where no line of other tags borders
it, §7 sets it apart from the markdown lines beside it, which read as blocks of
their own. A line in a heading, a footnote definition, a mark or a link keeps
CommonMark's reading, as does a carrier tag sharing its line with text or with
a tag outside the carrier (§6.4).
A line holding only tags, carrier or not, on the line after a pipe table's rows ends the table rather than adding a row.
The allowlist is inline: <u> or <br> alone on its line opens an HTML block
like any other tag. An inline <u> pairs with a </u> as HTML pairs them,
inside its paragraph, heading, list item's text or table cell: a </u> closes
the innermost <u> still open, whatever marks lie between, so an underline
crosses ** or ~~ freely. A <u> carrying an attribute drops and still
takes its </u>, and one still open where its text ends drops. Export writes
<u> and </u> outside the emphasis delimiters closing and opening where they
stand, so a tag never sits at a delimiter run's edge, where its < or >
would change the run's flanking; a delimiter run spanning that position stays
open around the tag. An import reports each dropped opening tag by its lowercase name, under
parse::dropped_construct, and a block holding no other opening tag under its
first where it drops text with it, qm-anchor included; any other closing
tag, a comment, the content of a type 1–5 block and any other qm-anchor
report nothing. A qm-island token reports as §6.4 sets out.
Rationale: Typst has no HTML renderer, and arbitrary passthrough would create
an injection vector for downstream HTML-producing tooling; <u> is an
exception because no CommonMark-native syntax covers underline, and <br>
because a pipe-table row is one source line, with no room for a native hard
break. Every HTML block keeps CommonMark's reading, which the import reports,
once §7 has set each run of carrier tag lines apart: HTML asks for no blank line
around a tag, and neither does the carrier.
A column-zero ~~~ with a blank line above it opens a card-yaml block,
not a fenced code block, whatever its info string (§3.2, §4). A backtick
fence is the one fence for code. Rationale: the card-yaml format claims the
tilde fence outright, so whether a block is data never depends on its info
string.
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:
- 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. - HTML comments: accepted syntactically, not rendered (see §6.2).
<br>(any case, with attributes or a closing/) inside a paragraph or a table cell: a hard break. In a paragraph, one at its block's start or right after another break is dropped; in a heading it is a space. A soft break beside a hard break, a<br>or CommonMark's own, is nothing, soa<br>on one line andbon the next read asa, a break,b. One alone on its line, a blank line above it, opens an HTML block (§6.2) and drops. Outside a table, export writes the CommonMark-native hard break (trailing\\plus newline); inside a cell it writes<br>.
6.4 The qm-* Carrier¶
A qm-* tag spells what CommonMark has no syntax for: a run's font family
(qm-span), an anchor (qm-anchor) and an island markdown cannot spell
(qm-island). Each is a CommonMark raw-HTML tag and a valid custom-element
name, which an HTML renderer draws as its children.
Names. A carrier tag's name is qm- and a name matching
[a-z][a-z0-9]*(-[a-z0-9]+)*: qm-span, qm-sig, qm-a1-b. CommonMark tag
names admit no :, so the prefix stands where XML would write a namespace
(quill:span). A tag name reads ASCII-case-insensitively, as HTML names do,
and the canonical spelling is lowercase. qm-, qm-a--b and qm-9 are
outside the grammar, raw tags like any other (§6.2).
Attributes. A name matches [a-z][a-z0-9_]* and is none of style,
class, id, href and src, nor any name opening on, so the carrier
never holds markup a downstream HTML renderer would act on (§6.2's
rationale). An attribute outside that grammar, or one repeating a name already
read, is refused by name. A value reads double-quoted, single-quoted or
unquoted, and decodes &, <, >, ", ' and decimal or
hexadecimal references to a Unicode scalar value, � reading as U+FFFD as
CommonMark reads it; any other & is text.
Tag lines. A line holding only qm-* tags in the grammar, none
self-closing, is a tag line wherever it stands (§6.2), set apart from the
markdown beside it (§7). Inside a line of text, a table cell's included, or
beside a tag outside the carrier in a block tight against markdown, a carrier
tag drops like any raw tag; qm-anchor and qm-island read inside a line as
their own entries below set out.
Canonical spelling. A carrier tag is written with its attributes sorted by
name, each value double-quoted, with &, <, > and " as &, <,
> and ", and a |, a control character, a bidi control or a line
separator in a value as a hexadecimal reference (|): a | ends a table
cell, a line ending ends the tag's line, and §7 rewrites the rest.
Any other name. A qm-<name> tag other than qm-span, qm-anchor and
qm-island carries nothing. An open tag drops, reported under parse::dropped_construct
by its tag name (qm-keep), its attributes with it; a close tag drops without a
report; and what lies between imports as written. A pair of such tag lines
around a paragraph, <qm-keep> tight above it included, leaves the paragraph
as if the tags were absent.
qm-span is an inline pair whose attributes set formatting of the text it
wraps, whatever quill reads the document. Each attribute is a mark of its own,
and family is the one it carries:
| Attribute | Value | Default |
|---|---|---|
family |
a font family's name: not empty, no space at either edge, and no control character, bidi control or line separator | the surrounding text's family |
Pay <qm-span family="Carlito">**today**, as <qm-span family="Arimo">§4</qm-span> sets out</qm-span>.
- A pair pairs as
<u>does (§6.2): inside its paragraph, heading, list item's text, table cell or footnote, a</qm-span>closes the innermost<qm-span>still open, whatever marks lie between. An inner pair sets its family over its own text and the outer family resumes past it, so nested pairs import as flat marks, one family per character. - A family stores as written and names no font file. A backend resolves it at render, and text in a family none of its fonts holds keeps the surrounding family. The Typst backend sets no family over a code span, math or a table, whose cells take pairs of their own, and a footnote's note keeps its own family.
- An attribute other than
familydrops alone, reported asqm-span[<name>], as does a family outside its spelling. A tag setting no family, a self-closing tag and a pair still open where its text ends drop, reported asqm-span. - Export writes one pair per family mark and never nests pairs, so the
example above comes back as three pairs side by side. A pair sits outside
<u>and the emphasis delimiters closing and opening where it stands, as<u>is (§6.2). - A
qm-spantag line wraps nothing: its tags drop, the open tag reported asqm-span, and the blocks between them stay. A family is a run's, never a block's.
The annotated read is the markdown an LLM or another writer reads and writes back: the plain export with an anchor tag at each anchor's start and an island token for each island whose markup does not read back as itself. Both tags are reserved: no stored element carries their names. A cold import drops both and mints nothing from them. A revise, which imports the markdown as an edit of the value stored at its address, reads them against that value: the stored value is the side map, and the markdown stays the one carrier.
qm-anchor, <qm-anchor ref="ID"></qm-anchor>, stands where the anchor
ID starts. A cold import drops it without a report, inline or alone on its
line, unless its line drops markdown with it (§6.2). A revise reads each inline
tag in prose:
- A tag the writer kept pins: the anchor starts where the tag stands. Where its start was elsewhere, it ends where the diff carries its stored end, when that lands on the tag's line after the tag, and otherwise spans its stored length, cut at the line's end.
- A tag the writer dropped leaves the anchor to the diff, which carries it with its text or drops it with its text.
- A tag whose
refnames no anchor the stored value holds in prose, and a second tag naming one anchor, drops reported asqm-anchor[ref]. - A tag carrying
atpins nothing. The read writesat="span"where an anchor's start sits inside a code span or a link, whose start the tag moves to, andat="line"where the line's tags move to its end. - A tag in a table cell, a footnote's note, an image's alt text or a code block pins nothing.
The annotated read writes one inline at each anchor's start:
- after the delimiters of the marks closing there and before those opening there;
- at a code span's or link's start, for an anchor inside one, with
at="span"; - in no code block, block island's line, table cell, note or empty line, an anchor in a cell or a note listed with no tag;
- at the end of a line whose tags in place would change what it imports to,
each with
at="line", and nowhere on a line where the end changes it too.
qm-island, <qm-island ref="ID"></qm-island>, stands for the stored
island ID with its type and props as stored: a table's spans, widths and
keys Quillmark does not name, an equation's TeX its dollars cannot write, an
image's props beyond url and alt. The annotated read writes it in place of
the markup of each island that does not read back as itself, an anchor in a
cell aside, and writes every other island as markup. A revise reads each
inline token in prose as that island, a table's taking a line of its own; a
writer moves an island by moving its token, and copies one by writing it
twice, its cells' anchors staying on the copy that continues the stored
island alone. A token whose ref names no island the stored value holds drops
reported as qm-island[ref], and one in a table cell, a footnote's note or an
image's alt text, where no island sits, drops reported as qm-island. A cold
import drops every token, reported as qm-island.
6.5 Footnotes¶
A footnote reference imports as a footnote island at its slot, inline in its
paragraph, holding its definition's note in the slot note as a table cell
holds its text: one paragraph, the note's paragraphs and line breaks joined by
hard breaks (U+2028). Each reference holds
its own copy, so a note cited twice is two footnotes. The copies past each
note's first share one budget per import, MAX_NOTE_COPIES (1 000 000)
characters, so a long note cited many times cannot multiply into the content;
a reference past it stays as its [^label] text. A label matches its
definition as GFM matches it, case-insensitively.
What the island cannot hold drops, reported under parse::dropped_construct:
| Spelling | Reported as |
|---|---|
| A definition nothing references, or a second definition of one label | footnote |
| A reference in a table cell, an image's alt text or a note | footnote |
| A reference past the budget the copies of the notes share | footnote:size |
| A heading, list, code block, block quote, table or rule in a note, which keeps its text as lines of the note | footnote[<block>]: heading, list, code, quote, table, rule |
Export numbers the references [^1], [^2], … in reading order and writes
each note as a definition after the body, a line break as <br>. Link text
holds no reference, so a link over one writes as two links, one each side of
it.
6.6 Math¶
$tex$ is an inline equation and $$tex$$ a display one, each a math
island at its slot holding the TeX between the dollars as written, a display
one with display: true. The rules are pulldown-cmark's: an opening $ is
not followed by whitespace and a closing $ not preceded by it, the braces
inside balance, and \$ is a dollar sign that opens nothing, so
$5 and $10 is text. A % opens a TeX comment, which storage drops to the
end of its line. Inside a ~~~ card-yaml payload $ is reserved as the
prefix for system-metadata keys (§3.3).
An equation in a table cell, an image's alt text or a footnote's note keeps its
source as text, reported under parse::dropped_construct as math.
Export writes $tex$ or $$tex$$, and writes each $ of the text in a
paragraph, cell, note or alt text as \$ wherever another $ or an equation
there could pair with it, so a lone $5 stays as written. A tex the dollars
cannot read back as itself (an unescaped $, unbalanced braces, an empty inline
equation) an authored write refuses. Import keeps such an equation's source as
text, reported as math, where the TeX left once its edge whitespace drops is
one: $ $ around a Unicode space reads empty.
6.7 Definition Lists¶
A definition list is not read. A paragraph and a line opening : under it
are one paragraph, as CommonMark reads them, and export writes a line opening
: as it stands.
6.8 Tables¶
A table is written in one of two spellings, each table wholly in one: a GFM
pipe table, or an HTML table in the subset below. Import reads both into one
table island. Export picks the spelling from what the island holds, never
from how the source wrote it, so a plain <table> re-exports as pipes.
Pipes or HTML. Export writes a pipe table exactly when the table has one
header row and holds no widths, no placement, and no cell align,
valign, colspan or rowspan. Column alignment rides the delimiter row,
and a <br> in a cell is inline HTML GitHub renders, so neither forces HTML.
Any other table writes as HTML, and one that loses its last such key writes
as pipes again. A cell is one paragraph, its lines parted by hard breaks, so
no cell writes as several: <br><br> in a cell reads as one hard break in
either spelling.
<table widths="1 2 2" aligns="left left right" align="center">
<thead>
<tr><th colspan="3">FLIGHT X PERSONNEL</th></tr>
<tr><th>Date</th><th colspan="2">POC</th></tr>
</thead>
<tbody>
<tr><td rowspan="2" valign="middle">12 Nov</td><td>SSgt Doe</td><td>555-0101</td></tr>
<tr><td>SrA Roe</td><td>555-0102</td></tr>
</tbody>
</table>
The subset. The elements are table, thead, tbody, tfoot, tr,
th and td, read ASCII-case-insensitively, with these attributes:
| Tag | Attribute | Value | Default |
|---|---|---|---|
table |
widths |
whitespace-separated column weights, each a decimal integer from 1 to 2⁵³ − 1 or auto for an auto-fit column |
every column auto |
table |
aligns |
whitespace-separated column alignments, each none, left, center or right |
every column none |
table |
align |
the table's placement: left, center or right |
the plate's placement |
td, th |
colspan, rowspan |
the columns or rows the cell covers, a whole number from 1 | 1 |
td, th |
align |
the cell's horizontal alignment: left, center or right |
its column's |
td, th |
valign |
the cell's vertical alignment: top, middle or bottom |
the plate's |
- Any other tag outside a cell drops alone, reported under
parse::dropped_constructby its lowercase name (caption). Any other attribute, one repeating a name already read, and a value outside its spelling drop alone, reported as<tag>[<attribute>](td[nowrap],table[widths]).border,class,scopeandstyleonly style a table, and drop silently. An enumerated value reads ASCII-case-insensitively. - Weights are relative and store as written:
2 4lays out as1 2does. Awidthsoralignsshorter than the table pads withautoornone, and a longer one drops its extra entries. alignsis each column's alignment, so a cell'salignis an override that stays when its column's changes.alignplaces the table in the flow, beside no text, where a browser floats a<table align="left">or"right"and wraps text around it.- The
theadrows are the header rows, any number of them. A table with notheadtakes as its header rows the rows opening its first body group, itstbodytag written or not, whose cells are all<th>, as many as norowspanamong them reaches past, and otherwise has none. A<th>in a body row reads as a<td>.
Covered cells are left out, as in HTML. Import resolves the rows into the rectangular grid a table island holds, as HTML's table model forms one:
- The
theadrows come first and thetfootrows last, the body's between in source order. Each cell lands in the next slot of its row no span covers. - The grid is as wide as its widest row, its spans included. A table holding no cell imports nothing.
- A
rowspanstops at the end of its row group, silently. Acolspanpast 1000 reads as 1000, as HTML reads it. A span reaching a slot an earlier span covers stops short of it, silently, as normalization settles one. - A row short of the grid's width pads with empty cells, each such row
reported as
table:row. - Each slot a span covers stores as an empty cell.
Lenient where writers are. An unquoted value (colspan=2), a row directly
under <table> with no tbody, and an omitted </td>, </th> or </tr>
read as HTML reads them. tfoot rows read as body rows after the body's.
Text outside every cell, a <caption>'s say, drops, reported as
table:text, and a comment between rows drops silently.
Cells hold inline markdown. CommonMark reads a <table> as a raw HTML
block and parses no markdown inside it, so import scans the subset itself and
reads each cell's content as one line of inline markdown: <td>**Total**</td>
is bold. A line break in a cell's source is a space, as HTML reads one, and a
<br> is the cell's line break. A <p> boundary inside a cell, the run of
<p> and </p> tags and the whitespace around it, is a line break too, and
one at the cell's edge is nothing. A subset tag inside a comment, an
angle-bracketed link destination or after a backslash is the cell's text. A
code span never reaches past a subset tag, so a stray backtick is the cell's
text rather than a span that merges cells, rows or the prose after the table,
and a code mark over a subset tag exports the tag as escaped text between the
spans either side.
An equation keeps its source, reported as math, as in a pipe table's cell,
and a footnote reference is its text. GitHub draws the grid and the spans but
shows cell markdown literally.
A table is one HTML block. CommonMark ends a type 6 block at a blank
line, so a blank line inside a <table> ends the table there: the cut table
closes its open rows, reported as table, and what follows reads as ordinary
markdown, its own HTML block dropping as §6.2 reads one. A <table> inside an
open one ends it the same way. This holds inside a quote or list item, where
the block's text arrives with container prefixes stripped. What follows a
</table> in its block, on its line or the next, the repair sets apart as
markdown of its own (§7 step 5), so prose written tight under a table, a
second table or a wrapper's close tag reads as written.
A document's tables hold at most MAX_TABLE_CELLS (100 000) slots
together, each table's rows, the header among them, times its columns. A
grid pads every row to its width, so a few bytes can ask for millions of
slots, and many small tables as many as one large one; the rows past the
slots the tables before them leave drop, reported as table:size, and a
table whose first row alone is wider drops whole. Pipe and HTML tables draw
on the same bound.
Export writes the <table> tag, its thead and tbody, and each row on
a line of its own, inside the containers the table sits in. The attributes
come in the order widths, aligns, align and colspan, rowspan,
align, valign, each double-quoted and only away from its default. A
header row's cells are <th> and a body row's <td>. A cell's text is
escaped as in a pipe table's cell, but for |, so no < reads as a tag and
no & as an entity on the way back.
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.
- NUL replacement. Replace U+0000 with U+FFFD, as CommonMark §2.3 asks.
- Parser-guided repair. The text is parsed, edited inside the spans that parse locates, and parsed again; text in a fenced or indented code block is never edited.
- On a comment's last line, text after its
-->moves to a line of its own, keeping the line's indent. - In a type 6 block opening
<table>(§6.8), what follows the first</table>closing a table that anything but whitespace follows gets a blank line above it, on a line of its own keeping the line's indent where it shared the</table>'s line. - Neither edit lands where what it sets apart opens a fence or a type 1–5 HTML block it does not close on its line.
- A pipe-table row holding only tags gets a blank line above it, ending the table.
- In a type 6 or 7 HTML block outside a footnote definition that does not
open
<table>, a run of tag lines (§6.4), that no line of other tags borders gets a blank line above it where a line of text precedes it in the block and one below it where a line of text follows. A line holding only an HTML table's tags (§6.8) counts as a line of text here. The block's last line of text gets one below it where the next line would continue it lazily. A line of the run, or the line of text under it, indented an indented code line's four columns loses its indent.
A blank line written inside a container carries the container's >
markers, and one closes split-off text the next line would otherwise
continue lazily. Split-off text that opens another comment is repaired by
a further round.
Normalization is applied identically to the root body and every card body. It is not applied to YAML payload values.
A character reference can still spell a character steps 1–4 rewrite. Decoded in text, in a link or image destination or in an image's alt text, it is rewritten the same way, and a destination drops a line ending. A carrier attribute keeps it (§6.4).
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.
A block is refused as a YAML parse error when, with its aliases expanded, its scalar and tag bytes pass twice the block's bytes plus 4 KiB, or its nodes pass the block's bytes plus 4 KiB, so a block's value stays linear in its source.
A block whose comments and tags record more than 64 bytes of path per byte of
the block, plus 64 KiB, is refused as parse::invalid_structure: each comment
and tag records the path of the collection holding it, each level costing its
key's bytes and a fixed overhead, and the bound keeps that memory linear in the
block.
The document size bounds every markdown import, not only a parse: a body or
field's revise and a bare import refuse markdown past 10 MiB too
(edit::input_too_large).
Markdown block nesting depth (12) is enforced at import time by the
markdown→content parser (Document::parse): at every point of a body, the
list items and quotes open there number at most 12. Marks and <u>
count nothing: the Typst emitter wraps a run in at most seven show rules, its
mark kinds, raw and a font run's context, so 12 quotes at four apiece leave
Typst's show-rule depth of 64 room for them. 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). A composable card emits
$kind: <kind> when it declares one, then its $id. 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 ~~~.
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.
Multi-line strings. A string spanning lines emits as a | literal block
scalar, |- when it ends without a newline, its lines indented past the key,
at every nesting level. It emits double-quoted with \n escapes where a block
would not read back as the same string or would not survive an editor: a first
line opening on whitespace, whitespace ending a line, more than one trailing
newline, or a \r, control character, U+2028, U+2029 or U+FEFF.
Programmatically constructed metadata that does not have a source-order
emits in the canonical key order $quill, $kind, $id, $ext, $seed:
the typed mutators (set_quill / set_kind / set_id / set_ext /
set_seed) insert at these positions.
9.1 Canonical Idempotence¶
A document in canonical form round-trips byte-equal under both
toMarkdown ∘ parse and fromStored ∘ toStored:
toMarkdown(parse(canonical)) == canonical: the canonical form is a parse-emit fixed point.toMarkdown(parse(arbitrary)) == toMarkdown(parse( toMarkdown(parse(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 byparse(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). - The root block declaring
$id. - A composable block whose mapping payload names no
$kind(MissingKind), located at the opener's line. Its hint names the$kind:line and the backtick fence (§6.2), since tilde-fenced code whose text reads as a mapping is the other usual source. - A duplicate
$keywithin a single block (caught by the YAML parser as a duplicate mapping key). - An unknown
$keyoutside the closed set{quill, kind, id, 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.
- A payload that is YAML but not a mapping: a string, number, boolean or
sequence (
PayloadNotMapping). An empty or null payload is an empty mapping. The error is located at the opener's line and names the backtick fence (§6.2), since tilde-fenced code is the usual source. - Any §8 limit exceeded.
11. References¶
- CommonMark 0.31.2
- GitHub Flavored Markdown: pipe tables and strikethrough.