@quillmark/wasm 0.108.3 → 0.109.1

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,288 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.109.1 - 2026-08-24
4
+
5
+ - fix(typst): **a `field-region` claim around inline content no longer widens
6
+ the line.** Each of the helper's two bracketing markers was written
7
+ `#metadata(..) <__qm_region__>`, and the space before the label survived into
8
+ the inline flow. It cost 2.715pt per marker at a 12pt body size, so a claim
9
+ on a date or any mid-paragraph composition shifted the text around it by
10
+ 5.43pt, against the layout-neutral contract the helper documents.
11
+ `form-field`'s marker carries the same shape and drops the space too, rather
12
+ than keep depending on the `box` that follows it.
13
+
14
+ <!-- seed: commits since v0.109.0, confirm the entries above cover them, then delete this comment
15
+ - docs: dense-prose pass over the branch
16
+ - fix(typst): a marker's label is not a space in the inline flow
17
+ - refactor(wasm): keep the weld rule internal, and pin what states it
18
+ - docs: dense-prose pass over the branch
19
+ - docs: classify 0.109's container break by consumer role
20
+ - feat(wasm)!: make a written container path spell its instance
21
+ - feat(content): name the rule a container's discriminator is minted against
22
+ -->
23
+
24
+
25
+ ## v0.109.0 - 2026-08-24
26
+
27
+ - **breaking** content: **`Normalized` is the precondition the projections
28
+ require, and every codec returns one.** A projection over a container tree
29
+ owes totality, and `Content` alone does not say whether `normalize` has run —
30
+ so `to_markdown` and `emit_content` each trusted a canonical shape their
31
+ signature did not ask for. `Content::into_normalized` is the mint (infallible:
32
+ canonicalizing is total, and the codecs go on calling `validate` after it),
33
+ `Normalized::into_content` the way back out, and the token derefs to
34
+ `&Content`, so **a read-only consumer needs no change** — `.text`, `.lines`,
35
+ `.marks`, `.islands`, `validate()`, `is_inline()` all reach through. What
36
+ moves is the signatures a caller names or a value it mutates:
37
+
38
+ | Crate | 0.108 | 0.109 |
39
+ |---|---|---|
40
+ | `quillmark-content` | `from_markdown` / `from_plaintext` / `from_canonical_json` / `serial::from_canonical_value` / `serial::from_authored_value` → `Content` | → `Normalized` |
41
+ | | `to_markdown(&Content)` | `to_markdown(&Normalized)` |
42
+ | | `Content::to_canonical_json` | `Normalized::to_canonical_json` |
43
+ | | `serial::to_canonical_value(&Content)` | `(&Normalized)` |
44
+ | `quillmark-typst` | `emit::emit_content(&Content)` | `(&Normalized)` |
45
+ | `quillmark-core` | `Card::body() -> &Content` | `-> &Normalized` |
46
+ | | `Card::overwrite_body(Content)` / `overwrite_field(_, Content)` | `impl Into<Normalized>` — a `Content` still passes |
47
+ | | `TypedReader::get_content{,_at}` / `CardReader::get_content{,_at}` → `Option<Content>` | `Option<Normalized>` |
48
+
49
+ A consumer that *mutates* a decoded content takes the round trip, which is
50
+ what the codecs used to run for it silently:
51
+
52
+ ```rust
53
+ // 0.108
54
+ let mut rt = from_markdown(md)?;
55
+ rt.marks.push(mark);
56
+ rt.normalize();
57
+
58
+ // 0.109
59
+ let mut rt = from_markdown(md)?.into_content();
60
+ rt.marks.push(mark);
61
+ let rt = rt.into_normalized();
62
+ ```
63
+
64
+ The op channel needs none of that: `apply_text_delta`, `apply_mark_ops`,
65
+ `apply_line_ops`, `apply_island_ops` and `apply_field_change` are forwarded on
66
+ `Normalized` and re-establish the invariant on the error path as well as the
67
+ success one. `to_plaintext` still takes `&Content` and reads a token through
68
+ the deref, projecting `text` alone with no walk to make total.
69
+
70
+ - feat(content): **`quillmark_content::traverse` is where the container walks
71
+ live**, `runs` (adjacent lines sharing one container instance at a depth),
72
+ `items` (adjacent lines whose whole container is equal) and `segment` (a line
73
+ plus the continuations at its own nesting). Five call sites across three
74
+ crates had spelled these by hand, each with its own idea of when a run ends;
75
+ `Span` and the walks are public so a consumer reading `Content.lines` groups
76
+ them the way both projections and the quill census do.
77
+
78
+ - perf(content): **`serial::to_canonical_value` and `to_canonical_json` take a
79
+ `Normalized`.**
80
+ Both cloned the whole content and normalized the copy on every call, on a
81
+ lane whose callers — the codecs, `Card::body`, the storage DTO — were already
82
+ holding the canonical form. The token now carries that, so the serialize path
83
+ spends an encode instead of a deep clone plus a repair pass. `to_canonical_json`
84
+ moves from `Content` to `Normalized` with it; a caller holding a raw `Content`
85
+ mints first, which is what the old body did for them silently.
86
+
87
+ - fix(core): **a document body that `validate` refuses is refused on write, not
88
+ discovered on read.** `CanonicalContent`'s `Deserialize` parsed, normalized and
89
+ validated; its `Serialize` validated nothing, and `Card::overwrite_body` takes a
90
+ caller's content on the canonical-form token alone. A store could therefore
91
+ accept bytes it could not read back. The serializer now validates too and fails
92
+ with the invariant, at the boundary that cares and while the caller still holds
93
+ the value that produced it.
94
+
95
+ - refactor(content): **the leaf-segment walk is one loop, not two.** `traverse`
96
+ gains `segment` — the block-opening line plus every following one that
97
+ continues it at the same nesting — and `export::emit_block` and the Typst
98
+ emitter's `segment_end` both call it. The fifth duplicated traversal, the one
99
+ #1364 did not list.
100
+
101
+ - fix(content): **`to_markdown` no longer aborts the process on a deeply nested
102
+ content.** `Normalized` states that `normalize` has run, and `normalize`
103
+ repairs where `validate` rejects: nothing about canonicalization brings a
104
+ container path under `MAX_NESTING_DEPTH`, so a hand-built `Content` mints a
105
+ token that `validate` refuses. `export::emit_block` recursed one frame per
106
+ container level and overflowed the stack a few thousand levels down — a
107
+ SIGABRT no caller can catch, against a Typst emitter that checks the depth up
108
+ front and returns `EmitError::NestingTooDeep` for the same input. The walk is
109
+ now an explicit frame stack, as `json_depth_exceeds` and the quill census
110
+ already are, so the projection is total over every token its signature
111
+ accepts. `Normalized`'s docs settle the half the newtype does not close: the
112
+ token promises canonical, not valid — the mint stays infallible, the codecs go
113
+ on calling `validate` after it, and a projection that takes one owes totality
114
+ rather than trust. Only a Rust embedder hand-building a `Content` reaches the
115
+ shape; every decode lane (`from_markdown`, `from_canonical_value`, storage,
116
+ WASM, Python) rejects the depth already.
117
+
118
+ - fix(typst): **a container inside a list item no longer terminates the list.**
119
+ The item's continuation indent reached its leaf path only, so a quote inside
120
+ an item opened at column 0 — where Typst ends the enclosing list. The item's
121
+ later blocks came back as top-level paragraphs and the next item started a
122
+ fresh list, which renumbers an ordered one from the quote on. A fence and a
123
+ nested list escaped it by reaching that indented leaf path; a transparent
124
+ unknown container did not, and neither would any container added later.
125
+ Indentation is now the walk's rather than each construct's: one rule opens
126
+ every block, leaf and container alike, at the enclosing list depth, so what
127
+ the content nests, the markup nests.
128
+
129
+ - fix(content): **a `continues` line that crosses a container boundary no longer
130
+ survives.** A within-block break lives inside one container, and `LineOp::Join`
131
+ mints the crossing shape whenever it merges two lines of differing paths — the
132
+ line after the seam keeps continuing across it. Both projections already read
133
+ the flag as dead there (`export::emit_block` and `emit::segment_end` each
134
+ require the depth to match before absorbing a continuation), so `normalize`
135
+ now clears it, which states what was already true and changes nothing
136
+ observable. `Content::validate` gains `Invariant::ContinuesAcrossContainers`
137
+ to catch a hand-built content that skipped `normalize`, and
138
+ `LineOp::SetContinues` refuses the *deliberate* crossing up front with
139
+ `ApplyError::ContinuesAcrossContainers` — the same repair-or-refuse split the
140
+ line-kind rule already makes. This was the one relational line invariant
141
+ nothing checked: `validate` is otherwise strictly per-line, while every
142
+ container rule is a property of a line pair.
143
+
144
+ - fix(content): **two adjacent containers of one shape are no longer read as
145
+ one.** Container identity is the container path plus contiguity, and the path
146
+ carried nothing to tell one instance from the next, so two adjacent runs of
147
+ equal shape welded: `[Quote], [Quote]` read as a single two-paragraph quote,
148
+ and two one-item lists as a single item whose second line came back as an
149
+ unnumbered continuation paragraph — the marker gone. `Container` now carries
150
+ an `instance` discriminator on every arm, `Content::normalize` canonicalizes
151
+ it to `0` (flipping to `1` only where the adjacent preceding run would
152
+ otherwise weld), and the two projections read it. Four defects close with it:
153
+ - `from_markdown("- a\n\n<!-- -->\n\n- b")` — the CommonMark idiom for
154
+ spelling two lists apart — no longer destroys the second list's marker.
155
+ - Two adjacent ordered lists typeset with their own numbering. They reached
156
+ the Typst emitter as one run and `+` markers numbered the second list on
157
+ from the first, so `1. 2.` / `1. 2.` rendered **1 2 3 4**. The run's first
158
+ item now states its number, which resets Typst's running counter. Every
159
+ ordered run's first item therefore lowers as `N. ` where a run starting at
160
+ 1 lowered as `+ `; the page is identical, the generated markup is not, so
161
+ anything diffing or golden-comparing Typst source sees it.
162
+ - `1. a` beside a list starting at `3` keeps that `start` through the
163
+ Markdown projection. CommonMark reads only a list's first number, so
164
+ `1. a\n\n3. b` re-imported as one list of two items and the `start` was
165
+ lost — breaking the round-trip fixed point `export` documents. Adjacent
166
+ lists now alternate their marker (`-`/`+`, `.`/`)`), which is how
167
+ CommonMark itself spells two lists apart, so the boundary survives the
168
+ projection with no comment marker in the authored file.
169
+ - Adjacent `Unknown` containers of equal `(tag, attrs)` round-trip as two
170
+ **through storage**, the lane they have: an unknown container has no
171
+ Markdown syntax to alternate, so it projects transparently there as it
172
+ always did. The open-set promise that a container this build does not know
173
+ survives untouched is now total rather than holding up to an adjacency
174
+ quotient.
175
+
176
+ An item boundary is a parent boundary: two inner lists under two outer list
177
+ items are two lists, so an inner run restarts its `ordinal` and needs no
178
+ discriminator. `ordinal` is canonicalized alongside it, to a gapless 0-based index within
179
+ its run, so `[5, 9]` and `[0, 1]` stop being two spellings of the same two
180
+ items. `instance` is written to the wire only when non-zero, so a stored row
181
+ that needs no discriminator — nearly all of them — keeps its exact bytes and
182
+ its content hash. **Breaking for Rust consumers** that match `Container`
183
+ exhaustively: `Quote` is now a struct variant, and `ListItem`/`Unknown` carry
184
+ the extra field.
185
+
186
+ On the TypeScript surface `instance` is optional — the wire omits a zero, so a
187
+ read shape cannot require it — and nothing there stops compiling. A consumer
188
+ that only reads needs no change. **A consumer that writes container paths owes
189
+ the field**, which is every codec flattening a tree: two `bullet_list` nodes in
190
+ a row are adjacent same-shape siblings, and omitting the discriminator lands
191
+ them welded. Such a host keeps producing what it produced on 0.108, so the
192
+ four defects above stay open for it until its codec stamps the discriminator.
193
+ Nothing reports the omission. Two adjacent lines with equal paths are one
194
+ container, which is also how a two-paragraph quote is spelled: the model
195
+ cannot tell a boundary a writer meant from one it did not.
196
+
197
+ The block census counts what the projections see, so two adjacent runs of one
198
+ shape now count two where they counted one: a quill declining `list` or
199
+ `quote` reports the construct at a document that has two of them where it
200
+ reported one, and `plate::unsupported_construct` moves with it.
201
+
202
+ A blob written here carries `instance` only where a document holds adjacent
203
+ same-shape siblings, and a reader that predates the field ignores the key —
204
+ so such a blob loads on 0.108 with the two runs welded, and re-saving there
205
+ drops the boundary for good. A 0.109 host whose codec drops the key on
206
+ write-back loses it the same way, with no version skew involved. The boundary
207
+ is written by whatever produced the row — `from_markdown`, the CLI, any Rust
208
+ caller — and survives only as far as the next writer that carries it.
209
+ The `@0.93.0` tag is unchanged because every blob written before this release
210
+ re-encodes byte for byte; the forward direction is the one that costs, and
211
+ only for the documents that spend the key.
212
+
213
+ - fix(blueprint): **a variant's `object` or `array<object>` cell expands per
214
+ property.** The cell went through the scalar path, so it rendered as
215
+ `controlled_by: !must_fill # object` — a null where the schema wants a
216
+ mapping, with every property's description, `default:` and type annotation
217
+ dropped, and the marker on a path the obligation predicate never warns at.
218
+ A cell is a field of its container and now expands as one, like every other
219
+ surface already did.
220
+ - fix(core): **a `.quillignore` pattern holding more than one `*` ignores what
221
+ it names.** The matcher handled exactly one wildcard and returned no match
222
+ for the rest, so `**/*.tmp` and `*.sublime-*` were dead lines. Patterns now
223
+ compile once through `glob::Pattern`, matched against the whole path and the
224
+ basename. Two readings tighten to gitignore's: `*` stops at `/`, and a
225
+ pattern spelling out a `/` anchors at the bundle root rather than matching
226
+ any path that opens and closes with its halves. Both narrow what a line
227
+ ignores, so a bundle can gain a file it used to drop: `assets/*` covers
228
+ `assets/logo.png` and no longer `assets/icons/logo.png`, which `assets/**`
229
+ or the directory line `assets/` covers. No in-tree quill spells either shape.
230
+ A line always ignores the
231
+ name it spells out as well: `[` opens a character class and is an ordinary
232
+ character in a filename, so `Cinzel[wght].ttf` ignores both the variable font
233
+ of that name and the class it describes.
234
+ - refactor: **`RenderError::coded(code, message)` is the one constructor for a
235
+ single-error-diagnostic failure.** Nine sites across five crates spelled
236
+ `from_diag(Diagnostic::new(Severity::Error, msg).with_code(code))` by hand,
237
+ two of them as a per-crate `engine_err` helper the backends each carried
238
+ their own copy of. Additive to `quillmark-core`'s public API; no code, message
239
+ or shape changes.
240
+ - refactor(pdfform): **a session holds its flattened PDF parsed, not as bytes
241
+ each render path reparses.** Flatten and parse now happen together in `open`
242
+ and `update`, the two places `field_specs` are set, so the derived flat PDF
243
+ moves only with the specs it comes from and `render_svg`/`render_png`/
244
+ `render_rgba` paint parsed pages. A malformed flatten now surfaces from the
245
+ call that produced it under one code, `pdfform::flat_parse_failed`, replacing
246
+ the per-format `pdfform::svg_parse_failed` and `pdfform::png_parse_failed`
247
+ raised at render time (neither documented, and both reachable only through a
248
+ bug in this crate's own flatten). Opening a session fails on that bug now,
249
+ including for a caller that only ever renders the AcroForm PDF, which is
250
+ stamped from the base and reads nothing flattened.
251
+ - perf(pdf): **filling a PDF form no longer slows down with the size of its
252
+ background or its page count.** Reading one object from the base walks every
253
+ byte of it — the live copy is the last revision, so a scan cannot stop early
254
+ — and nothing memoized that, so a stamp or flatten pass paid O(pages) whole-
255
+ file scans and the live-edit path repaid them on every keystroke. The base's
256
+ object offsets are now collected in one pass and each read is a lookup: a
257
+ 20-page 300 KB form stamps in 0.7 ms rather than 37 ms, flat in page count.
258
+
259
+ **breaking** in `quillmark-pdf`: `PdfUpdate::begin` and
260
+ `PdfUpdate::resolve_pages` take the `&ObjectIndex` the caller builds over the
261
+ base rather than its bytes, and `reader::find_object_bytes` /
262
+ `reader::object_dict` become `ObjectIndex::object_bytes` / `ObjectIndex::dict`.
263
+ - **breaking** content: the op wire is a reading direction. `mark_op_to_value`,
264
+ `line_op_to_value` and `island_op_to_value` are removed from
265
+ `quillmark-content` — an op bundle is authored on the JS/Python side and
266
+ reaches Rust through `change_bundle_from_value`, so nothing in the workspace
267
+ ever emitted one and every wire change was made twice, once in code no product
268
+ path executes. The decoders are unchanged. Their round-trip tests become
269
+ decoder tests over literal JSON, which is what the wire actually is: an
270
+ encoder agreeing with its own reader never proved the shape a binding sends.
271
+ - perf(content): **canonical serialization stops rebuilding the tree it just
272
+ built.** `to_canonical_value` normalized a copy — which already recursively
273
+ key-sorts every opaque bag reachable from it (island `props`, an unknown's
274
+ `attrs`) — and then ran a whole-tree `sort_keys_owned` over the encoded
275
+ result, re-collecting and re-allocating every object and array in the document
276
+ to reorder the handful of fixed keys the encoders insert themselves. The
277
+ encoders now emit those keys in ascending order and the terminal pass is
278
+ `canonicalize_keys`, which scans and returns when the tree is already
279
+ canonical. Canonical bytes are unchanged, byte for byte; a tree that somehow
280
+ arrives unsorted is still repaired rather than shipped.
281
+
282
+ The public `container_to_value` and `mark_to_value`, and the crate-internal
283
+ `island_to_value`, now emit their own keys in a different order. An unknown's `attrs` bag is
284
+ untouched, as in 0.99, and nothing hashes the op wire.
285
+
3
286
  ## v0.108.3 - 2026-08-21
4
287
 
5
288
  - fix(typst): **a paragraph holding one bare `/` renders instead of failing the
@@ -29,13 +312,6 @@
29
312
  the line-anchor guard uses. Debug builds parse every emission with Typst's
30
313
  parser, so markup that reaches it as syntax fails a test rather than a render.
31
314
 
32
- <!-- seed: commits since v0.108.2, confirm the entries above cover them, then delete this comment
33
- - fix(typst): stop a `#…` expression running on into the text behind it
34
- - fix(typst): guard the line anchor at every position Typst reads as one
35
- - fix(typst): escape a line-anchor marker that ends its run
36
- -->
37
-
38
-
39
315
  ## v0.108.2 - 2026-08-20
40
316
 
41
317
  - fix(core): **storage blobs tagged `@0.81.0` and `@0.82.0` load again.** Both
@@ -93,11 +93,43 @@ export type ContentLineKind =
93
93
 
94
94
  /** An ancestor block a line nests inside, outermost first. Open like
95
95
  * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
96
- * and renders transparently (its lines sit at the enclosing level). */
96
+ * and renders transparently (its lines sit at the enclosing level).
97
+ *
98
+ * Two adjacent lines sit in the same container iff their whole path matches, so
99
+ * `instance` is what tells one container from an adjacent sibling of identical
100
+ * shape — two consecutive quotes, two consecutive lists — which contiguity
101
+ * alone reads as one.
102
+ *
103
+ * **A writer owes it.** Give adjacent sibling runs of one shape distinct
104
+ * values, or they arrive as one: a second list's items come back as
105
+ * continuation paragraphs of the first, markers gone. Nothing reports that,
106
+ * since the flat form cannot tell a boundary you meant from one you did not. A
107
+ * codec flattening a tree stamps the field with `assignInstances` from
108
+ * `@quillmark/wasm/runtime` rather than by hand. Any distinct pair of values
109
+ * works; a write is canonicalized to `0`/`1`.
110
+ *
111
+ * Reading is not the mirror of writing. The field is absent where it is `0`,
112
+ * and it appears on pairs no writer had to spell. `1.` beside a list starting
113
+ * at `3` differs by `start`, so those runs arrive apart with nothing written —
114
+ * and the canonical form spends a discriminator anyway, because Markdown reads
115
+ * only a list's first number. */
97
116
  export type ContentContainer =
98
- | { container: "list_item"; ordered: boolean; start: number; ordinal: number }
99
- | { container: "quote" }
100
- | { container: string; attrs: unknown };
117
+ | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance?: number }
118
+ | { container: "quote"; instance?: number }
119
+ | { container: string; attrs: unknown; instance?: number };
120
+
121
+ /** A container path on a lane that only ever carries host-built values:
122
+ * `ContentContainer` with `instance` spelled out rather than defaulted. The
123
+ * field that decides whether two adjacent runs weld cannot be omitted by
124
+ * accident here. `assignInstances` returns this shape, and a path copied off a
125
+ * line carries its own instance through — `{ ...c, instance: c.instance ?? 0 }`.
126
+ *
127
+ * `Content` itself keeps the optional field: it is a read shape as much as a
128
+ * write one, and the wire omits a zero. */
129
+ export type ContentContainerInput =
130
+ | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance: number }
131
+ | { container: "quote"; instance: number }
132
+ | { container: string; attrs: unknown; instance: number };
101
133
 
