@quillmark/wasm 0.111.0 → 0.113.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
@@ -15,10 +15,10 @@ The package has one import surface: `@quillmark/wasm`, whose `init` resolves to
15
15
  `Quill` and `Document` are the internal Typst-less core build's own classes,
16
16
  handed out verbatim by `init`, so editor/validation code (`Quill.fromTree`,
17
17
  `Document.fromMarkdown`) loads only that small core binary: no backend is
18
- loaded until you render. The `Engine` hides everything else: each backend
19
- (`typst`, `pdfform`) is a separate, private WASM binary with its own linear
20
- memory, lazily loaded on the first render. The Engine clones a `Quill` /
21
- `Document` into the backend's memory as data and frees the clones: you never
18
+ loaded until you render. The `Engine` hides everything else: the render build,
19
+ carrying both backends (`typst`, `acroform`), is a private WASM binary with its
20
+ own linear memory, lazily loaded on the first render. The Engine clones a
21
+ `Quill` / `Document` into that memory as data and frees the clones: you never
22
22
  hold a backend object or cross a memory boundary yourself.
23
23
 
24
24
  ## Build
@@ -27,9 +27,9 @@ hold a backend object or cross a memory boundary yourself.
27
27
  bash scripts/build-wasm.sh
28
28
  ```
29
29
 
30
- The script builds three variants: the core (no backend), the Typst backend
31
- (default features), and the Typst-free pdfform backend (`pdfform` feature):
32
- each with `--target web` and `--weak-refs` enabled (see
30
+ The script builds two variants, the core (no backend) and the render build
31
+ (default features, both backends), each with `--target web` and `--weak-refs`
32
+ enabled (see
33
33
  [Initialization](#initialization) and [Lifecycle](#lifecycle)). It then asserts
34
34
  none of them carries a `.wasm` ESM import or a top-level await.
35
35
 
@@ -119,18 +119,17 @@ A load failure surfaces as `runtime::init_failed`, whose hint names that line.
119
119
  Create the render dispatcher. Routes each quill to its backend by
120
120
  `quill.backendId`, lazily loads that backend binary, and renders: cloning the
121
121
  quill/document into the backend's memory and freeing the clones internally.
122
- `render`, `open`, `supportedFormats`, and `supportsCanvas` are **async** (the
123
- first call may load a backend). Pass `{ backends }` to register or override
124
- backend descriptors. Each entry is a descriptor
125
- (`{ [backendId]: { load, formats, canvas } }`) where `load` is the lazy thunk
126
- returning the backend module and `formats`/`canvas` are the **required** static
127
- capability manifest. A malformed descriptor throws at `new Engine(...)`, naming
128
- the backend id.
129
-
130
- **Capability probes are always free.** `supportedFormats` and `supportsCanvas`
131
- depend only on `quill.backendId`, and answer from the descriptor's required
132
- `formats`/`canvas` manifest: never loading the multi-MB backend binary and
133
- never cloning the quill. Use them as non-failing pre-render probes.
122
+ `render`, `open`, and `supportedFormats` are **async** (the first call may load
123
+ a backend). Pass `{ backends }` to register or override backend descriptors.
124
+ Each entry is a descriptor (`{ [backendId]: { load, formats } }`) where `load`
125
+ is the lazy thunk returning the backend module and `formats` is the
126
+ **required** static capability manifest. A malformed descriptor throws at
127
+ `new Engine(...)`, naming the backend id.
128
+
129
+ **The format probe is always free.** `supportedFormats` depends only on
130
+ `quill.backendId`, and answers from the descriptor's required `formats`
131
+ manifest: never loading the multi-MB backend binary and never cloning the
132
+ quill. Use it as a non-failing pre-render probe.
134
133
 
135
134
  ### The two doors: `Document.fromMarkdown` vs `quill.parse` / `quill.conform`
136
135
 
@@ -148,7 +147,7 @@ document was built. Parse warnings and the `conform::*` warnings both ride
148
147
  `doc.warnings`.
149
148
 
150
149
  `quill.conform(doc)` is the same walk in place on a document that arrived any
151
- other way (`fromJson`, a stored row), returning the `conform::*` `Diagnostic[]`
150
+ other way (`fromStored`, a stored row), returning the `conform::*` `Diagnostic[]`
152
151
  (`[]` when everything rested). It is idempotent and a byte no-op on an
153
152
  already-canonical document, YAML comments included, so calling it on every load
154
153
  is safe. A `!must_fill` marker anywhere in a field's value skips that field; a
@@ -158,33 +157,39 @@ mutation.
158
157
 
159
158
  ```ts
