@quillmark/wasm 0.92.0 → 0.94.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
@@ -14,13 +14,13 @@ The package exposes **one import surface**:
14
14
  an `Engine` that renders them.
15
15
 
16
16
  `Quill` and `Document` are re-exported verbatim from the internal Typst-less
17
- core build, so engine-free editor/validation code (`Quill.fromTree`,
17
+ core build, so editor/validation code (`Quill.fromTree`,
18
18
  `Document.fromMarkdown`) loads only that small core binary — no backend is
19
- loaded until you render. The `Engine` hides everything else: each backend (Typst
20
- today) is a separate, private WASM binary with its own linear memory, lazily
21
- loaded on the first render. The Engine clones a `Quill` / `Document` into the
22
- backend's memory as data and frees the clones — you never hold a backend object
23
- or cross a memory boundary yourself.
19
+ loaded until you render. The `Engine` hides everything else: each backend
20
+ (`typst`, `pdfform`) is a separate, private WASM binary with its own linear
21
+ memory, lazily loaded on the first render. The Engine clones a `Quill` /
22
+ `Document` into the backend's memory as data and frees the clones — you never
23
+ hold a backend object or cross a memory boundary yourself.
24
24
 
25
25
  ## Build
26
26
 
@@ -28,9 +28,10 @@ or cross a memory boundary yourself.
28
28
  bash scripts/build-wasm.sh
29
29
  ```
30
30
 
31
- The script builds two feature variants — the core (no Typst) and the default
32
- (Typst backend) both with `--target bundler` and `--weak-refs` enabled
33
- (see [Lifecycle](#lifecycle)).
31
+ The script builds three variants — the core (no backend), the Typst backend
32
+ (default features), and the Typst-free pdfform backend (`pdfform` feature)
33
+ each with `--target bundler` and `--weak-refs` enabled (see
34
+ [Lifecycle](#lifecycle)).
34
35
 
35
36
  ## Test
36
37
 
@@ -46,7 +47,7 @@ npm test
46
47
  ```ts
47
48
  import { Document, Quill, Engine } from "@quillmark/wasm";
48
49
 
49
- const quill = Quill.fromTree(tree); // engine-free: build + validate
50
+ const quill = Quill.fromTree(tree); // no engine needed: build + validate
50
51
  const engine = new Engine(); // loads a backend lazily on first render
51
52
 
52
53
  const markdown = `~~~
@@ -84,6 +85,14 @@ never cloning the quill. Use them as non-failing pre-render probes.
84
85
  Build + validate a `Quill` from an in-memory tree. Pure — the declared backend
85
86
  is resolved at render time, not here. Loads no backend binary.
86
87
 
88
+ ### `new Document(quillRef)`
89
+ A blank document: a main card carrying only `$quill`, an empty body, and no
90
+ composable cards — the programmatic blank canvas. Absent fields resolve at
91
+ render time (schema `default`, else type-empty zero), so nothing the caller
92
+ did not set reaches the output. Build it up with `setFields` / `pushCard`.
93
+ For an example-filled starter use `quill.seedDocument()`. Throws on an
94
+ invalid quill reference.
95
+
87
96
  ### `Document.fromMarkdown(markdown)`
88
97
  Parse markdown to a parsed document. Throws a JS `Error` (with `.diagnostics`
89
98
  attached, see [Errors](#errors)) on any parse failure, including a missing
@@ -145,9 +154,9 @@ genuinely malformed Markdown.
145
154
 
146
155
  ### Storage compatibility across versions
147
156
 
148
- The `schema` value (`quillmark/document@0.82.0`) is the **model version**,
157
+ The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
149
158
  not the running crate version. It is a hand-set constant, bumped only when
150
- the `Document` model itself changes — so every `0.82.x` patch release reads
159
+ the `Document` model itself changes — so every `0.93.x` patch release reads
151
160
  and writes that same value.
152
161
 
153
162
  - **Upgrading is safe.** A newer build always reads documents written by an
@@ -188,15 +197,15 @@ and compare instead of re-parsing on every keystroke.
188
197
  ### `doc.cardCount`
189
198
  O(1) getter for the number of composable cards (excluding the main card).
190
199
  Use this to validate indices before calling card mutators (`removeCard`,
191
- `updateCardField`, etc.) without allocating the full `cards` array.
200
+ `setCardField`, etc.) without allocating the full `cards` array.
192
201
 
193
202
  ### `quill.validate(doc)`
194
203
 
195
204
  Returns `Diagnostic[]` — the document validated against the quill schema,
196
205
  without invoking the backend. An empty array means the document is valid.
197
206
  Each diagnostic carries the canonical `validation::*` `code`, `path`, and
198
- `hint`. Includes the non-fatal `validation::field_absent` completeness
199
- signal that `render` demotes (an absent Unendorsed field zero-fills rather
207
+ `hint`. Includes the non-fatal `validation::must_fill` warning for each
208
+ `!must_fill` marker left in the document (render zero-fills these rather
200
209
  than failing), so filter by `severity`/`code` for blockers vs. hints:
201
210
 
202
211
  ```ts
@@ -204,9 +213,9 @@ const diagnostics = quill.validate(Document.fromMarkdown(markdown));
204
213
  const errors = diagnostics.filter(d => d.severity === "error");
205
214
  ```
206
215
 
207
- To render a form editor, read field definitions from `quill.schema` (sort
208
- fields by each field's `ui.order`) and the authored values from the
209
- `Document` payload — there is no separate form-view projection.
216
+ To render a form editor, read field definitions from `quill.schema` (walk
217
+ `fields` in key order — declaration order is display order) and the authored
218
+ values from the `Document` payload — there is no separate form-view projection.
210
219
 
211
220
  ### `quill.seedDocument()`
212
221
 
@@ -223,51 +232,112 @@ const markdown = doc.toMarkdown();
223
232
 
224
233
  For per-card seeding, `quill.seedMain()` returns just the `$kind: main` card
225
234
  and `quill.seedCard(kind)` returns a starter composable card (or `undefined`
226
- if the kind is not declared). Both return the same `Card` shape as
235
+ if the kind is not declared). Both return the read `Card` shape of
227
236
  `doc.main` / `doc.cards`, which `doc.pushCard` / `doc.insertCard` accept
228
237
  directly:
229
238
 
230
239
  ```ts
231
240
  doc.pushCard(quill.seedCard("note")); // seed → push
232
241
  doc.pushCard(Document.makeCard("note", { x: 1 })); // build from a flat map
242
+ doc.pushCard({ kind: "note", body: "Plain **markdown**." }); // bare inline
233
243
  ```
234
244
 
235
- There is one `Card` shape in both directions `pushCard` / `insertCard` take
236
- exactly what `cards` / `removeCard` / `seedCard` return. Build a fresh card
237
- from a flat field map with `Document.makeCard(kind, fields?, body?)`.
245
+ Reads and writes are two aligned shapes. A read `Card` always has `body:
246
+ RichText` (canonical corpus, never a raw string) no narrowing, no guessing
247
+ whether the body was normalized. The write shape `CardInput` widens `body` to
248
+ `RichText | string` (a markdown string imports to the corpus) and makes every
249
+ field but `kind` optional. Every `Card` is a valid `CardInput`, so `pushCard` /
250
+ `insertCard` still take exactly what `cards` / `removeCard` / `seedCard` return.
251
+ Build a fresh card from a flat field map with
252
+ `Document.makeCard(kind, fields?, body?)`.
253
+
254
+ Batch mutation: `doc.setFields({...})` / `doc.setCardFields(index, {...})`
255
+ apply a whole object atomically — on any invalid field nothing is applied and
256
+ the thrown error carries one diagnostic per offending field (`path` = field
257
+ name).
258
+
259
+ ### Typed writes: `commit*` is the default, `set*` is the quill-free primitive
260
+
261
+ A `Document` holds only a `$quill` *reference*, not the resolved schema, so it
262
+ mutates through two layers:
263
+
264
+ - **`commit*` — the schema-bound default whenever a quill is in hand.**
265
+ `doc.commitField(quill, name, value)` / `doc.commitFields(quill, {...})` (and
266
+ the `commitCard*` twins) resolve each field's schema `type`, coerce the value
267
+ to its canonical form (`"3"` → `3`, a markdown string → a richtext corpus),
268
+ and **fail now** on a mismatch instead of at render. A name the schema does
269
+ not declare throws `UnknownField` rather than falling to the opaque store — on
270
+ the typed path an undeclared name is a typo, not a fallback. The batch form is
271
+ all-or-nothing: an undeclared name aborts the whole write and its per-field
272
+ diagnostics name every offending field, so a whole-form submit surfaces every
273
+ typo `setFields` would silently absorb.
274
+
275
+ - **`set*` — the deliberate quill-free primitive.** `doc.setField(name, value)`
276
+ / `doc.setFields({...})` (and the `setCard*` twins) validate only the field
277
+ name/depth/kind and store the value verbatim, no quill required. Reach for it
278
+ on purpose when you *want* the opaque store: quill-agnostic storage/migration
279
+ infra that has no bundle and must write regardless of a drifted schema;
280
+ store-now-validate-later editors holding in-progress input that `commit`
281
+ would reject; or verbatim passthrough of fields the schema doesn't own. It is
282
+ the lower layer, not a lighter `commit` — a typo'd field name stores silently
283
+ and only surfaces at `quill.validate` / render.
284
+
285
+ Per-keystroke cost is the same either way (both mutate the in-memory `Document`
286
+ in place; no seam is crossed), so steering to `commit*` buys the type check for
287
+ free.
288
+
289
+ #### `DocumentWriter` / `CardWriter` — bind the quill once
290
+
291
+ The `commit*` verbs take the `quill` handle per call (the document carries no
292
+ schema). When you hold both a quill and a document — a form editor, an MCP
293
+ writer — bind them once with the writer sugar and issue bare verbs:
238
294
 
239
- ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
295
+ ```ts
296
+ import { DocumentWriter } from "@quillmark/wasm";
297
+
298
+ const ed = new DocumentWriter(quill, doc); // JS twin of Rust `quill.writer(doc)`
299
+ ed.set("subject", "Q3 results"); // strict-committed to the schema type
300
+ ed.setAll({ qty: "3", subject: "Q3" }); // all-or-nothing batch
301
+ ed.set("titel", "x"); // throws UnknownField — a typo, not a fallback
302
+ ed.card(2).set("body", "**note**"); // composable card, resolved by its $kind
303
+ ```
304
+
305
+ `DocumentWriter` / `CardWriter` are pure JS holding references to your existing
306
+ `quill` and `doc` — no WASM handle of their own, nothing to `free()`. `card(i)`
307
+ is lazy: it never throws; an out-of-range index throws `IndexOutOfRange` at the
308
+ write.
240
309
 
241
- > **Experimental:** the entire session surface — `engine.open`,
242
- > `RenderSession`, `paint`, `PaintOptions`, `PaintResult`, `PageSize`, and the
243
- > `supportsCanvas` probe — ships ahead of its first production consumer and
244
- > may change shape in any 0.x release. `engine.render` is the stable path.
310
+ ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
245
311
 
246
312
  Use **`engine.render`** for one-shot exports (PDF/SVG/PNG) — compiles, emits
247
- artifacts, done. Use **`RenderSession`** (returned by `engine.open`) for
248
- reactive previews where
249
- you'll paint or re-emit pages multiple times: the session retains the compiled
250
- snapshot so subsequent `paint` / `render` calls skip recompilation. Don't open
251
- a session per export.
313
+ artifacts, done. Use **`LiveSession`** (returned by `engine.open`) for
314
+ reactive previews: the session is a persistent compiler. `paint` / `render` /
315
+ `regions` / `fieldAt` read its current compile without recompiling, and `apply(doc)`
316
+ recompiles in place on each edit, returning a `ChangeSet` whose `dirtyPages`
317
+ tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional —
318
+ on throw, every read keeps serving the last-good compile. Don't open a session
319
+ per export, and don't re-open per edit — `apply` instead.
252
320
 
253
321
  ### `engine.render(quill, parsed, opts?)`
254
- Render a pre-parsed `Document` against `quill`. Throws `UnsupportedBackend` if
255
- no registered backend matches the quill's declared backend.
322
+ Render a pre-parsed `Document` against `quill`. Throws an
323
+ `engine::backend_not_found` error if no registered backend matches the quill's
324
+ declared backend.
256
325
 
257
326
  ### `engine.open(quill, parsed)` + `session.render(opts?)`
258
327
  Open once, render all or selected pages (`opts.pages`).
259
328
 
260
329
  The session also exposes `pageCount`, `backendId`, `supportsCanvas`,
261
- `warnings` (snapshot of session-level diagnostics attached at `open` time),
262
- `pageSize(page)`, and `paint(ctx, page, opts?)` for canvas previews. See
263
- below.
330
+ `warnings` (non-fatal diagnostics of the current compile — set at `open`,
331
+ refreshed by each committed `apply`),
332
+ `apply(doc)` for in-place recompiles, `pageSize(page)`, and
333
+ `paint(ctx, page, opts?)` for canvas previews. See below.
264
334
 
265
335
  A document that compiles to zero pages still produces a valid session
266
336
  (`pageCount === 0`); `paint(ctx, 0)` and `pageSize(0)` then throw
267
337
  `page index 0 out of range (pageCount=0)`. Branch on `pageCount === 0` to
268
338
  render a "no pages to preview" UI without relying on the throw.
269
339
 
270
- ### Canvas Preview (Typst only)
340
+ ### Canvas Preview
271
341
 
272
342
  `session.paint(ctx, page, opts?)` rasterizes a page directly into a
273
343
  `CanvasRenderingContext2D` (main thread) or
@@ -281,7 +351,7 @@ sets them) and read `layoutWidth` / `layoutHeight` from the returned
281
351
 
282
352
  ```ts
283
353
  const result = session.paint(canvas.getContext("2d"), 0, {
284
- layoutScale: 1, // layout px per Typst pt
354
+ layoutScale: 1, // layout px per pt (page geometry unit)
285
355
  densityScale: window.devicePixelRatio, // backing-store density
286
356
  });
287
357
 
@@ -291,17 +361,26 @@ canvas.style.height = `${result.layoutHeight}px`;
291
361
 
292
362
  - `layoutScale` (default 1) sets the canvas's display-box size:
293
363
  `layoutWidth = widthPt * layoutScale`. For on-screen canvases this is
294
- CSS pixels per Typst point. Defaults to 1 (one CSS pixel per pt).
364
+ CSS pixels per point. Defaults to 1 (one CSS pixel per pt).
295
365
  - `densityScale` (default 1) is the backing-store density multiplier.
296
366
  Fold `window.devicePixelRatio`, in-app zoom, and `visualViewport.scale`
297
367
  (pinch-zoom) into a single value here. Pass `devicePixelRatio` for
298
368
  crisp output on high-DPI displays.
299
369
  - The effective rasterization scale is `layoutScale * densityScale`. If
300
370
  that would exceed the safe maximum (16384 px per side), `densityScale`
301
- is clamped proportionally; compare `result.pixelWidth` against
302
- `Math.round(result.layoutWidth * densityScale)` to detect.
371
+ is clamped proportionally; `result.clamped` reports it and
372
+ `result.effectiveDensityScale` is the density actually applied. A
373
+ clamped page renders soft at the same `canvas.style` size.
374
+ - `paint` writes the whole backing store with `putImageData`, which
375
+ ignores the 2D context transform, `globalAlpha`, and clip. Give each
376
+ visible page its own `` element — you cannot composite two pages,
377
+ a sub-rect, or a context transform through `paint`.
303
378
  - `paint` is always a full repaint — setting the backing-store width /
304
- height clears it. No `clearRect` required.
379
+ height clears it. No `clearRect` required. Each call re-rasterizes from
380
+ scratch (no per-page raster cache), so keep a page's canvas alive while
381
+ it stays near the viewport rather than pooling one canvas across pages:
382
+ an idle canvas retains its pixels for free, whereas reusing a canvas on
383
+ scroll re-runs a full render.
305
384
  - `pageCount` and `pageSize(page)` are stable for the session's
306
385
  lifetime (immutable snapshot) — cache them.
307
386
  - Worker support: pass an `OffscreenCanvasRenderingContext2D` and the
@@ -319,19 +398,19 @@ canvas.style.height = `${result.layoutHeight}px`;
319
398
 
320
399
  A field's *cell* is inferred from whether its schema declares a `default:`:
321
400
 
322
- - **Unendorsed** (no `default:`) — `quill.blueprint` renders `<must-fill>`
323
- in the value cell. An absent Unendorsed field is a non-fatal signal
324
- (`validation::field_absent`) the render path zero-fills it
325
- silently. A surviving `<must-fill>` sentinel is fatal
326
- (`validation::must_fill_sentinel`). Partial documents are
327
- first-class; `engine.render(quill, doc)` only throws for malformed input.
401
+ - **Unendorsed** (no `default:`) — `quill.blueprint` renders the
402
+ `!must_fill` marker in the value cell (carrying the field's `example` as a
403
+ suggested value when one exists). An absent Unendorsed field zero-fills
404
+ silently. A `!must_fill` marker left in the document is non-fatal: it emits
405
+ the `validation::must_fill` warning and still renders. Partial documents
406
+ are accepted; `engine.render(quill, doc)` only throws for malformed
407
+ input.
328
408
  - **Endorsed** (with `default:`) — `quill.blueprint` renders the
329
- default value followed by a `; delete-ok` annotation, and the default
330
- is used when the document omits the field.
409
+ default value with a type-only `# <type>` annotation (shippable as-is),
410
+ and the default is used when the document omits the field.
331
411
 
332
- `QuillFieldSchema` has no `required` axis. Absent Unendorsed fields emit
333
- `validation::field_absent`; a surviving `<must-fill>` sentinel emits
334
- `validation::must_fill_sentinel`.
412
+ `QuillFieldSchema` has no `required` axis. A `!must_fill` marker left in the
413
+ document emits the non-fatal `validation::must_fill` warning.
335
414
 
336
415
  ### Errors
337
416
 
@@ -367,20 +446,26 @@ compilation failures. The same shape applies to every throw site:
367
446
 
368
447
  - `Document.fromMarkdown` — parse errors (missing root `$quill` metadata, YAML
369
448
  errors, `parse::input_too_large` for inputs > 10 MB).
370
- - `Document` mutators (`setField`, `updateCardField`, etc.) — `EditError`
449
+ - `Document` mutators (`setField`, `setCardField`, etc.) — `EditError`
371
450
  variants (`InvalidFieldName`, `InvalidKindName`, `ReservedKind`,
372
- `IndexOutOfRange`) appear in `diagnostics[0].message` with the
373
- `[EditError::<Variant>]` prefix.
451
+ `IndexOutOfRange`, `ValueTooDeep`) appear in `diagnostics[0].message` with
452
+ the `[EditError::<Variant>]` prefix.
374
453
  - `engine.render` / `session.render` — backend compilation failures and