102
134
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
103
135
  * arm blocks discriminant narrowing, so read a payload-carrying arm behind its
@@ -224,7 +256,7 @@ export type LineOp =
224
256
  | { op: "split"; at: number }
225
257
  | { op: "join"; line: number }
226
258
  | ({ op: "setKind"; line: number } & ContentLineKind)
227
- | { op: "setContainers"; line: number; containers: ContentContainer[] }
259
+ | { op: "setContainers"; line: number; containers: ContentContainerInput[] }
228
260
  | { op: "setContinues"; line: number; continues: boolean };
229
261
 
230
262
  /**
Binary file
@@ -93,11 +93,43 @@ export type ContentLineKind =
93
93
 
94
94
  /** An ancestor block a line nests inside, outermost first. Open like
95
95
  * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
96
- * and renders transparently (its lines sit at the enclosing level). */
96
+ * and renders transparently (its lines sit at the enclosing level).
97
+ *
98
+ * Two adjacent lines sit in the same container iff their whole path matches, so
99
+ * `instance` is what tells one container from an adjacent sibling of identical
100
+ * shape — two consecutive quotes, two consecutive lists — which contiguity
101
+ * alone reads as one.
102
+ *
103
+ * **A writer owes it.** Give adjacent sibling runs of one shape distinct
104
+ * values, or they arrive as one: a second list's items come back as
105
+ * continuation paragraphs of the first, markers gone. Nothing reports that,
106
+ * since the flat form cannot tell a boundary you meant from one you did not. A
107
+ * codec flattening a tree stamps the field with `assignInstances` from
108
+ * `@quillmark/wasm/runtime` rather than by hand. Any distinct pair of values
109
+ * works; a write is canonicalized to `0`/`1`.
110
+ *
111
+ * Reading is not the mirror of writing. The field is absent where it is `0`,
112
+ * and it appears on pairs no writer had to spell. `1.` beside a list starting
113
+ * at `3` differs by `start`, so those runs arrive apart with nothing written —
114
+ * and the canonical form spends a discriminator anyway, because Markdown reads
115
+ * only a list's first number. */
97
116
  export type ContentContainer =