160
159
  const doc = quill.parse(markdown); // rests canonical
161
- const stale = Document.fromJson(row);
160
+ const stale = Document.fromStored(row);
162
161
  const diags = quill.conform(stale); // converges in place
163
162
  ```
164
163
 
165
164
  ### Storage compatibility across versions
166
165
 
167
- Persist `doc.toJson()`, not `doc.toMarkdown()`: the DTO wire format is frozen
166
+ Persist `doc.toStored()`, not `doc.toMarkdown()`: the DTO wire format is frozen
168
167
  per `schema` version, whereas Markdown syntax evolves, and `toMarkdown` output
169
- is normalised rather than byte-equal to the source. `Document.tryFromJson`
170
- discriminates the two formats without exceptions as control flow:
168
+ is normalised rather than byte-equal to the source. `Document.storageVersionOf`
169
+ discriminates the two formats without exceptions as control flow: it answers
170
+ `undefined` for anything that is not a storage DTO.
171
171
 
172
172
  ```ts
173
- const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
173
+ const doc = Document.storageVersionOf(content)
174
+ ? Document.fromStored(content)
175
+ : Document.fromMarkdown(content);
174
176
  ```
175
177
 
176
- The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
178
+ The `schema` value (`quillmark/document@0.112.0`) is the **model version**,
177
179
  not the running crate version. It is a hand-set constant, bumped only when
178
- the `Document` model itself changes, so every `0.93.x` patch release reads
180
+ the `Document` model itself changes, so every `0.112.x` patch release reads
179
181
  and writes that same value.
180
182
 
181
- - **Upgrading is safe.** A newer build always reads documents written by an
182
- older one. Each schema version's wire format is frozen and never changes;
183
- when the model does change, the new build ships a migration that converts
184
- old payloads on `fromJson`. A document you commit as your canonical
185
- on-disk format keeps loading across crate upgrades: there is no need to
186
- pin old wasm to read old data.
187
- - **Downgrading is not.** `fromJson` rejects an *unknown* (i.e. newer)
183
+ - **Upgrading is safe.** A newer build reads documents an older build's
184
+ *writer* produced. Each schema version's wire format is frozen and never
185
+ changes; when the model does change, the new build ships a migration that
186
+ converts old payloads on `fromStored`. A document you commit as your
187
+ canonical on-disk format keeps loading across crate upgrades: there is no
188
+ need to pin old wasm to read old data. The exception is a row a host
189
+ authored a content construct of its own into a line `kind`, container,
190
+ mark `type`, island `type` or `loss` outside the vocabulary. Those are
191
+ refused from 0.113 on; see that release's migration guide.
192
+ - **Downgrading is not.** `fromStored` rejects an *unknown* (i.e. newer)
188
193
  `schema` version rather than guessing at a format it predates. Don't feed
189
194
  documents written by a newer build back into an older one.
190
195
 
@@ -200,9 +205,9 @@ if (v && v !== Document.currentStorageVersion()) {
200
205
  `storageVersionOf` does not validate the payload: it only reads the
201
206
  `schema` field, returning `undefined` for non-JSON, non-objects, or
202
207
  payloads that don't carry one. Use it to distinguish "wrong version" from
203
- "corrupt" when `fromJson` throws.
208
+ "corrupt" when `fromStored` throws.
204
209
 
205
- In short: persist the `toJson` string, upgrade freely, never downgrade. The
210
+ In short: persist the `toStored` string, upgrade freely, never downgrade. The
206
211
  full design (including how migrations are added) is in
207
212
  `prose/canon/DOCUMENT_STORAGE.md`.
208
213
 
@@ -220,9 +225,12 @@ accepts directly:
220
225
 
221
226
  ```ts
