0.103 → 0.104 — one spelling for a finite domain, an array element, and a thematic break¶
Three breaks on unrelated surfaces, plus a Node floor. In Quill.yaml the enum: modifier retires: type: enum with a values: list is the one spelling of a closed string domain. On the geometry surface an address parses segment-wise, so an array element spells main.references[0] instead of main.references.0 — and, for the first time, resolves at all. In exported markdown a thematic break writes ***. Documents in storage are untouched, as are the content model and rendered output.
| Break | Surface | Action |
|---|---|---|
enum: on type: string is a load error |
Quill.yaml | Rewrite as type: enum + values: |
QuillConfig::schema() re-emits every domain as values: |
Rust, WASM, Python | Read values; the WASM QuillFieldSchema.enum field is gone |
The transform schema carries every finite domain, not just declared type: enum |
Rust, bindings | A generated validator now rejects out-of-domain values it used to accept |
RenderedRegion.field / FieldRegion.field / ContentHit.field spell an index bracketed |
Rust, WASM, Python | Match on [0], not .0; drop any trailing-digit heuristic |
to_markdown writes *** for a thematic break |
Rust, WASM, Python | Re-baseline a golden or a diff over exported markdown |
@quillmark/wasm engines floor moves to Node 24 |
WASM | Run Node 24+ |
Two additive surfaces: doc.pathFor(addr) / doc.cardPath(i) on WASM mint an Addr as its canonical DocPath string, and a quill can declare the block constructs its plate does not typeset, which raises a new plate::unsupported_construct warning family.
enum: retires¶
type: enum with values: is the whole of a finite string domain. enum: on any type raises quill::field_parse_error at load, whose message names the replacement.
# 0.103
format:
type: string
enum: [standard, informal, separate_page]
default: standard
# 0.104
format:
type: enum
values: [standard, informal, separate_page]
default: standard
The modifier was deprecated in 0.94 with no warning code behind it, so a quill written against it received no runtime signal in the interval: the load error is the first and only one. Grep the corpus for enum: rather than waiting for a render to find it.
Wire data and rendered output do not move. The projections a backend dispatches on were keyed on the domain already, so {type: string, enum: […]} is what both spellings projected to and still project to. The usaf_memo and sample_form fixtures migrate in place with byte-equal golden output.
Reading a domain back¶
QuillConfig::schema() re-emits every domain as values:. A consumer that read enum off the schema echo reads values now:
The WASM QuillFieldSchema.enum declaration is dropped, so TypeScript reports the stale read rather than leaving it undefined.
The transform schema gains domains it was dropping¶
build_transform_schema keys a field's domain on the domain itself rather than on the Enum token. Under the retired spelling a type: string + enum: field projected as a bare {"type":"string"}, so a validator generated from the transform schema accepted anything — classification: "banana" passed, while pdfform drew the six-option dropdown for the same field and QuillConfig rejected the value at coercion.
Those three now agree. A consumer generating a JSON-Schema validator from the transform schema sees the enum keyword on every finite domain, at every depth, and starts rejecting values it previously let through. That is the intended reading; if a deployed corpus holds out-of-domain values, they were already failing at coercion.
Geometry addresses parse segment-wise¶
RenderedRegion.field, FieldRegion.field and ContentHit.field — on regions(), fieldAt, positionAt and RenderResult.regions — spell an array element bracketed:
This is the spelling schema validation already emits, so a Diagnostic.path and the geometry address for one place are now the same string and join on equality.
Two consumer-side shapes break:
- Prefix matching. Finding an address's children by
addr.startsWith(${field}.)misses the bracketed opener. Test both:s.startsWith(`${field}.`) || s.startsWith(`${field}[`). - Trailing-digit heuristics. Code reading a trailing all-digit field name as a lost index is dead; the index is a real segment now.
The addresses that were dead¶
The break rides on a fix. The translation boundary folded a plate address's whole tail into one Field, so references.0 minted main.references.0 — a string that reparses as a field literally named 0, and that the reverse direction refused outright. Both spellings returned None, which left caret placement and whole-field highlight dead for every array<richtext> element (the flagship memo's references among them) and for every nested key a pdfform widget binds (address.city).
So locate and fieldBoxes answer for a deeper-than-one-segment address for the first time. A consumer that worked around the hole — falling back to a whole-page highlight on an array element, say — can drop the fallback.
main.m.0 remains a field named 0, not an index: a nested YAML map key is unconstrained, and !must_fill collection mints that shape. The .N-is-an-index reading belongs to plate space, where addresses are schema-derived, and is translated at the geometry boundary rather than taught to the DocPath parser. doc_path_to_plate_addr answers None for a path plate space cannot spell, and the caller keeps its original string.
A thematic break exports as ***¶
to_markdown writes *** for every thematic break, whatever the source spelling was. ---, ___ and - - - all import to the same Rule line, so the canonical export is now the spelling with the fewest other readings.
The bug it fixes is a lost list item. A bullet marker is - — a dash and a space — so a rule as a bullet item's first block exported as - + ---: four dashes separated by spaces, which CommonMark reads as a thematic break, and a break outranks a list item wherever both readings fit. * ---, + ---, - *** and - ___ all imported to a Rule under a ListItem and exported to a top-level Rule, so the item did not survive the round-trip. An ordered marker never collided (digits are not break characters), and a rule as a later block is disambiguated by its indentation, so this was the one broken shape.
--- carries two other readings besides — a setext underline, and the root-block front-matter opener (markdown-spec §3.2.1). A mixed-character line is never a break, so *** survives every prefix a container can put in front of it, at any depth.
What this costs you: a golden file or a diff over exported markdown changes bytes for any document holding a rule. The content model, stored blobs and rendered output do not move — the export is a projection, and --- still imports exactly as before.
A quill can declare what its plate does not typeset¶
Additive. A plate is free to absorb a construct into a neighbour, move its text, or typeset nothing at all, and the render said nothing, because only the quill knows it did — the absence of ink is not a signal any backend reports.
A quill now names those constructs per body, from a closed set (heading, rule, code, list, quote, table, image), so a misspelling is a load error rather than a declaration matching nothing:
A body holding one anyway raises the non-fatal plate::unsupported_construct on the pre-render walk, beside the conform::* set. A consumer routing on diagnostic codes gains a fifth warning family and needs an arm for it — one diagnostic per (body, construct), carrying the count in args and the body's schema address, so forty rules collapse to one diagnostic with count: 40 rather than scattering.
The declaration also rides QuillConfig::schema() to the editor, which is the half a render-time warning cannot serve: it answers before the gesture rather than with the page. Nothing verifies a declaration against the plate, so an undeclared drop stays as silent as it was.
usaf_memo declares rule. The key is empty everywhere else, so no existing quill's schema or warning set changes until you write one.
Node 24¶
@quillmark/wasm declares engines: { node: ">=24" }. Node 24 is the tier CI builds and tests the bindings on, and the tier both devcontainers hand a contributor; the declared floor now matches. Nothing in the package requires it at runtime — --weak-refs needs only Node 14.6+ — so a Node 22 install fails engines checking without failing at import. The using sugar for explicit resource management sits on this floor; an explicit try / finally is the equivalent, and the form that also runs in a browser that has not shipped it.
pathFor and cardPath¶
Additive on WASM. doc.pathFor(addr) renders an Addr as the canonical DocPath string that Diagnostic.path carries and session.locate / session.fieldBoxes take; doc.cardPath(i) is the card's own root.
doc.pathFor() // "main.body"
doc.pathFor("intro") // "main.intro"
doc.pathFor({ card: 2 }) // "cards.<kind>[2].body"
doc.pathFor({ card: 2, field: "from" }) // "cards.<kind>[2].from"
doc.cardPath(2) // "cards.<kind>[2]"
A card path is kind-qualified, so building one by hand needs the card's $kind; a wrong-kind path is compared as a string, matches nothing, and draws no highlight without throwing. Both verbs are total on the index axis — a path is an anchor, not a read — so an out-of-range card mints the unknown-kind root rather than raising, and a per-keystroke call needs no try.
What did not change¶
Documents, stored blobs, and the plate-JSON wire format. The Rust DocPath grammar (its rustdoc was corrected — a field name was always any run excluding ., [, ], never an identifier). The CLI. Python's typed writer and reader.