98
- | { container: "list_item"; ordered: boolean; start: number; ordinal: number }
99
- | { container: "quote" }
100
- | { container: string; attrs: unknown };
117
+ | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance?: number }
118
+ | { container: "quote"; instance?: number }
119
+ | { container: string; attrs: unknown; instance?: number };
120
+
121
+ /** A container path on a lane that only ever carries host-built values:
122
+ * `ContentContainer` with `instance` spelled out rather than defaulted. The
123
+ * field that decides whether two adjacent runs weld cannot be omitted by
124
+ * accident here. `assignInstances` returns this shape, and a path copied off a
125
+ * line carries its own instance through — `{ ...c, instance: c.instance ?? 0 }`.
126
+ *
127
+ * `Content` itself keeps the optional field: it is a read shape as much as a
128
+ * write one, and the wire omits a zero. */
129
+ export type ContentContainerInput =
130
+ | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance: number }
131
+ | { container: "quote"; instance: number }
132
+ | { container: string; attrs: unknown; instance: number };
101
133
 
102
134
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
103
135
  * arm blocks discriminant narrowing, so read a payload-carrying arm behind its
@@ -224,7 +256,7 @@ export type LineOp =
224
256
  | { op: "split"; at: number }
225
257
  | { op: "join"; line: number }