375
454
  validation errors.
376
455
 
377
456
  ### Lifecycle
378
457
 
379
458
  The wasm bindings are built with `--weak-refs`, so dropped `Document`,
380
- `Quill`, and `RenderSession` handles are reclaimed by `FinalizationRegistry`
459
+ `Quill`, and `LiveSession` handles are reclaimed by `FinalizationRegistry`
381
460
  without manual `.free()` discipline. `.free()` is still emitted as an eager
382
461
  teardown hook for callers that want deterministic release.
383
462
 
463
+ `engine.render` and `engine.open` read the `quill` and `doc` handles
464
+ synchronously, before their first await, so freeing a handle as soon as the
465
+ call returns — `try { return engine.render(quill, doc); } finally
466
+ { doc.free(); }` — is safe even on the first render, while the backend
467
+ binary is still loading.
468
+
384
469
  The package floor is Node 22+ (`engines: { node: ">=22" }`) and current
385
470
  evergreen browsers; `--weak-refs` itself only needs Node 14.6+. The `using`
386
471
  sugar shown below ([explicit resource management][erm]) needs Node 24, but is
@@ -390,7 +475,7 @@ For environments where `using` (the [explicit resource management][erm]
390
475
  proposal) hasn't landed, use an explicit `try` / `finally`:
