0.81.0 → 0.82.0 — card-yaml syntax¶
0.82.0 replaces Quillmark's Markdown metadata syntax with the card-yaml
format. This is a hard cutover: the 0.81.0 ---/QUILL: frontmatter and
the ```card <kind> fenced cards (and the legacy ---/CARD: fence) are
removed and no longer parse. Stored documents must be migrated.
Alongside the syntax change, the structured-metadata type is renamed
Frontmatter → Payload, and the card-API surface is reworked (see §6). The
rest of the API — Document, Quill, and the rendering entry points — is
unchanged.
TL;DR¶
Every metadata block is now a ~~~card-yaml fence. The block's YAML payload
carries $-prefixed reserved keys for system metadata; the root block must
declare $quill:
- ---
- QUILL: my_quill
- title: Main Document
- ---
+ ~~~card-yaml
+ $quill: my_quill
+ title: Main Document
+ ~~~
Some content here.
- ```card products
- name: Widget
- price: 19.99
- ```
+ ~~~card-yaml
+ $kind: products
+ name: Widget
+ price: 19.99
+ ~~~
Widget description.
Why: isolating the structured payload inside an explicitly delimited block — separate from the prose body that follows it — keeps LLM generation stable and prevents state corruption. A generator editing prose cannot disturb the structured fields, and vice versa.
1. The card-yaml block¶
A card-yaml block has three parts, in order:
- Opening fence — exactly
~~~card-yaml(three tildes, at column zero). The info string alone identifies a metadata block. - YAML payload — a standard YAML mapping carrying both system metadata
(
$-prefixed reserved keys) and user-defined data fields. - Closing fence — exactly
~~~.
The prose body begins immediately after the closing ~~~ and runs to the next
block or end of document.
Root block and cards¶
The document is positional: the first ~~~card-yaml block is the
document root, and every later block is a composable card. The root is
identified both positionally (first block) and by its mandatory $kind: main
declaration. The kind main is reserved for the document root.
System metadata ($)¶
The block's YAML payload may carry up to three reserved $-prefixed keys.
Parsing extracts them from the user field set into a typed CardMetadata;
any other $-prefixed key is a parse error (the set is closed).
$quill: <name>@<version>— binds the document to a quill (replacing the oldQUILL:frontmatter key). The root block must declare$quill; composable cards must not.$kind: <kind>— identifies a card's kind. This replaces the```card <kind>info string and the legacyCARD:key. The root block must declare$kind: main; composable cards must declare some other kind matching[a-z_][a-z0-9_]*.$id: <value>— an opaque, optional identifier. Plain metadata: no validation, no uniqueness; carried through the round-trip unchanged.
$ metadata entries are read as ordinary YAML keys, so they may appear at
any position within the block's payload. Canonical emission preserves
source order — including any YAML comments adjacent to $ lines.
Programmatically constructed metadata (no source order) emits in the
canonical key order $quill, $kind, $id. A duplicate $key within a
block is rejected by the YAML parser as a duplicate mapping key.
Blank-line rule¶
A blank line is required immediately above every ~~~card-yaml opener, except
when the opener is the very first line of the document. A ~~~card-yaml line
without a blank line above it is treated as an ordinary CommonMark fenced code
block, not a metadata block (a parse::card_fence_missing_blank warning is
emitted).
2. Migrating stored documents¶
For each document:
- Replace the opening
---of the frontmatter with~~~card-yaml, rename theQUILL:line to$quill:, and add a$kind: mainline below it. Replace the closing---with~~~. - For each card written as
```card <kind>, replace the opener with~~~card-yamlfollowed by a$kind: <kind>line, and replace the closing```with~~~. The kind must not bemain— that's reserved for the document root. - For each legacy card written as a
---block with aCARD:key, replace the fences with~~~card-yaml/~~~and renameCARD:to$kind:. - Ensure every
~~~card-yamlopener has a blank line above it.
A bare --- in body prose was already an ordinary CommonMark thematic break;
it is unaffected and needs no change.
Document::to_markdown() (doc.toMarkdown() in WASM, doc.to_markdown() in
Python) emits the canonical card-yaml form, so re-serializing a parsed
document produces compliant output automatically.
Storage DTO (JSON) — quillmark/document@0.81.0 → @0.82.0¶
The versioned JSON envelope (Document::toJson / fromJson in WASM,
serde_json on Document in Rust) also bumped its schema tag from
quillmark/document@0.81.0 to quillmark/document@0.82.0. Two paths apply,
depending on how you consume the DTO.
You use Document.fromJson (recommended). Nothing to do. fromJson
accepts both schema tags: V0_81_0 payloads are migrated forward in-process
on read, and the next toJson re-emits them under the V0_82_0 tag. The
migration is structural (the V0_81_0 sentinel becomes typed $ items at
the head of payload.items), so a round-trip through the new build
produces a Document equal to one re-parsed from the same Markdown.
Migration is forward-only: a V0_82_0 blob cannot be read by a 0.81.x
build.
You read the DTO JSON directly. The wire shape changed. The separate
sentinel and frontmatter collapse into a single ordered
payload.items list, and the per-item discriminator is renamed kind →
type (the field name kind is reused as a payload-item variant for
$kind, so the discriminator had to move):
{
- "schema": "quillmark/document@0.81.0",
+ "schema": "quillmark/document@0.82.0",
"main": {
- "sentinel": { "kind": "main", "quill": "usaf_memo@0.1" },
- "frontmatter": { "items": [ { "kind": "field", "key": "title", "value": "Hi" } ] },
+ "payload": {
+ "items": [
+ { "type": "quill", "value": "usaf_memo@0.1" },
+ { "type": "kind", "value": "main" },
+ { "type": "field", "key": "title", "value": "Hi" }
+ ]
+ },
"body": "..."
},
"cards": [
{
- "sentinel": { "kind": "card", "tag": "indorsement" },
- "frontmatter": { "items": [ { "kind": "field", "key": "for", "value": "X" } ] },
+ "payload": {
+ "items": [
+ { "type": "kind", "value": "indorsement" },
+ { "type": "field", "key": "for", "value": "X" }
+ ]
+ },
"body": "..."
}
]
}
Concrete deltas:
card.sentinelis gone. The main card emits$quillthen$kind: mainas the first twopayload.itemsentries; a composable card emits a single$kindentry. The old{"kind":"card","tag":"X"}becomes{"type":"kind","value":"X"}.card.frontmatter→card.payload.frontmatter.nested_comments→payload.nested_comments(same shape).- Payload-item discriminator:
"kind"→"type". New variants:quill,kind,idalongside the existingfieldandcomment. - The user-facing
fillflag onfielditems and theinlineflag oncommentitems are unchanged.
Detection helpers on the WASM Document class:
Document.currentSchemaVersion() // "quillmark/document@0.82.0"
Document.schemaVersionOf(blob) // reads the schema tag without a full parse
Document.tryFromJson(blob) // returns undefined instead of throwing
schemaVersionOf does not validate the payload — use it to distinguish
"wrong schema version" from "corrupt" when fromJson throws. The full
design lives in prose/canon/DOCUMENT_STORAGE.md.
3. What is unchanged¶
- Field names still match
[a-z_][a-z0-9_]*; card kinds still match[a-z_][a-z0-9_]*. QUILL,CARD,BODY, andCARDSremain reserved field names.- The plate wire format (
QUILL,BODY,CARDS, per-cardCARD) is unchanged — backends see the same JSON shape. - YAML payload comments (own-line and inline
field: value # note) and the!fillplaceholder tag are still supported and still round-trip. Document,Quill, and the rendering APIs keep their names and shapes across Rust, WASM, and Python. (TheFrontmattertype is renamed and theCardmetadata accessors are reworked — see §6.)
4. What is removed¶
- The
---/QUILL:frontmatter block. - The
```card <kind>fenced-card syntax and the legacy---/CARD:card fence. - The "cannot specify both QUILL and CARD" error — it cannot arise under card-yaml.
ParseError::Otherand itsFrom<String>/From<&str>/From<Box<dyn Error>>impls — the variant had no callers and its diagnostic carried noparse::*code. Exhaustivematchcallers should drop the arm.
5. Error messages¶
Parse errors are reworded for the new syntax. Match on ParseError variants
(or diagnostic codes) rather than message text:
| Situation | Variant |
|---|---|
No ~~~card-yaml block, or the root block has no $quill |
MissingQuill |
~~~card-yaml opener with no ~~~ closer |
InvalidStructure |
Unknown $key outside the closed set {$quill, $kind, $id} |
InvalidStructure |
Invalid $kind value |
InvalidStructure |
Root block missing $kind: main, or declaring a non-main $kind |
InvalidStructure |
Composable card declaring $kind: main |
InvalidStructure |
!fill applied to a $ metadata key |
InvalidStructure |
| Reserved name used as a field | InvalidStructure |
Invalid YAML payload (including duplicate $key) |
YamlErrorWithLocation |
6. API changes¶
6.1 Frontmatter → Payload rename¶
The card-yaml format calls the structured YAML data inside a block — after
the $-prefixed metadata keys are extracted — its payload. The in-memory
type that holds it — historically named Frontmatter — is renamed to match.
This is a pure rename: the type's shape, methods, and behavior are unchanged.
Rust (quillmark-core):
| Old | New |
|---|---|
Frontmatter |
Payload |
FrontmatterItem |
PayloadItem |
Card::frontmatter() |
Card::payload() |
Card::frontmatter_mut() |
Card::payload_mut() |
RenderError::InvalidFrontmatter |
RenderError::InvalidPayload |
QuillConfig::coerce_frontmatter() |
QuillConfig::coerce_payload() |
WASM (@quillmark/wasm):
| Old | New |
|---|---|
card.frontmatter (map view) |
Removed — derive from card.payloadItems |
card.frontmatterItems |
card.payloadItems |
FrontmatterItem (TS type) |
PayloadItem |
Python (quillmark):
| Old | New |
|---|---|
doc.frontmatter (map view) |
Removed — derive from doc.main["payload_items"] |
card["frontmatter"] (map view) |
Removed — use card["payload_items"] |
card["frontmatter_items"] |
card["payload_items"] |
The map view (
card.frontmatter/doc.frontmatter) is not replaced by apayloadmap: it duplicated the items list and was dropped during the rename. Read fields by filtering:card.payloadItems.find(i => i.type === 'field' && i.key === 'x')?.valuein JS, or the equivalent comprehension in Python.
6.2 System metadata: Sentinel removed, typed accessors added¶
A block's $-prefixed reserved keys are now system metadata drawn from
a closed set of three keys — $quill, $kind, $id. Any other $key
is a parse error. The API reflects this:
- The
Sentinelenum is removed. $quillis parsed into a typedQuillReferenceat parse time.Card::sentinel()is replaced by three typed accessors onCard:Card::quill()—Option<&QuillReference>for the block's$quill.Card::kind()—Option<&str>for the block's$kind.Card::id()—Option<&str>for the block's$id.Card::is_main()is removed, along with theis_mainargument toCard::from_parts. Root vs. composable is positional: the rootCardlives inDocument::main, composable cards inDocument::cards.EditError::ReservedKindis retained —mainis a reserved kind for the document root, soCard::new("main")andset_card_kind(_, "main")raise it.
Code that matched on Sentinel variants or called Card::sentinel() must be
updated to read Card::quill() / kind() / id().
Bindings. The card object's discriminator key is renamed tag → kind
to match core vocabulary:
| Old | New |
|---|---|
card.tag (WASM) / card["tag"] (Python) |
card.kind / card["kind"] |
card.sentinel / card["sentinel"] |
removed — read Document.main vs Document.cards |
CardInput { tag, fields?, body? } |
{ kind, fields?, body? } |
Document.pushCard / insert_card callers must pass kind instead of tag.
The same tag → kind vocabulary change renames the editing API in both
core and the bindings:
| Old | New |
|---|---|
Document::set_card_tag / setCardTag (WASM) / set_card_tag (Python) |
set_card_kind / setCardKind |
EditError::InvalidTagName |
EditError::InvalidKindName |
form::unknown_card_tag diagnostic code |
form::unknown_card_kind |
Checklist¶
- [ ] Migrate stored Markdown from
---frontmatter /cardfences to~~~card-yamlblocks carrying$-prefixed reserved metadata keys. - [ ] Stored JSON DTOs (
schema: quillmark/document@0.81.0) load transparently viaDocument.fromJson; only consumers that read the DTO JSON directly need to update for thesentinel+frontmatter→ unifiedpayload.itemsshape (see §2 "Storage DTO"). - [ ] Ensure the root (first) block declares
$quill. - [ ] Ensure every
~~~card-yamlopener has a blank line above it. - [ ] Drop any inline comment that sat on a
QUILL:/CARD:line — they survive on data-field lines but not on$metadata lines. - [ ] Rename
Frontmatter/.frontmatterAPI usages toPayload/.payload(see §6.1). - [ ] Replace
Card::sentinel()/Sentinelusage with theCard::quill()/kind()/id()accessors (see §6.2). - [ ] In WASM/Python binding callers, rename the card
tagkey tokind(card objects andCardInput) — see §6.2. - [ ] Rename
set_card_tag/setCardTagtoset_card_kind/setCardKind,EditError::InvalidTagNametoInvalidKindName, and theform::unknown_card_tagdiagnostic code toform::unknown_card_kind. - [ ] Regenerate snapshot/golden fixtures that compare
to_markdown()output. - [ ] Update error handling that matched on old parse-error message text.