226
258
  | ({ op: "setKind"; line: number } & ContentLineKind)
227
- | { op: "setContainers"; line: number; containers: ContentContainer[] }
259
+ | { op: "setContainers"; line: number; containers: ContentContainerInput[] }
228
260
  | { op: "setContinues"; line: number; continues: boolean };
229
261
 
230
262
  /**
Binary file
package/core/wasm.d.ts CHANGED
@@ -93,11 +93,43 @@ export type ContentLineKind =
93
93
 
94
94
  /** An ancestor block a line nests inside, outermost first. Open like
95
95
  * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
96
- * and renders transparently (its lines sit at the enclosing level). */
96
+ * and renders transparently (its lines sit at the enclosing level).
97
+ *
98
+ * Two adjacent lines sit in the same container iff their whole path matches, so
99
+ * `instance` is what tells one container from an adjacent sibling of identical
100
+ * shape — two consecutive quotes, two consecutive lists — which contiguity
101
+ * alone reads as one.
102
+ *
103
+ * **A writer owes it.** Give adjacent sibling runs of one shape distinct
104
+ * values, or they arrive as one: a second list's items come back as
105
+ * continuation paragraphs of the first, markers gone. Nothing reports that,
106
+ * since the flat form cannot tell a boundary you meant from one you did not. A
107
+ * codec flattening a tree stamps the field with `assignInstances` from
108
+ * `@quillmark/wasm/runtime` rather than by hand. Any distinct pair of values
109
+ * works; a write is canonicalized to `0`/`1`.
110
+ *
111
+ * Reading is not the mirror of writing. The field is absent where it is `0`,
112
+ * and it appears on pairs no writer had to spell. `1.` beside a list starting
113
+ * at `3` differs by `start`, so those runs arrive apart with nothing written —
114
+ * and the canonical form spends a discriminator anyway, because Markdown reads
115
+ * only a list's first number. */
97
116
  export type ContentContainer =
