@fuzdev/mdz 0.1.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/Mdz.svelte +39 -0
  4. package/dist/Mdz.svelte.d.ts +17 -0
  5. package/dist/Mdz.svelte.d.ts.map +1 -0
  6. package/dist/MdzNodeView.svelte +140 -0
  7. package/dist/MdzNodeView.svelte.d.ts +9 -0
  8. package/dist/MdzNodeView.svelte.d.ts.map +1 -0
  9. package/dist/MdzPrecompiled.svelte +33 -0
  10. package/dist/MdzPrecompiled.svelte.d.ts +17 -0
  11. package/dist/MdzPrecompiled.svelte.d.ts.map +1 -0
  12. package/dist/MdzRoot.svelte +40 -0
  13. package/dist/MdzRoot.svelte.d.ts +14 -0
  14. package/dist/MdzRoot.svelte.d.ts.map +1 -0
  15. package/dist/MdzStream.svelte +37 -0
  16. package/dist/MdzStream.svelte.d.ts +18 -0
  17. package/dist/MdzStream.svelte.d.ts.map +1 -0
  18. package/dist/MdzStreamNodeView.svelte +150 -0
  19. package/dist/MdzStreamNodeView.svelte.d.ts +9 -0
  20. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -0
  21. package/dist/context_helpers.d.ts +23 -0
  22. package/dist/context_helpers.d.ts.map +1 -0
  23. package/dist/context_helpers.js +19 -0
  24. package/dist/mdz.d.ts +152 -0
  25. package/dist/mdz.d.ts.map +1 -0
  26. package/dist/mdz.js +61 -0
  27. package/dist/mdz_contexts.d.ts +106 -0
  28. package/dist/mdz_contexts.d.ts.map +1 -0
  29. package/dist/mdz_contexts.js +42 -0
  30. package/dist/mdz_helpers.d.ts +321 -0
  31. package/dist/mdz_helpers.d.ts.map +1 -0
  32. package/dist/mdz_helpers.js +668 -0
  33. package/dist/mdz_lexer.d.ts +123 -0
  34. package/dist/mdz_lexer.d.ts.map +1 -0
  35. package/dist/mdz_lexer.js +1405 -0
  36. package/dist/mdz_opcodes.d.ts +193 -0
  37. package/dist/mdz_opcodes.d.ts.map +1 -0
  38. package/dist/mdz_opcodes.js +14 -0
  39. package/dist/mdz_opcodes_to_nodes.d.ts +26 -0
  40. package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -0
  41. package/dist/mdz_opcodes_to_nodes.js +410 -0
  42. package/dist/mdz_stream_parser.d.ts +109 -0
  43. package/dist/mdz_stream_parser.d.ts.map +1 -0
  44. package/dist/mdz_stream_parser.js +699 -0
  45. package/dist/mdz_stream_parser_block.d.ts +99 -0
  46. package/dist/mdz_stream_parser_block.d.ts.map +1 -0
  47. package/dist/mdz_stream_parser_block.js +437 -0
  48. package/dist/mdz_stream_parser_inline.d.ts +98 -0
  49. package/dist/mdz_stream_parser_inline.d.ts.map +1 -0
  50. package/dist/mdz_stream_parser_inline.js +481 -0
  51. package/dist/mdz_stream_parser_link.d.ts +29 -0
  52. package/dist/mdz_stream_parser_link.d.ts.map +1 -0
  53. package/dist/mdz_stream_parser_link.js +227 -0
  54. package/dist/mdz_stream_parser_list.d.ts +106 -0
  55. package/dist/mdz_stream_parser_list.d.ts.map +1 -0
  56. package/dist/mdz_stream_parser_list.js +438 -0
  57. package/dist/mdz_stream_parser_state.d.ts +476 -0
  58. package/dist/mdz_stream_parser_state.d.ts.map +1 -0
  59. package/dist/mdz_stream_parser_state.js +651 -0
  60. package/dist/mdz_stream_parser_text.d.ts +25 -0
  61. package/dist/mdz_stream_parser_text.d.ts.map +1 -0
  62. package/dist/mdz_stream_parser_text.js +67 -0
  63. package/dist/mdz_stream_parser_url.d.ts +76 -0
  64. package/dist/mdz_stream_parser_url.d.ts.map +1 -0
  65. package/dist/mdz_stream_parser_url.js +328 -0
  66. package/dist/mdz_stream_state.svelte.d.ts +53 -0
  67. package/dist/mdz_stream_state.svelte.d.ts.map +1 -0
  68. package/dist/mdz_stream_state.svelte.js +360 -0
  69. package/dist/mdz_to_svelte.d.ts +71 -0
  70. package/dist/mdz_to_svelte.d.ts.map +1 -0
  71. package/dist/mdz_to_svelte.js +127 -0
  72. package/dist/mdz_token_parser.d.ts +20 -0
  73. package/dist/mdz_token_parser.d.ts.map +1 -0
  74. package/dist/mdz_token_parser.js +449 -0
  75. package/dist/svelte_preprocess_mdz.d.ts +80 -0
  76. package/dist/svelte_preprocess_mdz.d.ts.map +1 -0
  77. package/dist/svelte_preprocess_mdz.js +543 -0
  78. package/dist/tsdoc_mdz.d.ts +45 -0
  79. package/dist/tsdoc_mdz.d.ts.map +1 -0
  80. package/dist/tsdoc_mdz.js +88 -0
  81. package/package.json +131 -0
  82. package/src/lib/context_helpers.ts +52 -0
  83. package/src/lib/mdz.ts +190 -0
  84. package/src/lib/mdz_contexts.ts +99 -0
  85. package/src/lib/mdz_helpers.ts +727 -0
  86. package/src/lib/mdz_lexer.ts +1761 -0
  87. package/src/lib/mdz_opcodes.ts +227 -0
  88. package/src/lib/mdz_opcodes_to_nodes.ts +470 -0
  89. package/src/lib/mdz_stream_parser.ts +766 -0
  90. package/src/lib/mdz_stream_parser_block.ts +490 -0
  91. package/src/lib/mdz_stream_parser_inline.ts +527 -0
  92. package/src/lib/mdz_stream_parser_link.ts +283 -0
  93. package/src/lib/mdz_stream_parser_list.ts +525 -0
  94. package/src/lib/mdz_stream_parser_state.ts +957 -0
  95. package/src/lib/mdz_stream_parser_text.ts +95 -0
  96. package/src/lib/mdz_stream_parser_url.ts +386 -0
  97. package/src/lib/mdz_stream_state.svelte.ts +383 -0
  98. package/src/lib/mdz_to_svelte.ts +213 -0
  99. package/src/lib/mdz_token_parser.ts +544 -0
  100. package/src/lib/svelte_preprocess_mdz.ts +777 -0
  101. package/src/lib/tsdoc_mdz.ts +97 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Opcode types for the mdz streaming parser.
