@quillmark/wasm 0.98.0 → 0.99.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.
@@ -1,10 +1,10 @@
1
1
  /* @ts-self-types="./runtime.d.ts" */
2
2
  //
3
- // @quillmark/wasm/runtime the canonical consumer API.
3
+ // @quillmark/wasm/runtime: the canonical consumer API.
4
4
  //
5
5
  // Consumers import `Quill`, `Document`, and `Engine` from here and never touch
6
6
  // the build-specific subpaths. The package ships multiple WASM binaries with
7
- // SEPARATE linear memories a Typst-less `core` build (small, eager) that is
7
+ // SEPARATE linear memories: a Typst-less `core` build (small, eager) that is
8
8
  // the canonical home of `Quill`/`Document`, and one private backend binary per
9
9
  // backend (`backends/typst/` today; more later) that carries an engine. A
10
10
  // handle from one memory cannot be used by another. This module hides that seam
@@ -22,16 +22,16 @@
22
22
  // renders, and the backend handles never escape.
23
23
  //
24
24
  // CLONE LIFETIMES (not all transient): the per-call `Document` clone IS
25
- // transient built and freed inside each call, because documents are small
25
+ // transient, built and freed inside each call, because documents are small
26
26
  // and mutate freely. The `Quill` clone is CACHED instead: re-cloning a quill
27
27
  // per call means re-serializing its whole file tree Rust→JS, copying it into
28
- // backend memory, and re-parsing + re-validating the bundle every time and
28
+ // backend memory, and re-parsing + re-validating the bundle every time, and
29
29
  // quills are validated, effectively-immutable bundles. So each `Engine`
30
30
  // memoizes the backend-memory quill per (engine, backendId, canonical quill
31
31
  // instance) in a `WeakMap` keyed on the canonical `Quill`: when the consumer
32
32
  // drops the core quill the cache entry becomes collectable and wasm-bindgen
33
33
  // weak-refs (`--weak-refs`) free the backend handle. The CONTRACT this buys:
34
- // a `Quill` instance's contents never change after construction mutate by
34
+ // a `Quill` instance's contents never change after construction, mutate by
35
35
  // replacing the instance (the clone is dropped with it via WeakMap +
36
36
  // weak-refs).
37
37
  //
@@ -39,26 +39,30 @@
39
39
  // `Quill`/`Document` to `engine.render(...)` and gets a `RenderResult` back.
40
40
 
41
41
  // ── CANONICAL INVARIANT: re-export the core build, never wrap ───────────────
42
- // The root re-exports the core build's `Quill`/`Document` classes verbatim
42
+ // The root re-exports the core build's `Quill`/`Document` classes verbatim,
43
43
  // NOT subclasses or wrappers. There is exactly ONE public entry point (this
44
44
  // module), so this identity is a structural fact: `Quill`/`Document` ARE the
45
45
  // core classes, and the only boundary that needs crossing is core→backend (a
46
46
  // separate WASM memory), which `Engine` does internally as data
47
47
  // (`toTree`/`toJson`).
48
48
  //
49
- // Do NOT replace this with a wrapper class that breaks the identity and turns
49
+ // Do NOT replace this with a wrapper class: that breaks the identity and turns
50
50
  // a structural fact into a converted type (a breaking design change, not a
51
- // refactor). Keep `Engine` duck-typed on `.toTree()`/`.backendId`/`.toJson()`
52
- // (it is) so it tolerates handles from any core instance. The `runtime.test.js`
53
- // "re-exports the internal core build classes verbatim" case
54
- // (`Quill === CoreQuill`) is the executable guard for this invariant.
51
+ // refactor). The `runtime.test.js` "re-exports the internal core build classes
52
+ // verbatim" case (`Quill === CoreQuill`) is the executable guard for this
53
+ // invariant.
54
+ //
55
+ // The identity is what makes `instanceof` the whole membership test: a handle
56
+ // either belongs to this copy's classes or it belongs to another copy, and the
57
+ // second is always a consumer bug. `Engine` is NOT duck-typed on its inputs; it
58
+ // checks them. See § "Handles from another copy" below.
55
59
  //
56
60
  // Imported (not bare re-exported) so `Quill` is a local binding this module can
57
- // augment `quill.writer(doc)` is patched onto its prototype below. The
61
+ // augment: `quill.writer(doc)` is patched onto its prototype below. The
58
62
  // re-export keeps the identity: the exported `Quill` IS the core class.
59
63
  import { Quill, Document, init } from '../core/wasm.js';
60
64
  export { Quill, Document, init };
61
- // The document-free content codec re-exported verbatim from the core build so
65
+ // The document-free content codec: re-exported verbatim from the core build so
62
66
  // the runtime subpath exposes `exportMarkdown(body)` (the on-demand markdown
63
67
  // projection), `importMarkdown`, and the position-mapping pair (`rebase`,
64
68
  // `mapPos`).
@@ -70,10 +74,10 @@ export { parseDocPath, formatDocPath } from '../core/wasm.js';
70
74
 
71
75
  // ── The main-card address ───────────────────────────────────────────────────
72
76
  /**
73
- * The main card's address the default target of the card-scoped verbs
77
+ * The main card's address: the default target of the card-scoped verbs
74
78
  * (`storeFields` / `storeExt` / `commitFields` / …). A named, `CardAddr`-typed
75
79
  * alias for the empty address `{}`, so a main-card write names its target:
76
- * `doc.storeFields(MAIN_CARD_ADDR, fields)`. It IS `{}` (frozen), a pure alias
80
+ * `doc.storeFields(MAIN_CARD_ADDR, fields)`. It IS `{}` (frozen), a pure alias:
77
81
  * `{}` and `undefined` stay equally valid. Card axis only: a card selector,
78
82
  * never a field address.
79
83
  * @type {import('../core/wasm.js').CardAddr}
@@ -81,15 +85,15 @@ export { parseDocPath, formatDocPath } from '../core/wasm.js';
81
85
  export const MAIN_CARD_ADDR = Object.freeze({});
82
86
 
83
87
  /**
84
- * Narrow an unknown caught value to a `QuillmarkError` the error every
88
+ * Narrow an unknown caught value to a `QuillmarkError`, the error every
85
89
  * fallible method in this package throws: a real `Error` with a non-empty
86
90
  * `diagnostics` array attached (same entry shape as `RenderResult.warnings`).
87
91
  *
88
92
  * Structural by necessity AND by design: the WASM layer constructs a plain
89
93
  * `Error` and attaches the property (there is no error class to `instanceof`),
90
- * and a structural check works on errors from any build or WASM instance in
91
- * the page consistent with the duck-typed handling of handles elsewhere in
92
- * this layer.
94
+ * and a structural check narrows errors from any build or WASM instance in the
95
+ * page. The deliberate exception to § "Handles from another copy": an error is
96
+ * data, not a handle, so nothing is gained by refusing one that crossed.
93
97
  *
94
98
  * @param {unknown} e
95
99
  * @returns {e is Error & { diagnostics: import('../core/wasm.js').Diagnostic[] }}
@@ -98,14 +102,173 @@ export function isQuillmarkError(e) {
98
102
  return e instanceof Error && Array.isArray(/** @type {any} */ (e).diagnostics);