98
- | { container: "list_item"; ordered: boolean; start: number; ordinal: number }
99
- | { container: "quote" }
100
- | { container: string; attrs: unknown };
117
+ | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance?: number }
118
+ | { container: "quote"; instance?: number }
119
+ | { container: string; attrs: unknown; instance?: number };
120
+
121
+ /** A container path on a lane that only ever carries host-built values:
122
+ * `ContentContainer` with `instance` spelled out rather than defaulted. The
123
+ * field that decides whether two adjacent runs weld cannot be omitted by
124
+ * accident here. `assignInstances` returns this shape, and a path copied off a
125
+ * line carries its own instance through — `{ ...c, instance: c.instance ?? 0 }`.
126
+ *
127
+ * `Content` itself keeps the optional field: it is a read shape as much as a
128
+ * write one, and the wire omits a zero. */
129
+ export type ContentContainerInput =
130
+ | { container: "list_item"; ordered: boolean; start: number; ordinal: number; instance: number }
131
+ | { container: "quote"; instance: number }
132
+ | { container: string; attrs: unknown; instance: number };
101
133
 
102
134
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
103
135
  * arm blocks discriminant narrowing, so read a payload-carrying arm behind its
@@ -224,7 +256,7 @@ export type LineOp =
224
256
  | { op: "split"; at: number }
