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:
codeis now set. Every mutator diagnostic carries itsedit::*code (previouslycodewasnullon the whole edit surface).- The message drops the prefix.
[EditError::UnknownField] field 'x' is not declared in the schemabecomesfield '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 structuredDocPathSeg[]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
cardsin aDiagnostic.pathis the document-model anchor; the sigileddata.$cardsa template author sees is plate JSON — a different namespace, not renamed. If you built a path by hand fromdata.$cardsarray indices, switch toparseDocPathon 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:
$kindis document-defined — present iff the card authors one. A kindless card now carries no$kind(previously a fabricated$kind: "").$bodyis 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')
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 FieldState →
ResolvedField, MainFieldStates → ResolvedMain, CardFieldStates →
ResolvedCard, FieldStates → Resolved (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
}
sourceis the commitment-ladder rung (seeSCHEMAS.md§ "Value sources and projections"):"authored"(the document's own value),"default"(the schemadefault:), or"zero"(the type-empty floor). The value it tags is the one the render projection emits — the two cut the same ladder — sosourceis the rung that produced it.- The body is a
bodysibling, not a row infields— present ({ name, value, source }) iff the kind enables a body, otherwisenull; 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 stayvalidate()'s (which you merge with your own producers — session warnings, render errors — regardless), and schema guidance (example:, labels, groups) reads fromquill.schema. A render-uncoercible value is kept raw and"authored", exactly as the plate carries it; the error surfaces throughvalidate(). - 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.groupsis now declared. A card'sui.groups— the ordered registry a field'sui.groupreferences — was always emitted inQuill.schema; the hand-written interface simply omitted it, so a consumer cast the schema JSON to read group order. It is nowgroups?: Record<string, QuillGroupUi>, the mapping form the wire carries (map key = group id, key order = declaration order = display order). Drop the cast.ContentIsland.propsis now typed bytype.propswasunknown; the engine now pinsTableProps({ header, rows, aligns }, cells{ text, marks }) andImageProps({ url, alt }), exported withTableCell.ContentIslandis an open discriminated union — atableorimageisland carries its typed props, any other type stays opaque. As withContentMark, the opentypearm means TS does not auto-narrowpropson a discriminant check; key offtypeand readpropsas the matching type. The win is single-source shapes: a codec re-exportsTableProps/ImagePropsinstead of hand-tracking them.