@quillmark/wasm 0.104.0 → 0.106.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.
@@ -2,121 +2,73 @@
2
2
  //
3
3
  // @quillmark/wasm/runtime: the canonical consumer API.
4
4
  //
5
- // Consumers reach `Quill` and `Document` through `await init()` and `Engine`
6
- // as a static export, and never touch the build-specific subpaths. The package
7
- // ships multiple WASM binaries with SEPARATE linear memories: a Typst-less
8
- // `core` build (small, eager) that is the canonical home of `Quill`/`Document`,
9
- // and one private backend binary per backend (`backends/typst/` today; more
10
- // later) that carries an engine. A handle from one memory cannot be used by
11
- // another. This module hides that seam and is exposed at the package root
12
- // (`@quillmark/wasm`):
5
+ // The package ships multiple WASM binaries with SEPARATE linear memories: a
6
+ // Typst-less `core` build (small, eager) that is the canonical home of
7
+ // `Quill`/`Document`, and one private binary per backend that carries an engine.
8
+ // A handle from one memory cannot be used by another; this module hides that
9
+ // seam behind `await init()` and a static `Engine`.
13
10
  //
14
11
  // - `Quill` and `Document` ARE the core build's classes, handed out by the
15
- // gate "Initialization"). They
16
- // hold the canonical data and the full sync surface (schema / validate /
17
- // seed / mutate / toJson / toTree). No backend is loaded to use them, so
18
- // the editor/validation path never pays for a multi-MB backend binary.
12
+ // gate below, never subclasses or wrappers: that identity is what makes
13
+ // `instanceof` the whole membership test, so a handle either belongs to this
14
+ // copy or to another copy, and the second is always a consumer bug.
15
+ // `runtime.test.js` guards it (`Quill === CoreQuill`). No backend is loaded
16
+ // to use them, so the editor path never pays for a multi-MB binary.
19
17
  //
20
18
  // - `Engine` is the render dispatcher. It routes on `quill.backendId`, lazily
21
- // imports that backend's build (so a consumer that never renders never
22
- // loads it), clones the canonical `Quill`/`Document` into the backend's
23
- // memory ON DEMAND as data (`toTree`→`fromTree`, `toJson`→`fromJson`),
24
- // renders, and the backend handles never escape.
25
- //
26
- // CLONE LIFETIMES (not all transient): the per-call `Document` clone IS
27
- // transient, built and freed inside each call, because documents are small
28
- // and mutate freely. The `Quill` clone is CACHED instead: re-cloning a quill
29
- // per call means re-serializing its whole file tree Rust→JS, copying it into
30
- // backend memory, and re-parsing + re-validating the bundle every time, and
31
- // quills are validated, effectively-immutable bundles. So each `Engine`
32
- // memoizes the backend-memory quill per (engine, backendId, canonical quill
33
- // instance) in a `WeakMap` keyed on the canonical `Quill`: when the consumer
34
- // drops the core quill the cache entry becomes collectable and wasm-bindgen
35
- // weak-refs (`--weak-refs`) free the backend handle. The CONTRACT this buys:
36
- // a `Quill` instance's contents never change after construction, mutate by
37
- // replacing the instance (the clone is dropped with it via WeakMap +
38
- // weak-refs).
39
- //
40
- // The cross-memory crossing is therefore invisible: a consumer hands canonical
41
- // `Quill`/`Document` to `engine.render(...)` and gets a `RenderResult` back.
42
-
43
- // ── CANONICAL INVARIANT: hand out the core build's classes, never wrap ──────
44
- // The `Quill`/`Document` a consumer holds ARE the core build's classes, NOT
45
- // subclasses or wrappers. `init` resolves to them (§ "Initialization"); which
46
- // door they come through changes nothing about the identity. The only boundary
47
- // that needs crossing is core→backend (a separate WASM memory), which `Engine`
48
- // does internally as data (`toTree`/`toJson`).
49
- //
50
- // Do NOT hand out a wrapper: that breaks the identity and turns a structural
51
- // fact into a converted type (a breaking design change, not a refactor). The
52
- // `runtime.test.js` "hands out the internal core build classes verbatim" case
53
- // (`Quill === CoreQuill`) is the executable guard for this 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.
19
+ // imports that backend's build, clones the canonical `Quill`/`Document` into
20
+ // the backend's memory as data (`toTree`→`fromTree`, `toJson`→`fromJson`),
21
+ // renders, and never lets the backend handles escape.
59
22
  //
23
+ // CLONE LIFETIMES: the per-call `Document` clone is transient, since
24
+ // documents are small and mutate freely. The `Quill` clone is CACHED,
25
+ // because re-cloning re-serializes its whole file tree, copies it into
26
+ // backend memory, and re-parses + re-validates the bundle every call. Each
27
+ // `Engine` memoizes it in a `WeakMap` keyed on the canonical `Quill`, so
28
+ // dropping the core quill makes the entry collectable and `--weak-refs`
29
+ // frees the backend handle. The contract this buys: a `Quill` instance's
30
+ // contents never change after construction — mutate by replacing the
31
+ // instance.
32
+
60
33
  // Local bindings, so this module can augment them: `quill.writer(doc)` is
61
- // patched onto the prototype below, and `instanceof` reads them directly.
62
- //
63
- // The default import is the core build's generated instantiation entry
64
- // (`--target web`); `init` below is the only thing that calls it.
34
+ // patched onto the prototype below, and `instanceof` reads them directly. The
35
+ // default import is the core build's generated instantiation entry; `init` is
36
+ // the only thing that calls it.
65
37
  import initCore, { Quill, Document } from '../core/wasm.js';
66
38
  // The wasm byte source, resolved per environment by package.json's `imports`
67
39
  // map: a pass-through in a browser (the glue fetches and streams the URL
68
40
  // itself), a `node:fs` read under Node, whose `fetch` rejects `file:` URLs.
69
41
  // Resolution-time, so `node:fs` never enters a browser graph.
70
42
  import { toModuleSource } from '#quillmark-env';
71
- // The document-free content codec: `exportMarkdown(body)` (the on-demand
72
- // markdown projection), `importMarkdown`, and the position-mapping pair
73
- // (`rebase`, `mapPos`).
74
43
  import { importMarkdown, exportMarkdown, rebase, mapPos } from '../core/wasm.js';
75
- // The document-model path parser/serializer: `parseDocPath(str) => DocPathSeg[]`
76
- // and its inverse `formatDocPath`, so a consumer routes on `Diagnostic.path`
77
- // segments instead of reverse-engineering the grammar.
78
44
  import { parseDocPath, formatDocPath } from '../core/wasm.js';