225
257
  | { op: "join"; line: number }
226
258
  | ({ op: "setKind"; line: number } & ContentLineKind)
227
- | { op: "setContainers"; line: number; containers: ContentContainer[] }
259
+ | { op: "setContainers"; line: number; containers: ContentContainerInput[] }
228
260
  | { op: "setContinues"; line: number; continues: boolean };
229
261
 
230
262
  /**
package/core/wasm_bg.wasm CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/wasm",
3
- "version": "0.108.3",
3
+ "version": "0.109.1",
4
4
  "description": "WebAssembly bindings for Quillmark, a schema-driven document engine",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -130,6 +130,7 @@ export type {
130
130
  ContentLine,
131
131
  ContentLineKind,
132
132
  ContentContainer,
133
+ ContentContainerInput,
133
134
  ContentMark,
134
135
  ContentIsland,
135
136
  TableProps,
@@ -204,7 +205,8 @@ import type {
204
205
  ImageProps,
205
206
  ContentMark,
206
207
  ContentLine,
207
- ContentContainer
208
+ ContentContainer,
209
+ ContentContainerInput
208
210
  } from '../core/wasm.js';
209
211
 
210
212
  /** Narrow a {@link ContentIsland} to the pinned `table` arm (`props: TableProps`). */
@@ -245,6 +247,7 @@ export declare function isListItemContainer(
245
247
  ordered: boolean;
246
248
  start: number;
247
249
  ordinal: number;
250
+ instance?: number;
248
251
  };
