@quillmark/quiver 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,24 @@ This keeps the author flow to a single command (`npm publish` or `git tag`)
29
29
  and puts the deployment-topology decision where it belongs: with the
30
30
  consumer.
31
31
 
32
+ ## The render model
33
+
34
+ `@quillmark/quiver` produces quills; `@quillmark/wasm` renders them.
35
+
36
+ - `quiver.getQuill(ref)` returns a core `Quill` — engine-free, portable data,
37
+ good for schema inspection, validation, blueprint access, and seeding. It is
38
+ the **only** way to obtain a quill from a quiver.
39
+ - `const engine = new Engine()` (from the `@quillmark/wasm` root) renders it:
40
+ `await engine.render(quill, doc, opts?)`. The Engine routes on
41
+ `quill.backendId`, lazily loads that backend, clones the quill and document
42
+ into the backend's WASM memory, renders, and frees the clones — so a core
43
+ `Quill` passes straight to `engine.render` with no boundary-crossing step.
44
+
45
+ `Engine.render`, `open`, `supportedFormats`, and `supportsCanvas` are all
46
+ **async** — always `await` them. The canonical `Quill`/`Document`/`Engine`
47
+ types are not re-exported from `@quillmark/quiver`; import them straight from
48
+ the `@quillmark/wasm` peer dependency, which is the single source of truth.
49
+
32
50
  ## Authoring a quiver
33
51
 
34
52
  Lay out the source per the spec, then publish to npm (or push a git tag):
@@ -50,6 +68,14 @@ consumer's build. The harness uses `node:test` (built into Node 18+); no extra
50
68
  test-runner dependency required. If you prefer vitest/jest/mocha, write a
51
69
  12-line loop against the main API instead.
52
70
 
71
+ ```ts
72
+ // quiver.test.ts — run with: node --test
73
+ import { Engine } from "@quillmark/wasm";
74
+ import { runQuiverTests } from "@quillmark/quiver/testing";
75
+
76
+ runQuiverTests(import.meta.url, new Engine());
77
+ ```
78
+
53
79
  ## Manual validation (rendering samples)
54
80
 
55
81
  The CI harness proves every quill _compiles_; it does not produce output a
@@ -58,11 +84,11 @@ human can look at. To eyeball real renders, run the
58
84
 
59
85
  ```ts
60
86
  // scripts/preview.ts — run with: node --experimental-strip-types scripts/preview.ts
61
- import { Quillmark } from "@quillmark/wasm";
87
+ import { Engine } from "@quillmark/wasm";
62
88
  import { renderQuiverSamples } from "@quillmark/quiver/preview";
63
89
 
64
90
  await renderQuiverSamples(import.meta.url, {
65
- engine: new Quillmark(),
91
+ engine: new Engine(),
66
92
  });
67
93
  // → writes ./preview/<name>@<version>.<fmt> + index.html
68
94
  ```
@@ -82,7 +108,7 @@ quill name or canonical ref):
82
108
 
83
109
  ```ts
84
110
  await renderQuiverSamples(import.meta.url, {
85
- engine: new Quillmark(),
111
+ engine: new Engine(),
86
112
  exclude: ["broken-quill"], // or: include: ["memo@1.0.0"]
87
113
  });
88
114
  ```