79
45
 
80
46
  // ── Initialization ──────────────────────────────────────────────────────────
81
47
  // The builds are `--target web`: they export their classes synchronously but
82
48
  // carry no wasm instance until something instantiates them. This module owns
83
49
  // that for core, behind one awaited gate; `Engine` owns it for the backends,
84
- // inside their lazy load, so a consumer never initializes a backend by hand.
85
- //
86
- // THE GATE IS THE ONLY DOOR. `init` resolves to the core surface, and this
87
- // module exports none of it statically, so a handle is unobtainable without
88
- // having awaited: the precondition is structural rather than a convention the
89
- // caller has to know. package.json's `exports` map carries exactly one entry,
90
- // so there is no subpath around the gate either.
91
- //
92
- // The gate is the shape the lazy-backend idiom (§ DEFAULT_BACKENDS) takes when
93
- // the surface it guards cannot be async: `Quill.fromTree` and
94
- // `quill.seedDocument` return synchronously, so there is nowhere to hide an
95
- // await except in front.
50
+ // inside their lazy load.
96
51
  //
97
- // WHAT STAYS A STATIC EXPORT is what needs no instance. `MAIN_CARD_ADDR`, the
98
- // open-set guards and `isQuillmarkError` are pure JS over plain objects; gating
99
- // them would cost a consumer of one an await it has no use for.
52
+ // THE GATE IS THE ONLY DOOR: `init` resolves to the core surface and nothing
53
+ // here exports it statically, so a handle is unobtainable without having
54
+ // awaited, and package.json's `exports` map carries exactly one entry. The
55
+ // guarded surface cannot be async (`Quill.fromTree` and `quill.seedDocument`
56
+ // return synchronously), so there is nowhere to hide an await except in front.
100
57
  //
58
+ // WHAT STAYS A STATIC EXPORT is what needs no instance: `MAIN_CARD_ADDR`, the
59
+ // open-set guards, and `isQuillmarkError` are pure JS over plain objects.
101
60
  // `Engine`, `LiveSession` and the four writer/reader classes stay static too,
102
- // gated by their ARGUMENTS rather than by the door. Every `Engine` verb takes a
103
- // `Quill` first (`#backendOf` is the single reader) and the writer/reader
104
- // constructors take both handles, so a caller who has not awaited cannot
105
- // produce an argument to call them with. The two constructors taking no handle
106
- // reach no wasm: `new Engine()` validates a descriptor map, and a `LiveSession`
107
- // forwards to the backend session `engine.open` is the sole source of. None of
108
- // the six carries a static method, the one member shape an argument cannot
109
- // gate. `gate.test.js` is the executable guard, driving the whole static
110
- // surface before `init`. Holding them out of the gate keeps them tree-shakable,
111
- // so the editor path drops the dispatcher it never calls.
61
+ // gated by their ARGUMENTS instead every verb takes a `Quill` or both
62
+ // handles, so a caller who has not awaited cannot produce an argument, and the
63
+ // two constructors taking no handle reach no wasm. None carries a static
64
+ // method, the one member shape an argument cannot gate. `gate.test.js` drives
65
+ // the whole static surface before `init`. Holding them out of the gate also
66
+ // keeps them tree-shakable.
112
67
  //
113
68
  // FAILURE DELIVERY follows the FUNCTION kind, not the failure kind: a sync verb
114
- // throws, a promise-returning verb rejects, and nothing does both. A
115
- // programming error reached through a promise-returning verb
116
- // (`runtime::foreign_handle` inside `Engine.render`) rejects like any other.
117
- // `init` is the one promise-returning export not declared `async`, because the
118
- // memo is returned by identity; its conflict guard rejects explicitly to hold
119
- // the rule, which the return type cannot declare and a `.catch` would not see.
69
+ // throws, a promise-returning verb rejects, and nothing does both. `init` is the
70
+ // one promise-returning export not declared `async`, because the memo is
71
+ // returned by identity; its conflict guard rejects explicitly to hold the rule.
120
72
 