249
252
 
250
253
  // The guards above answer "is this arm X". These four answer "is this a value
@@ -277,6 +280,41 @@ export declare function isUnknownIsland(
277
280
  island: ContentIsland
278
281
  ): island is ContentIsland & { type: string; props: unknown };
279
282
 
283
+ // `ContentContainer.instance` is a field a writer owes and, outside
284
+ // `ContentContainerInput`, no checker asks for. Adjacent runs of one shape that
285
+ // share it arrive welded. Nothing reports that: the flat `containers` form
286
+ // cannot tell it from one container spanning two paragraphs. This carries the
287
+ // rule a codec would otherwise re-derive.
288
+
289
+ /**
290
+ * Stamp `instance` across one parent's blocks at one depth, in document order,
291
+ * returning containers ready to write.
292
+ *
293
+ * One entry per container RUN — a list, not a list item — and `null` for a
294
+ * block carrying no container at this depth. A bare paragraph between two lists
295
+ * is such a block, and separates them on its own. Every line of a run then
296
+ * carries that run's returned container, `ordinal` varying per item and
297
+ * `instance` held.
298
+ *
299
+ * The `instance` it stamps is canonical, so a document reads back the value it
300
+ * was written. `ordinal` stays the caller's, and a write is renumbered to a
301
+ * gapless index within its run.
302
+ *
303
+ * Which fields decide a weld is coarser than equality for a list: CommonMark
304
+ * reads only a list's first number, so `1. a` beside `3. b` welds despite the
305
+ * differing `start`.
306
+ *
307
+ * ```js
308
+ * const [outer, , inner] = assignInstances([listA, null, listB]);
309
+ * // outer.instance === 0, inner.instance === 0 — the paragraph parts them
310
+ * const [a, b] = assignInstances([listA, listB]);
311
+ * // a.instance === 0, b.instance === 1 — adjacent, one shape
312
+ * ```
313
+ */
314
+ export declare function assignInstances(
315
+ runs: (ContentContainer | null)[]
316
+ ): (ContentContainerInput | null)[];
317
+
280
318
  // The backend-neutral render contract, defined here rather than re-exported from
