@quillmark/wasm 0.101.0 → 0.103.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/CHANGELOG.md CHANGED
@@ -1,5 +1,186 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.103.0 - 2026-08-09
4
+
5
+ - docs: `docs/integration/operations.md`, carrying what the other integration
6
+ pages leave unsaid: that **render is not bounded** — no deadline, no
7
+ cancellation, and the parse limits do not carry through — with the
8
+ worker-termination recipe that is the only abort a browser has; that
9
+ `Quillmark`, `Quill` and `Document` are `Send + Sync`, pinned by a test rather
10
+ than asserted; `comemo` eviction as what a long-lived process's memory tracks;
11
+ the no-network, no-ambient-filesystem isolation properties; and that a panic
12
+ is terminal on every surface.
13
+ - docs: `parse::input_too_large` carries four of the five §8 caps, separable
14
+ only by its `max` arg, which `error-handling.md` now says where it names the
15
+ code.
16
+ - test(cli): `quillmark-cli` gets its first tests. The bin carries
17
+ `test = false`, so twelve cases drive the built executable instead — every
18
+ subcommand, `-o` and `--stdout`, PDF and SVG output, and the error paths,
19
+ which must exit 1 rather than panic. The crate had no `[dev-dependencies]`
20
+ and no workflow invoked it (#1068).
21
+ - fix(cli)!: `render --verbose` writes its progress lines to stderr, as the
22
+ warning printer already did. Under `--stdout` they went to stdout ahead of and
23
+ after the artifact, so `quillmark render q --stdout --verbose > out.pdf`
24
+ produced a PDF with `Loading quill from: …` before its header and
25
+ `Rendering completed successfully` past its trailer. A script that parses
26
+ `--verbose` output from stdout reads it from stderr now.
27
+ - test(fuzz): `pdf_fuzz` covers the AcroForm stamp spine's byte-level reads,
28
+ the one hand-rolled parser with no fuzz target. Arbitrary bytes, and a real
29
+ form truncated, single-byte-corrupted, or spliced, all through
30
+ `page_media_boxes` / `PdfUpdate::begin` / `stamp`. The oracle is no panic:
31
+ nothing in the workspace catches unwind, so a panic there kills the CLI and
32
+ the Python extension and poisons the WASM module. No failures found.
33
+ - fix(core): `MAX_FIELD_COUNT`'s rustdoc said "per document"; the check is per
34
+ card-yaml block, counted after `$`-key extraction.
35
+ - refactor(wasm)!: `init()` resolves to the core surface, and it is the only way
36
+ to reach one. `Quill`, `Document`, `importMarkdown`, `exportMarkdown`,
37
+ `rebase`, `mapPos`, `parseDocPath` and `formatDocPath` leave the static
38
+ exports of `@quillmark/wasm`: `const { Quill, Document } = await init()`
39
+ replaces the value import. The precondition was carried entirely by
40
+ `init`'s signature, and a floating promise is an ESLint rule rather than a
41
+ `tsc` diagnostic, so a call site that skipped the await type-checked and then
42
+ passed or failed by load order. It now has no name to call. `Engine`,
43
+ `MAIN_CARD_ADDR`, `isQuillmarkError`, the open-set guards and the
44
+ writer/reader classes are unchanged, needing no instance or gated by their
45
+ arguments; the `Quill` / `Document` **type** exports are unchanged, so
46
+ annotations and `import type` compile as before. Class identity is untouched:
47
+ the gate hands out the core build's classes verbatim, and `instanceof` stays
48
+ the whole membership test. `runtime::not_initialized` and the build-time
49
+ sentinel that raised it retire with the door they guarded. Rust, Python,
50
+ documents and stored blobs are unaffected. See
51
+ `docs/migrations/0.102-to-0.103.md`
52
+
53
+ <!-- seed: commits since v0.102.0, confirm the entries above cover them, then delete this comment
54
+ - ci: tag the merge commit, and serialize a release against itself
55
+ - ci: drop the tagger GitHub App from the release workflows
56
+ - Cut SECURITY.md, and operations.md down to what only it can say
57
+ - Drop the audit gate; keep the reachability finding in ci.yml's note
58
+ - Fix two wrong claims, a dead property, and the stdout bug the review found
59
+ - dense-prose pass over the added prose, and read the fixture once
60
+ - Close four production-readiness gaps: audit gate, ops docs, CLI tests, PDF fuzzing
61
+ - Drop the README's claim about the deleted sentinel patch
62
+ - dense-prose pass: cut the restatement the change introduced
63
+ - docs: the 0.102 → 0.103 step, and correct the surface it describes
64
+ - The gate is the only door to the core surface
65
+ - docs(migration): a span guide for 0.92 → 0.102
66
+ - Cut over-specified style rules; author preference wins
67
+ - dense-prose: remove the em-dash ban
68
+ - dense-prose: drop the line-budget clause
69
+ - dense-prose: wrong is worse than missing; relax em-dash ban
70
+ - docs: dense-prose pass over the conform-scope and date-grammar prose
71
+ - docs: migration guides are era-stamped, not immutable
72
+ - docs(migrations): give the datetime split its corpus audit step
73
+ - docs: scope conform to content fields, state the date corpus precondition
74
+ - Tighten the failure-delivery prose
75
+ - Deliver init's conflict as a rejection, not a synchronous throw
76
+ -->
77
+
78
+
79
+ ## v0.102.0 - 2026-08-04
80
+
81
+ The pre-1.0 vocabulary reset. Verbs, diagnostic codes, and two words that meant
82
+ different things at different altitudes. Documents and stored blobs are
83
+ untouched: no document reparses, no blob remigrates, and the storage wire format
84
+ is byte-identical, but nearly every consumer touches at least one renamed verb.
85
+ The diagnostic codes are the part that could not wait, since consumers route on
86
+ them and 1.0 freezes them. All breaking changes are covered by
87
+ `docs/migrations/0.101-to-0.102.md`. Only two behaviors change, both called out
88
+ below. Separately, `@quillmark/wasm` ships `--target web`, which makes
89
+ `await init()` mandatory and the bundler plugins the package used to demand
90
+ unnecessary.
91
+
92
+ - refactor(core,wasm,python)!: the verb vocabulary is reset to say what the code
93
+ does. `install_body` / `install_field` / `doc.install` become `overwrite_*` /
94
+ `doc.overwrite`: the content lane is a ladder sorted by the fate of the
95
+ identity anchors already on the value (overwrite destroys, revise rebases,
96
+ apply preserves), and only the first keeps nothing, which grouping the three as
97
+ "identity-aware" hid. `LiveSession::apply` becomes `update`, taking the trait
98
+ seam, the feature-gated raw-plate seam, and `backend::apply_unsupported` →
99
+ `backend::update_unsupported` with it, so the content lane's `applyChange`
100
+ splice is no longer a homograph of a whole-document recompile.
101
+ `apply_field_richtext_change` becomes `apply_field_change`, matching its
102
+ schema-blind neighbour `revise_field`, which never carried the codec in its
103
+ name. `store_seed_namespace` / `remove_seed_namespace` become
104
+ `store_seed_overlay` / `remove_seed_overlay`, closing the asymmetry with the
105
+ `seedOverlay` read and the `SeedOverlay` type that already shipped: `$seed` is
106
+ keyed by a validated card-kind, `$ext` by a free-form consumer namespace.
107
+ `getMarkdown` and reader `get_body` become `bodyMarkdown` / `body_markdown`,
108
+ the name core already used, so one projection has one name on every surface
109
+ (#1186). See `docs/migrations/0.101-to-0.102.md`
110
+ - refactor(core,wasm,python)!: the mutator and validation diagnostic codes are
111
+ corrected, and the content-field failures carry the codec that ran.
112
+ `edit::field_richtext_decode` becomes `edit::field_decode` and
113
+ `edit::field_richtext_not_inline` becomes `edit::field_not_inline`, each
114
+ gaining a `codec` arg (`"richtext"` or `"plaintext"`): both were raised for
115
+ plaintext failures under a richtext name, and `plaintext(inline)` fell through
116
+ to the generic code. `edit::field_conform` becomes
117
+ `edit::field_coercion_failed`, killing the `conform::field_conform` stutter and
118
+ landing the variant beside its real twin `validation::coercion_failed`, minted
119
+ from the same `CoercionError`. `richtext::not_inline` and
120
+ `plaintext::not_plain` become `validation::not_inline` / `validation::not_plain`,
121
+ stage-namespaced like every other code. The `conform::*` twins rename in
122
+ lockstep. Route on the code and read `codec` for the lane (#1186). See
123
+ `docs/migrations/0.101-to-0.102.md`
124
+ - fix(core)!: a splice against an absent field is no longer a decode error. The
125
+ writer `set_body` / `setBody` becomes `revise_body` / `reviseBody` and returns
126
+ the text `Delta` it was already computing (a body carries no field schema, so a
127
+ typed-lane verb had nothing to type); Python discards the receipt, as on
128
+ `revise_field`. `apply_field_change` on an absent field splices against the
129
+ empty content, as `revise_field` diffs against it, instead of reporting
130
+ `FieldRichtextDecode { message: "field is absent" }`: the one place in the API
131
+ where a missing field was not simply `None`. No error is lost. A bundle that
132
+ expected content still fails, and now reports the condition it hit, since the
133
+ text delta declares the base length it was computed against, so a stale splice
134
+ lands as `edit::content_apply`. Route a vanished-field check on that code, or
135
+ check presence before splicing. These are the release's only behavior changes
136
+ (#1186). See `docs/migrations/0.101-to-0.102.md`
137
+ - refactor(core)!: `EditError::variant_name` is removed and the typed primitives
138
+ leave the documented surface. The bare variant name was a second discriminator
139
+ kept in lockstep with `code()` that nothing routed on: both binding error
140
+ mappers stamp `code()` onto the `Diagnostic` they raise, and `ERROR.md` has
141
+ said identity is the code since the `edit::*` family landed. Assert on `code()`
142
+ or match the enum, which is `#[non_exhaustive]` either way. `Card::commit_field`
143
+ and `Card::revise_field_checked` become `#[doc(hidden)]`: a resolved
144
+ `FieldSchema` argument was the only thing telling them from their opaque and
145
+ schema-blind neighbours, and disambiguating by argument is a third mechanism
146
+ beside the receiver and the verb. `quill.writer(&mut doc)` is the typed door on
147
+ every surface now, core included; the primitives stay callable on the same
148
+ terms as the other hidden items (#1186). See `docs/migrations/0.101-to-0.102.md`
149
+ - docs(content,core)!: restoring a deleted island re-lands its original id, and
150
+ the island channel states two contracts it had left to the implementation. The
151
+ never-ambient minting rule (continue the field's positional `isl-{n}` sequence,
152
+ never a UUID or a clock reading) covers a *new* island; a delete frees its id
153
+ and the id travels back with the island, so an editor that mints fresh on undo
154
+ renames it, moving the content hash of a document the user believes they
155
+ restored. A pasted copy of a live island is new and mints fresh. Alongside it:
156
+ an island op's `at` is *sequenced*, counting the text the delta and this
157
+ bundle's earlier island ops left rather than the shared post-delta frame; and a
158
+ producer's whole-field diff that carries an island slot must split into the
159
+ slot-free `delta` plus one `Insert` per slot, since a slot in an insert string
160
+ orphans. Neither is a behavior change, and a producer that guessed wrong got a
161
+ wrong document rather than an error (#1185)
162
+ - feat(wasm)!: the package ships `--target web` and the runtime owns
163
+ instantiation, so `await init()` once at startup is required before any export
164
+ is used. `--target bundler` emitted `import * as wasm from "./wasm_bg.wasm"`,
165
+ which no browser and no bundler resolves natively; the plugin that fixed it
166
+ rewrote the import into a top-level await, and because the runtime statically
167
+ re-exports core, that await landed on the static module graph of everything
168
+ importing `@quillmark/wasm`: a permanent constraint on consumer architecture
169
+ and a blank SvelteKit route in Safari's dev server that neither Chrome nor
170
+ `vite build` showed. In exchange, `vite-plugin-wasm` and
171
+ `vite-plugin-top-level-await` are no longer needed, a static import is safe
172
+ anywhere including SSR, and plain Node can import the package at all (the ESM
173
+ `.wasm` import needed `--experimental-wasm-modules`). Only
174
+ `optimizeDeps: { exclude: ['@quillmark/wasm'] }` stays, for Vite's dev server.
175
+ `init` memoizes its promise, so several entry points share one instantiation
176
+ and a failed attempt clears the memo for a retry; backends are not the
177
+ consumer's to initialize, since `Engine` instantiates one inside its lazy load.
178
+ Reaching the surface early throws `runtime::not_initialized` naming the fix,
179
+ and `build-wasm.sh` asserts both the guard's anchors and that no artifact
180
+ carries a `.wasm` import or a top-level await. `initSync` is not exported: the
181
+ capability remains through `init(source)`, which takes bytes, a `Response`, a
182
+ `WebAssembly.Module`, or a URL (#1189). See `docs/migrations/0.101-to-0.102.md`
183
+
3
184
  ## v0.101.0 - 2026-08-03
4
185
 
5
186
  - refactor(core): gate the raw-plate seam behind a feature, and fixes from review
package/README.md CHANGED
@@ -10,11 +10,11 @@ Use Quillmark in browsers/Node.js with explicit in-memory trees (`Map<string, Ui
10
10
 
11
11
  The package exposes **one import surface**:
12
12
 
13
- - `@quillmark/wasm` (the root), the **canonical API**: `Quill`, `Document`, and
14
- an `Engine` that renders them.
13
+ - `@quillmark/wasm` (the root), the **canonical API**: `init`, resolving to
14
+ `Quill` and `Document`, and an `Engine` that renders them.
15
15
 
16
- `Quill` and `Document` are re-exported verbatim from the internal Typst-less
17
- core build, so editor/validation code (`Quill.fromTree`,
16
+ `Quill` and `Document` are the internal Typst-less core build's own classes,
17
+ handed out verbatim by `init`, so editor/validation code (`Quill.fromTree`,
18
18
  `Document.fromMarkdown`) loads only that small core binary: no backend is
19
19
  loaded until you render. The `Engine` hides everything else: each backend
20
20
  (`typst`, `pdfform`) is a separate, private WASM binary with its own linear
@@ -30,8 +30,9 @@ bash scripts/build-wasm.sh
30
30
 
31
31
  The script builds three variants: the core (no backend), the Typst backend
32
32
  (default features), and the Typst-free pdfform backend (`pdfform` feature):
33
- each with `--target bundler` and `--weak-refs` enabled (see
34
- [Lifecycle](#lifecycle)).
33
+ each with `--target web` and `--weak-refs` enabled (see
34
+ [Initialization](#initialization) and [Lifecycle](#lifecycle)). It then asserts
35
+ none of them carries a `.wasm` ESM import or a top-level await.
35
36
 
36
37
  ## Test
37
38
 
@@ -45,7 +46,9 @@ npm test
45
46
  ## Usage
46
47
 
47
48
  ```ts
48
- import { Document, Quill, Engine } from "@quillmark/wasm";
49
+ import { init, Engine } from "@quillmark/wasm";
50
+
51
+ const { Quill, Document } = await init(); // see Initialization
49
52
 
50
53
  const quill = Quill.fromTree(tree); // no engine needed: build + validate
51
54
  const engine = new Engine(); // loads a backend lazily on first render
@@ -62,6 +65,58 @@ const parsed = Document.fromMarkdown(markdown);
62
65
  const result = await engine.render(quill, parsed, { format: "pdf" });
63
66
  ```
64
67
 
68
+ ## Initialization
69
+
70
+ `init` resolves to the core surface: `Quill`, `Document`, and the free
71
+ functions. Everything after the await is the synchronous surface the rest of
72
+ this README describes.
73
+
74
+ ```js
75
+ import { init, Engine } from "@quillmark/wasm";
76
+ const { Quill, Document } = await init();
77
+ ```
78
+
79
+ The same line works everywhere: the binary streams from a URL in a browser and
80
+ is read off disk under Node, chosen by the package's `#quillmark-env` subpath
81
+ import rather than a runtime environment check. No bundler plugin is required:
82
+ the builds are `--target web`, so nothing in the package graph imports a `.wasm`
83
+ module or carries a top-level await, and a static `import` of this package is
84
+ safe anywhere, SSR included.
85
+
86
+ `init` is idempotent and concurrency-safe: every non-conflicting call returns
87
+ the same promise, so several entry points may each `await init()` for one
88
+ instantiation. Destructure at **every** entry point (route loader, hydration
89
+ path, worker) rather than threading one result around. A failed init clears the
90
+ memo, so a retry works. Each realm initializes its own copy, a Worker included.
91
+
92
+ **Backends need nothing.** `Engine` instantiates a backend inside its lazy load,
93
+ on the first render against it.
94
+
95
+ **Overriding the source.** `init(source)` accepts bytes, a `Response`, a
96
+ `WebAssembly.Module`, or a URL, for hosts that route assets themselves or embed
97
+ the binary. Pass it on the first call; a later call passing a *different* source
98
+ rejects with `runtime::init_conflict` rather than silently ignoring it. Passing
99
+ the same value again is fine, so several entry points may each
100
+ `await init(BYTES)` against one constant.
101
+
102
+ **Both failures reject.** `runtime::init_conflict` and `runtime::init_failed`
103
+ alike ride the returned promise, so one `catch` around `await init(...)` covers
104
+ the gate. See [Errors](#errors) for the rule this follows.
105
+
106
+ **You cannot forget.** The core surface has no static export, so a call site
107
+ that skips the await has no name to call. The precondition is structural rather
108
+ than a convention: no load order can make one entry point pass and another fail.
109
+
110
+ **Vite's dev server** pre-bundles dependencies, which moves the package away
111
+ from its binary. Exclude it:
112
+
113
+ ```js
114
+ // vite.config.js
115
+ export default { optimizeDeps: { exclude: ["@quillmark/wasm"] } };
116
+ ```
117
+
118
+ A load failure surfaces as `runtime::init_failed`, whose hint names that line.
119
+
65
120
  ## API
66
121
 
67
122
  ### `new Engine(options?)`
@@ -107,7 +162,7 @@ The **bound door**, and the primary ingestion path. `quill.parse` is
107
162
  `Document.fromMarkdown` followed by `conform`: the returned document's declared
108
163
  content fields rest at one form per codec (a `richtext` field as the canonical
109
164
  content object, a `plaintext` field as its literal string), so `getStored`
110
- answers "corpus or string?" by the field's declared codec rather than by how the
165
+ answers "content object or string?" by the field's declared codec rather than by how the
111
166
  document was built. Parse warnings and the `conform::*` warnings both ride
112
167
  `doc.warnings`.
113
168
 
@@ -199,13 +254,13 @@ and writes that same value.
199
254
  To detect a version mismatch before parsing, use the static accessors:
200
255
 
201
256
  ```ts
202
- const v = Document.schemaVersionOf(blob); // undefined | string
203
- if (v && v !== Document.currentSchemaVersion()) {
257
+ const v = Document.storageVersionOf(blob); // undefined | string
258
+ if (v && v !== Document.currentStorageVersion()) {
204
259
  // payload is from a build with a different model version
205
260
  }
206
261
  ```
207
262
 
208
- `schemaVersionOf` does not validate the payload: it only reads the
263
+ `storageVersionOf` does not validate the payload: it only reads the
209
264
  `schema` field, returning `undefined` for non-JSON, non-objects, or
210
265
  payloads that don't carry one. Use it to distinguish "wrong version" from
211
266
  "corrupt" when `fromJson` throws.
@@ -285,11 +340,11 @@ main card's `qty`, `doc.storeField({ card: 2, field: "qty" }, 3)` a composable
285
340
  card's. Reads are total over the field axis (`getStored` → `undefined`, `isFill` → `false` for
286
341
  an absent field; only an out-of-range card throws); field writes throw on a body
287
342
  address. `getStored` is the verbatim transport read, distinct from the interpreted
288
- `quill.reader(doc).get`; `getMarkdown` is the body markdown read (a `CardAddr`; a field's
343
+ `quill.reader(doc).get`; `bodyMarkdown` is the body markdown read (a `CardAddr`; a field's
289
344
  markdown is read through `quill.reader(doc).get(field)`). A content field's stored
290
345
  form follows how the document was built (a canonical content object when the
291
346
  typed writer committed it, the authored string when a markdown parse produced
292
- it), so for the corpus either way read `quill.reader(doc).getContent(addr)`, which
347
+ it), so for the `Content` either way read `quill.reader(doc).getContent(addr)`, which
293
348
  decodes through the codec the field's declared type names. Card-scoped verbs take a
294
349
  `CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`, and the batch below.
295
350
 
@@ -309,7 +364,7 @@ writes go through the schema-bound writer while the quill-free opaque store sits
309
364
  on `Document` itself (**store** = verbatim, **set** = typed):
310
365
 
311
366
  - **`quill.writer(doc)`: the typed door whenever a quill is in hand.** Bind the
312
- schema once and issue bare `set` / `setAll` / `setBody` / `reviseField` /
367
+ schema once and issue bare `set` / `setAll` / `reviseBody` / `reviseField` /
313
368
  `addCard` / `card(i)`. Each resolves the field's schema `type`, coerces the
314
369
  value to its canonical form (`"3"` → `3`, a markdown string → a richtext
315
370
  content), and **fails now** on a mismatch instead of at render. A name the schema
@@ -362,16 +417,16 @@ write.
362
417
  ```ts
363
418
  const v = quill.reader(doc);
364
419
  v.get("subject"); // by declared type: richtext → markdown, plaintext → literal text
365
- v.getContent("subject"); // the same read as a Content corpus, whichever lane stored it
366
- v.getBody(); // the main body markdown (quill-free)
420
+ v.getContent("subject"); // the same read as a `Content`, whichever lane stored it
421
+ v.bodyMarkdown(); // the main body markdown (quill-free)
367
422
  v.card(0).get("body"); // a card field, resolved by its $kind
368
423
  ```
369
424
 
370
- `get` projects and `getContent` returns the corpus; both decode through the codec
425
+ `get` projects and `getContent` returns the `Content`; both decode through the codec
371
426
  the field's **declared type** names, which is why they bind the quill and the
372
427
  verbatim `doc.getStored` does not. An undeclared name throws `UnknownField`, a
373
428
  type that is not a content leaf throws `FieldNotContent`, and an undecodable
374
- value throws `FieldRichtextDecode`; an absent field reads back `undefined`.
429
+ value throws `FieldDecode`; an absent field reads back `undefined`.
375
430
 
376
431
  ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
377
432
 
@@ -497,6 +552,14 @@ try {
497
552
  }
498
553
  ```
499
554
 
555
+ **Delivery follows the function, not the failure.** A synchronous method throws;
556
+ a promise-returning one rejects. The promise-returning surface is `init` and the
557
+ four `Engine` verbs (`render`, `open`, `supportedFormats`, `supportsCanvas`), so
558
+ a programming error reached through one of them (a foreign handle, an
559
+ unregistered backend) rejects like any other failure. Nothing here both returns
560
+ a promise and throws, so a `.catch` on a promise-returning call is a whole
561
+ guard.
562
+
500
563
  `QuillmarkError` is a **structural interface, not a class**: the WASM layer
501
564
  throws a real `Error` and attaches the property, so there is no constructor to
502
565
  `instanceof` against; narrow with `isQuillmarkError` (which also works on
@@ -515,7 +578,7 @@ compilation failures. The same shape applies to every throw site:
515
578
  - `Document` mutators (`storeField`, the writer's `set`, etc.): mutator
516
579
  failures carry a namespaced `edit::*` `code` on `diagnostics[0]`
517
580
  (`edit::invalid_field_name`, `edit::unknown_field`, `edit::index_out_of_range`,
518
- `edit::field_conform`, …). Route on `diagnostics[0].code`, never on message
581
+ `edit::field_coercion_failed`, …). Route on `diagnostics[0].code`, never on message
519
582
  text.
520
583
  - `engine.render` / `session.render`: backend compilation failures and
521
584
  validation errors.
@@ -527,6 +590,9 @@ compilation failures. The same shape applies to every throw site:
527
590
 
528
591
  ### Lifecycle
529
592
 
593
+ Handles begin at [`init`](#initialization), which instantiates the core build;
594
+ `Engine` instantiates a backend on the first render against it.
595
+
530
596
  The wasm bindings are built with `--weak-refs`, so dropped `Document`,
531
597
  `Quill`, and `LiveSession` handles are reclaimed by `FinalizationRegistry`
532
598
  without manual `.free()` discipline. `.free()` is still emitted as an eager