222
227
  doc.insertCard(quill.seedCard("note")); // seed → append
223
- doc.insertCard(Document.makeCard("note", { x: 1 })); // build from a flat map
224
228
  doc.insertCard({ kind: "note", body: "Plain **markdown**." }); // bare inline
225
229
  doc.insertCard({ kind: "note" }, 0); // insert at index 0
230
+ doc.insertCard({ // fields → payload items
231
+ kind: "note",
232
+ payloadItems: [{ type: "field", key: "x", value: 1 }],
233
+ });
226
234
  ```
227
235
 
228
236
  Reads and writes are two aligned shapes. A read `Card` always has `body:
@@ -230,9 +238,9 @@ Content` (canonical content, never a raw string): no narrowing, no guessing
230
238
  whether the body was normalized. The write shape `CardInput` widens `body` to
231
239
  `Content | string` (a markdown string imports to the content) and makes every
232
240
  field but `kind` optional. Every `Card` is a valid `CardInput`, so `insertCard`
233
- still takes exactly what `cards` / `removeCard` / `seedCard` return.
234
- Build a fresh card from a flat field map with
235
- `Document.makeCard(kind, fields?, body?)`.
241
+ still takes exactly what `cards` / `removeCard` / `seedCard` return. A fresh
242
+ card is an object literal: one `{ type: "field", key, value }` per field in
243
+ `payloadItems`, in the order they should appear.
236
244
 
237
245
  **One address for the whole surface.** Reads and writes navigate by an `Addr`:
238
246
  `{ card?, field? }`, absent `card` = main, absent `field` = body, and a bare
@@ -243,10 +251,11 @@ an absent field; only an out-of-range card throws); field writes throw on a body
243
251
  address. `getStored` is the verbatim transport read, distinct from the interpreted
244
252
  `quill.reader(doc).get`; `bodyMarkdown` is the body markdown read (a `CardAddr`; a field's
245
253
  markdown is read through `quill.reader(doc).get(field)`). A content field's stored
246
- form follows how the document was built (a canonical content object when the
247
- typed writer committed it, the authored string when a markdown parse produced
248
- it), so for the `Content` either way read `quill.reader(doc).getContent(addr)`, which
249
- decodes through the codec the field's declared type names. Card-scoped verbs take a
254
+ form rests per its declared codec through the bound door (a `richtext` field as
255
+ the content object, a `plaintext` field as its literal string), and as authored
256
+ through the transport door. For the `Content` either way read
257
+ `quill.reader(doc).getContent(addr)`, which decodes through the codec the
258
+ field's declared type names. Card-scoped verbs take a
250
259
  `CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`, and the batch below.
251
260
 
252
261
  Batch mutation: `doc.storeFields({}, {...})` / `doc.storeFields({ card: index }, {...})`
@@ -255,7 +264,7 @@ the thrown error carries one diagnostic per offending field (`path` = field
255
264
  name). The address is first (never shape-overloaded, since `card` is a legal
256
265
  field name), and parses strictly: a stray key throws rather than silently
257
266
  reading as `{}`. The main card is `{}`, or **`MAIN_CARD_ADDR`** (from
258
- `@quillmark/wasm/runtime`), a frozen alias that spells the intent:
267
+ `@quillmark/wasm`), a frozen alias that spells the intent:
259
268
  `doc.storeFields(MAIN_CARD_ADDR, {...})`.
260
269
 
261
270
  ### Typed writes: `commit*` is the default, `store*` is the quill-free primitive
@@ -317,34 +326,43 @@ write.
317
326
 
318
327
  ```ts
