Skip to content

0.95 → 0.96 — one address grammar (DocPath) on every boundary, mutator edit::* codes + paths, the resolved-value view resolve(), the typed reader front door reader()

The headline break is addressing: every field address the engine hands a consumer — diagnostics and geometry — now speaks one canonical DocPath grammar, parsed by one exported parser. The plate-space $cards.<kind>.<ordinal> form the geometry queries used to emit no longer crosses the boundary. See Geometry addresses are now DocPath — it changes the field strings your preview/overlay code reads.

Mutator failures — the errors raised by Document and card mutators (storeField, the writer's set / setAll / addCard, removeField, insertCard, moveCard, reviseField, …) — become first-class members of the diagnostic taxonomy. Each now carries a namespaced edit::* code, the mutator peer of parse::*, validation::*, and quill::*. The old identity channel — an [EditError::<Variant>] prefix glued to the front of the message — is deleted outright. Route on diagnostics[0].code, never on message text.

What changed

The thrown error shape is unchanged: still a single exception (WASM JS Error, Python QuillmarkError) carrying a non-empty diagnostics array/list, each a Diagnostic with { severity, code, message, path? }. Two things move:

  • code is now set. Every mutator diagnostic carries its edit::* code (previously code was null on the whole edit surface).
  • The message drops the prefix. [EditError::UnknownField] field 'x' is not declared in the schema becomes field 'x' is not declared in the schema. The display text is otherwise identical — only the bracketed prefix is gone.

Code map

One code per EditError variant, produced once in core (EditError::code(), crates/core/src/document/edit.rs) so both bindings share the mapping:

Variant Code
InvalidFieldName edit::invalid_field_name
UnknownField edit::unknown_field
InvalidKindName edit::invalid_kind_name
ReservedKind edit::reserved_kind
IndexOutOfRange edit::index_out_of_range
ValueTooDeep edit::value_too_deep
Import edit::import
FieldRichtextDecode edit::field_richtext_decode
FieldRichtextNotInline edit::field_richtext_not_inline
FieldConform edit::field_conform
ContentApply edit::content_apply

Migrate: route on code

Any consumer that pattern-matched the message prefix to identify a mutator failure must read the code instead. The two most common routes — telling a typed coercion failure (edit::field_conform) from an undeclared name (edit::unknown_field) — need only diagnostics[0].code.

WASM / TypeScript:

// 0.95
try { doc.storeField(name, value) }
catch (err) {
  if (/\[EditError::UnknownField\]/.test(err.message)) { /* … */ }
}

// 0.96
try { doc.storeField(name, value) }
catch (err) {
  if (err.diagnostics[0].code === 'edit::unknown_field') { /* … */ }
}

Python:

# 0.95
try:
    quill.writer(doc).set(name, value)
except QuillmarkError as exc:
    if "[EditError::UnknownField]" in str(exc):  # …
        ...

# 0.96
try:
    quill.writer(doc).set(name, value)
except QuillmarkError as exc:
    if exc.diagnostics[0].code == "edit::unknown_field":  # …
        ...

The batched mutators (setAll / set_all, storeFields) still emit one diagnostic per offending field; each now carries its edit::* code, and its path is the field's full DocPath (see the next section).

No action: null ≡ absent, ratified

No behavior change — a clarification worth reading. SCHEMAS.md now states null ≡ absent as a chosen 1.0 commitment rather than an incidental rule: field: null and an omitted field are one indistinguishable state, so removeField (drop the key) stays the sole unset verb and there is no separate "cleared, not default" signal. The tri-state alternative (absent / null / value) is foreclosed. If your host code already treated present-null as absent — the engine's behavior since 0.92 — nothing changes.

New: parseDocPath — route on Diagnostic.path segments

Diagnostic.path — the document-model anchor into a Document (main.title, main.recipients[0].name, main.body, cards.<kind>[<index>].<field>) — is now owned by one canonical type, DocPath, with one serializer and an exported parser.

Break — main-field paths are now rooted. A main field that used to emit a bare title now emits main.title — the same main root the body (main.body) always carried. Every path now roots at main or cards, so the grammar is uniform and a main field named cards or main no longer collides with a root. Card-field strings are unchanged (already kind-qualified). Migrate any consumer that matched a bare main-field path (diagnostic.path === 'title') to the rooted form ('main.title'), or route on segments via parseDocPath.

Two things are also new:

  • parseDocPath(path) / formatDocPath(segs) on the WASM surface. Parse a path into a structured DocPathSeg[] and route on it, instead of splitting or regexing the string:
import { parseDocPath } from '@quillmark/wasm'

const [head] = parseDocPath(diagnostic.path)
if (head.seg === 'card') {
  // head.kind (string | null), head.index (number) — no regex
}

A DocPathSeg is a tagged union: { seg: "main" }, { seg: "card", kind, index }, { seg: "field", name }, { seg: "index", index }, { seg: "body" }.

  • The two path namespaces are stated in canon. The unsigiled cards in a Diagnostic.path is the document-model anchor; the sigiled data.$cards a template author sees is plate JSON — a different namespace, not renamed. If you built a path by hand from data.$cards array indices, switch to parseDocPath on the emitted diagnostic instead.

If you read diagnostic.path as an opaque string and never matched a main-field value, no action — it is still a string. ERROR.md's stale cards[2].author example (missing the kind segment) is corrected to cards.indorsement[2].author.

Mutator (edit::*) diagnostics now carry a DocPath too. Every mutator error is { severity, code, message, path } (WASM): a field error anchors at its field (main.font_size, or cards.<kind>[<index>].<field> on a card), and a structural out-of-range op (insertCard / moveCard / setCardKind) at its array slot cards[<index>]. So a coercion failure on a card field routes to the exact card without the consumer reconstructing the address from call-site knowledge. (Python keeps its edit::* codes; the mutator-path retrofit is WASM only, pending a Python consumer that routes on it.)

Break: geometry addresses are now DocPath

The LiveSession geometry queries — regions(), fieldBoxes(field), fieldAt(), positionAt(), locate(field, pos) — used to key on the backend's plate-space address ($body, $cards.<kind>.<ordinal>.<field> with a per-kind ordinal). They now key on the canonical DocPath — the same grammar Diagnostic.path carries and parseDocPath reads. The session resolves the per-kind ordinal to the absolute card index, so one parser routes every address.

plate-space (0.95) DocPath (0.96)
$body main.body
subject main.subject
$cards.indorsement.1.from cards.indorsement[<abs>].from
$cards.note.0.$body cards.note[<abs>].body

<abs> is the card's absolute index in the document array, not its per-kind ordinal; when kinds interleave the two differ (the 2nd note may be cards[3]). The one-shot render sidecar (render({ regions: true }).regions) keys on the same DocPath grammar, so a consumer sees one address grammar however it reads geometry.

Migrate the preview/overlay code that reads region.field / hit.field or passes a field to fieldBoxes / locate:

// 0.95 — plate-space strings, parsed by hand
const body = session.regions().find((r) => r.field === '$body')
session.fieldBoxes('$cards.note.0.$body')

// 0.96 — DocPath, routed through parseDocPath
const body = session.regions().find((r) => r.field === 'main.body')
session.fieldBoxes('cards.note[0].body')

// A click resolves to a DocPath now; route it with the one parser:
const field = session.fieldAt(page, x, y)          // e.g. "cards.note[2].body"
const [head] = parseDocPath(field)                 // { seg: "card", kind: "note", index: 2 }

If you carried a hand-rolled $cards.<kind>.<ordinal> → card-index bridge (the per-kind ordinal is not the absolute index), delete it: the session does that translation now, and parseDocPath gives you { kind, index } directly. The plate-side $path grammar is unchanged for template authors — a plate still composes card.at("$path") + "from"; only what crosses to a consumer moved.

Break: plate $body / $kind are absent on undefined

The render plate (compile_data — the JSON your Typst plate reads as data) now carries a $-metadata key on a card exactly where the schema defines it — the "absent on undefined" rule, split by which definition gates the key:

  • $kind is document-defined — present iff the card authors one. A kindless card now carries no $kind (previously a fabricated $kind: "").
  • $body is schema-defined — present iff the card's kind is declared and enables a body. A body-disabled kind, an unknown kind, and a body-disabled main now carry no $body (previously an always-present body object).

This also removes a silent type hazard. On a body-disabled or unknown kind the plate used to ship $body as a raw content-JSON object — not a lowered, renderable content block — so card.$body was renderable content on one card and an inert dict on the next. A present $body is now always a content object.

Who is affected. Only a body-disabled card or a body-disabled main reaches an engine-compiled plate — an unknown-kind or kindless card is a hard validation::unknown_card error before render, so those rows appear only if you call Document::to_plate_json directly. A plate that read a body-disabled card's $body was already reading the inert dict; nothing was rendering it.

Migrate. Read $-metadata with Typst's total accessor, never a bare field:

// 0.95 — a body-disabled / kindless card ships a raw dict (or a "" $kind)
#card.$body
#card.$kind

// 0.96 — total, absence-safe
#card.at("$body", default: "")
#card.at("$kind", default: none)

A plate that already guards with card.at("$body", default: "") — the documented idiom, and what the shipped fixtures use — needs no change.

Break: the typed reader front door is reader(), not view()

The schema-bound read front door — the read twin of writer() — is renamed view()reader() on every binding, so the entry point is named after the type it hands you (writer() returns a writer; reader() now returns a reader) rather than being the one verb that wasn't. Behavior is unchanged: it still interprets each field by its declared type (a richtext field to markdown, every other type verbatim), still throws edit::unknown_field for a name the schema does not declare. Only the name moved.

0.95 0.96
Rust quill.view(&doc) quill.reader(&doc)
WASM quill.view(doc)DocumentView / CardView quill.reader(doc)DocumentReader / CardReader
Python quill.view(doc)View / CardView quill.reader(doc)Reader / CardReader
// 0.95
const subject = quill.view(doc).get('subject')
// 0.96
const subject = quill.reader(doc).get('subject')
# 0.95
subject = quill.view(doc).get("subject")
# 0.96
subject = quill.reader(doc).get("subject")

Migrate every .view( call site to .reader(, and any type reference to the returned class (DocumentView/CardView, Python View/CardView) to its *Reader name. The core Rust TypedReader / CardReader types were already reader-named and are unchanged.

Also renamed: the resolved-value view's types. The resolve() rows and containers below carry reader-consistent names: WASM FieldStateResolvedField, MainFieldStatesResolvedMain, CardFieldStatesResolvedCard, FieldStatesResolved (Rust FieldState/FieldStates/ MainStates/CardStates likewise → ResolvedField/Resolved/ResolvedMain/ ResolvedCard). FieldSource keeps its name. Since resolve() itself is new in 0.96, these names never shipped under the old spelling — only a consumer tracking this release from a pre-release build needs to know.

Internally, compile_data (render) and resolve() now cut one shared resolver instead of mirroring the coerce → NFC → ladder pipeline twice; the plate bytes and the resolved rows are byte-for-byte what 0.95's render path produced — no observable change, listed here only because it is the reason the two surfaces can no longer drift.

New: resolve() — the resolved-value view

quill.resolve(doc) is additive: the resolved-value view of a document against its quill schema. For every declared field it returns the value the render projection would use and the source rung it came from — in one call, so you stop reading the value off the Document payload and re-deriving the default:/zero fallback yourself.

The shape is nested — a main card and a cards list. Each card's fields is an ordered array of rows in declaration order, every row carrying its own name (order is structural, not object-key order); the body is a body sibling on the card, not a row in fields:

const states = quill.resolve(doc)

const title = states.main.fields.find((r) => r.name === 'title')
title.value        // the value the plate carries
title.source       // "authored" | "default" | "zero"

states.main.body   // { name: "body", value, source } | null

for (const card of states.cards) {
  card.kind        // the card's $kind — null for an unknown-kind card
  card.index       // its position in the document's card array
  card.fields      // ordered ResolvedField[] — same row shape as main.fields
  card.body        // its body row, or null when the kind enables no body
}
  • source is the commitment-ladder rung (see SCHEMAS.md § "Value sources and projections"): "authored" (the document's own value), "default" (the schema default:), or "zero" (the type-empty floor). The value it tags is the one the render projection emits — the two cut the same ladder — so source is the rung that produced it.
  • The body is a body sibling, not a row in fields — present ({ name, value, source }) iff the kind enables a body, otherwise null; its source is only ever "authored" (non-blank) or "zero" (blank). The old $body-keyed map entry is gone: a consumer iterating declared fields never trips over it.
  • Value and provenance only. Each row is exactly { name, value, source }. Diagnostics stay validate()'s (which you merge with your own producers — session warnings, render errors — regardless), and schema guidance (example:, labels, groups) reads from quill.schema. A render-uncoercible value is kept raw and "authored", exactly as the plate carries it; the error surfaces through validate().
  • WASM only. There is no Python resolve — it awaits a Python consumer that names a call site (the Tier-1 scope, BINDINGS.md).

No action — purely additive. validate() is unchanged and remains the completeness surface.

New: typed ui.groups and island props

Two type-only widenings on the WASM surface — no runtime shape moved, so no action unless you reach for the newly named types (all re-exported from @quillmark/wasm).

  • QuillCardUi.groups is now declared. A card's ui.groups — the ordered registry a field's ui.group references — was always emitted in Quill.schema; the hand-written interface simply omitted it, so a consumer cast the schema JSON to read group order. It is now groups?: Record<string, QuillGroupUi>, the mapping form the wire carries (map key = group id, key order = declaration order = display order). Drop the cast.
  • ContentIsland.props is now typed by type. props was unknown; the engine now pins TableProps ({ header, rows, aligns }, cells { text, marks }) and ImageProps ({ url, alt }), exported with TableCell. ContentIsland is an open discriminated union — a table or image island carries its typed props, any other type stays opaque. As with ContentMark, the open type arm means TS does not auto-narrow props on a discriminant check; key off type and read props as the matching type. The win is single-source shapes: a codec re-exports TableProps / ImageProps instead of hand-tracking them.