Skip to content

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 FrontmatterPayload, 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:

  1. Opening fence — exactly ~~~card-yaml (three tildes, at column zero). The info string alone identifies a metadata block.
  2. YAML payload — a standard YAML mapping carrying both system metadata ($-prefixed reserved keys) and user-defined data fields.
  3. 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 old QUILL: 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 legacy CARD: 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:

  1. Replace the opening --- of the frontmatter with ~~~card-yaml, rename the QUILL: line to $quill:, and add a $kind: main line below it. Replace the closing --- with ~~~.
  2. For each card written as ```card <kind>, replace the opener with ~~~card-yaml followed by a $kind: <kind> line, and replace the closing ``` with ~~~. The kind must not be main — that's reserved for the document root.
  3. For each legacy card written as a --- block with a CARD: key, replace the fences with ~~~card-yaml / ~~~ and rename CARD: to $kind:.
  4. Ensure every ~~~card-yaml opener 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 kindtype (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.sentinel is gone. The main card emits $quill then $kind: main as the first two payload.items entries; a composable card emits a single $kind entry. The old {"kind":"card","tag":"X"} becomes {"type":"kind","value":"X"}.
  • card.frontmattercard.payload. frontmatter.nested_commentspayload.nested_comments (same shape).
  • Payload-item discriminator: "kind""type". New variants: quill, kind, id alongside the existing field and comment.
  • The user-facing fill flag on field items and the inline flag on comment items 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, and CARDS remain reserved field names.
  • The plate wire format (QUILL, BODY, CARDS, per-card CARD) is unchanged — backends see the same JSON shape.
  • YAML payload comments (own-line and inline field: value # note) and the !fill placeholder tag are still supported and still round-trip.
  • Document, Quill, and the rendering APIs keep their names and shapes across Rust, WASM, and Python. (The Frontmatter type is renamed and the Card metadata 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::Other and its From<String> / From<&str> / From<Box<dyn Error>> impls — the variant had no callers and its diagnostic carried no parse::* code. Exhaustive match callers 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 FrontmatterPayload 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 a payload map: 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')?.value in 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 Sentinel enum is removed.
  • $quill is parsed into a typed QuillReference at parse time.
  • Card::sentinel() is replaced by three typed accessors on Card:
  • 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 the is_main argument to Card::from_parts. Root vs. composable is positional: the root Card lives in Document::main, composable cards in Document::cards.
  • EditError::ReservedKind is retainedmain is a reserved kind for the document root, so Card::new("main") and set_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 tagkind 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 tagkind 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 / card fences to ~~~card-yaml blocks carrying $-prefixed reserved metadata keys.
  • [ ] Stored JSON DTOs (schema: quillmark/document@0.81.0) load transparently via Document.fromJson; only consumers that read the DTO JSON directly need to update for the sentinel + frontmatter → unified payload.items shape (see §2 "Storage DTO").
  • [ ] Ensure the root (first) block declares $quill.
  • [ ] Ensure every ~~~card-yaml opener 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 / .frontmatter API usages to Payload / .payload (see §6.1).
  • [ ] Replace Card::sentinel() / Sentinel usage with the Card::quill() / kind() / id() accessors (see §6.2).
  • [ ] In WASM/Python binding callers, rename the card tag key to kind (card objects and CardInput) — see §6.2.
  • [ ] Rename set_card_tag / setCardTag to set_card_kind / setCardKind, EditError::InvalidTagName to InvalidKindName, and the form::unknown_card_tag diagnostic code to form::unknown_card_kind.
  • [ ] Regenerate snapshot/golden fixtures that compare to_markdown() output.
  • [ ] Update error handling that matched on old parse-error message text.