319
328
  const v = quill.reader(doc);
320
- v.get("subject"); // by declared type: richtext markdown, plaintext literal text
329
+ v.get("subject"); // the values form: every content leaf as its codec's text, else as stored
321
330
  v.getContent("subject"); // the same read as a `Content`, whichever lane stored it
322
331
  v.bodyMarkdown(); // the main body markdown (quill-free)
323
332
  v.card(0).get("body"); // a card field, resolved by its $kind
333
+ v.resolve(); // the render view: blank-filled, coerced, each field tagged with its rung
324
334
  ```
325
335
 
326
336
  `get` projects and `getContent` returns the `Content`; both decode through the codec
327
337
  the field's **declared type** names, which is why they bind the quill and the
328
338
  verbatim `doc.getStored` does not. An undeclared name throws `UnknownField`, a
329
339
  type that is not a content leaf throws `FieldNotContent`, and an undecodable
330
- value throws `FieldDecode`; an absent field reads back `undefined`.
340
+ value throws `FieldDecode`; an absent field reads back `undefined` and a
341
+ present-null `null`. A read never coerces a scalar (`qty: "3"` reads `"3"`);
342
+ `resolve()` is the coerced view.
331
343
 
332
344
  ### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
333
345
 
334
346
  Use **`engine.render`** for one-shot exports (PDF/SVG/PNG): compiles, emits
335
347
  artifacts, done. Use **`LiveSession`** (returned by `engine.open`) for
336
348
  reactive previews: the session is a persistent compiler. `paint` / `render` /
337
- `regions` / `fieldAt` read its current compile without recompiling, and `apply(doc)`
349
+ `regions` / `fieldAt` read its current compile without recompiling, and `update(doc)`
338
350
  recompiles in place on each edit, returning a `ChangeSet` whose `dirtyPages`
339
- tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional:
351
+ tells you which pages to repaint (`dirty ∩ visible`). `update` is transactional:
340
352
  on throw, every read keeps serving the last-good compile. Don't open a session
341
- per export, and don't re-open per edit: `apply` instead.
353
+ per export, and don't re-open per edit: `update` instead.
342
354
 
343
355
  A document that compiles to zero pages still produces a valid session
344
356
  (`pageCount === 0`); `paint(ctx, 0)` and `pageSize(0)` then throw. Branch on
345
357
  `pageCount === 0` to render a "no pages to preview" UI rather than relying on
346
358
  the throw.
347
359
 
360
+ Their `warnings` differ in reach. `engine.render` returns one list for the
361
+ whole pipeline: `doc.warnings` (parse, `conform::*`,
362
+ `plate::unsupported_construct`) ahead of the compile's own. A session outlives
363
+ the document it opened from, so `session.render` and `session.warnings` carry
364
+ the compile half alone — read `doc.warnings` beside them.
365
+
348
366
  ### Canvas Preview
349
367
 
350
368
  `session.paint(ctx, page, opts?)` rasterizes a page directly into a
@@ -378,14 +396,16 @@ canvas.style.height = `${result.layoutHeight}px`;
378
396
  a page's canvas alive while it stays near the viewport: an idle canvas retains
379
397
  its pixels for free, whereas pooling one canvas across pages re-renders on
380
398
  every scroll.
381
- - `pageCount` and `pageSize(page)` are stable for the session's lifetime: cache
382
- them.
399
+ - `pageCount` and `pageSize(page)` read the current compile, not the session:
400
+ cache them between committed `update`s only. After one, the count is
401
+ `ChangeSet.pageCount`, and every page in `ChangeSet.dirtyPages` needs its
402
+ `pageSize` re-read.
383
403
  - In a Worker, pass an `OffscreenCanvasRenderingContext2D`; the layout
384
404
  dimensions are informational there. Loading the WASM module inside the Worker
385
405
  is the host's responsibility.
386
- - Backend support is gated by `supportsCanvas`. Probe upfront with
387
- `engine.supportsCanvas(quill)`; the throw on `paint` / `pageSize` remains the
388
- enforcement contract and names the resolved `backendId`.
406
+ - `paint` / `pageSize` throw on a page the compile does not have, a zero-page
407
+ compile included, naming the index and the `pageCount` that excludes it. That
408
+ throw is the whole contract: open the session and handle it.
389
409
 
390
410
  ### Schema model
391
411
 
@@ -430,9 +450,9 @@ try {
430
450
 
431
451
  **Delivery follows the function, not the failure.** A synchronous method throws;
432
452
  a promise-returning one rejects. The promise-returning surface is `init` and the
433
- four `Engine` verbs (`render`, `open`, `supportedFormats`, `supportsCanvas`), so
434
- a programming error reached through one of them (a foreign handle, an
435
- unregistered backend) rejects like any other failure. Nothing here both returns
453
+ three `Engine` verbs (`render`, `open`, `supportedFormats`), so a programming
454
+ error reached through one of them (a foreign handle, an unregistered backend)
455
+ rejects like any other failure. Nothing here both returns
436
456
  a promise and throws, so a `.catch` on a promise-returning call is a whole
437
457
  guard.
438
458
 
@@ -447,21 +467,26 @@ applies to every throw site:
447
467
 
448
468
  - `Document.fromMarkdown`: parse errors (missing root `$quill` metadata, YAML
449
469
  errors, `parse::input_too_large` for inputs > 10 MiB).
450
- - `Document` mutators (`storeField`, the writer's `set`, etc.): mutator
451
- failures carry a namespaced `edit::*` `code` on `diagnostics[0]`
452
- (`edit::invalid_field_name`, `edit::unknown_field`, `edit::index_out_of_range`,
453
- `edit::field_coercion_failed`, …). Route on `diagnostics[0].code`, never on message
454
- text.
470
+ - `Document` mutators (`storeField`, `insertCard`, the writer's
471
+ `set`, etc.): mutator failures carry a namespaced `edit::*` `code` on
472
+ `diagnostics[0]` (`edit::invalid_field_name`, `edit::unknown_field`,
473
+ `edit::index_out_of_range`, `edit::field_coercion_failed`, …). Route on
474
+ `diagnostics[0].code`, never on message text.
455
475
  - `engine.render` / `session.render`: backend compilation failures and
456
476
  validation errors.
457
477
  - `engine.render(quill, parsed)` against a quill whose *name* differs
458
478
  (`quill::name_mismatch`) or whose *version* falls outside the document's
459
479
  selector (`quill::version_mismatch`): a throw, never a warning.
460
- - Any method taking a `Quill` or `Document`: a handle from a *second* copy of
461
- `@quillmark/wasm` is refused with `runtime::foreign_handle`, hinting `npm ls
462
- @quillmark/wasm`. Two copies are two WASM memories and two `Quill`/`Document`
463
- classes; dedupe to one. A value that is not a handle at all keeps its own
464
- `runtime::not_a_document` / `runtime::not_a_quill`.
480
+ - The four `Engine` verbs against a quill whose declared `backend:` is not in
481
+ the registry: `engine::backend_not_found`, the code core raises for the same
482
+ condition, hinting the registered ids.
483
+ - The `Engine` verbs, `session.update`, and the writer/reader binds against a
484
+ value that is not one of *this* copy's handles — the wrong type, or the right
485
+ class from a second copy of `@quillmark/wasm`: `runtime::not_a_quill` /
486
+ `runtime::not_a_document`, hinting `npm ls @quillmark/wasm` for the second
487
+ case. Two copies are two WASM memories and two `Quill`/`Document` classes;
488
+ dedupe to one. Elsewhere a foreign handle meets wasm-bindgen's own
489
+ `expected instance of …`, which is not a `QuillmarkError`.
465
490
 
466
491
  ### Lifecycle
467
492