281
319
  // one private backend because no single backend owns the canonical API's types.
282
320
  // Every backend build must satisfy these shapes; `runtime.types.test-d.ts` keeps
@@ -454,7 +454,7 @@ export function isCodeLine(line) {
454
454
 
455
455
  /**
456
456
  * @param {import('../core/wasm.js').ContentContainer} container
457
- * @returns {container is import('../core/wasm.js').ContentContainer & { container: 'list_item'; ordered: boolean; start: number; ordinal: number }}
457
+ * @returns {container is import('../core/wasm.js').ContentContainer & { container: 'list_item'; ordered: boolean; start: number; ordinal: number; instance?: number }}
458
458
  */
459
459
  export function isListItemContainer(container) {
460
460
  return container.container === 'list_item';
@@ -510,6 +510,64 @@ export function isUnknownIsland(island) {
510
510
  return typeof island?.type === 'string' && !KNOWN_ISLAND_TYPES.has(island.type);
511
511
  }
512
512
 
513
+ // ── Container run boundaries ────────────────────────────────────────────────
514
+ // `ContentContainer.instance` is what keeps two adjacent runs of one shape
515
+ // apart, and only a writer knows where a boundary is: the flat `containers`
516
+ // form cannot tell a list ending beside another from one list of two items, so
517
+ // an omitted discriminator welds them and nothing reports it.
518
+ //
519
+ // WELD_KEYS is the rule `Container::same_weld` owns upstream: which fields two
520
+ // adjacent runs must share for the markdown projection to read them as one, and
521
+ // therefore for the canonical form to have to spend a discriminator. `start` is
522
+ // not among them, since CommonMark reads only a list's first number. A table
523
+ // rather than a switch, so `tests/known_names_drift.rs` can pin it against the
524
+ // Rust predicate.
525
+
526
+ const WELD_KEYS = { list_item: ['ordered'], quote: [] };
527
+
528
+ function sameJson(a, b) {
529
+ if (a === b) return true;
530
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
531
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
532
+ const ka = Object.keys(a);
533
+ return (
534
+ ka.length === Object.keys(b).length &&
535
+ ka.every((k) => Object.hasOwn(b, k) && sameJson(a[k], b[k]))
536
+ );
537
+ }
538
+
539
+ /**
540
+ * @param {import('../core/wasm.js').ContentContainer} a
541
+ * @param {import('../core/wasm.js').ContentContainer} b
542
+ * @returns {boolean}
543
+ */
544
+ function weldsWith(a, b) {
545
+ // A malformed value welds with nothing. The membership guards' posture:
546
+ // answer rather than throw.
547
+ if (typeof a?.container !== 'string' || a.container !== b?.container) return false;
548
+ // `hasOwn`, so a tag colliding with an `Object.prototype` member reaches the
549
+ // unknown branch rather than a function.
550
+ if (!Object.hasOwn(WELD_KEYS, a.container)) return sameJson(a.attrs, b.attrs);
551
+ return WELD_KEYS[a.container].every((k) => a[k] === b[k]);
552
+ }
553
+
554
+ /**
555
+ * @param {(import('../core/wasm.js').ContentContainer | null)[]} runs
556
+ * @returns {(import('../core/wasm.js').ContentContainerInput | null)[]}
557
+ */
558
+ export function assignInstances(runs) {
559
+ let prev = null;
560
+ return runs.map((run) => {
561
+ if (run == null) {
562
+ prev = null;
563
+ return null;
564
+ }
565
+ const instance = prev && weldsWith(prev, run) ? 1 - prev.instance : 0;
566
+ prev = { ...run, instance };
567
+ return prev;
568
+ });
569
+ }
570
+
513
571
  /**
514
572
  * Build a `load` thunk: dynamic-import a backend build, then instantiate it.
515
573
  *