3
+ *
4
+ * Opcodes are serializable rendering instructions emitted by `MdzStreamParser`.
5
+ * They tell a renderer what to do next — open a container, append text, close it,
6
+ * or revert an optimistic assumption. Target-agnostic: works for HTML, Svelte, PDF, etc.
7
+ *
8
+ * The parser makes optimistic assumptions about ambiguous syntax (e.g., `**` is probably bold)
9
+ * and emits `revert` opcodes to correct when wrong. This enables true streaming rendering
10
+ * without ever re-parsing.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ /**
16
+ * Unique monotonic identifier for each node created by the parser.
17
+ * IDs are never reused within a parser instance.
18
+ */
19
+ export type MdzNodeId = number;
20
+
21
+ /**
22
+ * Node types that can be opened as containers.
23
+ *
24
+ * `Code` appears here *and* in `MdzNodeTypeText`: inline code resolved within
25
+ * one chunk emits as a single `text` opcode, while a backtick with no closer
26
+ * visible yet opens an optimistic `Code` container whose content streams as
27
+ * text children (collapsed back to `content` by consumers at `close`).
28
+ */
29
+ export type MdzNodeTypeContainer =
30
+ | 'Paragraph'
31
+ | 'Bold'
32
+ | 'Italic'
33
+ | 'Strikethrough'
34
+ | 'Link'
35
+ | 'Heading'
36
+ | 'List'
37
+ | 'ListItem'
38
+ | 'Blockquote'
39
+ | 'Element'
40
+ | 'Component'
41
+ | 'Codeblock'
42
+ | 'Code';
43
+
44
+ /** Node types for self-contained leaf elements. */
45
+ export type MdzNodeTypeVoid = 'Hr';
46
+
47
+ /** Discriminant for leaf text nodes (see `MdzNodeTypeContainer` for `Code`'s dual form). */
48
+ export type MdzNodeTypeText = 'Text' | 'Code';
49
+
50
+ /**
51
+ * Open a container node. The renderer starts a new element/wrapper.
52
+ * Children are subsequent opcodes until the matching `close`.
53
+ */
54
+ export interface MdzOpcodeOpen {
55
+ type: 'open';
56
+ id: MdzNodeId;
57
+ node_type: MdzNodeTypeContainer;
58
+ /** Byte offset in the full input where the opening delimiter begins. */
59
+ start: number;
60
+ /** Heading level (1-6). Present when `node_type` is `'Heading'`. */
61
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
62
+ /** Tag name. Present when `node_type` is `'Element'` or `'Component'`. */
63
+ name?: string;
64
+ /** Language hint. Present when `node_type` is `'Codeblock'`. */
65
+ lang?: string | null;
66
+ /** Whether the list is ordered. Present when `node_type` is `'List'`. */
67
+ ordered?: boolean;
68
+ /** First item's authored number — the `<ol start>` attribute. Present for ordered `'List'`. */
69
+ start_number?: number;
70
+ /** Authored item number. Present when `node_type` is `'ListItem'` in an ordered list. */
71
+ number?: number;
72
+ }
73
+
74
+ /**
75
+ * Close a previously opened container node.
76
+ * Carries deferred metadata that wasn't known at open time.
77
+ */
78
+ export interface MdzOpcodeClose {
79
+ type: 'close';
80
+ id: MdzNodeId;
81
+ /** Byte offset in the full input immediately after the closing delimiter. */
82
+ end: number;
83
+ /** Link URL/path, resolved when `](url)` completes. */
84
+ reference?: string;
85
+ /** Link type, resolved alongside `reference`. */
86
+ link_type?: 'external' | 'internal';
87
+ /** Heading slug, computed from full heading content. */
88
+ heading_id?: string;
89
+ /**
90
+ * If true, consumer drops this node and its descendants from the tree.
91
+ * Used for whitespace-only paragraphs that match nothing in `mdz_parse`'s
92
+ * output — the streaming parser emits open/text speculatively, then
93
+ * retroactively drops the empty wrapper at close.
94
+ */
95
+ discard?: boolean;
96
+ }
97
+
98
+ /**
99
+ * Create a leaf text or code node.
100
+ * The parent is implicit — the innermost open container on the renderer's stack.
101
+ */
102
+ export interface MdzOpcodeText {
103
+ type: 'text';
104
+ id: MdzNodeId;
105
+ content: string;
106
+ text_type: MdzNodeTypeText;
107
+ /** Byte offset where this node begins (for Code, the opening backtick). */
108
+ start: number;
109
+ /** Byte offset immediately after this node ends (for Code, after the closing backtick). */
110
+ end: number;
111
+ }
112
+
113
+ /**
114
+ * Append content to an existing text node.
115
+ * Streaming optimization — avoids creating a new node per chunk
116
+ * during plain text runs.
117
+ */
118
+ export interface MdzOpcodeAppendText {
119
+ type: 'append_text';
120
+ id: MdzNodeId;
121
+ content: string;
122
+ /**
123
+ * Byte offset immediately after the appended content in the full input.
124
+ * When absent, consumers extend the node's `end` by `content.length`;
125
+ * present when the two differ — list continuation lines strip structural
126
+ * indent from content, so the source span outgrows the content length.
127
+ */
128
+ end?: number;
129
+ }
130
+
131
+ /**
132
+ * Trim `count` characters from the end of an existing text node.
133
+ * If trimming empties the node, the consumer removes it from its parent.
134
+ *
135
+ * Used by paragraph/codeblock close to drop the trailing newline that
136
+ * separates inline content from the block boundary. Emitted instead of
137
+ * retroactively mutating the prior `text`/`append_text` opcode, so the
138
+ * opcode stream is append-only.
139
+ */
140
+ export interface MdzOpcodeTrimText {
141
+ type: 'trim_text';
142
+ id: MdzNodeId;
143
+ count: number;
144
+ }
145
+
146
+ /**
147
+ * Create a self-contained leaf node (e.g., horizontal rule).
148
+ * Inserted as a child of the innermost open container, or at root level.
149
+ */
150
+ export interface MdzOpcodeVoid {
151
+ type: 'void';
152
+ id: MdzNodeId;
153
+ node_type: MdzNodeTypeVoid;
154
+ /** Byte offset in the full input where this element begins. */
155
+ start: number;
156
+ /** Byte offset immediately after this element ends. */
157
+ end: number;
158
+ }
159
+
160
+ /**
161
+ * Undo an optimistic open. Removes the container wrapper,
162
+ * inserts `replacement_text` as literal text at the container's position,
163
+ * and re-parents the container's children to the grandparent.
164
+ *
165
+ * Only inline containers revert — block structure (paragraphs, headings,
166
+ * codeblocks, lists, blockquotes) is decided from bounded input before
167
+ * opening, so block opens are never speculative.
168
+ */
169
+ export interface MdzOpcodeRevert {
170
+ type: 'revert';
171
+ id: MdzNodeId;
172
+ /** The delimiter text to emit as literal content (e.g., `"**"`, `"["`, `"<Tag>"`). */
173
+ replacement_text: string;
174
+ /** Byte offset of the original opening delimiter in the full input. */
175
+ start: number;
176
+ }
177
+
178
+ /**
179
+ * Retroactively wrap an existing text node in a container.
180
+ * Used for text-first auto-links: URL/path text streams as plain text,
181
+ * then gets wrapped in a Link when the URL boundary is found.
182
+ *
183
+ * Specific to text-first auto-detected content — not a general
184
+ * retroactive-container mechanism. Delimiter-paired inlines (bold, italic,
185
+ * strikethrough) use optimistic `open` + `revert` instead: their opening
186
+ * delimiter is an explicit open event, and the delimiters are discarded
187
+ * rather than retained as content.
188
+ *
189
+ * When `trim_end` is set, trailing characters (punctuation) are trimmed
190
+ * from the target text node and placed in a new sibling Text node after
191
+ * the Link wrapper, identified by `trim_id`.
192
+ */
193
+ export interface MdzOpcodeWrap {
194
+ type: 'wrap';
195
+ /** ID for the new Link container node. */
196
+ id: MdzNodeId;
197
+ /** Container type to wrap in (always `'Link'` for now). */
198
+ node_type: 'Link';
199
+ /** ID of the existing text node to wrap. */
200
+ target_id: MdzNodeId;
201
+ /** Resolved URL or path reference. */
202
+ reference: string;
203
+ /** Whether the link is external (URL) or internal (path). */
204
+ link_type: 'external' | 'internal';
205
+ /** Byte offset where the URL/path begins. */
206
+ start: number;
207
+ /** Byte offset immediately after the URL/path (before any trimmed punctuation). */
208
+ end: number;
209
+ /** Number of trailing chars to trim from target and place after the link. */
210
+ trim_end?: number;
211
+ /** ID for the trimmed-text sibling node. Required when `trim_end` > 0. */
212
+ trim_id?: MdzNodeId;
213
+ }
214
+
215
+ /** All node types that can appear in the mdz tree. */
216
+ export type MdzNodeType = MdzNodeTypeContainer | MdzNodeTypeVoid | MdzNodeTypeText;
217
+
218
+ /** Discriminated union of all mdz opcodes. */
219
+ export type MdzOpcode =
220
+ | MdzOpcodeOpen
221
+ | MdzOpcodeClose
222
+ | MdzOpcodeText
223
+ | MdzOpcodeAppendText
224
+ | MdzOpcodeTrimText
225
+ | MdzOpcodeVoid
226
+ | MdzOpcodeRevert
227
+ | MdzOpcodeWrap;
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Converts a stream of mdz opcodes to the `MdzNode[]` tree representation.
3
+ *
4
+ * Stack-based replay over a single flat children buffer: leaves push to the
5
+ * shared buffer, and each `open` frame records where its children begin
6
+ * (`children_start`). `close` slices the frame's range out into the built
7
+ * node; `revert` dissolves the frame boundary in place — its accumulated
8
+ * children become the parent's without copying — and merges the replacement
9
+ * delimiter text into an adjacent text node. Adjacent text left behind by
10
+ * reverts is coalesced once per container by `mdz_merge_adjacent_text` at
11
+ * close, so cascading reverts of deeply nested unclosed containers cost O(n)
12
+ * total instead of re-parenting (O(n²)) at every level.
13
+ *
14
+ * The output is identical to `mdz_parse()` from `mdz.ts`, enabling
15
+ * validation of the streaming parser against the existing fixture suite.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ import {DEV} from 'esm-env';
21
+
22
+ import type {
23
+ MdzNode,
24
+ MdzNodeText,
25
+ MdzNodeLink,
26
+ MdzNodeHeading,
27
+ MdzNodeCode,
28
+ MdzNodeListItem,
29
+ } from './mdz.js';
30
+ import type {MdzOpcode, MdzNodeId} from './mdz_opcodes.js';
31
+ import {mdz_extract_single_tag, mdz_heading_id, mdz_merge_adjacent_text} from './mdz_helpers.js';
32
+
33
+ interface StackFrame {
34
+ id: MdzNodeId;
35
+ node_type: string;
36
+ /** Index into the shared flat children buffer where this frame's children begin. */
37
+ children_start: number;
38
+ start: number;
39
+ // metadata from open opcode
40
+ level?: number;
41
+ name?: string;
42
+ lang?: string | null;
43
+ ordered?: boolean;
44
+ start_number?: number;
45
+ number?: number;
46
+ // metadata from close opcode (deferred)
47
+ end?: number;
48
+ reference?: string;
49
+ link_type?: 'external' | 'internal';
50
+ heading_id?: string;
51
+ }
52
+
53
+ /**
54
+ * Convert an array of mdz opcodes to the `MdzNode[]` tree.
55
+ * Produces output identical to `mdz_parse()`.
56
+ */
57
+ export const mdz_opcodes_to_nodes = (opcodes: Array<MdzOpcode>): Array<MdzNode> => {
58
+ // flat children buffer shared by all open frames — see module docs.
59
+ // once every frame is closed it holds exactly the top-level nodes.
60
+ const children: Array<MdzNode> = [];
61
+ const stack: Array<StackFrame> = [];
62
+ // index node IDs to their text content (for append_text, trim_text, and wrap)
63
+ const text_nodes = new Map<MdzNodeId, MdzNodeText | MdzNodeCode>();
64
+
65
+ for (const op of opcodes) {
66
+ switch (op.type) {
67
+ case 'open': {
68
+ stack.push({
69
+ id: op.id,
70
+ node_type: op.node_type,
71
+ children_start: children.length,
72
+ start: op.start,
73
+ level: op.level,
74
+ name: op.name,
75
+ lang: op.lang,
76
+ ordered: op.ordered,
77
+ start_number: op.start_number,
78
+ number: op.number,
79
+ });
80
+ break;
81
+ }
82
+
83
+ case 'close': {
84
+ if (DEV && stack.length === 0) {
85
+ throw new Error(`mdz_opcodes_to_nodes: close for id ${op.id} but stack is empty`);
86
+ }
87
+ const frame = stack.pop();
88
+ if (!frame) break;
89
+
90
+ // discard signal: the parser determined this node and its descendants
91
+ // should not appear in the tree (e.g. whitespace-only paragraph)
92
+ if (op.discard) {
93
+ children.length = frame.children_start;
94
+ break;
95
+ }
96
+
97
+ // apply deferred metadata
98
+ frame.end = op.end;
99
+ if (op.reference !== undefined) frame.reference = op.reference;
100
+ if (op.link_type !== undefined) frame.link_type = op.link_type;
101
+ if (op.heading_id !== undefined) frame.heading_id = op.heading_id;
102
+
103
+ const node = build_node(frame, children.splice(frame.children_start));
104
+ if (node) children.push(node);
105
+ break;
106
+ }
107
+
108
+ case 'text': {
109
+ const node: MdzNodeText | MdzNodeCode =
110
+ op.text_type === 'Code'
111
+ ? {type: 'Code', content: op.content, start: op.start, end: op.end}
112
+ : {type: 'Text', content: op.content, start: op.start, end: op.end};
113
+ text_nodes.set(op.id, node);
114
+ children.push(node);
115
+ break;
116
+ }
117
+
118
+ case 'append_text': {
119
+ if (DEV && !text_nodes.has(op.id)) {
120
+ throw new Error(`mdz_opcodes_to_nodes: append_text for unknown id ${op.id}`);
121
+ }
122
+ const existing = text_nodes.get(op.id);
123
+ if (existing) {
124
+ existing.content += op.content;
125
+ // an explicit end covers stripped structural indent (list
126
+ // continuation lines), where content is shorter than its span
127
+ existing.end = op.end ?? existing.end + op.content.length;
128
+ }
129
+ break;
130
+ }
131
+
132
+ case 'trim_text': {
133
+ if (DEV && !text_nodes.has(op.id)) {
134
+ throw new Error(`mdz_opcodes_to_nodes: trim_text for unknown id ${op.id}`);
135
+ }
136
+ const existing = text_nodes.get(op.id);
137
+ if (existing) {
138
+ existing.content = existing.content.slice(0, existing.content.length - op.count);
139
+ existing.end -= op.count;
140
+ if (existing.content.length === 0) {
141
+ // trim targets the most recent text run, so the node is near the
142
+ // end of the buffer — backward identity scan
143
+ for (let i = children.length - 1; i >= 0; i--) {
144
+ if (children[i] === existing) {
145
+ children.splice(i, 1);
146
+ break;
147
+ }
148
+ }
149
+ text_nodes.delete(op.id);
150
+ }
151
+ }
152
+ break;
153
+ }
154
+
155
+ case 'void': {
156
+ children.push({type: 'Hr', start: op.start, end: op.end});
157
+ break;
158
+ }
159
+
160
+ case 'revert': {
161
+ // find and remove the reverted node's frame from the stack.
162
+ // fast path: most reverts are top-down (revert_above, via
163
+ // close_paragraph/close_heading); a failed-closer revert
164
+ // (revert_failed_close) targets a mid-stack frame,
165
+ // dissolving it while the frames above stay open.
166
+ let reverted: StackFrame | null = null;
167
+ let reverted_idx = -1;
168
+ if (stack.length > 0 && stack[stack.length - 1]!.id === op.id) {
169
+ reverted_idx = stack.length - 1;
170
+ reverted = stack.pop()!;
171
+ } else {
172
+ for (let i = stack.length - 1; i >= 0; i--) {
173
+ if (stack[i]!.id === op.id) {
174
+ reverted_idx = i;
175
+ reverted = stack.splice(i, 1)[0]!;
176
+ break;
177
+ }
178
+ }
179
+ }
180
+
181
+ if (DEV && !reverted) {
182
+ throw new Error(`mdz_opcodes_to_nodes: revert for id ${op.id} but not found on stack`);
183
+ }
184
+ if (!reverted) break;
185
+
186
+ if (op.replacement_text) {
187
+ // the frame dissolves — its children already sit in the buffer
188
+ // and now belong to the parent; only the replacement delimiter
189
+ // text needs to materialize at the frame's open position.
190
+ insert_replacement(children, stack, reverted_idx, reverted.children_start, op);
191
+ }
192
+ break;
193
+ }
194
+
195
+ case 'wrap': {
196
+ const text_node = text_nodes.get(op.target_id);
197
+ if (!text_node) break;
198
+
199
+ // wraps always target a text node in the still-open container, near
200
+ // the end of the buffer — backward identity scan
201
+ let idx = -1;
202
+ for (let i = children.length - 1; i >= 0; i--) {
203
+ if (children[i] === text_node) {
204
+ idx = i;
205
+ break;
206
+ }
207
+ }
208
+ if (idx === -1) break;
209
+
210
+ // handle trailing punctuation trim
211
+ let trimmed_node: MdzNodeText | null = null;
212
+ if (op.trim_end && op.trim_end > 0 && op.trim_id != null) {
213
+ const trimmed_content = text_node.content.slice(text_node.content.length - op.trim_end);
214
+ text_node.content = text_node.content.slice(0, text_node.content.length - op.trim_end);
215
+ text_node.end -= op.trim_end;
216
+ trimmed_node = {
217
+ type: 'Text',
218
+ content: trimmed_content,
219
+ start: text_node.end,
220
+ end: text_node.end + trimmed_content.length,
221
+ };
222
+ text_nodes.set(op.trim_id, trimmed_node);
223
+ }
224
+
225
+ // create Link wrapping the text node
226
+ const link: MdzNodeLink = {
227
+ type: 'Link',
228
+ reference: op.reference,
229
+ children: [text_node],
230
+ link_type: op.link_type,
231
+ start: op.start,
232
+ end: op.end,
233
+ };
234
+
235
+ // replace text node with [Link, trimmed?] in the buffer
236
+ if (trimmed_node) {
237
+ children.splice(idx, 1, link as MdzNode, trimmed_node as MdzNode);
238
+ } else {
239
+ children[idx] = link;
240
+ }
241
+ break;
242
+ }
243
+ }
244
+ }
245
+
246
+ return children;
247
+ };
248
+
249
+ /**
250
+ * Materialize an inline revert's replacement text at `at` (the dissolved
251
+ * frame's `children_start`), merging into an adjacent Text node when possible
252
+ * so the hot path — cascading reverts of nested unclosed containers — never
253
+ * shifts buffer elements:
254
+ *
255
+ * 1. append to the preceding sibling when it's a Text within the parent's range
256
+ * 2. else prepend into the frame's first child when it's a Text (top-of-stack
257
+ * reverts only — for a mid-stack revert the node at `at` may belong to a
258
+ * still-open inner frame)
259
+ * 3. else splice in a standalone Text node
260
+ *
261
+ * Mutating the adjacent nodes is safe: a `revert` seals the parser's active
262
+ * text run (no later `append_text`/`trim_text` targets them), and pending
263
+ * auto-link wraps always complete before an enclosing container reverts.
264
+ * Adjacent Text nodes this leaves behind are coalesced by
265
+ * `mdz_merge_adjacent_text` when the enclosing container closes.
266
+ *
267
+ * @mutates `children` and possibly the `children_start` of open frames on `stack`
268
+ */
269
+ const insert_replacement = (
270
+ children: Array<MdzNode>,
271
+ stack: Array<StackFrame>,
272
+ reverted_idx: number,
273
+ at: number,
274
+ op: {replacement_text?: string; start: number},
275
+ ): void => {
276
+ const text = op.replacement_text!;
277
+ const parent_start = reverted_idx > 0 ? stack[reverted_idx - 1]!.children_start : 0;
278
+ // 1. append to the preceding sibling within the parent's range
279
+ if (at > parent_start) {
280
+ const prev = children[at - 1]!;
281
+ if (prev.type === 'Text') {
282
+ prev.content += text;
283
+ prev.end = op.start + text.length;
284
+ return;
285
+ }
286
+ }
287
+ // after pop/splice, the frame was top of stack iff its index equals the new length
288
+ const was_top = reverted_idx === stack.length;
289
+ // 2. prepend into the frame's first child
290
+ if (was_top && at < children.length) {
291
+ const first = children[at]!;
292
+ if (first.type === 'Text') {
293
+ first.content = text + first.content;
294
+ first.start = op.start;
295
+ return;
296
+ }
297
+ }
298
+ // 3. standalone Text node
299
+ const node: MdzNode = {type: 'Text', content: text, start: op.start, end: op.start + text.length};
300
+ if (at >= children.length) {
301
+ children.push(node);
302
+ } else {
303
+ children.splice(at, 0, node);
304
+ }
305
+ // shift the ranges of open frames above the reverted one (they opened
306
+ // later, so their children_start is at or after the insertion point) —
307
+ // also when the insert was an append: a mid-stack revert can land at the
308
+ // buffer end while childless frames above share the same children_start,
309
+ // and without the shift the replacement text falls inside their ranges
310
+ for (let i = reverted_idx; i < stack.length; i++) {
311
+ stack[i]!.children_start++;
312
+ }
313
+ };
314
+
315
+ /**
316
+ * Build an `MdzNode` from a completed stack frame and its children
317
+ * (sliced out of the shared flat buffer by the caller).
318
+ */
319
+ const build_node = (frame: StackFrame, frame_children: Array<MdzNode>): MdzNode | null => {
320
+ switch (frame.node_type) {
321
+ case 'Paragraph': {
322
+ const children = mdz_merge_adjacent_text(frame_children);
323
+ if (children.length === 0) return null;
324
+
325
+ // extract single tag (MDX convention)
326
+ const single_tag = mdz_extract_single_tag(children);
327
+ if (single_tag) return single_tag;
328
+
329
+ return {
330
+ type: 'Paragraph',
331
+ children,
332
+ start: children[0]!.start,
333
+ end: children[children.length - 1]!.end,
334
+ };
335
+ }
336
+
337
+ case 'Heading': {
338
+ const children = mdz_merge_adjacent_text(frame_children);
339
+ const id = frame.heading_id ?? mdz_heading_id(children);
340
+ return {
341
+ type: 'Heading',
342
+ level: frame.level ?? 1,
343
+ id,
344
+ children,
345
+ start: frame.start,
346
+ end: frame.end!,
347
+ } as MdzNodeHeading;
348
+ }
349
+
350
+ case 'Bold':
351
+ return {
352
+ type: 'Bold',
353
+ children: mdz_merge_adjacent_text(frame_children),
354
+ start: frame.start,
355
+ end: frame.end!,
356
+ };
357
+
358
+ case 'Italic':
359
+ return {
360
+ type: 'Italic',
361
+ children: mdz_merge_adjacent_text(frame_children),
362
+ start: frame.start,
363
+ end: frame.end!,
364
+ };
365
+
366
+ case 'Strikethrough':
367
+ return {
368
+ type: 'Strikethrough',
369
+ children: mdz_merge_adjacent_text(frame_children),
370
+ start: frame.start,
371
+ end: frame.end!,
372
+ };
373
+
374
+ case 'Link':
375
+ return {
376
+ type: 'Link',
377
+ reference: frame.reference ?? '',
378
+ children: mdz_merge_adjacent_text(frame_children),
379
+ link_type: frame.link_type ?? 'internal',
380
+ start: frame.start,
381
+ end: frame.end!,
382
+ };
383
+
384
+ case 'List': {
385
+ // items are the only possible children; `end` derives from the last
386
+ // one, matching the sync token parser (the close offset is cosmetic)
387
+ const children = frame_children as Array<MdzNodeListItem>;
388
+ const end = children.length > 0 ? children[children.length - 1]!.end : frame.end!;
389
+ if (frame.ordered) {
390
+ return {
391
+ type: 'List',
392
+ ordered: true,
393
+ start_number: frame.start_number ?? 1,
394
+ children,
395
+ start: frame.start,
396
+ end,
397
+ };
398
+ }
399
+ return {type: 'List', ordered: false, children, start: frame.start, end};
400
+ }
401
+
402
+ case 'ListItem': {
403
+ const children = mdz_merge_adjacent_text(frame_children);
404
+ // childless means an empty item — its close carried the marker end
405
+ const end = children.length > 0 ? children[children.length - 1]!.end : frame.end!;
406
+ if (frame.number !== undefined) {
407
+ return {type: 'ListItem', number: frame.number, children, start: frame.start, end};
408
+ }
409
+ return {type: 'ListItem', children, start: frame.start, end};
410
+ }
411
+
412
+ case 'Blockquote': {
413
+ // block children only — `end` derives from the last one, matching the
414
+ // sync token parser (the close offset is cosmetic)
415
+ const end =
416
+ frame_children.length > 0 ? frame_children[frame_children.length - 1]!.end : frame.end!;
417
+ return {type: 'Blockquote', children: frame_children, start: frame.start, end};
418
+ }
419
+
420
+ case 'Code': {
421
+ // optimistic inline code container — concatenate children text into leaf MdzNodeCode
422
+ let content = '';
423
+ for (const c of frame_children) {
424
+ if (c.type === 'Text') content += c.content;
425
+ }
426
+ return {
427
+ type: 'Code',
428
+ content,
429
+ start: frame.start,
430
+ end: frame.end!,
431
+ };
432
+ }
433
+
434
+ case 'Codeblock': {
435
+ // code block content is in children as text
436
+ let content = '';
437
+ for (const c of frame_children) {
438
+ if (c.type === 'Text') content += c.content;
439
+ }
440
+ return {
441
+ type: 'Codeblock',
442
+ lang: frame.lang ?? null,
443
+ content,
444
+ start: frame.start,
445
+ end: frame.end!,
446
+ };
447
+ }
448
+
449
+ case 'Element':
450
+ return {
451
+ type: 'Element',
452
+ name: frame.name ?? '',
453
+ children: mdz_merge_adjacent_text(frame_children),
454
+ start: frame.start,
455
+ end: frame.end!,
456
+ };
457
+
458
+ case 'Component':
459
+ return {
460
+ type: 'Component',
461
+ name: frame.name ?? '',
462
+ children: mdz_merge_adjacent_text(frame_children),
463
+ start: frame.start,
464
+ end: frame.end!,
465
+ };
466
+
467
+ default:
468
+ return null;
469
+ }
470
+ };