@quillmark/wasm 0.98.0 → 0.100.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/README.md CHANGED
@@ -10,16 +10,16 @@ Use Quillmark in browsers/Node.js with explicit in-memory trees (`Map<string, Ui
10
10
 
11
11
  The package exposes **one import surface**:
12
12
 
13
- - `@quillmark/wasm` (the root) the **canonical API**: `Quill`, `Document`, and
13
+ - `@quillmark/wasm` (the root), the **canonical API**: `Quill`, `Document`, and
14
14
  an `Engine` that renders them.
15
15
 
16
16
  `Quill` and `Document` are re-exported verbatim from the internal Typst-less
17
17
  core build, so editor/validation code (`Quill.fromTree`,
18
- `Document.fromMarkdown`) loads only that small core binary no backend is
18
+ `Document.fromMarkdown`) loads only that small core binary: no backend is
19
19
  loaded until you render. The `Engine` hides everything else: each backend
20
20
  (`typst`, `pdfform`) is a separate, private WASM binary with its own linear
21
21
  memory, lazily loaded on the first render. The Engine clones a `Quill` /
22
- `Document` into the backend's memory as data and frees the clones you never
22
+ `Document` into the backend's memory as data and frees the clones: you never
23
23
  hold a backend object or cross a memory boundary yourself.
24
24
 
25
25
  ## Build
@@ -28,8 +28,8 @@ hold a backend object or cross a memory boundary yourself.
28
28
  bash scripts/build-wasm.sh
29
29
  ```
30
30
 
31
- The script builds three variants the core (no backend), the Typst backend
32
- (default features), and the Typst-free pdfform backend (`pdfform` feature)
31
+ The script builds three variants: the core (no backend), the Typst backend
32
+ (default features), and the Typst-free pdfform backend (`pdfform` feature):
33
33
  each with `--target bundler` and `--weak-refs` enabled (see
34
34
  [Lifecycle](#lifecycle)).
35
35
 
@@ -66,7 +66,7 @@ const result = await engine.render(quill, parsed, { format: "pdf" });
66
66
 
67
67
  ### `new Engine(options?)`
68
68
  Create the render dispatcher. Routes each quill to its backend by
69
- `quill.backendId`, lazily loads that backend binary, and renders cloning the
69
+ `quill.backendId`, lazily loads that backend binary, and renders: cloning the
70
70
  quill/document into the backend's memory and freeing the clones internally.
71
71
  `render`, `open`, `supportedFormats`, and `supportsCanvas` are **async** (the
72
72
  first call may load a backend). Pass `{ backends }` to register or override
@@ -78,37 +78,64 @@ the backend id.
78
78
 
79
79
  **Capability probes are always free.** `supportedFormats` and `supportsCanvas`
80
80
  depend only on `quill.backendId`, and answer from the descriptor's required
81
- `formats`/`canvas` manifest never loading the multi-MB backend binary and
81
+ `formats`/`canvas` manifest: never loading the multi-MB backend binary and
82
82
  never cloning the quill. Use them as non-failing pre-render probes.
83
83
 
84
84
  ### `Quill.fromTree(tree)`
85
- Build + validate a `Quill` from an in-memory tree. Pure the declared backend
85
+ Build + validate a `Quill` from an in-memory tree. Pure: the declared backend
86
86
  is resolved at render time, not here. Loads no backend binary.
87
87
 
88
88
  ### `new Document(quillRef)`
89
89
  A blank document: a main card carrying only `$quill`, an empty body, and no
90
- composable cards the programmatic blank canvas. Absent fields resolve at
90
+ composable cards: the programmatic blank canvas. Absent fields resolve at
91
91
  render time (schema `default`, else type-empty zero), so nothing the caller
92
92
  did not set reaches the output. Build it up with `storeFields` / `insertCard`.
93
93
  For an example-filled starter use `quill.seedDocument()`. Throws on an
94
94
  invalid quill reference.
95
95
 
96
96
  ### `Document.fromMarkdown(markdown)`
97
- Parse markdown to a parsed document. Throws a JS `Error` (with `.diagnostics`
98
- attached, see [Errors](#errors)) on any parse failure, including a missing
99
- root `$quill` metadata line, malformed YAML, and inputs over the 10 MB
100
- `parse::input_too_large` limit.
97
+ Parse markdown to a parsed document, quill-free: the **transport door**
98
+ (migrations, `$ext` stamping, a quill that will not load, opening a document to
99
+ fix its `$quill`). Throws a JS `Error` (with `.diagnostics` attached, see
100
+ [Errors](#errors)) on any parse failure, including a missing root `$quill`
101
+ metadata line, malformed YAML, and inputs over the 10 MiB
102
+ `parse::input_too_large` limit. A content field rests as authored; `quill.parse`
103
+ below is the bound door that lands it at its canonical rest.
104
+
105
+ ### `quill.parse(markdown)` / `quill.conform(doc)`
106
+ The **bound door**, and the primary ingestion path. `quill.parse` is
107
+ `Document.fromMarkdown` followed by `conform`: the returned document's declared
108
+ content fields rest at one form per codec (a `richtext` field as the canonical
109
+ content object, a `plaintext` field as its literal string), so `getStored`
110
+ answers "corpus or string?" by the field's declared codec rather than by how the
111
+ document was built. Parse warnings and the `conform::*` warnings both ride
112
+ `doc.warnings`.
113
+
114
+ `quill.conform(doc)` is the same walk in place on a document that arrived any
115
+ other way (`fromJson`, a stored row), returning the `conform::*` `Diagnostic[]`
116
+ (`[]` when everything rested). It is idempotent and a byte no-op on an
117
+ already-canonical document, YAML comments included, so calling it on every load
118
+ is safe. A `!must_fill` marker anywhere in a field's value skips that field; a
119
+ value the strict write refuses stays as authored under a warning. Both throw
120
+ when the document declares a `$quill` this quill does not answer to, before any
121
+ mutation.
122
+
123
+ ```ts
124
+ const doc = quill.parse(markdown); // rests canonical
125
+ const stale = Document.fromJson(row);
126
+ const diags = quill.conform(stale); // converges in place
127
+ ```
101
128
 
102
129
  ### `doc.toMarkdown()`
103
130
  Emit canonical Quillmark Markdown. Type-fidelity round-trip safe:
104
131
  `Document.fromMarkdown(doc.toMarkdown())` returns a document equal to `doc`
105
132
  under [`doc.equals`](#docequalsother). The output is **not** guaranteed
106
- byte-equal to the original source YAML quoting, key ordering, and
133
+ byte-equal to the original source: YAML quoting, key ordering, and
107
134
  whitespace are normalised. Use `equals` (not string comparison) to test
108
135
  semantic equality.
109
136
 
110
137
  ### `doc.toJson()`
111
- Serialize the document to a versioned storage DTO a JSON **string**
138
+ Serialize the document to a versioned storage DTO: a JSON **string**
112
139
  carrying a `schema` version. Use this (not `toMarkdown`) to persist a
113
140
  document across a process restart or crate upgrade: the wire format is
114
141
  frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
@@ -116,11 +143,11 @@ frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
116
143
 
117
144
  The string is produced inside the module by `serde_json`; the JS `JSON`
118
145
  global is not involved. It is standard JSON text, so callers may
119
- `JSON.parse` it to inspect it but it is intended as an opaque blob you
146
+ `JSON.parse` it to inspect it, but it is intended as an opaque blob you
120
147
  persist and hand back.
121
148
 
122
149
  `toJson()` is **deterministic**: a `Document` that is `equals` to another
123
- serializes to a byte-identical string across repeated calls, and across
150
+ serializes to a byte-identical string: across repeated calls, and across
124
151
  any crate upgrade that keeps the same `schema` version (every release does until
125
152
  the `Document` model changes; see [Storage compatibility](#storage-compatibility-across-versions)).
126
153
  Field order is fixed and object key order is preserved, so content hashes
@@ -145,7 +172,7 @@ not a valid storage DTO. Use it to branch on format without a heuristic or
145
172
  `try`/`catch` as control flow:
146
173
 
147
174
  ```ts
148
- // "JSON canonical, Markdown fallback" no exceptions, no string sniffing
175
+ // "JSON canonical, Markdown fallback": no exceptions, no string sniffing
149
176
  const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
150
177
  ```
151
178
 
@@ -156,14 +183,14 @@ genuinely malformed Markdown.
156
183
 
157
184
  The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
158
185
  not the running crate version. It is a hand-set constant, bumped only when
159
- the `Document` model itself changes so every `0.93.x` patch release reads
186
+ the `Document` model itself changes, so every `0.93.x` patch release reads
160
187
  and writes that same value.
161
188
 
162
189
  - **Upgrading is safe.** A newer build always reads documents written by an
163
190
  older one. Each schema version's wire format is frozen and never changes;
164
191
  when the model does change, the new build ships a migration that converts
165
192
  old payloads on `fromJson`. A document you commit as your canonical
166
- on-disk format keeps loading across crate upgrades there is no need to
193
+ on-disk format keeps loading across crate upgrades: there is no need to
167
194
  pin old wasm to read old data.
168
195
  - **Downgrading is not.** `fromJson` rejects an *unknown* (i.e. newer)
169
196
  `schema` version rather than guessing at a format it predates. Don't feed
@@ -178,13 +205,13 @@ if (v && v !== Document.currentSchemaVersion()) {
178
205
  }
179
206
  ```
180
207
 
181
- `schemaVersionOf` does not validate the payload it only reads the
208
+ `schemaVersionOf` does not validate the payload: it only reads the
182
209
  `schema` field, returning `undefined` for non-JSON, non-objects, or
183
210
  payloads that don't carry one. Use it to distinguish "wrong version" from
184
211
  "corrupt" when `fromJson` throws.
185
212
 
186
213
  In short: persist the `toJson` string, upgrade freely, never downgrade. The
187
- full design including how migrations are added is in
214
+ full design (including how migrations are added) is in
188
215
  `prose/canon/DOCUMENT_STORAGE.md`.
189
216
 
190
217
  ### `doc.equals(other)`
@@ -201,7 +228,7 @@ Use this to validate indices before calling card mutators (`removeCard`,
201
228
 
202
229
  ### `quill.validate(doc)`
203
230
 
204
- Returns `Diagnostic[]` the document validated against the quill schema,
231
+ Returns `Diagnostic[]`: the document validated against the quill schema,
205
232
  without invoking the backend. An empty array means the document is valid.
206
233
  Each diagnostic carries the canonical `validation::*` `code`, `path`, and
207
234
  `hint`. Includes the non-fatal `validation::must_fill` warning for each
@@ -214,14 +241,14 @@ const errors = diagnostics.filter(d => d.severity === "error");
214
241
  ```
215
242
 
216
243
  To render a form editor, read field definitions from `quill.schema` (walk
217
- `fields` in key order declaration order is display order) and the authored
218
- values from the `Document` payload there is no separate form-view projection.
244
+ `fields` in key order: declaration order is display order) and the authored
245
+ values from the `Document` payload: there is no separate form-view projection.
219
246
 
220
247
  ### `quill.seedDocument()`
221
248
 
222
249
  Returns a starter `Document` seeded from the schema: each field's `example:`
223
250
  is committed and every other field is left absent (the render layer fills
224
- `default:` → type-empty zero). Illustration-first a field with both an
251
+ `default:` → type-empty zero). Illustration-first: a field with both an
225
252
  `example` and a `default` renders its example. Use as the initial state for a
226
253
  "new document" editor.
227
254
 
@@ -243,7 +270,7 @@ doc.insertCard({ kind: "note" }, 0); // insert at index 0
243
270
  ```
244
271
 
245
272
  Reads and writes are two aligned shapes. A read `Card` always has `body:
246
- Content` (canonical content, never a raw string) no narrowing, no guessing
273
+ Content` (canonical content, never a raw string): no narrowing, no guessing
247
274
  whether the body was normalized. The write shape `CardInput` widens `body` to
248
275
  `Content | string` (a markdown string imports to the content) and makes every
249
276
  field but `kind` optional. Every `Card` is a valid `CardInput`, so `insertCard`
@@ -251,22 +278,26 @@ still takes exactly what `cards` / `removeCard` / `seedCard` return.
251
278
  Build a fresh card from a flat field map with
252
279
  `Document.makeCard(kind, fields?, body?)`.
253
280
 
254
- **One address for the whole surface.** Reads and writes navigate by an `Addr` —
255
- `{ card?, field? }`, absent `card` = main, absent `field` = body and a bare
281
+ **One address for the whole surface.** Reads and writes navigate by an `Addr`:
282
+ `{ card?, field? }`, absent `card` = main, absent `field` = body, and a bare
256
283
  string is shorthand for `{ field }`. So `doc.storeField("qty", 3)` targets the
257
284
  main card's `qty`, `doc.storeField({ card: 2, field: "qty" }, 3)` a composable
258
285
  card's. Reads are total over the field axis (`getStored` → `undefined`, `isFill` → `false` for
259
286
  an absent field; only an out-of-range card throws); field writes throw on a body
260
287
  address. `getStored` is the verbatim transport read, distinct from the interpreted
261
288
  `quill.reader(doc).get`; `getMarkdown` is the body markdown read (a `CardAddr`; a field's
262
- markdown is read through `quill.reader(doc).get(field)`). Card-scoped verbs take a
289
+ markdown is read through `quill.reader(doc).get(field)`). A content field's stored
290
+ form follows how the document was built (a canonical content object when the
291
+ typed writer committed it, the authored string when a markdown parse produced
292
+ it), so for the corpus either way read `quill.reader(doc).getContent(addr)`, which
293
+ decodes through the codec the field's declared type names. Card-scoped verbs take a
263
294
  `CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`, and the batch below.
264
295
 
265
296
  Batch mutation: `doc.storeFields({}, {...})` / `doc.storeFields({ card: index }, {...})`
266
- apply a whole object atomically on any invalid field nothing is applied and
297
+ apply a whole object atomically: on any invalid field nothing is applied and
267
298
  the thrown error carries one diagnostic per offending field (`path` = field
268
299
  name). The address is first (never shape-overloaded, since `card` is a legal
269
- field name), and parses strictly a stray key throws rather than silently
300
+ field name), and parses strictly: a stray key throws rather than silently
270
301
  reading as `{}`. The main card is `{}`, or **`MAIN_CARD_ADDR`** (from
271
302
  `@quillmark/wasm/runtime`), a frozen alias that spells the intent:
272
303
  `doc.storeFields(MAIN_CARD_ADDR, {...})`.
@@ -277,12 +308,12 @@ A `Document` holds only a `$quill` *reference*, not the resolved schema, so type
277
308
  writes go through the schema-bound writer while the quill-free opaque store sits
278
309
  on `Document` itself (**store** = verbatim, **set** = typed):
279
310
 
280
- - **`quill.writer(doc)` the typed door whenever a quill is in hand.** Bind the
311
+ - **`quill.writer(doc)`: the typed door whenever a quill is in hand.** Bind the
281
312
  schema once and issue bare `set` / `setAll` / `setBody` / `reviseField` /
282
313
  `addCard` / `card(i)`. Each resolves the field's schema `type`, coerces the
283
314
  value to its canonical form (`"3"` → `3`, a markdown string → a richtext
284
315
  content), and **fails now** on a mismatch instead of at render. A name the schema
285
- does not declare throws `UnknownField` rather than falling to the opaque store
316
+ does not declare throws `UnknownField` rather than falling to the opaque store:
286
317
  on the typed path an undeclared name is a typo, not a fallback. The batch form
287
318
  (`setAll`) is all-or-nothing: an undeclared name aborts the whole write and its
288
319
  per-field diagnostics name every offending field, so a whole-form submit
@@ -290,21 +321,21 @@ on `Document` itself (**store** = verbatim, **set** = typed):
290
321
  carries the quill-taking `_commitField` / `_commitFields` / `_addCard` /
291
322
  `_reviseField` ABI the writer delegates to, hidden from the `.d.ts`.)
292
323
 
293
- - **`store*` the deliberate quill-free primitive.** `doc.storeField(addr, value)`
324
+ - **`store*`: the deliberate quill-free primitive.** `doc.storeField(addr, value)`
294
325
  / `doc.storeFields(cardAddr, {...})` (and `storeFill`) validate only the field
295
326
  name/depth/kind and store the value verbatim, no quill required. Reach for it
296
327
  on purpose when you *want* the opaque store: quill-agnostic storage/migration
297
328
  infra that has no bundle and must write regardless of a drifted schema;
298
329
  store-now-validate-later editors holding in-progress input that `commit`
299
330
  would reject; or verbatim passthrough of fields the schema doesn't own. It is
300
- the lower layer, not a lighter `commit` a typo'd field name stores silently
331
+ the lower layer, not a lighter `commit`: a typo'd field name stores silently
301
332
  and only surfaces at `quill.validate` / render.
302
333
 
303
334
  Per-keystroke cost is the same either way (both mutate the in-memory `Document`
304
335
  in place; no seam is crossed), so steering to the writer buys the type check for
305
336
  free.
306
337
 
307
- #### `DocumentWriter` / `CardWriter` bind the quill once
338
+ #### `DocumentWriter` / `CardWriter`: bind the quill once
308
339
 
309
340
  `quill.writer(doc)` binds the quill's schema to the document once, so a form
310
341
  editor or MCP writer that holds both issues bare verbs (the writer forwards to
@@ -315,25 +346,43 @@ const ed = quill.writer(doc); // Rust `quill.writer(doc)`
315
346
  ed.set("subject", "Q3 results"); // strict-committed to the schema type
316
347
  ed.setAll({ qty: "3", subject: "Q3" }); // all-or-nothing batch
317
348
  ed.reviseField("subject", "Q3 **results**"); // typed AND anchor-preserving; returns a Delta
318
- ed.set("titel", "x"); // throws UnknownField a typo, not a fallback
349
+ ed.set("titel", "x"); // throws UnknownField: a typo, not a fallback
319
350
  ed.card(2).set("body", "**note**"); // composable card, resolved by its $kind
320
351
  ```
321
352
 
322
353
  `DocumentWriter` / `CardWriter` are pure JS holding references to your existing
323
- `quill` and `doc` no WASM handle of their own, nothing to `free()`. `card(i)`
354
+ `quill` and `doc`: no WASM handle of their own, nothing to `free()`. `card(i)`
324
355
  is lazy: it never throws; an out-of-range index throws `IndexOutOfRange` at the
325
356
  write.
326
357
 
358
+ #### `DocumentReader` / `CardReader`: the read twin
359
+
360
+ `quill.reader(doc)` carries the writer's ephemerality and its schema authority:
361
+
362
+ ```ts
363
+ const v = quill.reader(doc);
364
+ v.get("subject"); // by declared type: richtext → markdown, plaintext → literal text
365
+ v.getContent("subject"); // the same read as a Content corpus, whichever lane stored it
366
+ v.getBody(); // the main body markdown (quill-free)
367
+ v.card(0).get("body"); // a card field, resolved by its $kind
368
+ ```
369
+
370
+ `get` projects and `getContent` returns the corpus; both decode through the codec
371
+ the field's **declared type** names, which is why they bind the quill and the
372
+ verbatim `doc.getStored` does not. An undeclared name throws `UnknownField`, a
373
+ type that is not a content leaf throws `FieldNotContent`, and an undecodable
374
+ value throws `FieldRichtextDecode`; an absent field reads back `undefined`.
375
+
327
376
  ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
328
377
 
329
- Use **`engine.render`** for one-shot exports (PDF/SVG/PNG) compiles, emits
378
+ Use **`engine.render`** for one-shot exports (PDF/SVG/PNG): compiles, emits
330
379
  artifacts, done. Use **`LiveSession`** (returned by `engine.open`) for
331
380
  reactive previews: the session is a persistent compiler. `paint` / `render` /
332
381
  `regions` / `fieldAt` read its current compile without recompiling, and `apply(doc)`
333
382
  recompiles in place on each edit, returning a `ChangeSet` whose `dirtyPages`
334
- tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional
383
+ tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional:
335
384
  on throw, every read keeps serving the last-good compile. Don't open a session
336
- per export, and don't re-open per edit `apply` instead.
385
+ per export, and don't re-open per edit: `apply` instead.
337
386
 
338
387
  ### `engine.render(quill, parsed, opts?)`
339
388
  Render a pre-parsed `Document` against `quill`. Throws an
@@ -344,7 +393,7 @@ declared backend.
344
393
  Open once, render all or selected pages (`opts.pages`).
345
394
 
346
395
  The session also exposes `pageCount`, `backendId`, `supportsCanvas`,
347
- `warnings` (non-fatal diagnostics of the current compile set at `open`,
396
+ `warnings` (non-fatal diagnostics of the current compile: set at `open`,
348
397
  refreshed by each committed `apply`),
349
398
  `apply(doc)` for in-place recompiles, `pageSize(page)`, and
350
399
  `paint(ctx, page, opts?)` for canvas previews. See below.
@@ -361,7 +410,7 @@ render a "no pages to preview" UI without relying on the throw.
361
410
  `OffscreenCanvasRenderingContext2D` (Worker), skipping PNG/SVG byte
362
411
  round-trips.
363
412
 
364
- The painter owns `canvas.width` / `canvas.height` it sizes the backing
413
+ The painter owns `canvas.width` / `canvas.height`: it sizes the backing
365
414
  store itself. Consumers own `canvas.style.*` (or the layout system that
366
415
  sets them) and read `layoutWidth` / `layoutHeight` from the returned
367
416
  `PaintResult`.
@@ -390,16 +439,16 @@ canvas.style.height = `${result.layoutHeight}px`;
390
439
  clamped page renders soft at the same `canvas.style` size.
391
440
  - `paint` writes the whole backing store with `putImageData`, which
392
441
  ignores the 2D context transform, `globalAlpha`, and clip. Give each
393
- visible page its own `` element you cannot composite two pages,
442
+ visible page its own `<canvas>` element: you cannot composite two pages,
394
443
  a sub-rect, or a context transform through `paint`.
395
- - `paint` is always a full repaint setting the backing-store width /
444
+ - `paint` is always a full repaint: setting the backing-store width /
396
445
  height clears it. No `clearRect` required. Each call re-rasterizes from
397
446
  scratch (no per-page raster cache), so keep a page's canvas alive while
398
447
  it stays near the viewport rather than pooling one canvas across pages:
399
448
  an idle canvas retains its pixels for free, whereas reusing a canvas on
400
449
  scroll re-runs a full render.
401
450
  - `pageCount` and `pageSize(page)` are stable for the session's
402
- lifetime (immutable snapshot) cache them.
451
+ lifetime (immutable snapshot): cache them.
403
452
  - Worker support: pass an `OffscreenCanvasRenderingContext2D` and the
404
453
  same call signature works. `layoutWidth` / `layoutHeight` are
405
454
  informational in that mode (no CSS layout box); fold everything into
@@ -415,14 +464,14 @@ canvas.style.height = `${result.layoutHeight}px`;
415
464
 
416
465
  A field's *cell* is inferred from whether its schema declares a `default:`:
417
466
 
418
- - **Unendorsed** (no `default:`) `quill.blueprint` renders the
467
+ - **Unendorsed** (no `default:`): `quill.blueprint` renders the
419
468
  `!must_fill` marker in the value cell (carrying the field's `example` as a
420
469
  suggested value when one exists). An absent Unendorsed field zero-fills
421
470
  silently. A `!must_fill` marker left in the document is non-fatal: it emits
422
471
  the `validation::must_fill` warning and still renders. Partial documents
423
472
  are accepted; `engine.render(quill, doc)` only throws for malformed
424
473
  input.
425
- - **Endorsed** (with `default:`) `quill.blueprint` renders the
474
+ - **Endorsed** (with `default:`): `quill.blueprint` renders the
426
475
  default value with a type-only `# <type>` annotation (shippable as-is),
427
476
  and the default is used when the document omits the field.
428
477
 
@@ -431,7 +480,7 @@ document emits the non-fatal `validation::must_fill` warning.
431
480
 
432
481
  ### Errors
433
482
 
434
- Every method that can fail throws a **`QuillmarkError`** a JS `Error` with
483
+ Every method that can fail throws a **`QuillmarkError`**: a JS `Error` with
435
484
  `.diagnostics` attached. The type and a guard are exported from the root:
436
485
 
437
486
  ```ts
@@ -443,17 +492,17 @@ try {
443
492
  if (isQuillmarkError(e)) {
444
493
  for (const d of e.diagnostics) console.error(d.severity, d.message);
445
494
  } else {
446
- throw e; // not a quillmark failure programming error, re-throw
495
+ throw e; // not a quillmark failure: programming error, re-throw
447
496
  }
448
497
  }
449
498
  ```
450
499
 
451
- `QuillmarkError` is a **structural interface, not a class** the WASM layer
500
+ `QuillmarkError` is a **structural interface, not a class**: the WASM layer
452
501
  throws a real `Error` and attaches the property, so there is no constructor to
453
502
  `instanceof` against; narrow with `isQuillmarkError` (which also works on
454
503
  errors from any build or WASM instance in the page).
455
504
 
456
- `diagnostics` is always non-empty length 1 for most failures, length N for
505
+ `diagnostics` is always non-empty: length 1 for most failures, length N for
457
506
  backend compilation errors. `message` is derived from `diagnostics`
458
507
  (`diagnostics[0].message` for single-diagnostic errors; an aggregate
459
508
  `"<N> error(s): <first.message>"` summary for compilation failures).
@@ -461,15 +510,20 @@ backend compilation errors. `message` is derived from `diagnostics`
461
510
  Read `err.diagnostics[0]` for the primary diagnostic; iterate the array for
462
511
  compilation failures. The same shape applies to every throw site:
463
512
 
464
- - `Document.fromMarkdown` parse errors (missing root `$quill` metadata, YAML
465
- errors, `parse::input_too_large` for inputs > 10 MB).
466
- - `Document` mutators (`storeField`, the writer's `set`, etc.) mutator
513
+ - `Document.fromMarkdown`: parse errors (missing root `$quill` metadata, YAML
514
+ errors, `parse::input_too_large` for inputs > 10 MiB).
515
+ - `Document` mutators (`storeField`, the writer's `set`, etc.): mutator
467
516
  failures carry a namespaced `edit::*` `code` on `diagnostics[0]`
468
517
  (`edit::invalid_field_name`, `edit::unknown_field`, `edit::index_out_of_range`,
469
518
  `edit::field_conform`, …). Route on `diagnostics[0].code`, never on message
470
519
  text.
471
- - `engine.render` / `session.render` backend compilation failures and
520
+ - `engine.render` / `session.render`: backend compilation failures and
472
521
  validation errors.
522
+ - Any method taking a `Quill` or `Document`: a handle from a *second* copy of
523
+ `@quillmark/wasm` is refused with `runtime::foreign_handle`, hinting `npm ls
524
+ @quillmark/wasm`. Two copies are two WASM memories and two `Quill`/`Document`
525
+ classes; dedupe to one. A value that is not a handle at all keeps its own
526
+ `runtime::not_a_document` / `runtime::not_a_quill`.
473
527
 
474
528
  ### Lifecycle
475
529
 
@@ -480,17 +534,15 @@ teardown hook for callers that want deterministic release.
480
534
 
481
535
  `engine.render` and `engine.open` read the `quill` and `doc` handles
482
536
  synchronously, before their first await, so freeing a handle as soon as the
483
- call returns `try { return engine.render(quill, doc); } finally
484
- { doc.free(); }` is safe even on the first render, while the backend
537
+ call returns: `try { return engine.render(quill, doc); } finally
538
+ { doc.free(); }`: is safe even on the first render, while the backend
485
539
  binary is still loading.
486
540
 
487
541
  The package floor is Node 22+ (`engines: { node: ">=22" }`) and current
488
542
  evergreen browsers; `--weak-refs` itself only needs Node 14.6+. The `using`
489
- sugar shown below ([explicit resource management][erm]) needs Node 24, but is
490
- optional the `try` / `finally` fallback runs on the Node 22 floor.
491
-
492
- For environments where `using` (the [explicit resource management][erm]
493
- proposal) hasn't landed, use an explicit `try` / `finally`:
543
+ sugar ([explicit resource management][erm]) needs Node 24 and is optional.
544
+ Where it hasn't landed, an explicit `try` / `finally` runs on the Node 22
545
+ floor:
494
546
 
495
547
  ```ts
496
548
  const session = await engine.open(quill, doc);