99
103
  }
100
104
 
105
+ /**
106
+ * Build a `QuillmarkError` JS-side: a real `Error` carrying `diagnostics`, the
107
+ * shape `isQuillmarkError` narrows and the shape Rust's `WasmError::to_js_value`
108
+ * produces. Errors raised by this hand-written layer belong to the same contract
109
+ * as the ones raised across the WASM boundary.
110
+ * @param {string} code
111
+ * @param {string} message
112
+ * @param {string} hint
113
+ * @returns {Error & { diagnostics: import('../core/wasm.js').Diagnostic[] }}
114
+ */
115
+ function quillmarkError(code, message, hint) {
116
+ const err = /** @type {any} */ (new Error(message));
117
+ err.diagnostics = [{ severity: 'error', code, message, hint }];
118
+ return err;
119
+ }
120
+
121
+ // ── Handles from another copy: always a bug ─────────────────────────────────
122
+ // A duplicate install (two copies of this package in one `node_modules` tree)
123
+ // is two `core` builds: two linear memories and two distinct `Quill`/`Document`
124
+ // classes. No topology legitimately loads a multi-megabyte WASM package twice
125
+ // AND needs handles to cross between the copies, so a crossing is a consumer
126
+ // bug. Every seam taking a core handle says so, uniformly, at the crossing.
127
+ //
128
+ // Crossing read-only handles as data is mechanically possible (`toJson` and
129
+ // `toTree` serialize either way) and is not done. It leaves a package where
130
+ // some verbs work and some throw, and it hides a cliff: a crossed read is a
131
+ // whole-document `toJson` + `fromJson`, so a form reading fifty fields pays
132
+ // fifty round trips and the symptom is "the editor got slow". A duplicate
133
+ // install that limps is a duplicate install nobody removes.
134
+ //
135
+ // What the checks deliver is the ERROR, not the rejection. wasm-bindgen's
136
+ // generated glue already rejects a foreign class on every method declaring a
137
+ // reference parameter (`Document.equals`, `Quill.validate`, `Quill.resolve`,
138
+ // the `&Quill`-taking `Document._commitField` and friends): its `_assertClass`
139
+ // is emitted unconditionally and runs before any of our code. It throws a bare
140
+ // `Error` reading `expected instance of Document` at a value that IS a
141
+ // `Document`, so `isQuillmarkError` returns false and the failure leaves this
142
+ // package's error contract, naming neither the cause nor the cure. The checks
143
+ // front-run it with a `QuillmarkError` that names both.
144
+ //
145
+ // They also cover the seams with NO `_assertClass` to front-run: `Engine` and
146
+ // `LiveSession.apply` cross into backend memory as data (`toTree`/`toJson`), so
147
+ // a foreign handle there would silently work, at the price of the round-trip
148
+ // above and a quill clone cache split per copy.
149
+ //
150
+ // Not an API widening in either direction: the declared parameter types stay
151
+ // `Quill`/`Document`, and the accepted set is exactly this copy's instances.
152
+
153
+ /** Per class: the code and hint for "not one at all", and the probe for "from another copy". */
154
+ const HANDLE_KINDS = {
155
+ Quill: {
156
+ code: 'runtime::not_a_quill',
157
+ probe: 'toTree',
158
+ hint: 'Pass a Quill built by Quill.fromTree.'
159
+ },
160
+ Document: {
161
+ code: 'runtime::not_a_document',
162
+ probe: 'toJson',
163
+ hint: 'Pass a Document built by Document.fromMarkdown / fromJson or quill.seedDocument.'
164
+ }
165
+ };
166
+
167
+ /**
168
+ * The rejection for a value that is not one of this copy's handles. Two cures,
169
+ * so two diagnostics: a value carrying the class's serializer is that class
170
+ * from ANOTHER copy (dedupe the install), anything else is the wrong argument
171
+ * (fix the call).
172
+ * @param {unknown} value
173
+ * @param {string} method
174
+ * @param {'Quill' | 'Document'} className
175
+ * @returns {Error & { diagnostics: import('../core/wasm.js').Diagnostic[] }}
176
+ */
177
+ function notLocal(value, method, className) {
178
+ const { code, probe, hint } = HANDLE_KINDS[className];
179
+ if (value && typeof (/** @type {any} */ (value)[probe]) === 'function') {
180
+ return quillmarkError(
181
+ 'runtime::foreign_handle',
182
+ `${method}: the ${className} belongs to a different copy of @quillmark/wasm. Handles never cross between copies: each copy is its own WASM linear memory and its own ${className} class.`,
183
+ 'Two copies of @quillmark/wasm are installed. Run `npm ls @quillmark/wasm` and dedupe to one.'
184
+ );
185
+ }
186
+ return quillmarkError(
187
+ code,
188
+ `${method}: expected a ${className}, got ${value === null ? 'null' : typeof value}.`,
189
+ hint
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Throw unless `doc` is THIS copy's `Document`.
195
+ * @param {unknown} doc
196
+ * @param {string} method
197
+ * @returns {void}
198
+ */
199
+ function requireLocalDoc(doc, method) {
200
+ if (doc instanceof Document) return;
201
+ throw notLocal(doc, method, 'Document');
202
+ }
203
+
204
+ /**
205
+ * Throw unless `quill` is THIS copy's `Quill`.
206
+ * @param {unknown} quill
207
+ * @param {string} method
208
+ * @returns {void}
209
+ */
210
+ function requireLocalQuill(quill, method) {
211
+ if (quill instanceof Quill) return;
212
+ throw notLocal(quill, method, 'Quill');
213
+ }
214
+
215
+ // Marker for the patches below. `Symbol.for`, not a module-local `Symbol()`:
216
+ // re-evaluating THIS module (Vite HMR, a Vitest worker sharing a module graph)
217
+ // against an already-patched (because cached) core build must see the existing
218
+ // marker, or each pass wraps the previous wrapper.
219
+ const HANDLE_CHECKED = Symbol.for('@quillmark/wasm:handle-checked');
220
+
221
+ /**
222
+ * Replace `proto[name]` with `wrap(original)`, once.
223
+ * @param {object} proto
224
+ * @param {string} name
225
+ * @param {(original: Function) => Function} wrap
226
+ */
227
+ function patchHandleChecked(proto, name, wrap) {
228
+ const original = /** @type {any} */ (proto)[name];
229
+ if (typeof original !== 'function' || original[HANDLE_CHECKED]) return;
230
+ const patched = wrap(original);
231
+ /** @type {any} */ (patched)[HANDLE_CHECKED] = true;
232
+ // Keep the method's name so a stack trace still reads `Quill.validate`.
233
+ Object.defineProperty(patched, 'name', { value: name, configurable: true });
234
+ /** @type {any} */ (proto)[name] = patched;
235
+ }
236
+
237
+ // The three core methods declaring a `&Document` parameter. Each already refuses
238
+ // a foreign handle inside `_assertClass`; the patch is what makes the refusal
239
+ // legible. Only the argument can be foreign, the receiver being whichever copy
240
+ // the caller reached for.
241
+ patchHandleChecked(Document.prototype, 'equals', (original) =>
242
+ function equals(/** @type {any} */ other) {
243
+ requireLocalDoc(other, 'Document.equals');
244
+ return original.call(this, other);
245
+ }
246
+ );
247
+ for (const name of /** @type {const} */ (['validate', 'resolve'])) {
248
+ // Named once per patch, not per call: `validate` runs per keystroke.
249
+ const method = `Quill.${name}`;
250
+ patchHandleChecked(Quill.prototype, name, (original) =>
251
+ function (/** @type {any} */ doc) {
252
+ requireLocalDoc(doc, method);
253
+ return original.call(this, doc);
254
+ }
255
+ );
256
+ }
257
+
258
+ // The typed writer/reader primitives (`Document._commitField` and friends) take
259
+ // the QUILL by reference, so they hit the same `_assertClass` from the other
260
+ // direction. Checked at the four writer/reader classes below, not patched onto
261
+ // `Document`: a foreign document carries its OWN prototype, so patching this
262
+ // copy's would never run.
263
+
101
264
  // ── Open-set discriminant guards ────────────────────────────────────────────
102
265
  // `ContentIsland.type`, `ContentMark.type`, `ContentLine.kind`, and
103
266
  // `ContentContainer.container` are OPEN sets: each union carries a residual
104
267
  // `{ …: string; … }` arm, so a bare `x.type === 'table'` check never narrows the
105
- // payload TS keeps the residual arm live (a `string` can be `'table'`),
268
+ // payload, TS keeps the residual arm live (a `string` can be `'table'`),
106
269
  // leaving `props` / the mark payload / `level` opaque at every consumer. These
107
270
  // are the checked narrowing path: on the true branch the payload's pinned shape
108
- // is asserted. Only the payload-carrying arms get a guard an island always
271
+ // is asserted. Only the payload-carrying arms get a guard: an island always
109
272
  // carries `props`, a `link` mark carries `url`, an `anchor` mark carries `id`, a
110
273
  // `heading` line carries `level` and a `code` line `lang`, a `list_item`
111
274
  // container its shape; the payload-free arms (`strong`/`emph`/`underline`/
@@ -169,7 +332,66 @@ export function isListItemContainer(container) {
169
332
  return container.container === 'list_item';
170
333
  }
171
334
 
172
- // Backend builds are NEVER statically imported here — that would pull a
335
+ // ── Open-set membership guards ──────────────────────────────────────────────
336
+ // The guards above each answer "is this arm X": one pinned arm at a time. These
337
+ // four answer the other question: "is this a value this build knows?" A consumer
338
+ // that must branch known-vs-unknown, any read-modify-write consumer, since
339
+ // lowering an edit restates every line's kind and containers, otherwise
340
+ // enumerates the built-in names in its own source, recreating the closed-set
341
+ // coupling the open set exists to remove. That list is correct until the release
342
+ // that adds a built-in, at which point the new construct is misclassified as
343
+ // unknown and round-trips through the consumer's unknown carrier, losing any
344
+ // sibling-key payload.
345
+ //
346
+ // A predicate rather than an exported name list, because the known tables below
347
+ // are upstream's business. They are pinned against the Rust source
348
+ // (`Content::RESERVED_*` and `KnownIslandType`) by the
349
+ // `known_open_set_names_are_pinned` drift-guard test in
350
+ // `crates/content/src/model.rs`: adding a built-in means editing there, here, and
351
+ // the TS unions in `crates/bindings/wasm/src/engine.rs` in one commit.
352
+ //
353
+ // These classify unknown *tags*, not unknown *payloads on known tags*. A future
354
+ // `kind: "footnote"` with a sibling `ref` loses `ref` at a consumer that predates
355
+ // it either way.
356
+
357
+ const KNOWN_LINE_KINDS = new Set(['para', 'heading', 'code', 'island', 'rule']);
358
+ const KNOWN_CONTAINERS = new Set(['list_item', 'quote']);
359
+ const KNOWN_MARK_TYPES = new Set(['strong', 'emph', 'underline', 'strike', 'code', 'link', 'anchor']);
360
+ const KNOWN_ISLAND_TYPES = new Set(['table', 'image']);
361
+
362
+ /**
363
+ * @param {import('../core/wasm.js').ContentLine} line
364
+ * @returns {line is import('../core/wasm.js').ContentLine & { kind: string; attrs: unknown }}
365
+ */
366
+ export function isUnknownLine(line) {
367
+ return typeof line?.kind === 'string' && !KNOWN_LINE_KINDS.has(line.kind);
368
+ }
369
+
370
+ /**
371
+ * @param {import('../core/wasm.js').ContentContainer} container
372
+ * @returns {container is import('../core/wasm.js').ContentContainer & { container: string; attrs: unknown }}
373
+ */
374
+ export function isUnknownContainer(container) {
375
+ return typeof container?.container === 'string' && !KNOWN_CONTAINERS.has(container.container);
376
+ }
377
+
378
+ /**
379
+ * @param {import('../core/wasm.js').ContentMark} mark
380
+ * @returns {mark is import('../core/wasm.js').ContentMark & { type: string; attrs: unknown }}
381
+ */
382
+ export function isUnknownMark(mark) {
383
+ return typeof mark?.type === 'string' && !KNOWN_MARK_TYPES.has(mark.type);
384
+ }
385
+
386
+ /**
387
+ * @param {import('../core/wasm.js').ContentIsland} island
388
+ * @returns {island is import('../core/wasm.js').ContentIsland & { type: string; props: unknown }}
389
+ */
390
+ export function isUnknownIsland(island) {
391
+ return typeof island?.type === 'string' && !KNOWN_ISLAND_TYPES.has(island.type);
392
+ }
393
+
394
+ // Backend builds are NEVER statically imported here: that would pull a
173
395
  // multi-MB binary into the eager graph and defeat lazy loading. Each entry is a
174
396
  // DESCRIPTOR: `load` is a thunk returning a dynamic `import()` (a backend's
175
397
  // chunk is fetched only when something actually renders against that backend),
@@ -200,7 +422,7 @@ const DEFAULT_BACKENDS = {
200
422
  * backend id on any malformed entry. Descriptors are the ONLY accepted form:
201
423
  * `{ load, formats, canvas }` with a callable `load`, a `formats` array, and a
202
424
  * boolean `canvas`. Failing at construction (not deep inside a render) keeps the
203
- * capability probes free they can answer from the manifest unconditionally.
425
+ * capability probes free; they can answer from the manifest unconditionally.
204
426
  * @param {string} id
205
427
  * @param {unknown} entry
206
428
  * @returns {{ load: () => Promise<unknown>, formats: string[], canvas: boolean }}
@@ -249,7 +471,7 @@ export class Engine {
249
471
  * @param {{ backends?: Record<string, { load: () => Promise<unknown>, formats: string[], canvas: boolean }> }} [options]
250
472
  * Extra or overriding backend descriptors, merged over the built-ins. Each
251
473
  * entry is a descriptor (`{ load, formats, canvas }`) with `formats` and
252
- * `canvas` REQUIRED that static manifest is what makes
474
+ * `canvas` REQUIRED: that static manifest is what makes
253
475
  * `supportedFormats`/`supportsCanvas` always free (no binary load, no quill
254
476
  * clone). Malformed entries throw here, at construction. The default
255
477
  * registry maps `"typst"` to the bundled Typst build.
@@ -266,7 +488,7 @@ export class Engine {
266
488
 
267
489
  /**
268
490
  * Look up the registered descriptor for `backendId`, throwing the canonical
269
- * "no backend registered" error if none. Pure touches no binary.
491
+ * "no backend registered" error if none. Pure, touches no binary.
270
492
  * @param {string} backendId
271
493
  * @returns {{ load: () => Promise<unknown>, formats: string[], canvas: boolean }}
272
494
  */
@@ -281,6 +503,20 @@ export class Engine {
281
503
  return descriptor;
282
504
  }
283
505
 
506
+ /**
507
+ * `quill`'s backend id, after checking the handle. The ONE way an `Engine`
508
+ * verb reaches `backendId`, so "no verb touches a foreign quill" is
509
+ * structural rather than four remembered calls. See § "Handles from another
510
+ * copy".
511
+ * @param {Quill} quill
512
+ * @param {string} method the caller's name, for the rejection message
513
+ * @returns {string}
514
+ */
515
+ #backendOf(quill, method) {
516
+ requireLocalQuill(quill, method);
517
+ return quill.backendId;
518
+ }
519
+
284
520
  /**
285
521
  * Resolve (and lazily load) the backend module + its engine for `backendId`.
286
522
  * @param {string} backendId
@@ -314,9 +550,9 @@ export class Engine {
314
550
 
315
551
  /**
316
552
  * Get (or materialize-and-cache) the backend-memory `Quill` clone for
317
- * `quill` under `backendId`. On a cache miss the clone is built from `tree`
318
- * the caller's pre-await `toTree()` snapshot; the canonical handle may be
319
- * freed by now and stored in the per-backend `WeakMap` keyed on the
553
+ * `quill` under `backendId`. On a cache miss the clone is built from `tree`,
554
+ * the caller's pre-await `toTree()` snapshot; the canonical handle may be
555
+ * freed by now, and stored in the per-backend `WeakMap` keyed on the
320
556
  * canonical `Quill` instance, so a later call with the same instance reuses
321
557
  * it (the hot-path fix: no re-serialize / re-copy / re-validate per call).
322
558
  * @param {any} mod the backend build module
@@ -346,33 +582,39 @@ export class Engine {
346
582
  *
347
583
  * OWNERSHIP WINDOW: both caller handles are snapshotted (`doc.toJson()`, and
348
584
  * `quill.toTree()` on a clone-cache miss) BEFORE the first await. The backend
349
- * load below is a real suspension point a multi-MB `import()` on first
350
- * render so reading the handles after it would race a caller that
585
+ * load below is a real suspension point (a multi-MB `import()` on first
586
+ * render) so reading the handles after it would race a caller that
351
587
  * `free()`s them as soon as this call returns its promise ("null pointer
352
588
  * passed to rust"). The snapshot makes that natural calling pattern correct.
353
589
  *
354
- * Clone lifetimes differ by design: the `doc` clone is TRANSIENT freed in
590
+ * Clone lifetimes differ by design: the `doc` clone is TRANSIENT, freed in
355
591
  * the `finally` of every call. The `quill` clone is CACHED per (engine,
356
592
  * backend, canonical quill instance) and is NOT freed here; a `Quill`
357
593
  * instance's contents never change after construction, so it is dropped with
358
594
  * the canonical quill (WeakMap collection → wasm-bindgen weak-ref free) when
359
595
  * the consumer replaces the instance. A cache miss materializes it once;
360
596
  * subsequent calls reuse it.
361
- * @param {string} backendId
362
- * @param {{ toTree(): Map<string, Uint8Array> }} quill
363
- * @param {{ toJson(): string }} doc
597
+ *
598
+ * Both handles are checked here, so the crossing is core→backend (always two
599
+ * memories, always as data); core→core is a duplicate install and never gets
600
+ * past the first line. See § "Handles from another copy".
601
+ * @param {string} method the caller's name, for the rejection message
602
+ * @param {Quill} quill
603
+ * @param {Document} doc
364
604
  * @param {(ctx: { mod: any, engine: any, quill: any, doc: any }) => any} fn
365
605
  */
366
- async #withClones(backendId, quill, doc, fn) {
606
+ async #withClones(method, quill, doc, fn) {
607
+ const backendId = this.#backendOf(quill, method);
608
+ requireLocalDoc(doc, method);
367
609
  const docJson = doc.toJson();
368
610
  const quillTree = this.#quillClones.get(backendId)?.has(quill) ? null : quill.toTree();
369
611
  const { mod, engine } = await this.#resolveBackend(backendId);
370
612
  // The quill clone is cached (see #cachedQuillClone); only the per-call doc
371
613
  // clone is transient. Bring the doc clone + `fn` under one try so the doc
372
- // clone is freed even if a later step throws (e.g. `Document.fromJson`
373
- // rejecting a cross-version DTO). The cached quill clone is intentionally
374
- // NOT freed here. `fn` MUST be synchronous the doc clone is freed as soon
375
- // as it returns, so an async `fn` would have it freed mid-flight.
614
+ // clone is freed even if a later step throws. The cached quill clone is
615
+ // intentionally NOT freed here. `fn` MUST be synchronous: the doc clone is
616
+ // freed as soon as it returns, so an async `fn` would have it freed
617
+ // mid-flight.
376
618
  const backendQuill = this.#cachedQuillClone(mod, backendId, quill, quillTree);
377
619
  let backendDoc = null;
378
620
  try {
@@ -393,7 +635,7 @@ export class Engine {
393
635
  * @returns {Promise<import('./runtime.js').RenderResult>}
394
636
  */
395
637
  async render(quill, doc, options) {
396
- return this.#withClones(quill.backendId, quill, doc, ({ engine, quill: q, doc: d }) =>
638
+ return this.#withClones('engine.render(quill, doc)', quill, doc, ({ engine, quill: q, doc: d }) =>
397
639
  engine.render(q, d, options ?? undefined)
398
640
  );
399
641
  }
@@ -411,7 +653,7 @@ export class Engine {
411
653
  */
412
654
  async open(quill, doc) {
413
655
  return this.#withClones(
414
- quill.backendId,
656
+ 'engine.open(quill, doc)',
415
657
  quill,
416
658
  doc,
417
659
  ({ mod, engine, quill: q, doc: d }) => new LiveSession(engine.open(q, d), mod)
@@ -421,30 +663,30 @@ export class Engine {
421
663
  /**
422
664
  * The output formats `quill`'s backend can emit. A cheap, non-failing,
423
665
  * ALWAYS-free pre-render probe: it answers from the descriptor's required
424
- * `formats` manifest NO binary load and NO quill clone depending only on
666
+ * `formats` manifest (NO binary load and NO quill clone) depending only on
425
667
  * `quill.backendId`. Stays `async` for API stability (it never awaits a load).
426
668
  * @param {Quill} quill
427
669
  * @returns {Promise<import('./runtime.js').OutputFormat[]>}
428
670
  */
429
671
  async supportedFormats(quill) {
430
- const descriptor = this.#descriptorFor(quill.backendId);
672
+ const descriptor = this.#descriptorFor(this.#backendOf(quill, 'engine.supportedFormats(quill)'));
431
673
  // Defensive copy so callers can't mutate the shared manifest.
432
674
  return descriptor.formats.slice();
433
675
  }
434
676
 
435
677
  /**
436
- * Whether `quill`'s BACKEND can paint sessions to a canvas a pre-session
678
+ * Whether `quill`'s BACKEND can paint sessions to a canvas: a pre-session
437
679
  * ESTIMATE, not a fact about any particular compile. Same ALWAYS-free probe
438
680
  * as `supportedFormats`: answered from the descriptor's required `canvas`
439
681
  * manifest, no load and no clone. A specific compile can still refuse to
440
682
  * paint (e.g. a 0-page document), so this can answer `true` while the
441
- * resulting `LiveSession.supportsCanvas` answers `false` gate mounting a
683
+ * resulting `LiveSession.supportsCanvas` answers `false`: gate mounting a
442
684
  * canvas UI on this, gate the actual `paint` call on the session's getter.
443
685
  * @param {Quill} quill
444
686
  * @returns {Promise<boolean>}
445
687
  */
446
688
  async supportsCanvas(quill) {
447
- const descriptor = this.#descriptorFor(quill.backendId);
689
+ const descriptor = this.#descriptorFor(this.#backendOf(quill, 'engine.supportsCanvas(quill)'));
448
690
  return descriptor.canvas;
449
691
  }
450
692
  }
@@ -453,16 +695,16 @@ export class Engine {
453
695
  * Thin wrapper over a backend's live render session. Reads serve the current
454
696
  * compile; `apply(doc)` recompiles in place (transactional: on throw, reads
455
697
  * keep serving the last-good compile). The quill/document clones it was
456
- * opened from have already been freed the session retains what `apply`
698
+ * opened from have already been freed: the session retains what `apply`
457
699
  * needs.
458
700
  *
459
701
  * Geometry reads (`regions`, `positionAt`, `locate`) resolve against the
460
702
  * current compile; anchoring a caret or selection across edits is the editor's
461
- * job (its own transaction mapping) re-read geometry after each committed
703
+ * job (its own transaction mapping): re-read geometry after each committed
462
704
  * `apply`.
463
705
  *
464
- * `paint` writes a COMPLETE page raster all content visible, no caller-side
465
- * compositing for every backend that supports canvas (Typst rasterizes
706
+ * `paint` writes a COMPLETE page raster (all content visible, no caller-side
707
+ * compositing) for every backend that supports canvas (Typst rasterizes
466
708
  * natively; pdfform rasterizes its pre-flattened page). See `runtime.d.ts`.
467
709
  */
468
710
  export class LiveSession {
@@ -478,13 +720,14 @@ export class LiveSession {
478
720
  #mod;
479
721
 
480
722
  /**
481
- * Recompile the session against `doc` the edit verb of a live preview.
723
+ * Recompile the session against `doc`: the edit verb of a live preview.
482
724
  * Transactional: on throw every read keeps serving the last-good compile.
483
725
  * On success reads serve the new compile; repaint `dirtyPages ∩ visible`.
484
726
  * @param {Document} doc
485
727
  * @returns {import('./runtime.d.ts').ChangeSet}
486
728
  */
487
729
  apply(doc) {
730
+ requireLocalDoc(doc, 'session.apply(doc)');
488
731
  let backendDoc = null;
489
732
  try {
490
733
  backendDoc = this.#mod.Document.fromJson(doc.toJson());
@@ -501,7 +744,7 @@ export class LiveSession {
501
744
  return this.#inner.backendId;
502
745
  }
503
746
  /**
504
- * `true` iff `paint`/`pageSize` will succeed for THIS compile the
747
+ * `true` iff `paint`/`pageSize` will succeed for THIS compile: the
505
748
  * authoritative answer, derived from the session's canvas seam, so it can
506
749
  * never disagree with what `paint` actually does. This can be `false` even
507
750
  * when `Engine.supportsCanvas` answered `true` for the same `quill` (that
@@ -523,7 +766,7 @@ export class LiveSession {
523
766
  }
524
767
 
525
768
  /**
526
- * Schema-field geometry for this compiled session one region per
769
+ * Schema-field geometry for this compiled session: one region per
527
770
  * schema-bound field, keyed on its quill schema field path. A session-level
528
771
  * query (no render); read it to place field overlays / cross-navigation over
529
772
  * a `paint`-ed canvas.
@@ -534,11 +777,11 @@ export class LiveSession {
534
777
  }
535
778
 
536
779
  /**
537
- * The whole-field highlight boxes for `field` one union rect per page,
780
+ * The whole-field highlight boxes for `field`: one union rect per page,
538
781
  * over the field's `span`-bearing content segments. Owns the union
539
782
  * `regions()` leaves derived (span-filter + per-page union), so a "highlight
540
783
  * the focused field" consumer stops reimplementing it. Content only: a field
541
- * placed solely as a scalar reference or a bound widget returns `[]` its
784
+ * placed solely as a scalar reference or a bound widget returns `[]`, its
542
785
  * box is a single `regions()` rect.
543
786
  * @param {string} field
544
787
  * @returns {import('./runtime.d.ts').FieldRegion[]}
@@ -548,7 +791,7 @@ export class LiveSession {
548
791
  }
549
792
 
550
793
  /**
551
- * The schema field whose content is under a point on `page` the forward
794
+ * The schema field whose content is under a point on `page`: the forward
552
795
  * (click → field) direction, resolving *every* placement, not just the first
553
796
  * that `regions` enumerates. `x`/`y` are PDF points with a bottom-left origin
554
797
  * (the `FieldRegion.rect` space). See `runtime.d.ts` for the click-to-point
@@ -587,8 +830,8 @@ export class LiveSession {
587
830
  }
588
831
 
589
832
  /**
590
- * Paint `page` into a 2D canvas context. The painted raster is COMPLETE
591
- * all page content visible, no caller-side compositing for both the Typst
833
+ * Paint `page` into a 2D canvas context. The painted raster is COMPLETE
834
+ * (all page content visible, no caller-side compositing) for both the Typst
592
835
  * and pdfform backends. See `runtime.d.ts` for the DPR/clamp math and the
593
836
  * region-overlay coordinate transform.
594
837
  * @param {CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D} ctx
@@ -607,21 +850,21 @@ export class LiveSession {
607
850
  // ── Typed-writer sugar: bind the quill once ─────────────────────────────────
608
851
  // Rust exposes `quill.writer(&mut doc)` so a caller issues bare `set` / `set_all`
609
852
  // without threading the schema per write. The WASM `commit*` verbs can't borrow
610
- // like that a `Document` carries only a `$quill` REFERENCE, not the resolved
853
+ // like that: a `Document` carries only a `$quill` REFERENCE, not the resolved
611
854
  // schema, so each `commit*` method takes the `quill` handle as its first
612
855
  // argument. These pure-JS classes restore the Rust ergonomics: bind `quill` +
613
856
  // `doc` once, then issue `set` / `setAll` / `card(i).set`.
614
857
  //
615
- // They hold JS references to the caller's EXISTING handles no WASM object of
616
- // their own, no `free()` burden, no second owner of either handle and every
858
+ // They hold JS references to the caller's EXISTING handles (no WASM object of
859
+ // their own, no `free()` burden, no second owner of either handle) and every
617
860
  // write delegates straight to the underlying `commit*` verb: a schema field is
618
861
  // typed-committed (coerced to canonical form, mismatch throws now), and a name
619
862
  // the schema does not declare throws `UnknownField` rather than falling to the
620
- // opaque store on the typed path an undeclared name is a typo. Opaque storage
863
+ // opaque store, on the typed path an undeclared name is a typo. Opaque storage
621
864
  // stays available through the raw addressed `Document.storeField` verb.
622
865
 
623
866
  /**
624
- * A {@link Document} bound to its {@link Quill} for typed writes the JS twin
867
+ * A {@link Document} bound to its {@link Quill} for typed writes: the JS twin
625
868
  * of Rust's `quill.writer(&mut doc)`. Writes target the main card; use
626
869
  * {@link card} for a composable card. Holds both handles by reference and owns
627
870
  * neither, so there is nothing to `free()`.
@@ -634,10 +877,12 @@ export class DocumentWriter {
634
877
  * @param {Document} doc the document to mutate, held by reference (not owned)
635
878
  */
636
879
  constructor(quill, doc) {
880
+ requireLocalQuill(quill, 'quill.writer(doc)');
881
+ requireLocalDoc(doc, 'quill.writer(doc)');
637
882
  this.#quill = quill;
638
883
  this.#doc = doc;
639
884
  }
640
- /** The bound document the same instance passed in, mutated in place. */
885
+ /** The bound document: the same instance passed in, mutated in place. */
641
886
  get document() {
642
887
  return this.#doc;
643
888
  }
@@ -652,7 +897,7 @@ export class DocumentWriter {
652
897
  return this.#doc._commitField(this.#quill, name, value);
653
898
  }
654
899
  /**
655
- * Typed-commit several main-card fields atomically nothing is applied on
900
+ * Typed-commit several main-card fields atomically: nothing is applied on
656
901
  * error (throws a per-field diagnostic bundle, including an `UnknownField`
657
902
  * for each undeclared name).
658
903
  * @param {Record<string, unknown>} fields
@@ -663,7 +908,7 @@ export class DocumentWriter {
663
908
  }
664
909
  /**
665
910
  * Set the main body from markdown (edit semantics: surviving anchors rebase),
666
- * discarding the text delta the receipt-free body write. Call
911
+ * discarding the text delta, the receipt-free body write. Call
667
912
  * `doc.revise({}, md)` for the {@link Delta} receipt.
668
913
  * @param {string} markdown
669
914
  * @returns {void}
@@ -672,7 +917,7 @@ export class DocumentWriter {
672
917
  this.#doc.revise({}, markdown);
673
918
  }
674
919
  /**
675
- * Revise the richtext main-card field `name` from markdown typed *and*
920
+ * Revise the richtext main-card field `name` from markdown: typed *and*
676
921
  * anchor-preserving. Surviving anchors rebase, then the diffed result is
677
922
  * schema-conformed (`richtext(inline)` rejects a multi-block result). Throws
678
923
  * `UnknownField` for a name the schema does not declare. Returns the text
@@ -686,7 +931,7 @@ export class DocumentWriter {
686
931
  }
687
932
  /**
688
933
  * Build a composable card of `kind`, typed-commit `fields` onto it, set its
689
- * body from optional markdown, and place it the fused `makeCard` + typed
934
+ * body from optional markdown, and place it: the fused `makeCard` + typed
690
935
  * commit + insertion. `at` picks the position: omitted appends, a number
691
936
  * inserts at that index (`0..=cardCount`), so a positioned typed insert is one
692
937
  * atomic call rather than `addCard` + `moveCard`. Transactional: the card is
@@ -704,7 +949,7 @@ export class DocumentWriter {
704
949
  }
705
950
  /**
706
951
  * Remove the composable card at `index`, returning it (or `undefined` if the
707
- * index is out of range) the writer spelling of `Document.removeCard`.
952
+ * index is out of range): the writer spelling of `Document.removeCard`.
708
953
  * @param {number} index
709
954
  * @returns {import('../core/wasm.js').Card | undefined}
710
955
  */
@@ -714,9 +959,10 @@ export class DocumentWriter {
714
959
  /**
715
960
  * A {@link CardWriter} bound to the composable card at `index`. Index
716
961
  * validity is checked lazily by the underlying write (it throws
717
- * `IndexOutOfRange` at commit time), so constructing one never throws.
962
+ * `IndexOutOfRange` at commit time), so an out-of-range index does not throw
963
+ * here.
718
964
  *
719
- * The cursor is ephemeral bind, write, discard. It holds `index`, not the
965
+ * The cursor is ephemeral: bind, write, discard. It holds `index`, not the
720
966
  * card: a `removeCard`/`addCard` between binding and writing silently
721
967
  * retargets it. For durable addressing stamp `$id` and re-resolve the index
722
968
  * at write time.
@@ -743,6 +989,8 @@ export class CardWriter {
743
989
  * @param {number} index the composable card's index
744
990
  */
745
991
  constructor(quill, doc, index) {
992
+ requireLocalQuill(quill, 'writer.card(index)');
993
+ requireLocalDoc(doc, 'writer.card(index)');
746
994
  this.#quill = quill;
747
995
  this.#doc = doc;
748
996
  this.#index = index;
@@ -753,7 +1001,7 @@ export class CardWriter {
753
1001
  }
754
1002
  /**
755
1003
  * The bound card's `$kind` (empty string when it carries none), read through
756
- * the document mirrors core `CardWriter::kind()`. Ephemeral like the cursor
1004
+ * the document: mirrors core `CardWriter::kind()`. Ephemeral like the cursor
757
1005
  * itself: throws `IndexOutOfRange` if the bound index is out of range.
758
1006
  * @returns {string}
759
1007
  */
@@ -782,7 +1030,7 @@ export class CardWriter {
782
1030
  return this.#doc._commitFields(this.#quill, { card: this.#index }, fields);
783
1031
  }
784
1032
  /**
785
- * Set this card's body from markdown (edit semantics), discarding the delta
1033
+ * Set this card's body from markdown (edit semantics), discarding the delta:
786
1034
  * the card twin of {@link DocumentWriter.setBody}.
787
1035
  * @param {string} markdown
788
1036
  * @returns {void}
@@ -791,7 +1039,7 @@ export class CardWriter {
791
1039
  this.#doc.revise({ card: this.#index }, markdown);
792
1040
  }
793
1041
  /**
794
- * Revise the richtext field `name` on this card from markdown typed *and*
1042
+ * Revise the richtext field `name` on this card from markdown: typed *and*
795
1043
  * anchor-preserving; the card twin of {@link DocumentWriter.reviseField}.
796
1044
  * Throws `UnknownField` for an undeclared name and `IndexOutOfRange` if the
797
1045
  * bound index is out of range. Returns the text {@link Delta}.
@@ -804,16 +1052,16 @@ export class CardWriter {
804
1052
  }
805
1053
  }
806
1054
 
807
- // ── `quill.writer(doc)` the typed front door ──────────────────────────────
1055
+ // ── `quill.writer(doc)`, the typed front door ──────────────────────────────
808
1056
  // The schema-bound writer: bind the quill's schema to a document and issue bare
809
- // typed writes. Mirrors core's `quill.writer(&mut doc)` the schema grants the
1057
+ // typed writes. Mirrors core's `quill.writer(&mut doc)`: the schema grants the
810
1058
  // typing, so the quill (not the document) is the factory. Patched onto the
811
1059
  // re-exported `Quill` prototype rather than wrapped: `Quill === CoreQuill`
812
1060
  // stays true (the identity invariant above); this only adds a method that
813
1061
  // constructs the pure-JS writer, which owns no WASM handle.
814
1062
  /**
815
1063
  * A {@link DocumentWriter} binding this quill's schema to `doc` for typed
816
- * writes the documented front door. The returned writer holds both handles by
1064
+ * writes: the documented front door. The returned writer holds both handles by
817
1065
  * reference and owns neither, so there is nothing to `free()`. Ephemeral by
818
1066
  * convention: bind, write, discard.
819
1067
  * @this {Quill}
@@ -825,8 +1073,8 @@ Quill.prototype.writer = function writer(doc) {
825
1073
  };
826
1074
 
827
1075
  // ── Typed-reader sugar: the schema-plane read surface ──────────────────────────
828
- // The read twin of the writer above. The transport `Document.getStored` is schema-free
829
- // a `Document` cannot say which fields are richtext, so an unknown field name
1076
+ // The read twin of the writer above. The transport `Document.getStored` is schema-free:
1077
+ // a `Document` cannot say which fields are richtext, so an unknown field name
830
1078
  // reads back `undefined` rather than as the typo it is. Binding the quill's
831
1079
  // schema (`_readerGet` takes the handle, like the `commit*` verbs) lets one `get`
832
1080
  // interpret by declared type: a richtext field to markdown, a plaintext field to
@@ -836,7 +1084,7 @@ Quill.prototype.writer = function writer(doc) {
836
1084
  // reference, own no WASM object, and have nothing to `free()`.
837
1085
 
838
1086
  /**
839
- * A {@link Document} bound to its {@link Quill} for typed reads the JS twin of
1087
+ * A {@link Document} bound to its {@link Quill} for typed reads: the JS twin of
840
1088
  * Rust's `quill.reader(&doc)` and the read counterpart of {@link DocumentWriter}.
841
1089
  * Reads target the main card; use {@link card} for a composable card. Holds both
842
1090
  * handles by reference and owns neither, so there is nothing to `free()`.
@@ -849,10 +1097,12 @@ export class DocumentReader {
849
1097
  * @param {Document} doc the document to read, held by reference (not owned)
850
1098
  */
851
1099
  constructor(quill, doc) {
1100
+ requireLocalQuill(quill, 'quill.reader(doc)');
1101
+ requireLocalDoc(doc, 'quill.reader(doc)');
852
1102
  this.#quill = quill;
853
1103
  this.#doc = doc;
854
1104
  }
855
- /** The bound document the same instance passed in. */
1105
+ /** The bound document: the same instance passed in. */
856
1106
  get document() {
857
1107
  return this.#doc;
858
1108
  }
@@ -870,7 +1120,7 @@ export class DocumentReader {
870
1120
  return this.#doc._readerGet(this.#quill, addr);
871
1121
  }
872
1122
  /**
873
- * The main body's markdown the quill-free body read (a body's type is a
1123
+ * The main body's markdown: the quill-free body read (a body's type is a
874
1124
  * format fact, not a schema fact). Equivalent to `get({})`.
875
1125
  * @returns {string}
876
1126
  */
@@ -880,9 +1130,9 @@ export class DocumentReader {
880
1130
  /**
881
1131
  * A {@link CardReader} bound to the composable card at `index`. Index validity
882
1132
  * is checked lazily by the underlying read (it throws `IndexOutOfRange` at read
883
- * time), so constructing one never throws. Ephemeral like the writer cursor —
884
- * it holds `index`, not the card, so a `removeCard`/`addCard` between binding
885
- * and reading silently retargets it.
1133
+ * time), so an out-of-range index does not throw here. Ephemeral like the
1134
+ * writer cursor: it holds `index`, not the card, so a `removeCard`/`addCard`
1135
+ * between binding and reading silently retargets it.
886
1136
  * @param {number} index
887
1137
  * @returns {CardReader}
888
1138
  */
@@ -906,6 +1156,8 @@ export class CardReader {
906
1156
  * @param {number} index the composable card's index
907
1157
  */
908
1158
  constructor(quill, doc, index) {
1159
+ requireLocalQuill(quill, 'reader.card(index)');
1160
+ requireLocalDoc(doc, 'reader.card(index)');
909
1161
  this.#quill = quill;
910
1162
  this.#doc = doc;
911
1163
  this.#index = index;
@@ -933,7 +1185,7 @@ export class CardReader {
933
1185
  return this.#doc._readerGet(this.#quill, { card: this.#index, field: name });
934
1186
  }
935
1187
  /**
936
- * This card's body markdown the card twin of {@link DocumentReader.getBody}.
1188
+ * This card's body markdown: the card twin of {@link DocumentReader.getBody}.
937
1189
  * @returns {string}
938
1190
  */
939
1191
  getBody() {
@@ -941,13 +1193,13 @@ export class CardReader {
941
1193
  }
942
1194
  }
943
1195
 
944
- // ── `quill.reader(doc)` the schema-plane read front door ─────────────────────
1196
+ // ── `quill.reader(doc)`: the schema-plane read front door ─────────────────────
945
1197
  // The read twin of `quill.writer(doc)`, patched onto the same re-exported `Quill`
946
- // prototype (the `Quill === CoreQuill` identity invariant holds this only adds
1198
+ // prototype (the `Quill === CoreQuill` identity invariant holds: this only adds
947
1199
  // a method constructing the pure-JS reader, which owns no WASM handle).
948
1200
  /**
949
1201
  * A {@link DocumentReader} binding this quill's schema to `doc` for interpreted
950
- * reads the read front door, mirroring core's `quill.reader(&doc)`. The returned
1202
+ * reads: the read front door, mirroring core's `quill.reader(&doc)`. The returned
951
1203
  * reader holds both handles by reference and owns neither, so there is nothing to
952
1204
  * `free()`. Ephemeral by convention: bind, read, discard.
953
1205
  * @this {Quill}