@quillmark/wasm 0.97.0 → 0.98.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,86 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.98.0 - 2026-07-28
4
+
5
+ Five breaking changes, all covered by `docs/migrations/0.97-to-0.98.md`.
6
+ Stored documents are unaffected: a `0.97` blob loads byte-identically and
7
+ `0.98` writes the same bytes for the same content.
8
+
9
+ - feat(core,content,wasm)!: the block vocabulary opens. A line's `kind` and a
10
+ container's name join the mark `type` and island `type` as open sets — an
11
+ unrecognized value round-trips opaque and renders as its nearest safe
12
+ neighbor instead of failing the load, so adding a block construct is no
13
+ longer a document schema-version event. `LineKind` and `Container` each gain
14
+ an `Unknown { tag, attrs }` variant (exhaustive matches need an arm) and
15
+ `Line` / `LineKind` / `Container` drop their `Eq` derive, matching `MarkKind`;
16
+ `Invariant` gains `ReservedUnknownLineKind` / `ReservedUnknownContainer`, so
17
+ an unknown may not reuse a built-in name. `ContentLine.kind` and
18
+ `ContentContainer.container` gain an open TS arm, so a bare discriminant check
19
+ no longer narrows — `isHeadingLine` / `isCodeLine` / `isListItemContainer`
20
+ join the existing guards in `@quillmark/wasm/runtime`. `Loss` describes
21
+ fidelity; it does not gate export (#1054). See
22
+ `docs/migrations/0.97-to-0.98.md`
23
+ - refactor(core,wasm,python,cli)!: `OutputFormat::Txt` is removed — the Rust
24
+ variant, Python's `OutputFormat.TXT`, the TS `'txt'` arm. No backend listed it
25
+ in `SUPPORTED_FORMATS`, so every path reaching it failed at render time;
26
+ `--format txt` and `from_str("txt")` now fail at the argument instead, and
27
+ there is no replacement format (#1058). See
28
+ `docs/migrations/0.97-to-0.98.md`
29
+ - refactor(typst,pdfform)!: `format_not_supported` was three codes for one
30
+ condition. Both backends now emit `backend::format_not_supported`, alongside
31
+ the sibling `backend::apply_unsupported` — route on the namespaced code
32
+ (#1057). See `docs/migrations/0.97-to-0.98.md`
33
+ - refactor(core)!: dead public surface is removed — `ReadValue::as_text` /
34
+ `as_value` (match the variants), `Quill::list_files` / `list_subdirectories`
35
+ (`quill.files()` owns the walk), and `QuillValue`'s eight `Deref`-shadowed
36
+ accessors (`QuillValue: Deref<Target = serde_json::Value>` resolves each call
37
+ unchanged — no action). `prescan`'s helpers and `reader::err` drop to
38
+ `pub(crate)` (#1066, #1064). See `docs/migrations/0.97-to-0.98.md`
39
+ - fix(python)!: rendered regions cross the boundary as `DocPath`. Python
40
+ returned plate-space addresses no document API accepts; the plate→`DocPath`
41
+ translation now lives in core as `regions_to_doc_path` and both bindings call
42
+ it, so `RenderResult.regions[].field` reads `main.body` /
43
+ `cards.<kind>[<i>].<field>` as WASM already did (#1063). See
44
+ `docs/migrations/0.97-to-0.98.md`
45
+ - fix(content): three codec holes close. A mark spanning an island slot no
46
+ longer swallows it; a `LineKind` that disagrees with its segment is caught
47
+ (`Invariant::LineKindMismatch`, carrying a `LineKindMismatch` reason); and
48
+ decode is bounded — `MAX_NESTING_DEPTH` is enforced at the door, and a wire
49
+ position is read checked rather than `as usize`, which on wasm32 landed
50
+ `2^32 + 5` at position `5` (#1051)
51
+ - fix(content): export's verify-and-drop safety net is bounded per line rather
52
+ than per mark; `CONVERT.md` states the import↔export coupling the net implies
53
+ (#1052)
54
+ - fix(content): markdown import keeps a literal `*` that abuts strong or
55
+ emphasis — `a***a**` imports as `a`, `*`, strong `a`, where the deleted fixup
56
+ dropped the typed star. Removing it also fixes a backslash escape or entity in
57
+ such a span re-entering the content as literal source bytes (#1053)
58
+ - feat(core): `FileTreeNode`, `QuillIgnore`, `QuillConfig`, the schema types,
59
+ and `ValidationError` are nameable from `quillmark` and from `quillmark_core`'s
60
+ root, so in-memory quill construction and schema reading need no direct core
61
+ dependency (#1055)
62
+ - refactor(core,pdfform): `From<PdfError> for RenderError` replaces two
63
+ byte-identical `map_pdf_err` copies; `lopdf`, `js-sys` and `wasm-bindgen`
64
+ hoist into `[workspace.dependencies]`, the no-op `default-features = false`
65
+ pins drop, and `publish` is explicit on every crate (#1070, #1064)
66
+ - docs(core): the card-kind validation line is documented where it lives —
67
+ construction (`Document::make_card`, `TryFrom<CardWire>`) is permissive
68
+ data-shaping and insertion is the gate, returning `edit::invalid_kind_name`
69
+ - docs(canon): `ERROR.md` stops documenting a `fmt_pretty_with_source()` that
70
+ does not exist and states what carries the source chain: serialization to
71
+ both bindings, no Rust formatter. `error.rs`'s divergent path-grammar copy
72
+ gives way to a pointer at `path.rs` and the canon anchor table (#1061, #1069)
73
+ - docs(canon): prune — canon stops re-documenting what `docs/` owns and states
74
+ each fact once; the lint checks truth rather than shape, and the CI job table
75
+ lists every job
76
+ - test: test clusters go table-driven, wrong-altitude binding tests move down to
77
+ core, tests that pin foreign behavior or assert nothing are dropped, and one
78
+ walker and one quill builder replace the per-suite copies (#1060, #1056,
79
+ #1065, #1062, #1067)
80
+ - ci: the zero-backend configuration is built and tested — `--workspace
81
+ --all-features` forces `typst` on, so that branch never compiled (#1068)
82
+
83
+
3
84
  ## v0.97.0 - 2026-07-24
4
85
 
5
86
  - core: one take_item primitive behind the three payload removers
@@ -84,23 +84,34 @@ export interface Content {
84
84
  islands: ContentIsland[];
85
85
  }
86
86
 
87
- /** One `\n`-separated segment of `Content.text`, in order. */
87
+ /** One `\n`-separated segment of `Content.text`, in order. `kind` is an open set
88
+ * (as on `ContentIsland` and `ContentMark`): a role this build does not know
89
+ * round-trips with opaque `attrs` and renders as a paragraph, so a document
90
+ * carrying a future block construct still opens. The open arm blocks discriminant
91
+ * narrowing, so read `level`/`lang` behind a check of the arm you want. */
88
92
  export type ContentLine = {
89
93
  containers: ContentContainer[];
90
94
  /** A within-block hard line break rather than a new block. Omitted (false) in the common case. */
91
95
  continues?: boolean;
92
- } & (
96
+ } & ContentLineKind;
97
+
98
+ /** A line's block role, declared once for `ContentLine` and the `setKind` op —
99
+ * a new role is one edit here, as for `ContentContainer`. */
100
+ export type ContentLineKind =
93
101
  | { kind: "para" }
94
102
  | { kind: "heading"; level: number }
95
103
  | { kind: "code"; lang?: string }
96
104
  | { kind: "island" }
97
105
  | { kind: "rule" }
98
- );
106
+ | { kind: string; attrs: unknown };
99
107
 
100
- /** An ancestor block a line nests inside, outermost first. */
108
+ /** An ancestor block a line nests inside, outermost first. Open like
109
+ * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
110
+ * and renders transparently (its lines sit at the enclosing level). */
101
111
  export type ContentContainer =
102
112
  | { container: "list_item"; ordered: boolean; start: number; ordinal: number }
103
- | { container: "quote" };
113
+ | { container: "quote" }
114
+ | { container: string; attrs: unknown };
104
115
 
105
116
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
106
117
  * arm blocks discriminant narrowing (as on `ContentIsland`), so read a
@@ -223,11 +234,7 @@ export type MarkOp =
223
234
  export type LineOp =
224
235
  | { op: "split"; at: number }
225
236
  | { op: "join"; line: number }
226
- | ({ op: "setKind"; line: number } & (
227
- | { kind: "para" | "island" | "rule" }
228
- | { kind: "heading"; level: number }
229
- | { kind: "code"; lang?: string }
230
- ))
237
+ | ({ op: "setKind"; line: number } & ContentLineKind)
231
238
  | { op: "setContainers"; line: number; containers: ContentContainer[] }
232
239
  | { op: "setContinues"; line: number; continues: boolean };
233
240
 
@@ -553,7 +560,7 @@ export interface RenderResult {
553
560
 
554
561
  export type HitGranularity = "cluster" | "segment";
555
562
 
556
- export type OutputFormat = "pdf" | "svg" | "txt" | "png";
563
+ export type OutputFormat = "pdf" | "svg" | "png";
557
564
 
558
565
  export type Severity = "error" | "warning";
559
566
 
@@ -720,8 +727,16 @@ export class Document {
720
727
  * Build a fresh `Card` from a kind and a flat field map — the ergonomic
721
728
  * constructor for `insertCard`. `fields` is an optional
722
729
  * `Record<string, unknown>` (each entry becomes a card field, in
723
- * insertion order); `body` defaults to `""`. Kind validity is checked by
724
- * `insertCard`, not here.
730
+ * insertion order); `body` defaults to `""`.
731
+ *
732
+ * Sugar, not a required step: `insertCard` takes any `Card` object, and
733
+ * `removeCard` returns one, so a card round-trips without passing through
734
+ * here.
735
+ *
736
+ * Checks only what a detached card can decide alone: field-name grammar
737
+ * and value depth. Kind validity is positional — `main` is right for the
738
+ * root, reserved for a composable card — so `insertCard` is its gate, and
739
+ * any kind string is accepted here.
725
740
  */
726
741
  static makeCard(kind: string, fields?: Record<string, unknown>, body?: string): Card;
727
742
  /**
@@ -687,8 +687,16 @@ export class Document {
687
687
  * Build a fresh `Card` from a kind and a flat field map — the ergonomic
688
688
  * constructor for `insertCard`. `fields` is an optional
689
689
  * `Record<string, unknown>` (each entry becomes a card field, in
690
- * insertion order); `body` defaults to `""`. Kind validity is checked by
691
- * `insertCard`, not here.
690
+ * insertion order); `body` defaults to `""`.
691
+ *
692
+ * Sugar, not a required step: `insertCard` takes any `Card` object, and
693
+ * `removeCard` returns one, so a card round-trips without passing through
694
+ * here.
695
+ *
696
+ * Checks only what a detached card can decide alone: field-name grammar
697
+ * and value depth. Kind validity is positional — `main` is right for the
698
+ * root, reserved for a composable card — so `insertCard` is its gate, and
699
+ * any kind string is accepted here.
692
700
  * @param {string} kind
693
701
  * @param {Record<string, unknown>} [fields]
694
702
  * @param {string} [body]
Binary file
@@ -84,23 +84,34 @@ export interface Content {
84
84
  islands: ContentIsland[];
85
85
  }
86
86
 
87
- /** One `\n`-separated segment of `Content.text`, in order. */
87
+ /** One `\n`-separated segment of `Content.text`, in order. `kind` is an open set
88
+ * (as on `ContentIsland` and `ContentMark`): a role this build does not know
89
+ * round-trips with opaque `attrs` and renders as a paragraph, so a document
90
+ * carrying a future block construct still opens. The open arm blocks discriminant
91
+ * narrowing, so read `level`/`lang` behind a check of the arm you want. */
88
92
  export type ContentLine = {
89
93
  containers: ContentContainer[];
90
94
  /** A within-block hard line break rather than a new block. Omitted (false) in the common case. */
91
95
  continues?: boolean;
92
- } & (
96
+ } & ContentLineKind;
97
+
98
+ /** A line's block role, declared once for `ContentLine` and the `setKind` op —
99
+ * a new role is one edit here, as for `ContentContainer`. */
100
+ export type ContentLineKind =
93
101
  | { kind: "para" }
94
102
  | { kind: "heading"; level: number }
95
103
  | { kind: "code"; lang?: string }
96
104
  | { kind: "island" }
97
105
  | { kind: "rule" }
98
- );
106
+ | { kind: string; attrs: unknown };
99
107
 
100
- /** An ancestor block a line nests inside, outermost first. */
108
+ /** An ancestor block a line nests inside, outermost first. Open like
109
+ * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
110
+ * and renders transparently (its lines sit at the enclosing level). */
101
111
  export type ContentContainer =
102
112
  | { container: "list_item"; ordered: boolean; start: number; ordinal: number }
103
- | { container: "quote" };
113
+ | { container: "quote" }
114
+ | { container: string; attrs: unknown };
104
115
 
105
116
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
106
117
  * arm blocks discriminant narrowing (as on `ContentIsland`), so read a
@@ -223,11 +234,7 @@ export type MarkOp =
223
234
  export type LineOp =
224
235
  | { op: "split"; at: number }
225
236
  | { op: "join"; line: number }
226
- | ({ op: "setKind"; line: number } & (
227
- | { kind: "para" | "island" | "rule" }
228
- | { kind: "heading"; level: number }
229
- | { kind: "code"; lang?: string }
230
- ))
237
+ | ({ op: "setKind"; line: number } & ContentLineKind)
231
238
  | { op: "setContainers"; line: number; containers: ContentContainer[] }
232
239
  | { op: "setContinues"; line: number; continues: boolean };
233
240
 
@@ -553,7 +560,7 @@ export interface RenderResult {
553
560
 
554
561
  export type HitGranularity = "cluster" | "segment";
555
562
 
556
- export type OutputFormat = "pdf" | "svg" | "txt" | "png";
563
+ export type OutputFormat = "pdf" | "svg" | "png";
557
564
 
558
565
  export type Severity = "error" | "warning";
559
566
 
@@ -720,8 +727,16 @@ export class Document {
720
727
  * Build a fresh `Card` from a kind and a flat field map — the ergonomic
721
728
  * constructor for `insertCard`. `fields` is an optional
722
729
  * `Record<string, unknown>` (each entry becomes a card field, in
723
- * insertion order); `body` defaults to `""`. Kind validity is checked by
724
- * `insertCard`, not here.
730
+ * insertion order); `body` defaults to `""`.
731
+ *
732
+ * Sugar, not a required step: `insertCard` takes any `Card` object, and
733
+ * `removeCard` returns one, so a card round-trips without passing through
734
+ * here.
735
+ *
736
+ * Checks only what a detached card can decide alone: field-name grammar
737
+ * and value depth. Kind validity is positional — `main` is right for the
738
+ * root, reserved for a composable card — so `insertCard` is its gate, and
739
+ * any kind string is accepted here.
725
740
  */
726
741
  static makeCard(kind: string, fields?: Record<string, unknown>, body?: string): Card;
727
742
  /**
@@ -687,8 +687,16 @@ export class Document {
687
687
  * Build a fresh `Card` from a kind and a flat field map — the ergonomic
688
688
  * constructor for `insertCard`. `fields` is an optional
689
689
  * `Record<string, unknown>` (each entry becomes a card field, in
690
- * insertion order); `body` defaults to `""`. Kind validity is checked by
691
- * `insertCard`, not here.
690
+ * insertion order); `body` defaults to `""`.
691
+ *
692
+ * Sugar, not a required step: `insertCard` takes any `Card` object, and
693
+ * `removeCard` returns one, so a card round-trips without passing through
694
+ * here.
695
+ *
696
+ * Checks only what a detached card can decide alone: field-name grammar
697
+ * and value depth. Kind validity is positional — `main` is right for the
698
+ * root, reserved for a composable card — so `insertCard` is its gate, and
699
+ * any kind string is accepted here.
692
700
  * @param {string} kind
693
701
  * @param {Record<string, unknown>} [fields]
694
702
  * @param {string} [body]
Binary file
package/core/wasm.d.ts CHANGED
@@ -84,23 +84,34 @@ export interface Content {
84
84
  islands: ContentIsland[];
85
85
  }
86
86
 
87
- /** One `\n`-separated segment of `Content.text`, in order. */
87
+ /** One `\n`-separated segment of `Content.text`, in order. `kind` is an open set
88
+ * (as on `ContentIsland` and `ContentMark`): a role this build does not know
89
+ * round-trips with opaque `attrs` and renders as a paragraph, so a document
90
+ * carrying a future block construct still opens. The open arm blocks discriminant
91
+ * narrowing, so read `level`/`lang` behind a check of the arm you want. */
88
92
  export type ContentLine = {
89
93
  containers: ContentContainer[];
90
94
  /** A within-block hard line break rather than a new block. Omitted (false) in the common case. */
91
95
  continues?: boolean;
92
- } & (
96
+ } & ContentLineKind;
97
+
98
+ /** A line's block role, declared once for `ContentLine` and the `setKind` op —
99
+ * a new role is one edit here, as for `ContentContainer`. */
100
+ export type ContentLineKind =
93
101
  | { kind: "para" }
94
102
  | { kind: "heading"; level: number }
95
103
  | { kind: "code"; lang?: string }
96
104
  | { kind: "island" }
97
105
  | { kind: "rule" }
98
- );
106
+ | { kind: string; attrs: unknown };
99
107
 
100
- /** An ancestor block a line nests inside, outermost first. */
108
+ /** An ancestor block a line nests inside, outermost first. Open like
109
+ * `ContentLine.kind`: an unrecognized container round-trips with opaque `attrs`
110
+ * and renders transparently (its lines sit at the enclosing level). */
101
111
  export type ContentContainer =
102
112
  | { container: "list_item"; ordered: boolean; start: number; ordinal: number }
103
- | { container: "quote" };
113
+ | { container: "quote" }
114
+ | { container: string; attrs: unknown };
104
115
 
105
116
  /** A mark over char range `[start, end)` into `Content.text`. The open `type`
106
117
  * arm blocks discriminant narrowing (as on `ContentIsland`), so read a
@@ -223,11 +234,7 @@ export type MarkOp =
223
234
  export type LineOp =
224
235
  | { op: "split"; at: number }
225
236
  | { op: "join"; line: number }
226
- | ({ op: "setKind"; line: number } & (
227
- | { kind: "para" | "island" | "rule" }
228
- | { kind: "heading"; level: number }
229
- | { kind: "code"; lang?: string }
230
- ))
237
+ | ({ op: "setKind"; line: number } & ContentLineKind)
231
238
  | { op: "setContainers"; line: number; containers: ContentContainer[] }
232
239
  | { op: "setContinues"; line: number; continues: boolean };
233
240
 
@@ -591,8 +598,16 @@ export class Document {
591
598
  * Build a fresh `Card` from a kind and a flat field map — the ergonomic
592
599
  * constructor for `insertCard`. `fields` is an optional
593
600
  * `Record<string, unknown>` (each entry becomes a card field, in
594
- * insertion order); `body` defaults to `""`. Kind validity is checked by
595
- * `insertCard`, not here.
601
+ * insertion order); `body` defaults to `""`.
602
+ *
603
+ * Sugar, not a required step: `insertCard` takes any `Card` object, and
604
+ * `removeCard` returns one, so a card round-trips without passing through
605
+ * here.
606
+ *
607
+ * Checks only what a detached card can decide alone: field-name grammar
608
+ * and value depth. Kind validity is positional — `main` is right for the
609
+ * root, reserved for a composable card — so `insertCard` is its gate, and
610
+ * any kind string is accepted here.
596
611
  */
597
612
  static makeCard(kind: string, fields?: Record<string, unknown>, body?: string): Card;
598
613
  /**
package/core/wasm_bg.js CHANGED
@@ -687,8 +687,16 @@ export class Document {
687
687
  * Build a fresh `Card` from a kind and a flat field map — the ergonomic
688
688
  * constructor for `insertCard`. `fields` is an optional
689
689
  * `Record<string, unknown>` (each entry becomes a card field, in
690
- * insertion order); `body` defaults to `""`. Kind validity is checked by
691
- * `insertCard`, not here.
690
+ * insertion order); `body` defaults to `""`.
691
+ *
692
+ * Sugar, not a required step: `insertCard` takes any `Card` object, and
693
+ * `removeCard` returns one, so a card round-trips without passing through
694
+ * here.
695
+ *
696
+ * Checks only what a detached card can decide alone: field-name grammar
697
+ * and value depth. Kind validity is positional — `main` is right for the
698
+ * root, reserved for a composable card — so `insertCard` is its gate, and
699
+ * any kind string is accepted here.
692
700
  * @param {string} kind
693
701
  * @param {Record<string, unknown>} [fields]
694
702
  * @param {string} [body]
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.97.0",
3
+ "version": "0.98.0",
4
4
  "description": "WebAssembly bindings for Quillmark, a schema-driven document engine",
5
5
  "type": "module",
6
6
  "license": "MIT OR Apache-2.0",
@@ -122,15 +122,24 @@ export interface QuillmarkError extends Error {
122
122
  export declare function isQuillmarkError(e: unknown): e is QuillmarkError;
123
123
 
124
124
  // ── Open-set discriminant guards ────────────────────────────────────────────
125
- // `ContentIsland.type` / `ContentMark.type` are open sets — each union has a
126
- // residual `{ type: string; }` arm, so a bare discriminant check never narrows
127
- // the payload (TS keeps the residual arm live, since a `string` can equal the
128
- // literal). These guards are the checked narrowing path for the pinned arms; an
129
- // unrecognized `type` fails every guard and keeps its opaque payload. Only the
130
- // payload-carrying arms get a guard the bare marks
131
- // (`strong`/`emph`/`underline`/`strike`/`code`) narrow to nothing.
132
-
133
- import type { ContentIsland, TableProps, ImageProps, ContentMark } from '../core/wasm.js';
125
+ // `ContentIsland.type`, `ContentMark.type`, `ContentLine.kind`, and
126
+ // `ContentContainer.container` are open sets each union has a residual
127
+ // `{ …: string; }` arm, so a bare discriminant check never narrows the payload
128
+ // (TS keeps the residual arm live, since a `string` can equal the literal).
129
+ // These guards are the checked narrowing path for the pinned arms; an
130
+ // unrecognized discriminant fails every guard and keeps its opaque payload. Only
131
+ // the payload-carrying arms get a guard — the bare marks
132
+ // (`strong`/`emph`/`underline`/`strike`/`code`), the payload-free lines
133
+ // (`para`/`island`/`rule`), and `quote` narrow to nothing.
134
+
135
+ import type {
136
+ ContentIsland,
137
+ TableProps,
138
+ ImageProps,
139
+ ContentMark,
140
+ ContentLine,
141
+ ContentContainer
142
+ } from '../core/wasm.js';
134
143
 
135
144
  /** Narrow a {@link ContentIsland} to the pinned `table` arm (`props: TableProps`). */
136
145
  export declare function isTableIsland(
@@ -152,6 +161,26 @@ export declare function isAnchorMark(
152
161
  mark: ContentMark
153
162
  ): mark is ContentMark & { type: 'anchor'; id: string };
154
163
 
164
+ /** Narrow a {@link ContentLine} to the `heading` arm (carries `level`). */
165
+ export declare function isHeadingLine(
166
+ line: ContentLine
167
+ ): line is ContentLine & { kind: 'heading'; level: number };
168
+
169
+ /** Narrow a {@link ContentLine} to the `code` arm (carries `lang`). */
170
+ export declare function isCodeLine(
171
+ line: ContentLine
172
+ ): line is ContentLine & { kind: 'code'; lang?: string };
173
+
174
+ /** Narrow a {@link ContentContainer} to the `list_item` arm (carries its shape). */
175
+ export declare function isListItemContainer(
176
+ container: ContentContainer
177
+ ): container is ContentContainer & {
178
+ container: 'list_item';
179
+ ordered: boolean;
180
+ start: number;
181
+ ordinal: number;
182
+ };
183
+
155
184
  // ── Canonical render-side types ─────────────────────────────────────────────
156
185
  // These are the BACKEND-NEUTRAL render contract of the plural-backend API. They
157
186
  // are defined HERE (not re-exported from one private backend) because no single
@@ -291,7 +320,7 @@ export interface RenderResult {
291
320
  }
292
321
 
293
322
  /** Canonical contract every backend build must satisfy. The emittable formats. */
294
- export type OutputFormat = 'pdf' | 'svg' | 'txt' | 'png';
323
+ export type OutputFormat = 'pdf' | 'svg' | 'png';
295
324
 
296
325
  /**
297
326
  * Canonical contract every backend build must satisfy. Page geometry in pt.
@@ -99,15 +99,19 @@ export function isQuillmarkError(e) {
99
99
  }
100
100
 
101
101
  // ── Open-set discriminant guards ────────────────────────────────────────────
102
- // `ContentIsland.type` and `ContentMark.type` are OPEN sets: each union carries
103
- // a residual `{ type: string; }` arm, so a bare `x.type === 'table'` check
104
- // never narrows the payload TS keeps the residual arm live (a `string` can be
105
- // `'table'`), leaving `props` / the mark payload opaque at every consumer. These
102
+ // `ContentIsland.type`, `ContentMark.type`, `ContentLine.kind`, and
103
+ // `ContentContainer.container` are OPEN sets: each union carries a residual
104
+ // `{ …: string; }` arm, so a bare `x.type === 'table'` check never narrows the
105
+ // payload TS keeps the residual arm live (a `string` can be `'table'`),
106
+ // leaving `props` / the mark payload / `level` opaque at every consumer. These
106
107
  // are the checked narrowing path: on the true branch the payload's pinned shape
107
108
  // is asserted. Only the payload-carrying arms get a guard — an island always
108
- // carries `props`, a `link` mark carries `url`, an `anchor` mark carries `id`;
109
- // the bare marks (`strong`/`emph`/`underline`/`strike`/`code`) narrow to
110
- // nothing. An unrecognized `type` fails every guard and keeps its opaque payload.
109
+ // carries `props`, a `link` mark carries `url`, an `anchor` mark carries `id`, a
110
+ // `heading` line carries `level` and a `code` line `lang`, a `list_item`
111
+ // container its shape; the payload-free arms (`strong`/`emph`/`underline`/
112
+ // `strike`/`code` marks, `para`/`island`/`rule` lines, `quote`) narrow to
113
+ // nothing. An unrecognized discriminant fails every guard and keeps its opaque
114
+ // `attrs`/`props`.
111
115
 
112
116
  /**
113
117
  * @param {import('../core/wasm.js').ContentIsland} island
@@ -141,6 +145,30 @@ export function isAnchorMark(mark) {
141
145
  return mark.type === 'anchor';
142
146
  }
143
147
 
148
+ /**
149
+ * @param {import('../core/wasm.js').ContentLine} line
150
+ * @returns {line is import('../core/wasm.js').ContentLine & { kind: 'heading'; level: number }}
151
+ */
152
+ export function isHeadingLine(line) {
153
+ return line.kind === 'heading';
154
+ }
155
+
156
+ /**
157
+ * @param {import('../core/wasm.js').ContentLine} line
158
+ * @returns {line is import('../core/wasm.js').ContentLine & { kind: 'code'; lang?: string }}
159
+ */
160
+ export function isCodeLine(line) {
161
+ return line.kind === 'code';
162
+ }
163
+
164
+ /**
165
+ * @param {import('../core/wasm.js').ContentContainer} container
166
+ * @returns {container is import('../core/wasm.js').ContentContainer & { container: 'list_item'; ordered: boolean; start: number; ordinal: number }}
167
+ */
168
+ export function isListItemContainer(container) {
169
+ return container.container === 'list_item';
170
+ }
171
+
144
172
  // Backend builds are NEVER statically imported here — that would pull a
145
173
  // multi-MB binary into the eager graph and defeat lazy loading. Each entry is a
146
174
  // DESCRIPTOR: `load` is a thunk returning a dynamic `import()` (a backend's