391
476
 
392
477
  ```ts
393
- const session = engine.open(quill, doc);
478
+ const session = await engine.open(quill, doc);
394
479
  try {
395
480
  for (let p = 0; p < session.pageCount; p++) {
396
481
  session.paint(ctx, p);
@@ -405,7 +490,7 @@ try {
405
490
  ## Notes
406
491
 
407
492
  - Parsed markdown requires a root `~~~` block (a bare three-tilde fence;
408
- the legacy `~~~card-yaml` opener is still accepted but non-canonical)
493
+ `~~~card-yaml` is also accepted as a non-canonical alias)
409
494
  with a `$quill` system-metadata line. Empty input surfaces a dedicated
410
495
  "Empty markdown input cannot be parsed" message.
411
496
  - A `$quill` mismatch during `engine.render(quill, parsed)` is a thrown error, not a warning: rendering with a quill whose *name* differs (`quill::name_mismatch`) or whose *version* falls outside the selector (`quill::version_mismatch`) is rejected.
@@ -413,8 +498,8 @@ try {
413
498
 
414
499
  ## Changelog
415
500
 
416
- See the [changelog](https://github.com/quillmark-org/quillmark/blob/main/CHANGELOG.md)
417
- and the [GitHub Releases](https://github.com/quillmark-org/quillmark/releases) page for
501
+ See the [changelog](https://github.com/borb-sh/quillmark/blob/main/CHANGELOG.md)
502
+ and the [GitHub Releases](https://github.com/borb-sh/quillmark/releases) page for
418
503
  release notes and version history.
419
504
 
420
505
  ## License