Skip to content

0.82.0 → 0.83.0 — $-prefixed plate JSON wire format

0.83.0 retires the legacy uppercase reserved keys from the plate JSON that backends and Typst plates consume. The same conceptual data is now exposed under $-prefixed metadata keys, alongside flat user payload fields. This is a hard cutover: there is no compatibility shim — plates that read the old keys (QUILL, BODY, CARDS, CARD) will fail to compile.

Wire-format change

Key (0.82) Key (0.83) Access in Typst plates
data.QUILL data.$quill data.at("$quill")
data.BODY data.$body data.at("$body")
data.CARDS data.$cards data.at("$cards")
card.CARD card.$kind card.at("$kind")
card.BODY card.$body card.at("$body")

User payload fields stay flat at the root of the plate JSON, alongside the $ metadata. They cannot collide with the $ keys because field names match [a-z_][a-z0-9_]* — the $ sigil is excluded from valid identifiers.

- #mainmatter[
-   #data.BODY
- ]
+ #mainmatter[
+   #data.at("$body")
+ ]

- #for card in data.CARDS {
-   if card.CARD == "indorsement" [
-     #card.BODY
-   ]
- }
+ #for card in data.at("$cards") {
+   if card.at("$kind") == "indorsement" [
+     #card.at("$body")
+   ]
+ }

Why .at("$...")? Typst identifiers do not allow the $ character, so data.$body is a parse error. Dictionary access via .at("$body") is the supported form. Lowercase user fields (e.g. data.title, card.from) keep their familiar .field access.

Removed APIs and validation

  • quillmark_core::document::edit::RESERVED_NAMES, is_reserved_name, and EditError::ReservedName are removed. The editor surface (Card::set_field, remove_field, set_fill) now returns EditError::InvalidFieldName for any name that does not match [a-z_][a-z0-9_]* — including the legacy uppercase sentinels and any $-prefixed name. WASM and Python bindings track the variant change in edit_error_to_js and convert_edit_error.
  • The markdown parser no longer rejects uppercase YAML keys like BODY:. They are simply parsed as user payload fields with invalid names; the editor surface refuses to mutate them, but from_markdown no longer errors on parse. The new wire format makes this safe — the user field cannot shadow $body regardless.
  • QuillConfig::schema() no longer prepends synthetic QUILL / CARD discriminator fields to main.fields / card_kinds.<name>.fields. The schema describes only the user-fillable fields. To construct a document:
  • The root $quill is ${meta.name}@${meta.version} (read from quill.metadata).
  • Each card's $kind is the key under which it is declared in card_kinds.
  • The quillmark::compile_data JSON output uses the new shape end-to- end. Any host code that inspected data["QUILL"], data["BODY"], data["CARDS"], or a card's CARD/BODY keys must switch to the $-prefixed equivalents.

Updating a quill bundle

  1. Edit every plate.typ (or other backend template) in the bundle. Replace data.BODYdata.at("$body"), data.CARDSdata.at("$cards"), card.CARDcard.at("$kind"), and card.BODYcard.at("$body").
  2. Regenerate any stored schema.yaml golden files — the discriminator entries (QUILL, CARD) no longer appear.
  3. If host code reads the plate JSON directly (form builders, custom exporters), switch every reserved-name lookup to the $-prefixed equivalent.

Editor-surface migration

Code that catches EditError::ReservedName should fold that branch into EditError::InvalidFieldName:

  match doc.set_field(name, value) {
-     Err(EditError::ReservedName(n)) => handle_invalid(n),
      Err(EditError::InvalidFieldName(n)) => handle_invalid(n),
      ...
  }

In WASM / Python bindings the thrown / raised error string changes from [EditError::ReservedName] ... to [EditError::InvalidFieldName] ... for the legacy uppercase names. Any tests that asserted on the ReservedName substring need updating.

New: $ext system metadata

0.83.0 adds $ext as a fourth $-prefixed system-metadata key in the closed set, alongside $quill, $kind, and $id. Its value is an opaque YAML mapping reserved for out-of-band extension data — per-card UI editor state (display renames, collapse flags), agent annotations, or anything bespoke to a consumer that should not reach the rendered output. The map round-trips through Markdown and the storage DTO, and is stripped from Document::to_plate_json() so backends never see it.

~~~card-yaml
$quill: my_quill@0.1.0
$kind: main
$ext:
  presentation:
    title: "Greeting Card"
  agent:
    last_visited: 2025-03-14
title: Hi
~~~

Rules at a glance — see markdown-spec.md §3.3 for the full specification:

  • Value must be a YAML mapping; scalars and sequences are parse errors.
  • !fill on $ext is rejected (the existing $-prefix guard covers it).
  • An empty $ext: {} is preserved as a distinct, explicit declaration and emits as $ext: {}.
  • Unknown sub-keys inside $ext are not checked — the map is opaque. Consumers namespace inside it ($ext.presentation, $ext.agent, …) to avoid collisions when more than one tool carries state on the same card.

API surface

Surface Read Write
Rust Card::ext()Option<&Map<String, Value>> Payload::set_ext(map), take_ext()
WASM card.ext (Record<string, unknown> or undefined) round-trip via Document.toJson() / fromJson()
Python card["ext"] (dict or None) round-trip via Document.to_json() / from_json()

The $ext map is stripped from the binding-level payloadItems list, just like the other $ entries. There is no in-place mutator on the WASM or Python Card object in v1; bespoke consumers mutate the DTO JSON and re-instantiate via fromJson/from_json.

Storage DTO

PayloadItemV0_82_0 gains a new variant under the existing quillmark/document@0.82.0 schema tag:

{
  "type": "ext",
  "value": { "presentation": { "title": "Greeting Card" } }
}

This is an additive change to the wire format — documents without $ext are byte-identical to the 0.82.0 form. Because 0.82.0 is yanked, no external consumer reads the old shape, so the DTO is extended in place rather than versioned to @0.83.0.

Migrating legacy PRESENTATION_METADATA-style state

Some pre-0.82 UI consumers stashed editor-only state under an all-uppercase frontmatter key like PRESENTATION_METADATA. The convention worked because pre-0.82 field names were also expected to be lowercase, so an uppercase key did not collide with user data and renderers ignored it. Under card-yaml that uppercase key parses as an ordinary user field and now reaches the plate JSON as flat root-level data — leaking editor state into renders.

Lift it into $ext:

  ~~~card-yaml
  $kind: indorsement
- PRESENTATION_METADATA:
-   title: "Cmdr's response"
+ $ext:
+   presentation:
+     title: "Cmdr's response"
  from: ORG/SYMBOL
  ~~~

The $ext.presentation sub-namespace is a convention, not a spec requirement — pick a sub-key that identifies your consumer.