121
73
  /**
122
74
  * The gated surface: the core build's values, which are exactly the ones its
@@ -289,38 +241,29 @@ function quillmarkError(code, message, hint) {
289
241
  }
290
242
 
291
243
  // ── Handles from another copy: always a bug ─────────────────────────────────
292
- // A duplicate install (two copies of this package in one `node_modules` tree)
293
- // is two `core` builds: two linear memories and two distinct `Quill`/`Document`
294
- // classes. No topology legitimately loads a multi-megabyte WASM package twice
295
- // AND needs handles to cross between the copies, so a crossing is a consumer
296
- // bug. Every seam taking a core handle says so, uniformly, at the crossing.
244
+ // A duplicate install is two `core` builds: two linear memories and two distinct
245
+ // `Quill`/`Document` classes. No topology legitimately loads a multi-megabyte
246
+ // WASM package twice AND needs handles to cross between the copies, so a
247
+ // crossing is a consumer bug, and every seam taking a core handle says so.
297
248
  //
298
249
  // Crossing read-only handles as data is mechanically possible (`toJson` and
299
- // `toTree` serialize either way) and is not done. It leaves a package where
300
- // some verbs work and some throw, and it hides a cliff: a crossed read is a
301
- // whole-document `toJson` + `fromJson`, so a form reading fifty fields pays
302
- // fifty round trips and the symptom is "the editor got slow". A duplicate
303
- // install that limps is a duplicate install nobody removes.
250
+ // `toTree` serialize either way) and is not done: it leaves a package where some
251
+ // verbs work and some throw, and it hides a cliff, since a crossed read is a
252
+ // whole-document `toJson` + `fromJson` and a form reading fifty fields pays
253
+ // fifty round trips.
304
254
  //
305
- // What the checks deliver is the ERROR, not the rejection. wasm-bindgen's
306
- // generated glue already rejects a foreign class on every method declaring a
307
- // reference parameter (`Document.equals`, `Quill.validate`, `Quill.resolve`,
308
- // the `&Quill`-taking `Document._commitField` and friends): its `_assertClass`
309
- // is emitted unconditionally and runs before any of our code. It throws a bare
310
- // `Error` reading `expected instance of Document` at a value that IS a
311
- // `Document`, so `isQuillmarkError` returns false and the failure leaves this
312
- // package's error contract, naming neither the cause nor the cure. The checks
313
- // front-run it with a `QuillmarkError` that names both.
314
- //
315
- // They also cover the seams with NO `_assertClass` to front-run: `Engine` and
316
- // `LiveSession.update` cross into backend memory as data (`toTree`/`toJson`), so
317
- // a foreign handle there would silently work, at the price of the round-trip
318
- // above and a quill clone cache split per copy.
319
- //
320
- // Not an API widening in either direction: the declared parameter types stay
321
- // `Quill`/`Document`, and the accepted set is exactly this copy's instances.
322
-
323
- /** Per class: the code and hint for "not one at all", and the probe for "from another copy". */
255
+ // What the checks deliver is the ERROR, not the rejection. wasm-bindgen's glue
256
+ // already rejects a foreign class wherever a method declares a reference
257
+ // parameter, but its `_assertClass` throws a bare `Error` reading
258
+ // `expected instance of Document` at a value that IS a `Document`, so
259
+ // `isQuillmarkError` returns false and the failure leaves this package's error
260
+ // contract. The checks front-run it with a `QuillmarkError` naming both cause
261
+ // and cure, and cover the seams with no `_assertClass` to front-run: `Engine`
262
+ // and `LiveSession.update` cross into backend memory as data, where a foreign
263
+ // handle would silently work at the price of that round trip and a per-copy
264
+ // split of the quill clone cache.
265
+
266
+ /** Per class: the code and hint for "not one at all", and the from-another-copy probe. */
324
267
  const HANDLE_KINDS = {
325
268
  Quill: {
326
269
  code: 'runtime::not_a_quill',
@@ -404,10 +347,8 @@ function patchHandleChecked(proto, name, wrap) {
404
347
  /** @type {any} */ (proto)[name] = patched;
405
348
  }
406
349
 
407
- // The three core methods declaring a `&Document` parameter. Each already refuses
408
- // a foreign handle inside `_assertClass`; the patch is what makes the refusal
409
- // legible. Only the argument can be foreign, the receiver being whichever copy
410
- // the caller reached for.
350
+ // The core methods declaring a `&Document` parameter. Each already refuses a
351
+ // foreign handle inside `_assertClass`; the patch makes the refusal legible.
411
352
  patchHandleChecked(Document.prototype, 'equals', (original) =>
412
353
  function equals(/** @type {any} */ other) {
413
354
  requireLocalDoc(other, 'Document.equals');
@@ -503,23 +444,13 @@ export function isListItemContainer(container) {
503
444
  }
504
445
 
505
446
  // ── Open-set membership guards ──────────────────────────────────────────────
506
- // The guards above each answer "is this arm X": one pinned arm at a time. These
507
- // four answer the other question: "is this a value this build knows?" A consumer
508
- // that must branch known-vs-unknown, any read-modify-write consumer, since
509
- // lowering an edit restates every line's kind and containers, otherwise
510
- // enumerates the built-in names in its own source, recreating the closed-set
511
- // coupling the open set exists to remove. That list is correct until the release
512
- // that adds a built-in, at which point the new construct is misclassified as
513
- // unknown and round-trips through the consumer's unknown carrier, losing any
514
- // sibling-key payload.
515
- //
516
- // A predicate rather than an exported name list, because the known tables below
517
- // are upstream's business. They are pinned against the Rust source
518
- // (`Content::RESERVED_*` and `KnownIslandType`) by the
519
- // `js_known_name_tables_match_the_rust_open_sets` drift-guard test in
520
- // `crates/bindings/wasm/tests/known_names_drift.rs`: adding a built-in means
521
- // editing there, here, and the TS unions in `crates/bindings/wasm/src/engine.rs`
522
- // in one commit.
447
+ // The guards above each answer "is this arm X". These four answer "is this a
448
+ // value this build knows?", the question any read-modify-write consumer must
449
+ // ask, since lowering an edit restates every line's kind and containers. A
450
+ // predicate rather than an exported name list, because the tables below are
451
+ // upstream's business: they are pinned against the Rust source by
452
+ // `tests/known_names_drift.rs`, so adding a built-in means editing there, here,
453
+ // and the TS unions in `src/engine.rs` in one commit.
523
454
  //
524
455
  // These classify unknown *tags*, not unknown *payloads on known tags*. A future
525
456
  // `kind: "footnote"` with a sibling `ref` loses `ref` at a consumer that predates
@@ -565,12 +496,11 @@ export function isUnknownIsland(island) {
565
496
  /**
566
497
  * Build a `load` thunk: dynamic-import a backend build, then instantiate it.
567
498
  *
568
- * Under `--target web` a freshly imported build is inert (the import resolves
569
- * before there is a wasm instance behind the classes), so instantiation is part
570
- * of loading and the consumer never sees it. Memoized at MODULE scope, not per
571
- * `Engine`: two engines issuing their first render concurrently must share one
572
- * instantiation, and the generated entry's own `wasm !== undefined` guard only
573
- * catches a call that arrives after one finished, not one already in flight.
499
+ * Under `--target web` a freshly imported build is inert, so instantiation is
500
+ * part of loading. Memoized at MODULE scope, not per `Engine`: two engines
501
+ * issuing their first render concurrently must share one instantiation, and the
502
+ * generated entry's own `wasm !== undefined` guard only catches a call arriving
503
+ * after one finished, not one already in flight.
574
504
  *
575
505
  * @param {string} id backend id, for the failure message
576
506
  * @param {() => Promise<any>} importThunk the dynamic `import()`
@@ -601,19 +531,15 @@ function backendLoad(id, importThunk, wasmUrl) {
601
531
  }));
602
532
  }
603
533
 
604
- // Backend builds are NEVER statically imported here: that would pull a
605
- // multi-MB binary into the eager graph and defeat lazy loading. Each entry is a
606
- // DESCRIPTOR: `load` is a thunk that dynamically imports a backend's chunk and
607
- // instantiates it, so the binary is fetched only when something actually renders
608
- // against that backend and is ready to use when the promise resolves;
609
- // `formats`/`canvas` are the REQUIRED static capability manifest so the
610
- // cheap probes (`supportedFormats`/`supportsCanvas`) ALWAYS answer without
611
- // loading the binary or cloning the quill. The manifest values are verified
612
- // against each backend's Rust source (`crates/backends/<id>/src/lib.rs`
613
- // `SUPPORTED_FORMATS`) and pinned by the `runtime.test.js` drift-guard test,
614
- // which renders once and asserts the loaded backend reports the same list.
615
- // `canvas` mirrors `quillmark_core::formats_support_canvas`: true iff the
616
- // format list includes a visual-page format (`svg` or `png`).
534
+ // Backend builds are NEVER statically imported here: that would pull a multi-MB
535
+ // binary into the eager graph and defeat lazy loading. Each entry is a
536
+ // DESCRIPTOR: `load` dynamically imports and instantiates a backend's chunk, so
537
+ // the binary is fetched only when something renders against it; `formats` and
538
+ // `canvas` are the required static capability manifest, so the probes
539
+ // (`supportedFormats` / `supportsCanvas`) answer without loading the binary or
540
+ // cloning the quill. The manifest mirrors each backend's Rust `SUPPORTED_FORMATS`
541
+ // (and `formats_support_canvas`: true iff the list includes `svg` or `png`),
542
+ // pinned by a `runtime.test.js` drift guard that renders once and compares.
617
543
  const DEFAULT_BACKENDS = {
618
544
  typst: {
619
545
  load: backendLoad(
@@ -637,11 +563,9 @@ const DEFAULT_BACKENDS = {
637
563
  };
638
564
 
639
565
  /**
640
- * Validate a backend registry descriptor, throwing a clear error naming the
641
- * backend id on any malformed entry. Descriptors are the ONLY accepted form:
642
- * `{ load, formats, canvas }` with a callable `load`, a `formats` array, and a
643
- * boolean `canvas`. Failing at construction (not deep inside a render) keeps the
644
- * capability probes free; they can answer from the manifest unconditionally.
566
+ * Validate a backend registry descriptor, naming the backend id on any
567
+ * malformed entry. Failing at construction rather than deep inside a render is
568
+ * what lets the capability probes answer from the manifest unconditionally.
645
569
  * @param {string} id
646
570
  * @param {unknown} entry
647
571
  * @returns {{ load: () => Promise<unknown>, formats: string[], canvas: boolean }}
@@ -678,10 +602,9 @@ export class Engine {
678
602
  /** backendId → descriptor `{ load, formats, canvas }`. */
679
603
  #loaders;
680
604
  /**
681
- * backendId → WeakMap<canonical Quill, backend-memory Quill clone>. Caches
682
- * the expensive quill materialization per (engine, backend, canonical quill
683
- * instance). WeakMap so dropping the canonical quill makes its clone
684
- * collectable; the backend handle is then freed by wasm-bindgen weak-refs.
605
+ * backendId → WeakMap<canonical Quill, backend-memory clone>, caching the
606
+ * expensive materialization. WeakMap so dropping the canonical quill makes
607
+ * its clone collectable, and wasm-bindgen weak-refs then free the handle.
685
608
  * @type {Map<string, WeakMap<object, any>>}
686
609
  */
687
610
  #quillClones = new Map();
@@ -689,11 +612,9 @@ export class Engine {
689
612
  /**
690
613
  * @param {{ backends?: Record<string, { load: () => Promise<unknown>, formats: string[], canvas: boolean }> }} [options]
691
614
  * Extra or overriding backend descriptors, merged over the built-ins. Each
692
- * entry is a descriptor (`{ load, formats, canvas }`) with `formats` and
693
- * `canvas` REQUIRED: that static manifest is what makes
694
- * `supportedFormats`/`supportsCanvas` always free (no binary load, no quill
695
- * clone). Malformed entries throw here, at construction. The default
696
- * registry maps `"typst"` to the bundled Typst build.
615
+ * is `{ load, formats, canvas }` with the manifest REQUIRED, since that is
616
+ * what makes `supportedFormats` / `supportsCanvas` free; malformed entries
617
+ * throw here, at construction.
697
618
  *
698
619
  * `load` resolves to a READY module: a registrant shipping its own
699
620
  * `--target web` build instantiates inside the thunk. More than one
@@ -710,8 +631,8 @@ export class Engine {
710
631
  }
711
632
 
712
633
  /**
713
- * Look up the registered descriptor for `backendId`, throwing the canonical
714
- * "no backend registered" error if none. Pure, touches no binary.
634
+ * The registered descriptor for `backendId`, or the "no backend registered"
635
+ * throw. Touches no binary.
715
636
  * @param {string} backendId
716
637
  * @returns {{ load: () => Promise<unknown>, formats: string[], canvas: boolean }}
717
638
  */
@@ -729,8 +650,7 @@ export class Engine {
729
650
  /**
730
651
  * `quill`'s backend id, after checking the handle. The ONE way an `Engine`
731
652
  * verb reaches `backendId`, so "no verb touches a foreign quill" is
732
- * structural rather than four remembered calls. See § "Handles from another
733
- * copy".
653
+ * structural rather than four remembered calls.
734
654
  * @param {Quill} quill
735
655
  * @param {string} method the caller's name, for the rejection message
736
656
  * @returns {string}
@@ -772,12 +692,9 @@ export class Engine {
772
692
  }
773
693
 
774
694
  /**
775
- * Get (or materialize-and-cache) the backend-memory `Quill` clone for
776
- * `quill` under `backendId`. On a cache miss the clone is built from `tree`,
777
- * the caller's pre-await `toTree()` snapshot; the canonical handle may be
778
- * freed by now, and stored in the per-backend `WeakMap` keyed on the
779
- * canonical `Quill` instance, so a later call with the same instance reuses
780
- * it (the hot-path fix: no re-serialize / re-copy / re-validate per call).
695
+ * Get (or materialize-and-cache) the backend-memory `Quill` clone for `quill`
696
+ * under `backendId`. On a miss the clone is built from `tree`, the caller's
697
+ * pre-await snapshot, since the canonical handle may be freed by now.
781
698
  * @param {any} mod the backend build module
782
699
  * @param {string} backendId
783
700
  * @param {object} quill the canonical instance (cache key only)
@@ -805,22 +722,15 @@ export class Engine {
805
722
  *
806
723
  * OWNERSHIP WINDOW: both caller handles are snapshotted (`doc.toJson()`, and
807
724
  * `quill.toTree()` on a clone-cache miss) BEFORE the first await. The backend
808
- * load below is a real suspension point (a multi-MB `import()` on first
809
- * render) so reading the handles after it would race a caller that
810
- * `free()`s them as soon as this call returns its promise ("null pointer
811
- * passed to rust"). The snapshot makes that natural calling pattern correct.
725
+ * load below is a real suspension point, so reading the handles after it
726
+ * would race a caller that `free()`s them as soon as this call returns its
727
+ * promise ("null pointer passed to rust").
812
728
  *
813
729
  * Clone lifetimes differ by design: the `doc` clone is TRANSIENT, freed in
814
- * the `finally` of every call. The `quill` clone is CACHED per (engine,
815
- * backend, canonical quill instance) and is NOT freed here; a `Quill`
816
- * instance's contents never change after construction, so it is dropped with
817
- * the canonical quill (WeakMap collection → wasm-bindgen weak-ref free) when
818
- * the consumer replaces the instance. A cache miss materializes it once;
819
- * subsequent calls reuse it.
820
- *
821
- * Both handles are checked here, so the crossing is core→backend (always two
822
- * memories, always as data); core→core is a duplicate install and never gets
823
- * past the first line. See § "Handles from another copy".
730
+ * the `finally` of every call, while the `quill` clone is CACHED and is not
731
+ * freed here a `Quill` instance's contents never change after
732
+ * construction, so it is dropped with the canonical quill when the consumer
733
+ * replaces the instance.
824
734
  * @param {string} method the caller's name, for the rejection message
825
735
  * @param {Quill} quill
826
736
  * @param {Document} doc
@@ -832,12 +742,9 @@ export class Engine {
832
742
  const docJson = doc.toJson();
833
743
  const quillTree = this.#quillClones.get(backendId)?.has(quill) ? null : quill.toTree();
834
744
  const { mod, engine } = await this.#resolveBackend(backendId);
835
- // The quill clone is cached (see #cachedQuillClone); only the per-call doc
836
- // clone is transient. Bring the doc clone + `fn` under one try so the doc
837
- // clone is freed even if a later step throws. The cached quill clone is
838
- // intentionally NOT freed here. `fn` MUST be synchronous: the doc clone is
839
- // freed as soon as it returns, so an async `fn` would have it freed
840
- // mid-flight.
745
+ // The doc clone and `fn` share one try so the clone is freed even if a
746
+ // later step throws; the cached quill clone is intentionally not freed.
747
+ // `fn` MUST be synchronous: an async one would run against a freed clone.
841
748
  const backendQuill = this.#cachedQuillClone(mod, backendId, quill, quillTree);
842
749
  let backendDoc = null;
843
750
  try {
@@ -849,9 +756,8 @@ export class Engine {
849
756
  }
850
757
 
851
758
  /**
852
- * Render `doc` against `quill` in one shot, returning a `RenderResult`.
853
- * Both handles are read synchronously before the first await, so the caller
854
- * may `free()` them as soon as this call returns.
759
+ * Render `doc` against `quill` in one shot. Both handles are read
760
+ * synchronously before the first await.
855
761
  * @param {Quill} quill
856
762
  * @param {Document} doc
857
763
  * @param {object} [options] render options (`{ format, ppi, pages, producer }`)
@@ -864,12 +770,9 @@ export class Engine {
864
770
  }
865
771
 
866
772
  /**
867
- * Open a live render session (canvas preview / per-page paint / `update`).
868
- * The session is self-contained (it retains what it needs for `update`), so
869
- * the transient quill and document clones are freed before this returns;
870
- * the caller owns the returned session and must `.free()` it. The `quill`
871
- * and `doc` handles are read synchronously before the first await, so the
872
- * caller may `free()` them as soon as this call returns.
773
+ * Open a live render session. It retains what `update` needs, so the
774
+ * transient clones are freed before this returns; the caller owns the session
775
+ * and must `.free()` it.
873
776
  * @param {Quill} quill
874
777
  * @param {Document} doc
875
778
  * @returns {Promise<LiveSession>}
@@ -884,10 +787,8 @@ export class Engine {
884
787
  }
885
788
 
886
789
  /**
887
- * The output formats `quill`'s backend can emit. A cheap, non-failing,
888
- * ALWAYS-free pre-render probe: it answers from the descriptor's required
889
- * `formats` manifest (NO binary load and NO quill clone) depending only on
890
- * `quill.backendId`. Stays `async` for API stability (it never awaits a load).
790
+ * The output formats `quill`'s backend can emit: an always-free probe over
791
+ * the descriptor's manifest. `async` for API stability; it awaits nothing.
891
792
  * @param {Quill} quill
892
793
  * @returns {Promise<import('./runtime.js').OutputFormat[]>}
893
794
  */
@@ -898,13 +799,9 @@ export class Engine {
898
799
  }
899
800
 
900
801
  /**
901
- * Whether `quill`'s BACKEND can paint sessions to a canvas: a pre-session
902
- * ESTIMATE, not a fact about any particular compile. Same ALWAYS-free probe
903
- * as `supportedFormats`: answered from the descriptor's required `canvas`
904
- * manifest, no load and no clone. A specific compile can still refuse to
905
- * paint (e.g. a 0-page document), so this can answer `true` while the
906
- * resulting `LiveSession.supportsCanvas` answers `false`: gate mounting a
907
- * canvas UI on this, gate the actual `paint` call on the session's getter.
802
+ * Whether `quill`'s backend can paint to a canvas: a pre-session estimate over
803
+ * the descriptor's manifest, so it can answer `true` where the resulting
804
+ * `LiveSession.supportsCanvas` answers `false`.
908
805
  * @param {Quill} quill
909
806
  * @returns {Promise<boolean>}
910
807
  */
@@ -915,20 +812,9 @@ export class Engine {
915
812
  }
916
813
 
917
814
  /**
918
- * Thin wrapper over a backend's live render session. Reads serve the current
919
- * compile; `update(doc)` recompiles in place (transactional: on throw, reads
920
- * keep serving the last-good compile). The quill/document clones it was
921
- * opened from have already been freed: the session retains what `update`
922
- * needs.
923
- *
924
- * Geometry reads (`regions`, `positionAt`, `locate`) resolve against the
925
- * current compile; anchoring a caret or selection across edits is the editor's
926
- * job (its own transaction mapping): re-read geometry after each committed
927
- * `update`.
928
- *
929
- * `paint` writes a COMPLETE page raster (all content visible, no caller-side
930
- * compositing) for every backend that supports canvas (Typst rasterizes
931
- * natively; pdfform rasterizes its pre-flattened page). See `runtime.d.ts`.
815
+ * Thin wrapper over a backend's live render session; see `runtime.d.ts` for the
816
+ * contract. The quill/document clones it was opened from have already been
817
+ * freed: the session retains what `update` needs.
932
818
  */
933
819
  export class LiveSession {
934
820
  /**
@@ -943,9 +829,6 @@ export class LiveSession {
943
829
  #mod;
944
830
 
945
831
  /**
946
- * Recompile the session against `doc`: the edit verb of a live preview.
947
- * Transactional: on throw every read keeps serving the last-good compile.
948
- * On success reads serve the new compile; repaint `dirtyPages ∩ visible`.
949
832
  * @param {Document} doc
950
833
  * @returns {import('./runtime.d.ts').ChangeSet}
951
834
  */
@@ -968,12 +851,8 @@ export class LiveSession {
968
851
  }
969
852
  /**
970
853
  * `true` iff `paint`/`pageSize` will succeed for THIS compile: the
971
- * authoritative answer, derived from the session's canvas seam, so it can
972
- * never disagree with what `paint` actually does. This can be `false` even
973
- * when `Engine.supportsCanvas` answered `true` for the same `quill` (that
974
- * probe is a pre-session backend estimate; e.g. a canvas-capable backend
975
- * compiled to a 0-page document has nothing to paint). Re-check this getter
976
- * after `open()` rather than relying on the engine hint alone.
854
+ * authoritative answer, which can be `false` where `Engine.supportsCanvas`
855
+ * answered `true` for the same quill.
977
856
  * @returns {boolean}
978
857
  */
979
858
  get supportsCanvas() {
@@ -989,10 +868,6 @@ export class LiveSession {
989
868
  }
990
869
 
991
870
  /**
992
- * Schema-field geometry for this compiled session: one region per
993
- * schema-bound field, keyed on its quill schema field path. A session-level
994
- * query (no render); read it to place field overlays / cross-navigation over
995
- * a `paint`-ed canvas.
996
871
  * @returns {import('./runtime.d.ts').FieldRegion[]}
997
872
  */
998
873
  regions() {
@@ -1000,12 +875,6 @@ export class LiveSession {
1000
875
  }
1001
876
 
1002
877
  /**
1003
- * The whole-field highlight boxes for `field`: one union rect per page,
1004
- * over the field's `span`-bearing content segments. Owns the union
1005
- * `regions()` leaves derived (span-filter + per-page union), so a "highlight
1006
- * the focused field" consumer stops reimplementing it. Content only: a field
1007
- * placed solely as a scalar reference or a bound widget returns `[]`, its
1008
- * box is a single `regions()` rect.
1009
878
  * @param {string} field
1010
879
  * @returns {import('./runtime.d.ts').FieldRegion[]}
1011
880
  */
@@ -1014,11 +883,6 @@ export class LiveSession {
1014
883
  }
1015
884
 
1016
885
  /**
1017
- * The schema field whose content is under a point on `page`: the forward
1018
- * (click → field) direction, resolving *every* placement, not just the first
1019
- * that `regions` enumerates. `x`/`y` are PDF points with a bottom-left origin
1020
- * (the `FieldRegion.rect` space). See `runtime.d.ts` for the click-to-point
1021
- * inverse transform.
1022
886
  * @param {number} page
1023
887
  * @param {number} x
1024
888
  * @param {number} y
@@ -1053,10 +917,6 @@ export class LiveSession {
1053
917
  }
1054
918
 
1055
919
  /**
1056
- * Paint `page` into a 2D canvas context. The painted raster is COMPLETE
1057
- * (all page content visible, no caller-side compositing) for both the Typst
1058
- * and pdfform backends. See `runtime.d.ts` for the DPR/clamp math and the
1059
- * region-overlay coordinate transform.
1060
920
  * @param {CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D} ctx
1061
921
  * @param {number} page
1062
922
  * @param {object} [options]
@@ -1130,10 +990,8 @@ export class DocumentWriter {
1130
990
  return this.#doc._commitFields(this.#quill, MAIN_CARD_ADDR, fields);
1131
991
  }
1132
992
  /**
1133
- * Revise the main body from markdown (edit semantics: surviving anchors
1134
- * rebase), returning the text {@link Delta}. The content lane's `revise`
1135
- * reached through the writer: a body carries no field schema, so there is
1136
- * nothing for a typed verb to type, and the receipt is the content lane's.
993
+ * Revise the main body from markdown; anchors rebase. A body carries no field
994
+ * schema, so this is the content lane's `revise` reached through the writer.
1137
995
  * @param {string} markdown
1138
996
  * @returns {import('../core/wasm.js').Delta}
1139
997
  */
@@ -1142,14 +1000,9 @@ export class DocumentWriter {
1142
1000
  }
1143
1001
  /**
1144
1002
  * Revise the content main-card field `name` from authored text: typed *and*
1145
- * anchor-preserving. Surviving anchors rebase, then the diffed result is
1146
- * schema-conformed (`richtext(inline)` rejects a multi-block result). Throws
1147
- * `UnknownField` for a name the schema does not declare. Returns the text
1148
- * {@link Delta}.
1149
- *
1150
- * The codec comes from the declared type: `richtext` diffs markdown, while
1151
- * `plaintext` diffs the literal text and never imports markdown, so a
1152
- * byte-identical revise of a value carrying escapes is a byte no-op.
1003
+ * anchor-preserving. Anchors rebase, then the diffed result is
1004
+ * schema-conformed. The codec comes from the declared type: `richtext` diffs
1005
+ * markdown, `plaintext` the literal text.
1153
1006
  * @param {string} name
1154
1007
  * @param {string} text
1155
1008
  * @returns {import('../core/wasm.js').Delta}
@@ -1159,13 +1012,9 @@ export class DocumentWriter {
1159
1012
  }
1160
1013
  /**
1161
1014
  * Build a composable card of `kind`, typed-commit `fields` onto it, set its
1162
- * body from optional markdown, and place it: the fused `makeCard` + typed
1163
- * commit + insertion. `at` picks the position: omitted appends, a number
1164
- * inserts at that index (`0..=cardCount`), so a positioned typed insert is one
1165
- * atomic call rather than `addCard` + `moveCard`. Transactional: the card is
1166
- * committed in full before it joins the document, so a rejected field (throws
1167
- * a per-field diagnostic bundle, `UnknownField` per undeclared name) or an
1168
- * invalid kind/body/position leaves the document untouched.
1015
+ * body from optional markdown, and place it. Transactional: the card is
1016
+ * committed in full before it joins the document, so a rejected field, kind,
1017
+ * body, or position leaves the document untouched.
1169
1018
  * @param {string} kind
1170
1019
  * @param {Record<string, unknown>} [fields]
1171
1020
  * @param {string} [body]
@@ -1176,8 +1025,6 @@ export class DocumentWriter {
1176
1025
  return this.#doc._addCard(this.#quill, kind, fields, body, at);
1177
1026
  }
1178
1027
  /**
1179
- * Remove the composable card at `index`, returning it (or `undefined` if the
1180
- * index is out of range): the writer spelling of `Document.removeCard`.
1181
1028
  * @param {number} index
1182
1029
  * @returns {import('../core/wasm.js').Card | undefined}
1183
1030
  */
@@ -1185,14 +1032,9 @@ export class DocumentWriter {
1185
1032
  return this.#doc.removeCard(index);
1186
1033
  }
1187
1034
  /**
1188
- * A {@link CardWriter} bound to the composable card at `index`. Index
1189
- * validity is checked lazily by the underlying write (it throws
1190
- * `IndexOutOfRange` at commit time), so an out-of-range index does not throw
1191
- * here.
1192
- *
1193
- * The cursor is ephemeral: bind, write, discard. It holds `index`, not the
1194
- * card: a `removeCard`/`addCard` between binding and writing silently
1195
- * retargets it. Re-resolve the index at write time when cards may move.
1035
+ * A {@link CardWriter} bound to the composable card at `index`, checked
1036
+ * lazily at the write. It holds `index`, not the card, so a
1037
+ * `removeCard`/`addCard` between binding and writing silently retargets it.
1196
1038
  * @param {number} index
1197
1039
  * @returns {CardWriter}
1198
1040
  */
@@ -1227,9 +1069,8 @@ export class CardWriter {
1227
1069
  return this.#index;
1228
1070
  }
1229
1071
  /**
1230
- * The bound card's `$kind` (empty string when it carries none), read through
1231
- * the document: mirrors core `CardWriter::kind()`. Ephemeral like the cursor
1232
- * itself: throws `IndexOutOfRange` if the bound index is out of range.
1072
+ * The bound card's `$kind`, empty string when it carries none. Throws
1073
+ * `IndexOutOfRange` for a bad bound index.
1233
1074
  * @returns {string}
1234
1075
  */
1235
1076
  get kind() {
@@ -1257,8 +1098,7 @@ export class CardWriter {
1257
1098
  return this.#doc._commitFields(this.#quill, { card: this.#index }, fields);
1258
1099
  }
1259
1100
  /**
1260
- * Revise this card's body from markdown (edit semantics), returning the text
1261
- * {@link Delta}: the card twin of {@link DocumentWriter.reviseBody}.
1101
+ * The card twin of {@link DocumentWriter.reviseBody}.
1262
1102
  * @param {string} markdown
1263
1103
  * @returns {import('../core/wasm.js').Delta}
1264
1104
  */
@@ -1266,11 +1106,8 @@ export class CardWriter {
1266
1106
  return this.#doc.revise({ card: this.#index }, markdown);
1267
1107
  }
1268
1108
  /**
1269
- * Revise the content field `name` on this card from authored text: typed *and*
1270
- * anchor-preserving; the card twin of {@link DocumentWriter.reviseField},
1271
- * codec included. Throws `UnknownField` for an undeclared name and
1272
- * `IndexOutOfRange` if the bound index is out of range. Returns the text
1273
- * {@link Delta}.
1109
+ * The card twin of {@link DocumentWriter.reviseField}. Throws `UnknownField`
1110
+ * for an undeclared name and `IndexOutOfRange` for a bad bound index.
1274
1111
  * @param {string} name
1275
1112
  * @param {string} text
1276
1113
  * @returns {import('../core/wasm.js').Delta}
@@ -1281,17 +1118,12 @@ export class CardWriter {
1281
1118
  }
1282
1119
 
1283
1120
  // ── `quill.writer(doc)`, the typed front door ──────────────────────────────
1284
- // The schema-bound writer: bind the quill's schema to a document and issue bare
1285
- // typed writes. Mirrors core's `quill.writer(&mut doc)`: the schema grants the
1286
- // typing, so the quill (not the document) is the factory. Patched onto the
1287
- // re-exported `Quill` prototype rather than wrapped: `Quill === CoreQuill`
1288
- // stays true (the identity invariant above); this only adds a method that
1289
- // constructs the pure-JS writer, which owns no WASM handle.
1121
+ // Patched onto the re-exported `Quill` prototype rather than wrapped, so
1122
+ // `Quill === CoreQuill` stays true: this only adds a method constructing the
1123
+ // pure-JS writer, which owns no WASM handle.
1290
1124
  /**
1291
- * A {@link DocumentWriter} binding this quill's schema to `doc` for typed
1292
- * writes: the documented front door. The returned writer holds both handles by
1293
- * reference and owns neither, so there is nothing to `free()`. Ephemeral by
1294
- * convention: bind, write, discard.
1125
+ * A {@link DocumentWriter} binding this quill's schema to `doc`. It holds both
1126
+ * handles by reference and owns neither: bind, write, discard.
1295
1127
  * @this {Quill}
1296
1128
  * @param {Document} doc the document to mutate, held by reference (not owned)
1297
1129
  * @returns {DocumentWriter}
@@ -1300,22 +1132,16 @@ Quill.prototype.writer = function writer(doc) {
1300
1132
  return new DocumentWriter(this, doc);
1301
1133
  };
1302
1134
 
1303
- // ── Typed-reader sugar: the schema-plane read surface ──────────────────────────
1304
- // The read twin of the writer above. The transport `Document.getStored` is schema-free:
1305
- // a `Document` cannot say which fields are richtext, so an unknown field name
1135
+ // ── Typed-reader sugar: the schema-plane read surface ─────────────────────────
1136
+ // The transport `Document.getStored` is schema-free, so an unknown field name
1306
1137
  // reads back `undefined` rather than as the typo it is. Binding the quill's
1307
- // schema (`_readerGet` takes the handle, like the `commit*` verbs) lets one `get`
1308
- // interpret by declared type: a richtext field to markdown, a plaintext field to
1309
- // its literal text, every other type verbatim, and an unknown name throws
1310
- // `UnknownField`. A field's markdown lives here, not on the body-only
1311
- // `bodyMarkdown`. Like the writer classes these hold the caller's handles by
1312
- // reference, own no WASM object, and have nothing to `free()`.
1138
+ // schema lets one `get` interpret by declared type and throw `UnknownField` on a
1139
+ // name the schema does not declare.
1313
1140
 
1314
1141
  /**
1315
- * A {@link Document} bound to its {@link Quill} for typed reads: the JS twin of
1316
- * Rust's `quill.reader(&doc)` and the read counterpart of {@link DocumentWriter}.
1317
- * Reads target the main card; use {@link card} for a composable card. Holds both
1318
- * handles by reference and owns neither, so there is nothing to `free()`.
1142
+ * A {@link Document} bound to its {@link Quill} for typed reads, the read
1143
+ * counterpart of {@link DocumentWriter}. Reads target the main card; use
1144
+ * {@link card} for a composable one. Owns neither handle.
1319
1145
  */
1320
1146
  export class DocumentReader {
1321
1147
  #quill;
@@ -1348,15 +1174,11 @@ export class DocumentReader {
1348
1174
  return this.#doc._readerGet(this.#quill, addr);
1349
1175
  }
1350
1176
  /**
1351
- * Read the content field at `addr` as its canonical `Content`: the
1352
- * `Content` twin of {@link get}, which projects. Decodes through the codec the
1353
- * declared type names (`richtext` as markdown, `plaintext` as literal text),
1354
- * so a field the writer committed as a `Content` and one a markdown parse left
1355
- * as an authored string read back the same; no branching on how the
1356
- * document was built. An absent `addr.field` reads the body `Content`.
1357
- * `undefined` for an absent field; throws `UnknownField`, `FieldNotContent`
1358
- * for a type that is not a content leaf, `FieldDecode` for an undecodable
1359
- * value, and `IndexOutOfRange` for a bad `addr.card`.
1177
+ * Read the content field at `addr` as canonical `Content`: the twin of
1178
+ * {@link get}, which projects. An absent `addr.field` reads the body
1179
+ * `Content`. `undefined` for an absent field; throws `UnknownField`,
1180
+ * `FieldNotContent` for a type that is not a content leaf, `FieldDecode` for
1181
+ * an undecodable value, and `IndexOutOfRange` for a bad `addr.card`.
1360
1182
  * @param {import('../core/wasm.js').Addr | string} addr
1361
1183
  * @returns {import('../core/wasm.js').Content | undefined}
1362
1184
  */
@@ -1364,18 +1186,29 @@ export class DocumentReader {
1364
1186
  return this.#doc._readerGetContent(this.#quill, addr);
1365
1187
  }
1366
1188
  /**
1367
- * The main body's markdown: the quill-free body read (a body's type is a
1368
- * format fact, not a schema fact). Equivalent to `get({})`.
1189
+ * Read the `Content` nested inside the composite field at `addr`, at `path`:
1190
+ * `[0]` an `array<richtext>` element, `["motto"]` an object's content property,
1191
+ * `[1, "notes"]` a leaf under both. The codec is the leaf's declared type's.
1192
+ * `undefined` for an absent field and for a path that names nothing stored;
1193
+ * throws `UnknownField`, `FieldNotContent` when `path` resolves to no content
1194
+ * leaf, `FieldDecode` anchored at the addressed path, and `IndexOutOfRange`.
1195
+ * @param {import('../core/wasm.js').Addr | string} addr
1196
+ * @param {import('../core/wasm.js').PathStep[]} path
1197
+ * @returns {import('../core/wasm.js').Content | undefined}
1198
+ */
1199
+ getContentAt(addr, path) {
1200
+ return this.#doc._readerGetContentAt(this.#quill, addr, path);
1201
+ }
1202
+ /**
1203
+ * The main body's markdown: the quill-free body read. Equals `get({})`.
1369
1204
  * @returns {string}
1370
1205
  */
1371
1206
  bodyMarkdown() {
1372
1207
  return this.#doc._readerGet(this.#quill, {});
1373
1208
  }
1374
1209
  /**
1375
- * A {@link CardReader} bound to the composable card at `index`. Index validity
1376
- * is checked lazily by the underlying read (it throws `IndexOutOfRange` at read
1377
- * time), so an out-of-range index does not throw here. Ephemeral like the
1378
- * writer cursor: it holds `index`, not the card, so a `removeCard`/`addCard`
1210
+ * A {@link CardReader} bound to the composable card at `index`, checked lazily
1211
+ * at the read. It holds `index`, not the card, so a `removeCard`/`addCard`
1379
1212
  * between binding and reading silently retargets it.
1380
1213
  * @param {number} index
1381
1214
  * @returns {CardReader}
@@ -1429,8 +1262,7 @@ export class CardReader {
1429
1262
  return this.#doc._readerGet(this.#quill, { card: this.#index, field: name });
1430
1263
  }
1431
1264
  /**
1432
- * Read the content field `name` on this card as its canonical `Content`
1433
- * `Content`: the card twin of {@link DocumentReader.getContent}.
1265
+ * The card twin of {@link DocumentReader.getContent}.
1434
1266
  * @param {string} name
1435
1267
  * @returns {import('../core/wasm.js').Content | undefined}
1436
1268
  */
@@ -1438,7 +1270,16 @@ export class CardReader {
1438
1270
  return this.#doc._readerGetContent(this.#quill, { card: this.#index, field: name });
1439
1271
  }
1440
1272
  /**
1441
- * This card's body markdown: the card twin of {@link DocumentReader.bodyMarkdown}.
1273
+ * The card twin of {@link DocumentReader.getContentAt}.
1274
+ * @param {string} name
1275
+ * @param {import('../core/wasm.js').PathStep[]} path
1276
+ * @returns {import('../core/wasm.js').Content | undefined}
1277
+ */
1278
+ getContentAt(name, path) {
1279
+ return this.#doc._readerGetContentAt(this.#quill, { card: this.#index, field: name }, path);
1280
+ }
1281
+ /**
1282
+ * The card twin of {@link DocumentReader.bodyMarkdown}.
1442
1283
  * @returns {string}
1443
1284
  */
1444
1285
  bodyMarkdown() {
@@ -1447,14 +1288,10 @@ export class CardReader {
1447
1288
  }
1448
1289
 
1449
1290
  // ── `quill.reader(doc)`: the schema-plane read front door ─────────────────────
1450
- // The read twin of `quill.writer(doc)`, patched onto the same re-exported `Quill`
1451
- // prototype (the `Quill === CoreQuill` identity invariant holds: this only adds
1452
- // a method constructing the pure-JS reader, which owns no WASM handle).
1291
+ // Patched onto the same re-exported `Quill` prototype as `writer`.
1453
1292
  /**
1454
- * A {@link DocumentReader} binding this quill's schema to `doc` for interpreted
1455
- * reads: the read front door, mirroring core's `quill.reader(&doc)`. The returned
1456
- * reader holds both handles by reference and owns neither, so there is nothing to
1457
- * `free()`. Ephemeral by convention: bind, read, discard.
1293
+ * A {@link DocumentReader} binding this quill's schema to `doc`. It holds both
1294
+ * handles by reference and owns neither: bind, read, discard.
1458
1295
  * @this {Quill}
1459
1296
  * @param {Document} doc the document to read, held by reference (not owned)
1460
1297
  * @returns {DocumentReader}