@quillmark/wasm 0.103.0 → 0.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +199 -26
- package/README.md +76 -210
- package/backends/pdfform/wasm.d.ts +484 -683
- package/backends/pdfform/wasm.js +415 -469
- package/backends/pdfform/wasm_bg.wasm +0 -0
- package/backends/pdfform/wasm_bg.wasm.d.ts +3 -0
- package/backends/typst/wasm.d.ts +484 -683
- package/backends/typst/wasm.js +415 -469
- package/backends/typst/wasm_bg.wasm +0 -0
- package/backends/typst/wasm_bg.wasm.d.ts +3 -0
- package/core/wasm.d.ts +356 -462
- package/core/wasm.js +360 -389
- package/core/wasm_bg.wasm +0 -0
- package/core/wasm_bg.wasm.d.ts +3 -0
- package/package.json +2 -2
- package/runtime/runtime.d.ts +181 -252
- package/runtime/runtime.js +191 -347
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,204 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.105.0 - 2026-08-14
|
|
4
|
+
|
|
5
|
+
- feat(core,wasm,python)!: a `Content` nested inside a composite field is
|
|
6
|
+
readable at its own codec. `TypedReader::get_content_at(name, path)` (and the
|
|
7
|
+
`CardReader` twin, `reader.getContentAt(addr, path)` in JS,
|
|
8
|
+
`reader.get_content_at(name, path)` in Python) walks a `PathSegment` path
|
|
9
|
+
through the field schema — `items` for an index, `properties` for a key — to
|
|
10
|
+
the leaf whose declared type names the codec, then decodes through the same
|
|
11
|
+
dispatch the whole-field read uses. So an `array<richtext>` element, an
|
|
12
|
+
`object`'s content property and a leaf under both each read back the same
|
|
13
|
+
`Content` whatever their resting form, where before every one of them
|
|
14
|
+
answered `FieldNotContent` and the consumer had to decide for itself what the
|
|
15
|
+
stored bytes meant (#1243). The empty path *is* `get_content`. A path naming
|
|
16
|
+
nothing in the stored value reads absent rather than throwing: an editor's
|
|
17
|
+
row index goes stale between derive and read, and that is the axis a repeater
|
|
18
|
+
mutates. `Addr` deliberately gains no element axis — the path is the read's
|
|
19
|
+
own argument, since `storeField` / `isFill` / `applyChange` could not answer
|
|
20
|
+
one. **Breaking**: `EditError::FieldDecode` and `EditError::FieldNotContent`
|
|
21
|
+
each gain an `at: Vec<PathSegment>` field carrying the in-field path, so the
|
|
22
|
+
diagnostic anchors at `main.paragraphs[1]` and parses back to those segments;
|
|
23
|
+
`field` stays a bare field name and `args` is unchanged. `FieldNotContent`
|
|
24
|
+
now names the type *reached*, so a `string[]` element reports `string` rather
|
|
25
|
+
than the field's `array`.
|
|
26
|
+
- feat(core,wasm)!: `must_fill:` on a field declares the **obligation** axis.
|
|
27
|
+
`default:` carried the fill value and the obligation signal on one bit, so
|
|
28
|
+
only that 2x2's diagonal was reachable; a safe value that still wants a
|
|
29
|
+
human's confirmation (`default: UNCLASSIFIED` with `must_fill: true`) and a
|
|
30
|
+
genuinely optional field with nothing to suggest (`must_fill: false`) now
|
|
31
|
+
each have a spelling. Left unset it derives `default.is_none()`, so no
|
|
32
|
+
existing quill's blueprint marker set changes. `Quill::validate` gains a
|
|
33
|
+
second trigger under the one `validation::must_fill` code, named by a
|
|
34
|
+
`trigger` arg: `marker` for a `!must_fill` tag the document carries, and
|
|
35
|
+
`unauthored` where the schema obliges a cell the document leaves absent or
|
|
36
|
+
present-null. The second closes a hole — `validate_fills` walked only the
|
|
37
|
+
payload, so a hand-written or programmatically built document drew no
|
|
38
|
+
completeness signal whatever `Quill.yaml` declared. **Breaking**: a merely
|
|
39
|
+
incomplete document no longer validates clean. Absence is still never
|
|
40
|
+
*malformed* and still never gates render, but a consumer reading "any
|
|
41
|
+
diagnostic ⇒ not done" now sees a warning per unauthored obliged cell on
|
|
42
|
+
documents that were silent. The obligation keys on cell presence rather than
|
|
43
|
+
the resolved source rung, so a must-fill leaf inside a container someone
|
|
44
|
+
touched still warns; a typed dict is never itself a cell and recurses to its
|
|
45
|
+
leaves, while an array is one cell, `[]` being a real answer. Authoring the
|
|
46
|
+
field's blank discharges it and `field: null` does not: null ≡ absent stays
|
|
47
|
+
unqualified on the value ladder, but obligation asks whether a human made a
|
|
48
|
+
call. Seeding stamps the marker on example-seeded obliged cells, so a fresh
|
|
49
|
+
seed and an empty document report the same cells, and the transform schema
|
|
50
|
+
carries `quillmark:must_fill` (#1255).
|
|
51
|
+
- fix(core,wasm,pdfform)!: a field's **blank** — its spelling of "explicitly
|
|
52
|
+
nothing" — is a property of the field rather than a member of its type's
|
|
53
|
+
domain, and an `enum`'s is `""`. The render floor for a defaultless enum
|
|
54
|
+
returned `values.first()`: a choice nobody made, indistinguishable at the
|
|
55
|
+
plate from a deliberate one and reachable from a cosmetic `values:` reorder.
|
|
56
|
+
An unanswered enum now renders `""`, so a reorder is render-safe for every
|
|
57
|
+
document and only removing or renaming a member breaks
|
|
58
|
+
([VERSIONING.md](prose/canon/VERSIONING.md)). **The accepted domain widens to
|
|
59
|
+
`values ∪ blank` for *every* enum**, defaulted ones included: `format: ""`
|
|
60
|
+
was a fatal `EnumViolation` and now coerces, validates and reaches the plate.
|
|
61
|
+
**A plate must therefore branch exhaustively over `values ∪ blank`** — an
|
|
62
|
+
`else` fallback re-opens exactly the fabrication the blank closes, and a
|
|
63
|
+
downstream package that asserts membership fails the compile outright. Note
|
|
64
|
+
`data.at(key, default: X)` is not a guard here: blank-filled render makes
|
|
65
|
+
every declared key present, so its `default:` is dead code and the blank
|
|
66
|
+
flows through. **Breaking**: `zero_value` → `blank`, `FieldSource::Zero` →
|
|
67
|
+
`Blank` and its wire token `"zero"` → `"blank"`; `""` declared in `values:`
|
|
68
|
+
is a load error (`quill::enum_blank_member`), the engine supplying the blank
|
|
69
|
+
instead; and `date: ""` renders blank rather than falling back to a
|
|
70
|
+
`default:`, settling a three-way disagreement between coercion, validation
|
|
71
|
+
and the floor. `default: ""` stays valid and keeps its meaning — `values:`
|
|
72
|
+
enumerates choices, `default:` is a value, and the blank is a legal value
|
|
73
|
+
that is never a choice. Additive: `ui.blank_title` labels an enum's blank and
|
|
74
|
+
rides the transform schema as `quillmark:blank_title`; that schema's `enum:`
|
|
75
|
+
leads with the blank, so a standard JSON-Schema validator accepts what the
|
|
76
|
+
engine accepts, and pdfform Choice widgets lead their options with it too. A
|
|
77
|
+
consumer's picker must keep the blank selectable and re-selectable — returning
|
|
78
|
+
to it is how an author clears a cell back to unset.
|
|
79
|
+
`integer`, `number` and `boolean` keep `0` / `false` as their blank,
|
|
80
|
+
indistinguishable from an authored zero — a permanent seam, since a wire
|
|
81
|
+
`none` would cost the totality the floor exists to buy. Full guide:
|
|
82
|
+
[0.104 → 0.105](docs/migrations/0.104-to-0.105.md) (#1254).
|
|
83
|
+
- fix(fixtures,docs): the three fixture plates that dispatch on `$kind` read it
|
|
84
|
+
with a bare `card.at("$kind")`, which panics on a kindless card, and guarded
|
|
85
|
+
declared fields against an absence blank-filled render makes impossible.
|
|
86
|
+
Plate authors copy the fixtures rather than `PLATE_DATA.md`, so the fixtures
|
|
87
|
+
were teaching both the unsafe metadata read and a dead presence check.
|
|
88
|
+
`classic_resume` carried the live consequence: `url` is declared with no
|
|
89
|
+
`default:`, so the floor delivers `""`, `default: none` never fires, and the
|
|
90
|
+
package's `url != none` test always passes — an empty Courier element where
|
|
91
|
+
the block should have been skipped. Its `subheading-*` guard cost an empty
|
|
92
|
+
grid row the same way. Declared fields now guard their *value*, and
|
|
93
|
+
`docs/quills/typst-backend.md` states the rule as a table over the three key
|
|
94
|
+
kinds. `fixture_quills_render_test` renders every fixture quill's seed
|
|
95
|
+
document — the net that was missing, since `classic_resume`'s plate had no
|
|
96
|
+
test reaching it (#1256, #1257).
|
|
97
|
+
- test(typst): `plaintext` reaches regions and navigation by inheritance — the
|
|
98
|
+
render floor coerces its resting literal to a content object, the backend
|
|
99
|
+
classifies that object by `contentMediaType` alone, and the shared lowering
|
|
100
|
+
emits it with a segment map — and every step was load-bearing and untested,
|
|
101
|
+
with the classification predicates named for richtext so the sharing read as
|
|
102
|
+
a coincidence. Pinned at engine altitude, because a test driving the backend
|
|
103
|
+
directly hand-builds the content object, bypassing the floor's coercion, and
|
|
104
|
+
would stay green through a regression that silently empties `regions()`. The
|
|
105
|
+
predicates are `is_content_field` / `is_content_array_field` /
|
|
106
|
+
`is_inline_content_field`, and `PREVIEW.md` names plaintext beside richtext
|
|
107
|
+
in its producer list (#1247, #1250).
|
|
108
|
+
|
|
109
|
+
## v0.104.0 - 2026-08-13
|
|
110
|
+
|
|
111
|
+
- feat(core,wasm): a quill declares, per body, the block constructs its plate
|
|
112
|
+
does not typeset (`main.body.unsupported`, `card_kinds.<k>.body.unsupported`;
|
|
113
|
+
names from `heading`, `rule`, `code`, `list`, `quote`, `table`, `image`).
|
|
114
|
+
A body holding one anyway draws the non-fatal `plate::unsupported_construct`,
|
|
115
|
+
a fifth warning family, on the pre-render walk `Quill::parse` runs beside
|
|
116
|
+
`conform`: one diagnostic per (body, construct) carrying the count in `args`
|
|
117
|
+
and the body's path, so occurrences collapse rather than scatter. The
|
|
118
|
+
declaration also rides `QuillConfig::schema()` to the editor, which is the
|
|
119
|
+
half a render-time warning could not serve: it answers before the gesture.
|
|
120
|
+
Nothing verifies a declaration — a plate that drops an undeclared construct
|
|
121
|
+
stays as silent as before. `usaf_memo` declares `rule`; empty everywhere
|
|
122
|
+
else, so no existing quill's schema or warnings change.
|
|
123
|
+
- fix(fixtures): `usaf_memo`'s `render-body` drained its heading buffer in the
|
|
124
|
+
three shapes that used to discard it. A heading with nothing after it (the
|
|
125
|
+
buffer died with the loop, taking a list item's bullet with it), a heading
|
|
126
|
+
whose next element opened a *different* list item (its text was delivered
|
|
127
|
+
into that item), and a heading following a heading (the assignment overwrote
|
|
128
|
+
the earlier one) each lost their text with nothing in the render to say so.
|
|
129
|
+
The run-in style is unchanged where it was right: a heading joins the next
|
|
130
|
+
block of its own item, or the next paragraph at top level.
|
|
131
|
+
- fix(content)!: `to_markdown` writes `***` for a thematic break, not `---`.
|
|
132
|
+
`- ` + `---` is four dashes separated by spaces, which re-imports as a
|
|
133
|
+
top-level break, so a rule as a bullet item's first block lost its item on
|
|
134
|
+
every markdown round-trip. The canonical spelling is now the one with the
|
|
135
|
+
fewest other readings (`---` is also a setext underline and the root-block
|
|
136
|
+
front-matter opener). Exported markdown changes for documents holding a
|
|
137
|
+
rule; the content model, wire data and rendered output do not.
|
|
138
|
+
- refactor(core,pdfform,cli,wasm)!: the `enum:` modifier on `type: string`
|
|
139
|
+
retires. `type: enum` with a `values:` list is the one spelling of a finite
|
|
140
|
+
string domain; `enum:` on any type is now `quill::field_parse_error`, whose
|
|
141
|
+
message names the replacement — it is the only diagnostic a quill written
|
|
142
|
+
against the modifier ever received, since the deprecation shipped in 0.94
|
|
143
|
+
with no warning code behind it. `QuillConfig::schema()` re-emits every
|
|
144
|
+
domain as `values:`, so a consumer reading `enum:` off the schema echo (the
|
|
145
|
+
wasm `QuillFieldSchema.enum`, dropped here) reads `values:` instead. The
|
|
146
|
+
`usaf_memo` and `sample_form` fixtures migrate; wire data and rendered
|
|
147
|
+
output are unchanged, the projections being domain-keyed already.
|
|
148
|
+
- fix(core): `build_transform_schema` keys a field's finite domain on the
|
|
149
|
+
domain itself rather than the `Enum` token, joining the render floor, the
|
|
150
|
+
pdfform widget kind and the blueprint annotation. Under the retired
|
|
151
|
+
spelling every `usaf_memo` enum — `classification`, `format`, `action` —
|
|
152
|
+
projected as a bare `{"type":"string"}`, so a consumer building a
|
|
153
|
+
JSON-Schema validator from the transform schema accepted
|
|
154
|
+
`classification: "banana"` while pdfform drew the six-option dropdown for
|
|
155
|
+
the same field and `QuillConfig` rejected the value at coercion (#1237)
|
|
156
|
+
- fix(core)!: geometry addresses parse segment-wise, so `locate` and
|
|
157
|
+
`fieldBoxes` answer for an address deeper than one segment. The translation
|
|
158
|
+
boundary folded a plate address's whole tail into one `Field`, so
|
|
159
|
+
`references.0` minted `main.references.0` — a string that reparses as a field
|
|
160
|
+
literally named `0`, and that the reverse direction refused outright. Both
|
|
161
|
+
spellings returned `None`, leaving caret placement and whole-field highlight
|
|
162
|
+
dead for **every** `array<richtext>` element (the flagship memo's
|
|
163
|
+
`references` among them) and for every nested key a pdfform widget binds
|
|
164
|
+
(`address.city`). `region.rs` now reads and renders a plate tail one segment
|
|
165
|
+
at a time: an all-digit segment is an array index, `$body` the body terminal,
|
|
166
|
+
anything else a field or map key.
|
|
167
|
+
- change(wasm, python)!: `RenderedRegion.field`, `FieldRegion.field` and
|
|
168
|
+
`ContentHit.field` spell an array element bracketed — `main.references.0`
|
|
169
|
+
becomes `main.references[0]` — on `regions()`, `fieldAt`, `positionAt` and
|
|
170
|
+
`RenderResult.regions`. This is the spelling schema validation already emits,
|
|
171
|
+
so a `Diagnostic.path` and the geometry address for one place are now the same
|
|
172
|
+
string. A consumer finding an address's children by prefix (`startsWith(`${field}.`)`)
|
|
173
|
+
needs the `[` opener too, and any heuristic reading a trailing all-digit field
|
|
174
|
+
name as a lost index is dead.
|
|
175
|
+
- feat(wasm): `doc.pathFor(addr)` mints an `Addr` as the canonical `DocPath`
|
|
176
|
+
string `Diagnostic.path` carries and `session.locate` / `session.fieldBoxes`
|
|
177
|
+
take; `doc.cardPath(i)` is the card's own root. `Document` computed the
|
|
178
|
+
kind-qualified root for every addressed write and did not hand it out, so a
|
|
179
|
+
consumer building a path restated the kind lookup, the `Addr` defaults and the
|
|
180
|
+
range guard — and a wrong-kind path is compared as a string, matching nothing
|
|
181
|
+
and drawing no highlight without throwing. Both are quill-free (the stored
|
|
182
|
+
`$kind` verbatim) and total on the index axis: a path is an anchor, not a
|
|
183
|
+
read, so a per-keystroke call needs no `try` (#1225)
|
|
184
|
+
- change(wasm)!: `@quillmark/wasm` declares `engines: { node: ">=24" }`, the
|
|
185
|
+
tier CI builds and tests the bindings on and the one both devcontainers hand a
|
|
186
|
+
contributor. Nothing in the package requires it at runtime, so a Node 22
|
|
187
|
+
install fails `engines` checking without failing at import.
|
|
188
|
+
- docs: `docs/migrations/0.103-to-0.104.md` carries the four breaks — the
|
|
189
|
+
retired `enum:` modifier, the bracketed index spelling, the `***` thematic
|
|
190
|
+
break and the Node floor — with the prefix-match, trailing-digit and stored
|
|
191
|
+
-markdown shapes a consumer has to fix, and the two additive surfaces
|
|
192
|
+
(`pathFor` / `cardPath`, and the `plate::unsupported_construct` family a
|
|
193
|
+
code-routing consumer gains an arm for).
|
|
194
|
+
- test(core): three characterization tests pin the render floor's two
|
|
195
|
+
type-domain edges (a defaultless enum, top-level and nested in a typed
|
|
196
|
+
dictionary) and an authored empty `date` beside an empty `string`, so the
|
|
197
|
+
coercion difference between the two is one test's diff. Every shipped quill
|
|
198
|
+
declares a `default:` on every enum and none authors an empty `date`, so the
|
|
199
|
+
fixture suite reached neither path. A fourth carries a `!must_fill` tag on two
|
|
200
|
+
example-seeded cells through seed → store → load → conform. Refs #1234
|
|
201
|
+
|
|
3
202
|
## v0.103.0 - 2026-08-09
|
|
4
203
|
|
|
5
204
|
- docs: `docs/integration/operations.md`, carrying what the other integration
|
|
@@ -50,32 +249,6 @@
|
|
|
50
249
|
documents and stored blobs are unaffected. See
|
|
51
250
|
`docs/migrations/0.102-to-0.103.md`
|
|
52
251
|
|
|
53
|
-
<!-- seed: commits since v0.102.0, confirm the entries above cover them, then delete this comment
|
|
54
|
-
- ci: tag the merge commit, and serialize a release against itself
|
|
55
|
-
- ci: drop the tagger GitHub App from the release workflows
|
|
56
|
-
- Cut SECURITY.md, and operations.md down to what only it can say
|
|
57
|
-
- Drop the audit gate; keep the reachability finding in ci.yml's note
|
|
58
|
-
- Fix two wrong claims, a dead property, and the stdout bug the review found
|
|
59
|
-
- dense-prose pass over the added prose, and read the fixture once
|
|
60
|
-
- Close four production-readiness gaps: audit gate, ops docs, CLI tests, PDF fuzzing
|
|
61
|
-
- Drop the README's claim about the deleted sentinel patch
|
|
62
|
-
- dense-prose pass: cut the restatement the change introduced
|
|
63
|
-
- docs: the 0.102 → 0.103 step, and correct the surface it describes
|
|
64
|
-
- The gate is the only door to the core surface
|
|
65
|
-
- docs(migration): a span guide for 0.92 → 0.102
|
|
66
|
-
- Cut over-specified style rules; author preference wins
|
|
67
|
-
- dense-prose: remove the em-dash ban
|
|
68
|
-
- dense-prose: drop the line-budget clause
|
|
69
|
-
- dense-prose: wrong is worse than missing; relax em-dash ban
|
|
70
|
-
- docs: dense-prose pass over the conform-scope and date-grammar prose
|
|
71
|
-
- docs: migration guides are era-stamped, not immutable
|
|
72
|
-
- docs(migrations): give the datetime split its corpus audit step
|
|
73
|
-
- docs: scope conform to content fields, state the date corpus precondition
|
|
74
|
-
- Tighten the failure-delivery prose
|
|
75
|
-
- Deliver init's conflict as a rejection, not a synchronous throw
|
|
76
|
-
-->
|
|
77
|
-
|
|
78
|
-
|
|
79
252
|
## v0.102.0 - 2026-08-04
|
|
80
253
|
|
|
81
254
|
The pre-1.0 vocabulary reset. Verbs, diagnostic codes, and two words that meant
|
package/README.md
CHANGED
|
@@ -6,12 +6,11 @@ Maintained by [TTQ](https://tonguetoquill.com).
|
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Quillmark in browsers and Node, over explicit in-memory trees
|
|
10
|
+
(`Map<string, Uint8Array>` / `Record<string, Uint8Array>`).
|
|
10
11
|
|
|
11
|
-
The package
|
|
12
|
-
|
|
13
|
-
- `@quillmark/wasm` (the root), the **canonical API**: `init`, resolving to
|
|
14
|
-
`Quill` and `Document`, and an `Engine` that renders them.
|
|
12
|
+
The package has one import surface: `@quillmark/wasm`, whose `init` resolves to
|
|
13
|
+
`Quill` and `Document`, plus an `Engine` that renders them.
|
|
15
14
|
|
|
16
15
|
`Quill` and `Document` are the internal Typst-less core build's own classes,
|
|
17
16
|
handed out verbatim by `init`, so editor/validation code (`Quill.fromTree`,
|
|
@@ -101,11 +100,8 @@ the same value again is fine, so several entry points may each
|
|
|
101
100
|
|
|
102
101
|
**Both failures reject.** `runtime::init_conflict` and `runtime::init_failed`
|
|
103
102
|
alike ride the returned promise, so one `catch` around `await init(...)` covers
|
|
104
|
-
the gate.
|
|
105
|
-
|
|
106
|
-
**You cannot forget.** The core surface has no static export, so a call site
|
|
107
|
-
that skips the await has no name to call. The precondition is structural rather
|
|
108
|
-
than a convention: no load order can make one entry point pass and another fail.
|
|
103
|
+
the gate. The core surface has no static export, so a call site that skips the
|
|
104
|
+
await has no name to call.
|
|
109
105
|
|
|
110
106
|
**Vite's dev server** pre-bundles dependencies, which moves the package away
|
|
111
107
|
from its binary. Exclude it:
|
|
@@ -136,29 +132,14 @@ depend only on `quill.backendId`, and answer from the descriptor's required
|
|
|
136
132
|
`formats`/`canvas` manifest: never loading the multi-MB backend binary and
|
|
137
133
|
never cloning the quill. Use them as non-failing pre-render probes.
|
|
138
134
|
|
|
139
|
-
### `
|
|
140
|
-
|
|
141
|
-
is
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
did not set reaches the output. Build it up with `storeFields` / `insertCard`.
|
|
148
|
-
For an example-filled starter use `quill.seedDocument()`. Throws on an
|
|
149
|
-
invalid quill reference.
|
|
150
|
-
|
|
151
|
-
### `Document.fromMarkdown(markdown)`
|
|
152
|
-
Parse markdown to a parsed document, quill-free: the **transport door**
|
|
153
|
-
(migrations, `$ext` stamping, a quill that will not load, opening a document to
|
|
154
|
-
fix its `$quill`). Throws a JS `Error` (with `.diagnostics` attached, see
|
|
155
|
-
[Errors](#errors)) on any parse failure, including a missing root `$quill`
|
|
156
|
-
metadata line, malformed YAML, and inputs over the 10 MiB
|
|
157
|
-
`parse::input_too_large` limit. A content field rests as authored; `quill.parse`
|
|
158
|
-
below is the bound door that lands it at its canonical rest.
|
|
159
|
-
|
|
160
|
-
### `quill.parse(markdown)` / `quill.conform(doc)`
|
|
161
|
-
The **bound door**, and the primary ingestion path. `quill.parse` is
|
|
135
|
+
### The two doors: `Document.fromMarkdown` vs `quill.parse` / `quill.conform`
|
|
136
|
+
|
|
137
|
+
`Document.fromMarkdown` is the quill-free **transport door** (migrations, `$ext`
|
|
138
|
+
stamping, a quill that will not load, opening a document to fix its `$quill`).
|
|
139
|
+
It needs a root `~~~` block carrying a `$quill` line, and a content field rests
|
|
140
|
+
as authored.
|
|
141
|
+
|
|
142
|
+
`quill.parse` is the **bound door**, and the primary ingestion path. It is
|
|
162
143
|
`Document.fromMarkdown` followed by `conform`: the returned document's declared
|
|
163
144
|
content fields rest at one form per codec (a `richtext` field as the canonical
|
|
164
145
|
content object, a `plaintext` field as its literal string), so `getStored`
|
|
@@ -181,61 +162,17 @@ const stale = Document.fromJson(row);
|
|
|
181
162
|
const diags = quill.conform(stale); // converges in place
|
|
182
163
|
```
|
|
183
164
|
|
|
184
|
-
###
|
|
185
|
-
Emit canonical Quillmark Markdown. Type-fidelity round-trip safe:
|
|
186
|
-
`Document.fromMarkdown(doc.toMarkdown())` returns a document equal to `doc`
|
|
187
|
-
under [`doc.equals`](#docequalsother). The output is **not** guaranteed
|
|
188
|
-
byte-equal to the original source: YAML quoting, key ordering, and
|
|
189
|
-
whitespace are normalised. Use `equals` (not string comparison) to test
|
|
190
|
-
semantic equality.
|
|
191
|
-
|
|
192
|
-
### `doc.toJson()`
|
|
193
|
-
Serialize the document to a versioned storage DTO: a JSON **string**
|
|
194
|
-
carrying a `schema` version. Use this (not `toMarkdown`) to persist a
|
|
195
|
-
document across a process restart or crate upgrade: the wire format is
|
|
196
|
-
frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
|
|
197
|
-
`warnings` are not part of the DTO.
|
|
198
|
-
|
|
199
|
-
The string is produced inside the module by `serde_json`; the JS `JSON`
|
|
200
|
-
global is not involved. It is standard JSON text, so callers may
|
|
201
|
-
`JSON.parse` it to inspect it, but it is intended as an opaque blob you
|
|
202
|
-
persist and hand back.
|
|
203
|
-
|
|
204
|
-
`toJson()` is **deterministic**: a `Document` that is `equals` to another
|
|
205
|
-
serializes to a byte-identical string: across repeated calls, and across
|
|
206
|
-
any crate upgrade that keeps the same `schema` version (every release does until
|
|
207
|
-
the `Document` model changes; see [Storage compatibility](#storage-compatibility-across-versions)).
|
|
208
|
-
Field order is fixed and object key order is preserved, so content hashes
|
|
209
|
-
and string-equality dirty-checks over the output are stable.
|
|
210
|
-
|
|
211
|
-
### `Document.fromJson(json)`
|
|
212
|
-
Reconstruct a `Document` from a storage DTO string produced by `toJson`.
|
|
213
|
-
Round-trips losslessly:
|
|
214
|
-
|
|
215
|
-
```ts
|
|
216
|
-
const stored = doc.toJson(); // persist this string
|
|
217
|
-
const restored = Document.fromJson(stored);
|
|
218
|
-
restored.equals(doc); // true
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Throws a JS `Error` on malformed JSON, an unknown `schema` version, or a
|
|
222
|
-
malformed payload. The restored document has no parse-time `warnings`.
|
|
165
|
+
### Storage compatibility across versions
|
|
223
166
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
167
|
+
Persist `doc.toJson()`, not `doc.toMarkdown()`: the DTO wire format is frozen
|
|
168
|
+
per `schema` version, whereas Markdown syntax evolves, and `toMarkdown` output
|
|
169
|
+
is normalised rather than byte-equal to the source. `Document.tryFromJson`
|
|
170
|
+
discriminates the two formats without exceptions as control flow:
|
|
228
171
|
|
|
229
172
|
```ts
|
|
230
|
-
// "JSON canonical, Markdown fallback": no exceptions, no string sniffing
|
|
231
173
|
const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
|
|
232
174
|
```
|
|
233
175
|
|
|
234
|
-
`undefined` means only "not a storage DTO"; `fromMarkdown` still throws on
|
|
235
|
-
genuinely malformed Markdown.
|
|
236
|
-
|
|
237
|
-
### Storage compatibility across versions
|
|
238
|
-
|
|
239
176
|
The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
|
|
240
177
|
not the running crate version. It is a hand-set constant, bumped only when
|
|
241
178
|
the `Document` model itself changes, so every `0.93.x` patch release reads
|
|
@@ -269,53 +206,17 @@ In short: persist the `toJson` string, upgrade freely, never downgrade. The
|
|
|
269
206
|
full design (including how migrations are added) is in
|
|
270
207
|
`prose/canon/DOCUMENT_STORAGE.md`.
|
|
271
208
|
|
|
272
|
-
###
|
|
273
|
-
Structural equality between two `Document` handles. Compares `main` and
|
|
274
|
-
`cards` by value; parse-time `warnings` are intentionally excluded.
|
|
275
|
-
|
|
276
|
-
Use this to debounce upstream prop updates: keep the last parsed `Document`
|
|
277
|
-
and compare instead of re-parsing on every keystroke.
|
|
278
|
-
|
|
279
|
-
### `doc.cardCount`
|
|
280
|
-
O(1) getter for the number of composable cards (excluding the main card).
|
|
281
|
-
Use this to validate indices before calling card mutators (`removeCard`,
|
|
282
|
-
`storeField({ card, field }, …)`, etc.) without allocating the full `cards` array.
|
|
283
|
-
|
|
284
|
-
### `quill.validate(doc)`
|
|
285
|
-
|
|
286
|
-
Returns `Diagnostic[]`: the document validated against the quill schema,
|
|
287
|
-
without invoking the backend. An empty array means the document is valid.
|
|
288
|
-
Each diagnostic carries the canonical `validation::*` `code`, `path`, and
|
|
289
|
-
`hint`. Includes the non-fatal `validation::must_fill` warning for each
|
|
290
|
-
`!must_fill` marker left in the document (render zero-fills these rather
|
|
291
|
-
than failing), so filter by `severity`/`code` for blockers vs. hints:
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
const diagnostics = quill.validate(Document.fromMarkdown(markdown));
|
|
295
|
-
const errors = diagnostics.filter(d => d.severity === "error");
|
|
296
|
-
```
|
|
209
|
+
### Cards, seeds, and addresses
|
|
297
210
|
|
|
298
211
|
To render a form editor, read field definitions from `quill.schema` (walk
|
|
299
212
|
`fields` in key order: declaration order is display order) and the authored
|
|
300
213
|
values from the `Document` payload: there is no separate form-view projection.
|
|
214
|
+
`quill.validate(doc)` scores it without invoking the backend.
|
|
301
215
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
`default:` → type-empty zero). Illustration-first: a field with both an
|
|
307
|
-
`example` and a `default` renders its example. Use as the initial state for a
|
|
308
|
-
"new document" editor.
|
|
309
|
-
|
|
310
|
-
```ts
|
|
311
|
-
const doc = quill.seedDocument();
|
|
312
|
-
const markdown = doc.toMarkdown();
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
For per-card seeding, `quill.seedMain()` returns just the `$kind: main` card
|
|
316
|
-
and `quill.seedCard(kind)` returns a starter composable card (or `undefined`
|
|
317
|
-
if the kind is not declared). Both return the read `Card` shape of
|
|
318
|
-
`doc.main` / `doc.cards`, which `doc.insertCard` accepts directly:
|
|
216
|
+
`quill.seedDocument()` returns a starter document with each field's `example:`
|
|
217
|
+
committed; `quill.seedMain()` and `quill.seedCard(kind)` seed one card. All
|
|
218
|
+
return the read `Card` shape of `doc.main` / `doc.cards`, which `doc.insertCard`
|
|
219
|
+
accepts directly:
|
|
319
220
|
|
|
320
221
|
```ts
|
|
321
222
|
doc.insertCard(quill.seedCard("note")); // seed → append
|
|
@@ -412,7 +313,7 @@ write.
|
|
|
412
313
|
|
|
413
314
|
#### `DocumentReader` / `CardReader`: the read twin
|
|
414
315
|
|
|
415
|
-
`quill.reader(doc)` carries the writer's ephemerality and
|
|
316
|
+
`quill.reader(doc)` carries the writer's ephemerality and schema authority:
|
|
416
317
|
|
|
417
318
|
```ts
|
|
418
319
|
const v = quill.reader(doc);
|
|
@@ -439,24 +340,10 @@ tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional:
|
|
|
439
340
|
on throw, every read keeps serving the last-good compile. Don't open a session
|
|
440
341
|
per export, and don't re-open per edit: `apply` instead.
|
|
441
342
|
|
|
442
|
-
### `engine.render(quill, parsed, opts?)`
|
|
443
|
-
Render a pre-parsed `Document` against `quill`. Throws an
|
|
444
|
-
`engine::backend_not_found` error if no registered backend matches the quill's
|
|
445
|
-
declared backend.
|
|
446
|
-
|
|
447
|
-
### `engine.open(quill, parsed)` + `session.render(opts?)`
|
|
448
|
-
Open once, render all or selected pages (`opts.pages`).
|
|
449
|
-
|
|
450
|
-
The session also exposes `pageCount`, `backendId`, `supportsCanvas`,
|
|
451
|
-
`warnings` (non-fatal diagnostics of the current compile: set at `open`,
|
|
452
|
-
refreshed by each committed `apply`),
|
|
453
|
-
`apply(doc)` for in-place recompiles, `pageSize(page)`, and
|
|
454
|
-
`paint(ctx, page, opts?)` for canvas previews. See below.
|
|
455
|
-
|
|
456
343
|
A document that compiles to zero pages still produces a valid session
|
|
457
|
-
(`pageCount === 0`); `paint(ctx, 0)` and `pageSize(0)` then throw
|
|
458
|
-
`
|
|
459
|
-
|
|
344
|
+
(`pageCount === 0`); `paint(ctx, 0)` and `pageSize(0)` then throw. Branch on
|
|
345
|
+
`pageCount === 0` to render a "no pages to preview" UI rather than relying on
|
|
346
|
+
the throw.
|
|
460
347
|
|
|
461
348
|
### Canvas Preview
|
|
462
349
|
|
|
@@ -480,58 +367,47 @@ canvas.style.width = `${result.layoutWidth}px`;
|
|
|
480
367
|
canvas.style.height = `${result.layoutHeight}px`;
|
|
481
368
|
```
|
|
482
369
|
|
|
483
|
-
- `layoutScale`
|
|
484
|
-
`
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
it stays near the viewport rather than pooling one canvas across pages:
|
|
503
|
-
an idle canvas retains its pixels for free, whereas reusing a canvas on
|
|
504
|
-
scroll re-runs a full render.
|
|
505
|
-
- `pageCount` and `pageSize(page)` are stable for the session's
|
|
506
|
-
lifetime (immutable snapshot): cache them.
|
|
507
|
-
- Worker support: pass an `OffscreenCanvasRenderingContext2D` and the
|
|
508
|
-
same call signature works. `layoutWidth` / `layoutHeight` are
|
|
509
|
-
informational in that mode (no CSS layout box); fold everything into
|
|
510
|
-
`densityScale`. Loading the WASM module inside a Worker is the host's
|
|
511
|
-
responsibility.
|
|
512
|
-
- Backend support: gated by `supportsCanvas`. Probe upfront with
|
|
513
|
-
`engine.supportsCanvas(quill)` (or `session.supportsCanvas`) before mounting
|
|
514
|
-
a canvas-based UI; the throw on `paint` / `pageSize` remains the
|
|
515
|
-
enforcement contract and includes the resolved `backendId` for
|
|
516
|
-
debugging.
|
|
370
|
+
- `layoutScale` sets the display-box size (`layoutWidth = widthPt * layoutScale`);
|
|
371
|
+
fold `devicePixelRatio`, in-app zoom, and `visualViewport.scale` into
|
|
372
|
+
`densityScale`. Their product is the rasterization scale, clamped at 16384 px
|
|
373
|
+
per side (`result.clamped`, `result.effectiveDensityScale`).
|
|
374
|
+
- `paint` writes the whole backing store with `putImageData`, which ignores the
|
|
375
|
+
2D context transform, `globalAlpha`, and clip. Give each visible page its own
|
|
376
|
+
`<canvas>`: no compositing, sub-rect, or transform reaches through `paint`.
|
|
377
|
+
- `paint` is always a full repaint, and there is no per-page raster cache. Keep
|
|
378
|
+
a page's canvas alive while it stays near the viewport: an idle canvas retains
|
|
379
|
+
its pixels for free, whereas pooling one canvas across pages re-renders on
|
|
380
|
+
every scroll.
|
|
381
|
+
- `pageCount` and `pageSize(page)` are stable for the session's lifetime: cache
|
|
382
|
+
them.
|
|
383
|
+
- In a Worker, pass an `OffscreenCanvasRenderingContext2D`; the layout
|
|
384
|
+
dimensions are informational there. Loading the WASM module inside the Worker
|
|
385
|
+
is the host's responsibility.
|
|
386
|
+
- Backend support is gated by `supportsCanvas`. Probe upfront with
|
|
387
|
+
`engine.supportsCanvas(quill)`; the throw on `paint` / `pageSize` remains the
|
|
388
|
+
enforcement contract and names the resolved `backendId`.
|
|
517
389
|
|
|
518
390
|
### Schema model
|
|
519
391
|
|
|
520
|
-
A field
|
|
392
|
+
A field carries two independent axes, and no `required` one.
|
|
393
|
+
|
|
394
|
+
**Value** — what the cell holds. With a `default:`, `quill.blueprint` renders
|
|
395
|
+
that value under a type-only `# <type>` annotation and the render path uses it
|
|
396
|
+
when the document omits the field. Without one, an `example` takes the cell as
|
|
397
|
+
a suggested value, and an absent field blank-fills.
|
|
521
398
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
and the default is used when the document omits the field.
|
|
399
|
+
**Obligation** — whether a human must author the field, declared by
|
|
400
|
+
`must_fill:` and deriving from `default:`'s absence when left unset. An obliged
|
|
401
|
+
field carries the `!must_fill` marker in `quill.blueprint`, and
|
|
402
|
+
`quill.validate(doc)` emits the non-fatal `validation::must_fill` warning while
|
|
403
|
+
the document leaves it unauthored — from either of two triggers, named by the
|
|
404
|
+
diagnostic's `trigger` arg: `marker` for a marker the document still carries,
|
|
405
|
+
`unauthored` for a cell the schema obliges and the document never filled.
|
|
406
|
+
Authoring the field's blank discharges the obligation; clearing the key does
|
|
407
|
+
not.
|
|
532
408
|
|
|
533
|
-
|
|
534
|
-
|
|
409
|
+
Neither axis gates render. Partial documents are accepted, and
|
|
410
|
+
`engine.render(quill, doc)` throws only for malformed input.
|
|
535
411
|
|
|
536
412
|
### Errors
|
|
537
413
|
|
|
@@ -562,16 +438,12 @@ guard.
|
|
|
562
438
|
|
|
563
439
|
`QuillmarkError` is a **structural interface, not a class**: the WASM layer
|
|
564
440
|
throws a real `Error` and attaches the property, so there is no constructor to
|
|
565
|
-
`instanceof` against
|
|
566
|
-
|
|
441
|
+
`instanceof` against. Narrow with `isQuillmarkError`, which also works on errors
|
|
442
|
+
from any build or WASM instance in the page.
|
|
567
443
|
|
|
568
444
|
`diagnostics` is always non-empty: length 1 for most failures, length N for
|
|
569
|
-
backend compilation errors
|
|
570
|
-
|
|
571
|
-
`"<N> error(s): <first.message>"` summary for compilation failures).
|
|
572
|
-
|
|
573
|
-
Read `err.diagnostics[0]` for the primary diagnostic; iterate the array for
|
|
574
|
-
compilation failures. The same shape applies to every throw site:
|
|
445
|
+
backend compilation errors, and `message` is derived from it. The same shape
|
|
446
|
+
applies to every throw site:
|
|
575
447
|
|
|
576
448
|
- `Document.fromMarkdown`: parse errors (missing root `$quill` metadata, YAML
|
|
577
449
|
errors, `parse::input_too_large` for inputs > 10 MiB).
|
|
@@ -582,6 +454,9 @@ compilation failures. The same shape applies to every throw site:
|
|
|
582
454
|
text.
|
|
583
455
|
- `engine.render` / `session.render`: backend compilation failures and
|
|
584
456
|
validation errors.
|
|
457
|
+
- `engine.render(quill, parsed)` against a quill whose *name* differs
|
|
458
|
+
(`quill::name_mismatch`) or whose *version* falls outside the document's
|
|
459
|
+
selector (`quill::version_mismatch`): a throw, never a warning.
|
|
585
460
|
- Any method taking a `Quill` or `Document`: a handle from a *second* copy of
|
|
586
461
|
`@quillmark/wasm` is refused with `runtime::foreign_handle`, hinting `npm ls
|
|
587
462
|
@quillmark/wasm`. Two copies are two WASM memories and two `Quill`/`Document`
|
|
@@ -604,11 +479,11 @@ call returns: `try { return engine.render(quill, doc); } finally
|
|
|
604
479
|
{ doc.free(); }`: is safe even on the first render, while the backend
|
|
605
480
|
binary is still loading.
|
|
606
481
|
|
|
607
|
-
The package floor is Node
|
|
482
|
+
The package floor is Node 24+ (`engines: { node: ">=24" }`) and current
|
|
608
483
|
evergreen browsers; `--weak-refs` itself only needs Node 14.6+. The `using`
|
|
609
|
-
sugar ([explicit resource management][erm])
|
|
610
|
-
|
|
611
|
-
|
|
484
|
+
sugar ([explicit resource management][erm]) is on that floor and optional;
|
|
485
|
+
an explicit `try` / `finally` is the equivalent, and the form that also runs
|
|
486
|
+
in a browser that hasn't shipped it:
|
|
612
487
|
|
|
613
488
|
```ts
|
|
614
489
|
const session = await engine.open(quill, doc);
|
|
@@ -623,15 +498,6 @@ try {
|
|
|
623
498
|
|
|
624
499
|
[erm]: https://github.com/tc39/proposal-explicit-resource-management
|
|
625
500
|
|
|
626
|
-
## Notes
|
|
627
|
-
|
|
628
|
-
- Parsed markdown requires a root `~~~` block (a bare three-tilde fence;
|
|
629
|
-
`~~~card-yaml` is also accepted as a non-canonical alias)
|
|
630
|
-
with a `$quill` system-metadata line. Empty input surfaces a dedicated
|
|
631
|
-
"Empty markdown input cannot be parsed" message.
|
|
632
|
-
- A `$quill` mismatch during `engine.render(quill, parsed)` is a thrown error, not a warning: rendering with a quill whose *name* differs (`quill::name_mismatch`) or whose *version* falls outside the selector (`quill::version_mismatch`) is rejected.
|
|
633
|
-
- Output schema APIs live on `Quill`, not the engine.
|
|
634
|
-
|
|
635
501
|
## Changelog
|
|
636
502
|
|
|
637
503
|
See the [changelog](https://github.com/borb-sh/quillmark/blob/main/CHANGELOG.md)
|