@@ -95,21 +121,38 @@ await renderQuiverSamples(import.meta.url, {
95
121
  ## Consuming a quiver (Node)
96
122
 
97
123
  ```ts
98
- import { Quillmark, Document } from "@quillmark/wasm";
124
+ import { Engine, Document } from "@quillmark/wasm";
99
125
  import { Quiver } from "@quillmark/quiver/node";
100
126
 
101
- const engine = new Quillmark();
102
127
  const quiver = await Quiver.fromPackage("@org/my-quiver");
128
+ const engine = new Engine();
103
129
 
104
130
  const doc = Document.fromMarkdown(markdownString);
105
- const quill = await quiver.getQuill(doc.quillRef, { engine });
106
- const result = quill.render(doc, { format: "pdf" });
131
+ const quill = await quiver.getQuill(doc.quillRef);
132
+ const result = await engine.render(quill, doc, { format: "pdf" });
107
133
  ```
108
134
 
109
135
  `getQuill` accepts both selector refs (`"memo"`, `"memo@1"`) and canonical
110
136
  refs (`"memo@1.0.0"`). It resolves the selector, materializes the quill via
111
- `engine.quill(tree)`, and caches per (engine, canonical-ref). Concurrent
112
- calls for the same ref share a single load.
137
+ `Quill.fromTree(tree)` (engine-free), and caches one instance per canonical
138
+ ref for the lifetime of the `Quiver`. Concurrent calls for the same ref
139
+ coalesce into a single load.
140
+
141
+ The returned quill lives in the core build's WASM memory and is suitable for
142
+ schema inspection, validation, blueprint access, and seeding. To render it,
143
+ hand it straight to `engine.render(quill, doc)` — the Engine clones both
144
+ handles into the backend on demand, so no separate boundary-crossing step is
145
+ needed:
146
+
147
+ ```ts
148
+ // Editor path — core only, ~0.66 MB gzip
149
+ const coreQuill = await quiver.getQuill(ref);
150
+ coreQuill.schema; // ✓ schema, validate, blueprint, seed — all fine
151
+ const doc = coreQuill.seedDocument(); // a fully-filled example document
152
+
153
+ // Render path — the same core handles render directly.
154
+ const result = await engine.render(coreQuill, doc, { format: "pdf" });
155
+ ```
113
156
 
114
157
  If you only need the canonical ref (without materializing), use `resolve`:
115
158
 
@@ -117,6 +160,10 @@ If you only need the canonical ref (without materializing), use `resolve`:
117
160
  const canonicalRef = await quiver.resolve("memo"); // "memo@1.1.0"
118
161
  ```
119
162
 
163
+ If you need the raw file tree (e.g. to drive a backend binary directly), call
164
+ `(await quiver.getQuill(ref)).toTree()` on the core `Quill` — it is I/O-free
165
+ once the quill is materialized.
166
+
120
167
  ## Consuming a quiver (browser)
121
168
 
122
169
  Browsers cannot read the source layout directly, so build at deploy time and
@@ -134,10 +181,15 @@ await Quiver.build(
134
181
 
135
182
  ```ts
136
183
  // browser runtime
184
+ import { Engine, Document } from "@quillmark/wasm";
137
185
  import { Quiver } from "@quillmark/quiver";
138
186
 
139
187
  const quiver = await Quiver.fromBuiltUrl("/quivers/my-quiver/");
140
- const quill = await quiver.getQuill(doc.quillRef, { engine });
188
+ const engine = new Engine();
189
+
190
+ const doc = Document.fromMarkdown(markdownString);
191
+ const quill = await quiver.getQuill(doc.quillRef);
192
+ const result = await engine.render(quill, doc, { format: "pdf" });
141
193
  ```
142
194
 
143
195
  ## Server-side runtime (Node, packed artifact on disk)
@@ -177,15 +229,10 @@ await quiver.warm();
177
229
 
178
230
  `warm()` is I/O-only: it loads every quill's tree (over the network for
179
231
  `fromBuiltUrl`, off the filesystem for `fromPackage` / `fromDir` /
180
- `fromBuiltDir`) and caches
181
- them. It does not require an engine and does not materialize Quill
182
- instances — that happens lazily on the first `getQuill` call, which is
183
- microseconds. A subsequent `getQuill` reuses the cached tree, skipping
184
- the load.
185
-
186
- Once a tree has been turned into a Quill, the cached tree is dropped so
187
- its bytes can be GC'd — the materialized Quill is the runtime artifact.
188
- Calling `warm()` again refills the tree cache.
232
+ `fromBuiltDir`) and caches them. It does not require an engine and does
233
+ not materialize Quill instances — that happens lazily on the first
234
+ `getQuill` call, which is microseconds. A subsequent `getQuill` reuses
235
+ the cached tree, skipping the load.
189
236
 
190
237
  ## Error handling
191
238
 
@@ -206,3 +253,27 @@ try {
206
253
  ```
207
254
 
208
255
  Error codes: `invalid_ref`, `quill_not_found`, `quiver_invalid`, `transport_error`.
256
+
257
+ ## Using `getQuill` vs calling `Quill.fromTree` directly
258
+
259
+ `getQuill(ref)` is the correct entry point for any code that loads a quill
260
+ through a `Quiver`. It handles ref resolution, tree fetching, `Quill.fromTree`
261
+ construction, and per-ref caching in one call. Do **not** reach for
262
+ `Quill.fromTree` directly inside a Quiver consumer:
263
+
264
+ ```ts
265
+ import { Quill } from "@quillmark/wasm";
266
+
267
+ // wrong — bypasses Quiver's cache; duplicates work getQuill already does
268
+ const tree = new Map<string, Uint8Array>();
269
+ tree.set("Quill.yaml", new TextEncoder().encode("name: memo\n..."));
270
+ // ...assemble the rest of the file tree by hand...
271
+ const quill = Quill.fromTree(tree);
272
+
273
+ // right
274
+ const quill = await quiver.getQuill(ref);
275
+ ```
276
+
277
+ `Quill.fromTree` is for code that builds quills **outside** of a Quiver (e.g.
278
+ a server route that receives a raw file tree over the network, or a test
279
+ fixture that constructs a quill from a hand-rolled in-memory tree).
@@ -42,29 +42,35 @@ function hasFlag(name) {
42
42
  // Engine discovery
43
43
  // ---------------------------------------------------------------------------
44
44
  async function loadEngine(cwd) {
45
- // 1. Named export from quiver.config.js, if present.
45
+ // Resolve @quillmark/wasm from the collection's node_modules (best-effort).
46
+ // Its `Engine` renders every quill (cloning the core Quill/Document into the
47
+ // backend itself). A quiver.config.js may export a custom `engine`.
48
+ let wasm;
49
+ try {
50
+ const req = createRequire(pathToFileURL(join(cwd, "package.json")).href);
51
+ const wasmPath = req.resolve("@quillmark/wasm");
52
+ wasm = (await import(pathToFileURL(wasmPath).href));
53
+ }
54
+ catch {
55
+ // Not installed — a quiver.config.js may still provide an engine.
56
+ }
57
+ // 1. Named `engine` export from quiver.config.js, if present.
46
58
  try {
47
59
  const cfg = await import(pathToFileURL(join(cwd, "quiver.config.js")).href);
48
60
  if (cfg.engine != null) {
49
- return { engine: cfg.engine };
61
+ return cfg.engine;
50
62
  }
51
63
  }
52
64
  catch {
53
65
  // File absent or incomplete — fall through to auto-discovery.
54
66
  }
55
- // 2. Auto-discover @quillmark/wasm from the collection's own node_modules.
56
- const req = createRequire(pathToFileURL(join(cwd, "package.json")).href);
57
- let wasmPath;
58
- try {
59
- wasmPath = req.resolve("@quillmark/wasm");
60
- }
61
- catch {
67
+ // 2. Auto-discover from @quillmark/wasm.
68
+ if (wasm == null) {
62
69
  throw new Error("Cannot find @quillmark/wasm in this collection's node_modules.\n" +
63
70
  " Install it: npm install @quillmark/wasm\n" +
64
71
  " Or export { engine } from quiver.config.js for a custom engine.");
65
72
  }
66
- const wasm = (await import(pathToFileURL(wasmPath).href));
67
- return { engine: new wasm.Quillmark() };
73
+ return new wasm.Engine();
68
74
  }
69
75
  // ---------------------------------------------------------------------------
70
76
  // Commands
@@ -78,7 +84,7 @@ async function build() {
78
84
  }
79
85
  async function test() {
80
86
  const cwd = process.cwd();
81
- const { engine } = await loadEngine(cwd);
87
+ const engine = await loadEngine(cwd);
82
88
  const quiver = await Quiver.fromDir(cwd);
83
89
  const names = quiver.quillNames();
84
90
  if (names.length === 0) {
@@ -91,9 +97,15 @@ async function test() {
91
97
  for (const version of quiver.versionsOf(name)) {
92
98
  const ref = `${name}@${version}`;
93
99
  try {
94
- const quill = await quiver.getQuill(ref, { engine });
100
+ const quill = await quiver.getQuill(ref);
95
101
  const doc = quill.seedDocument();
96
- const result = quill.render(doc);
102
+ let result;
103
+ try {
104
+ result = await engine.render(quill, doc);
105
+ }
106
+ finally {
107
+ doc.free();
108
+ }
97
109
  if (!Array.isArray(result.artifacts) || result.artifacts.length === 0) {
98
110
  throw new Error("example render produced no artifacts");
99
111
  }
@@ -113,7 +125,7 @@ async function test() {
113
125
  }
114
126
  async function preview() {
115
127
  const cwd = process.cwd();
116
- const { engine } = await loadEngine(cwd);
128
+ const engine = await loadEngine(cwd);
117
129
  const outDir = flag("--out");
118
130
  const format = flag("--format");
119
131
  const quiet = hasFlag("--quiet");
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@
5
5
  // live at `@quillmark/quiver/node`.
6
6
  export { QuiverError } from "./errors.js";
7
7
  export { Quiver } from "./quiver.js";
8
- // Engine types (`Quillmark`, `Quill`, `Document`, `RenderResult`, …) are not
9
- // re-exported: import them straight from the `@quillmark/wasm` peer dependency,
10
- // which is the single source of truth for the engine contract.
8
+ // The canonical API (`Quill`, `Document`, `Engine`, `RenderResult`, …) is not
9
+ // re-exported: import it straight from the `@quillmark/wasm` peer dependency,
10
+ // which is the single source of truth. `quiver.getQuill(ref)` returns a core
11
+ // `Quill` that an `Engine` renders directly — the engine clones it into the
12
+ // backend, so no boundary-crossing helper is needed.
package/dist/preview.d.ts CHANGED
@@ -11,12 +11,10 @@
11
11
  *
12
12
  * Usage (place a script next to your Quiver.yaml):
13
13
  *
14
- * import { Quillmark } from "@quillmark/wasm";
14
+ * import { Engine } from "@quillmark/wasm";
15
15
  * import { renderQuiverSamples } from "@quillmark/quiver/preview";
16
16
  *
17
- * await renderQuiverSamples(import.meta.url, {
18
- * engine: new Quillmark(),
19
- * });
17
+ * await renderQuiverSamples(import.meta.url, { engine: new Engine() });
20
18
  * // → open ./preview/index.html
21
19
  *
22
20
  * The sample document is the illustrative example seeded by
@@ -28,10 +26,14 @@
28
26
  * A `.gitignore` is written into `outDir` so the generated artifacts are not
29
27
  * accidentally committed.
30
28
  */
31
- import type { Quillmark } from "@quillmark/wasm";
29
+ import type { Engine } from "@quillmark/wasm";
32
30
  export interface RenderQuiverSamplesOptions {
33
- /** Quillmark engine instance (`new Quillmark()` from `@quillmark/wasm`). */
34
- engine: Quillmark;
31
+ /**
32
+ * Canonical render engine (`new Engine()` from `@quillmark/wasm`). It takes the
33
+ * core `Quill`/`Document` that quiver produces and clones them into the
34
+ * backend on demand — no build-matching or handle-crossing for the caller.
35
+ */
36
+ engine: Engine;
35
37
  /** Directory to write rendered artifacts into. Default: `preview`. */
36
38
  outDir?: string;
37
39
  /** Force an output format (`pdf`/`svg`/`png`/`txt`). Default: engine's choice. */
package/dist/preview.js CHANGED
@@ -11,12 +11,10 @@
11
11
  *
12
12
  * Usage (place a script next to your Quiver.yaml):
13
13
  *
14
- * import { Quillmark } from "@quillmark/wasm";
14
+ * import { Engine } from "@quillmark/wasm";
15
15
  * import { renderQuiverSamples } from "@quillmark/quiver/preview";
16
16
  *
17
- * await renderQuiverSamples(import.meta.url, {
18
- * engine: new Quillmark(),
19
- * });
17
+ * await renderQuiverSamples(import.meta.url, { engine: new Engine() });
20
18
  * // → open ./preview/index.html
21
19
  *
22
20
  * The sample document is the illustrative example seeded by
@@ -31,6 +29,7 @@
31
29
  import { mkdir, writeFile } from "node:fs/promises";
32
30
  import { join } from "node:path";
33
31
  import { Quiver } from "./node.js";
32
+ import { isQuillmarkError } from "@quillmark/wasm";
34
33
  /** Default directory rendered artifacts are written to. */
35
34
  const DEFAULT_OUT_DIR = "preview";
36
35
  /**
@@ -73,13 +72,12 @@ function isSelected(name, ref, opts) {
73
72
  }
74
73
  /**
75
74
  * Formats a thrown render error into one string per diagnostic. `@quillmark/wasm`
76
- * attaches a `diagnostics` array to its errors; fall back to the message when
77
- * an error carries none.
75
+ * throws `QuillmarkError`s (an `Error` carrying `diagnostics`); fall back to
76
+ * the message for anything else.
78
77
  */
79
78
  function failureReasons(err) {
80
- const diagnostics = err.diagnostics;
81
- if (Array.isArray(diagnostics) && diagnostics.length > 0) {
82
- return diagnostics.map((d) => `${d.severity}: ${d.message}`);
79
+ if (isQuillmarkError(err) && err.diagnostics.length > 0) {
80
+ return err.diagnostics.map((d) => `${d.severity}: ${d.message}`);
83
81
  }
84
82
  return [err.message];
85
83
  }
@@ -87,9 +85,16 @@ async function renderOne(quiver, name, version, outDir, opts) {
87
85
  const ref = `${name}@${version}`;
88
86
  let result;
89
87
  try {
90
- const quill = await quiver.getQuill(ref, { engine: opts.engine });
88
+ // Canonical render path: a core Quill + its seeded Document handed straight
89
+ // to the Engine, which clones them into the backend and frees the clones.
90
+ const quill = await quiver.getQuill(ref);
91
91
  const doc = quill.seedDocument();
92
- result = quill.render(doc, opts.format ? { format: opts.format } : undefined);
92
+ try {
93
+ result = await opts.engine.render(quill, doc, opts.format ? { format: opts.format } : undefined);
94
+ }
95
+ finally {
96
+ doc.free();
97
+ }
93
98
  }
94
99
  catch (err) {
95
100
  return {
package/dist/quiver.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * `build`) are installed on this class by `./node.js`, which is the
10
10
  * consumer's explicit opt-in to the Node API surface.
11
11
  */
12
- import type { Quillmark, Quill } from "@quillmark/wasm";
12
+ import { Quill } from "@quillmark/wasm";
13
13
  /** @internal Internal loader strategy: source or build output. */
14
14
  export interface QuiverLoader {
15
15
  loadTree(name: string, version: string): Promise<Map<string, Uint8Array>>;
@@ -49,16 +49,6 @@ export declare class Quiver {
49
49
  * Returns an empty array if the quill name is not in the catalog.
50
50
  */
51
51
  versionsOf(name: string): string[];
52
- /**
53
- * Lazily loads the file tree for a specific quill version.
54
- *
55
- * Returns `Map<string, Uint8Array>` suitable for `engine.quill(tree)`.
56
- * Does NOT cache the result — caching of materialized Quill instances
57
- * happens in `getQuill`.
58
- *
59
- * Throws `transport_error` if name/version not in catalog or I/O fails.
60
- */
61
- loadTree(name: string, version: string): Promise<Map<string, Uint8Array>>;
62
52
  /**
63
53
  * Resolves a selector ref → canonical ref (e.g. "memo" → "memo@1.1.0").
64
54
  *
@@ -71,29 +61,32 @@ export declare class Quiver {
71
61
  */
72
62
  resolve(ref: string): Promise<string>;
73
63
  /**
74
- * Returns a render-ready `Quill` for a ref (selector or canonical).
64
+ * Returns a `Quill` for a ref (selector or canonical).
65
+ *
66
+ * The returned quill is materialized from the `@quillmark/wasm` root export
67
+ * — it is engine-free, portable data suitable for schema inspection,
68
+ * validation, blueprint access, and document seeding.
69
+ *
70
+ * A core `Quill` renders directly: pass it to `engine.render(quill, doc)`.
75
71
  *
76
- * Selector refs (e.g. `"memo"`, `"memo@1"`) are resolved to canonical
77
- * form first. Materializes via `engine.quill(tree)` on first call;
78
- * caches per (engine, canonical-ref). Reuses a tree cached by `warm()`
79
- * (or a previous `getQuill`) so the network fetch isn't paid twice.
80
- * Concurrent calls for the same ref coalesce into a single load.
72
+ * Selector refs (e.g. `"memo"`, `"memo@1"`) are resolved to canonical form
73
+ * first. Materializes once and caches per canonical ref — concurrent calls
74
+ * coalesce into a single load.
81
75
  *
82
76
  * Throws:
83
77
  * - `invalid_ref` if ref is malformed
84
78
  * - `quill_not_found` if ref does not match any version in this quiver
85
- * - propagates I/O errors from loadTree unchanged
86
- * - propagates engine errors from engine.quill() unchanged
79
+ * - propagates I/O errors from the loader unchanged
80
+ * - propagates validation errors from Quill.fromTree() unchanged
87
81
  */
88
- getQuill(ref: string, opts: {
89
- engine: Quillmark;
90
- }): Promise<Quill>;
82
+ getQuill(ref: string): Promise<Quill>;
91
83
  /**
92
84
  * Prefetches the tree for every quill version in this quiver. Fail-fast.
93
85
  *
94
86
  * Network-bound only — does not materialize Quill instances and does not
95
87
  * require an engine. Subsequent `getQuill` calls reuse the cached trees,
96
- * skipping the fetch. Rejects on the first fetch failure.
88
+ * skipping the fetch (the tree then evicts as its quill materializes).
89
+ * Rejects on the first fetch failure.
97
90
  */
98
91
  warm(): Promise<void>;
99
92
  }
package/dist/quiver.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * consumer's explicit opt-in to the Node API surface.
11
11
  */
12
12
  import { QuiverError } from "./errors.js";
13
+ import { Quill } from "@quillmark/wasm";
13
14
  import { parseQuillRef } from "./ref.js";
14
15
  import { matchesSemverSelector, chooseHighestVersion } from "./semver.js";
15
16
  export class Quiver {
@@ -17,15 +18,16 @@ export class Quiver {
17
18
  #catalog;
18
19
  #loader;
19
20
  /**
20
- * Per-engine cache of materialized quills, keyed by canonical ref.
21
- * WeakMap so engines can be GC'd; Promise values so concurrent
22
- * getQuill calls coalesce into a single materialization.
21
+ * Cache of materialized quills, keyed by canonical ref. A `Quill` is now
22
+ * engine-free, portable data (`Quill.fromTree`), so one instance per ref is
23
+ * shared across every engine. Promise values so concurrent getQuill calls
24
+ * coalesce into a single materialization.
23
25
  */
24
- #quillCache = new WeakMap();
26
+ #quillCache = new Map();
25
27
  /**
26
- * Engine-independent cache of fetched trees, keyed by canonical ref.
27
- * Populated by `warm()` and on first `getQuill` for a ref. Promise
28
- * values so concurrent fetches coalesce.
28
+ * Cache of fetched trees, keyed by canonical ref. Populated by `warm()`
29
+ * and on first `getQuill` for a ref; an entry is evicted once its quill
30
+ * materializes successfully. Promise values so concurrent fetches coalesce.
29
31
  */
30
32
  #treeCache = new Map();
31
33
  /**
@@ -78,22 +80,6 @@ export class Quiver {
78
80
  versionsOf(name) {
79
81
  return [...(this.#catalog.get(name) ?? [])];
80
82
  }
81
- /**
82
- * Lazily loads the file tree for a specific quill version.
83
- *
84
- * Returns `Map<string, Uint8Array>` suitable for `engine.quill(tree)`.
85
- * Does NOT cache the result — caching of materialized Quill instances
86
- * happens in `getQuill`.
87
- *
88
- * Throws `transport_error` if name/version not in catalog or I/O fails.
89
- */
90
- async loadTree(name, version) {
91
- const versions = this.#catalog.get(name);
92
- if (!versions || !versions.includes(version)) {
93
- throw new QuiverError("transport_error", `Quill "${name}@${version}" not found in quiver "${this.name}"`, { quiverName: this.name, version, ref: `${name}@${version}` });
94
- }
95
- return this.#loader.loadTree(name, version);
96
- }
97
83
  /**
98
84
  * Resolves a selector ref → canonical ref (e.g. "memo" → "memo@1.1.0").
99
85
  *
@@ -120,63 +106,65 @@ export class Quiver {
120
106
  throw new QuiverError("quill_not_found", `No quill found for ref "${ref}" in quiver "${this.name}".`, { ref, quiverName: this.name });
121
107
  }
122
108
  /**
123
- * Returns a render-ready `Quill` for a ref (selector or canonical).
109
+ * Returns a `Quill` for a ref (selector or canonical).
110
+ *
111
+ * The returned quill is materialized from the `@quillmark/wasm` root export
112
+ * — it is engine-free, portable data suitable for schema inspection,
113
+ * validation, blueprint access, and document seeding.
114
+ *
115
+ * A core `Quill` renders directly: pass it to `engine.render(quill, doc)`.
124
116
  *
125
- * Selector refs (e.g. `"memo"`, `"memo@1"`) are resolved to canonical
126
- * form first. Materializes via `engine.quill(tree)` on first call;
127
- * caches per (engine, canonical-ref). Reuses a tree cached by `warm()`
128
- * (or a previous `getQuill`) so the network fetch isn't paid twice.
129
- * Concurrent calls for the same ref coalesce into a single load.
117
+ * Selector refs (e.g. `"memo"`, `"memo@1"`) are resolved to canonical form
118
+ * first. Materializes once and caches per canonical ref — concurrent calls
119
+ * coalesce into a single load.
130
120
  *
131
121
  * Throws:
132
122
  * - `invalid_ref` if ref is malformed
133
123
  * - `quill_not_found` if ref does not match any version in this quiver
134
- * - propagates I/O errors from loadTree unchanged
135
- * - propagates engine errors from engine.quill() unchanged
124
+ * - propagates I/O errors from the loader unchanged
125
+ * - propagates validation errors from Quill.fromTree() unchanged
136
126
  */
137
- async getQuill(ref, opts) {
127
+ async getQuill(ref) {
138
128
  const canonicalRef = await this.resolve(ref);
139
- const engine = opts.engine;
140
- let perEngine = this.#quillCache.get(engine);
141
- if (perEngine === undefined) {
142
- perEngine = new Map();
143
- this.#quillCache.set(engine, perEngine);
144
- }
145
- let entry = perEngine.get(canonicalRef);
129
+ let entry = this.#quillCache.get(canonicalRef);
146
130
  if (entry === undefined) {
147
- entry = this.#materializeQuill(canonicalRef, engine).catch((err) => {
148
- perEngine.delete(canonicalRef);
131
+ entry = this.#materializeQuill(canonicalRef).catch((err) => {
132
+ this.#quillCache.delete(canonicalRef);
149
133
  throw err;
150
134
  });
151
- perEngine.set(canonicalRef, entry);
135
+ this.#quillCache.set(canonicalRef, entry);
152
136
  }
153
137
  return entry;
154
138
  }
155
139
  /**
156
- * Internal: load tree (cached) + invoke engine.quill. Errors propagate.
157
- *
158
- * On success, evicts the tree from the cache so its bytes can be GC'd —
159
- * the materialized Quill is the runtime artifact; the tree is dead weight
160
- * once a Quill exists. On failure, the tree is retained so retries skip
161
- * the network.
140
+ * Internal: load tree (cached) + construct via Quill.fromTree. Errors
141
+ * propagate. On success the tree is evicted from `#treeCache` (the quill
142
+ * cache now holds the materialized result), so the tree is not retained
143
+ * past materialization. On `Quill.fromTree` failure the tree stays cached,
144
+ * so a retry skips the refetch.
162
145
  */
163
- async #materializeQuill(canonicalRef, engine) {
164
- const tree = await this.#getTreeCached(canonicalRef);
165
- const quill = engine.quill(tree);
166
- this.#treeCache.delete(canonicalRef);
146
+ async #materializeQuill(canonicalRef) {
147
+ const entry = this.#getTreeCached(canonicalRef);
148
+ const tree = await entry;
149
+ const quill = Quill.fromTree(tree);
150
+ // Evict by identity: only drop the entry if it is still the promise this
151
+ // materialization consumed (a concurrent refetch may have replaced it).
152
+ if (this.#treeCache.get(canonicalRef) === entry) {
153
+ this.#treeCache.delete(canonicalRef);
154
+ }
167
155
  return quill;
168
156
  }
169
157
  /**
170
- * Internal: tree cache reader. On miss, fetches via `loadTree` and stores
158
+ * Internal: tree cache reader. On miss, fetches via `#loadTree` and stores
171
159
  * the in-flight Promise. On rejection, evicts so a retry can succeed.
172
160
  */
173
- async #getTreeCached(canonicalRef) {
161
+ #getTreeCached(canonicalRef) {
174
162
  let entry = this.#treeCache.get(canonicalRef);
175
163
  if (entry === undefined) {
176
164
  const at = canonicalRef.indexOf("@");
177
165
  const name = canonicalRef.slice(0, at);
178
166
  const version = canonicalRef.slice(at + 1);
179
- entry = this.loadTree(name, version).catch((err) => {
167
+ entry = this.#loadTree(name, version).catch((err) => {
180
168
  this.#treeCache.delete(canonicalRef);
181
169
  throw err;
182
170
  });
@@ -184,12 +172,27 @@ export class Quiver {
184
172
  }
185
173
  return entry;
186
174
  }
175
+ /**
176
+ * Internal: validates name/version against the catalog, then delegates to
177
+ * the loader. Returns `Map<string, Uint8Array>` suitable for
178
+ * `Quill.fromTree(tree)`. Does NOT cache — caching lives in `#getTreeCached`.
179
+ *
180
+ * Throws `transport_error` if name/version not in catalog or I/O fails.
181
+ */
182
+ async #loadTree(name, version) {
183
+ const versions = this.#catalog.get(name);
184
+ if (!versions || !versions.includes(version)) {
185
+ throw new QuiverError("transport_error", `Quill "${name}@${version}" not found in quiver "${this.name}"`, { quiverName: this.name, version, ref: `${name}@${version}` });
186
+ }
187
+ return this.#loader.loadTree(name, version);
188
+ }
187
189
  /**
188
190
  * Prefetches the tree for every quill version in this quiver. Fail-fast.
189
191
  *
190
192
  * Network-bound only — does not materialize Quill instances and does not
191
193
  * require an engine. Subsequent `getQuill` calls reuse the cached trees,
192
- * skipping the fetch. Rejects on the first fetch failure.
194
+ * skipping the fetch (the tree then evicts as its quill materializes).
195
+ * Rejects on the first fetch failure.
193
196
  */
194
197
  async warm() {
195
198
  const promises = [];
package/dist/testing.d.ts CHANGED
@@ -7,14 +7,13 @@
7
7
  *
8
8
  * Usage (place this file next to your Quiver.yaml):
9
9
  *
10
- * import { Quillmark } from "@quillmark/wasm";
10
+ * import { Engine } from "@quillmark/wasm";
11
11
  * import { runQuiverTests } from "@quillmark/quiver/testing";
12
- * const engine = new Quillmark();
13
- * runQuiverTests(import.meta.url, engine);
12
+ * runQuiverTests(import.meta.url, new Engine());
14
13
  *
15
14
  * Run with `node --test`.
16
15
  */
17
- import type { Quillmark } from "@quillmark/wasm";
16
+ import type { Engine } from "@quillmark/wasm";
18
17
  /**
19
18
  * Registers a `node:test` describe block that validates every quill
20
19
  * version in the quiver at `metaUrlOrDir` against the provided engine.
@@ -23,7 +22,7 @@ import type { Quillmark } from "@quillmark/wasm";
23
22
  * to Quiver.yaml). Pass an absolute directory path for any other layout.
24
23
  *
25
24
  * Validation covers the full loading pipeline: Quiver.yaml, Quill.yaml,
26
- * all template files, engine compilation via engine.quill(tree), and a
27
- * full render of each quill's example document.
25
+ * all template files, quill construction via Quill.fromTree(tree), and a
26
+ * full render of each quill's example document via engine.render(quill, doc).
28
27
  */
29
- export declare function runQuiverTests(metaUrlOrDir: string, engine: Quillmark): void;
28
+ export declare function runQuiverTests(metaUrlOrDir: string, engine: Engine): void;
package/dist/testing.js CHANGED
@@ -7,10 +7,9 @@
7
7
  *
8
8
  * Usage (place this file next to your Quiver.yaml):
9
9
  *
10
- * import { Quillmark } from "@quillmark/wasm";
10
+ * import { Engine } from "@quillmark/wasm";
11
11
  * import { runQuiverTests } from "@quillmark/quiver/testing";
12
- * const engine = new Quillmark();
13
- * runQuiverTests(import.meta.url, engine);
12
+ * runQuiverTests(import.meta.url, new Engine());
14
13
  *
15
14
  * Run with `node --test`.
16
15
  */
@@ -27,8 +26,8 @@ import { Quiver } from "./node.js";
27
26
  * to Quiver.yaml). Pass an absolute directory path for any other layout.
28
27
  *
29
28
  * Validation covers the full loading pipeline: Quiver.yaml, Quill.yaml,
30
- * all template files, engine compilation via engine.quill(tree), and a
31
- * full render of each quill's example document.
29
+ * all template files, quill construction via Quill.fromTree(tree), and a
30
+ * full render of each quill's example document via engine.render(quill, doc).
32
31
  */
33
32
  export function runQuiverTests(metaUrlOrDir, engine) {
34
33
  describe("Quiver", () => {
@@ -45,9 +44,18 @@ export function runQuiverTests(metaUrlOrDir, engine) {
45
44
  for (const name of quiver.quillNames()) {
46
45
  for (const version of quiver.versionsOf(name)) {
47
46
  const ref = `${name}@${version}`;
48
- const quill = await quiver.getQuill(ref, { engine });
47
+ // The Engine takes the core Quill + its seeded Document directly and
48
+ // clones them into the backend; the doc clone is freed inside render,
49
+ // and we free our own core Document here.
50
+ const quill = await quiver.getQuill(ref);
49
51
  const doc = quill.seedDocument();
50
- const result = quill.render(doc);
52
+ let result;
53
+ try {
54
+ result = await engine.render(quill, doc);
55
+ }
56
+ finally {
57
+ doc.free();
58
+ }
51
59
  if (!Array.isArray(result.artifacts) || result.artifacts.length === 0) {
52
60
  throw new Error(`${ref}: example render produced no artifacts`);
53
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/quiver",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Quiver registry and build tooling for Quillmark",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "author": "Quillmark Contributors",
22
22
  "engines": {
23
- "node": ">=24"
23
+ "node": ">=22"
24
24
  },
25
25
  "exports": {
26
26
  ".": {
@@ -50,14 +50,14 @@
50
50
  "README.md"
51
51
  ],
52
52
  "peerDependencies": {
53
- "@quillmark/wasm": ">=0.88.0"
53
+ "@quillmark/wasm": ">=0.90.0-0"
54
54
  },
55
55
  "dependencies": {
56
56
  "fflate": "^0.8.2",
57
57
  "yaml": "^2.8.3"
58
58
  },
59
59
  "devDependencies": {
60
- "@quillmark/wasm": "^0.88.0",
60
+ "@quillmark/wasm": "0.90.0",
61
61
  "@types/node": "^25.3.3",
62
62
  "typescript": "^5.9.3",
63
63
  "vitest": "^4.0.18"
@@ -67,6 +67,7 @@
67
67
  },
68
68
  "scripts": {
69
69
  "build": "tsc",
70
+ "prepack": "rm -rf dist && tsc",
70
71
  "test": "vitest run",
71
72
  "lint": "tsc --noEmit"
72
73
  }