@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,957 @@
1
+ /**
2
+ * Streaming parser state and low-level operations.
3
+ *
4
+ * Free functions take a `MdzStreamParserState` first parameter so handlers can
5
+ * live in separate sibling modules — JS private (`#`) fields can't cross
6
+ * module boundaries.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type {MdzNodeTypeContainer, MdzNodeId, MdzOpcode} from './mdz_opcodes.js';
12
+ import {NEWLINE, has_non_whitespace, mdz_heading_id_from_text} from './mdz_helpers.js';
13
+
14
+ /**
15
+ * Tri-state result for `try_*` parser handlers.
16
+ * - `'consumed'`: input matched and the parser advanced
17
+ * - `'need_more'`: input is potentially valid but more bytes are required to decide
18
+ * - `'not_match'`: input definitely doesn't match — caller falls through to the next handler
19
+ *
20
+ * @nodocs
21
+ */
22
+ export type TryResult = 'consumed' | 'need_more' | 'not_match';
23
+
24
+ /** @nodocs */
25
+ export interface StackEntry {
26
+ id: MdzNodeId;
27
+ node_type: MdzNodeTypeContainer;
28
+ /**
29
+ * The opening delimiter text, used as `replacement_text` on revert.
30
+ * Empty for the block frames (`Paragraph`, `Heading`), which are never
31
+ * reverted — every inline open is optimistic (speculative, reverted if not
32
+ * closed), so the revert loops don't need a per-entry flag.
33
+ */
34
+ delimiter: string;
35
+ /** Tag name for Element/Component entries, `undefined` for all others. */
36
+ tag_name: string | undefined;
37
+ /** Whether any child content has been emitted inside this container. */
38
+ has_children: boolean;
39
+ /**
40
+ * Whether any non-whitespace text or non-text child has been emitted
41
+ * inside this container. Only tracked for `Paragraph` entries to detect
42
+ * whitespace-only paragraphs that should be dropped at close.
43
+ */
44
+ has_non_whitespace_content: boolean;
45
+ /** Global byte offset of the opening delimiter. */
46
+ start: number;
47
+ }
48
+
49
+ /**
50
+ * Memo entry for `buffer_index_of`, in **global** byte coordinates (see
51
+ * `MdzStreamParserState.base_offset`). Global coordinates make the memo
52
+ * immune to front-compaction: content at a global offset never changes once
53
+ * fed, so a found result stays valid forever and a not-found result stays
54
+ * valid for the byte range it scanned (`searched_to`).
55
+ *
56
+ * @nodocs
57
+ */
58
+ export interface BufferSearchMemo {
59
+ /** Global byte offset the memoized scan started from. */
60
+ from: number;
61
+ /** Global byte offset of the found occurrence, or `-1` when not found. */
62
+ result: number;
63
+ /** Global byte offset of the buffer end when the scan ran — the extent a `-1` result covers. */
64
+ searched_to: number;
65
+ }
66
+
67
+ /** @nodocs */
68
+ export interface CodeblockState {
69
+ id: MdzNodeId;
70
+ backtick_count: number;
71
+ text_id: MdzNodeId | null;
72
+ /**
73
+ * The opener line's indent — 0 for top-level fences, the marker-relative
74
+ * line indent for in-item fences. Closers match at any indent <= this,
75
+ * and content lines strip `min(fence_indent, line_indent)`.
76
+ */
77
+ fence_indent: number;
78
+ /** Global byte offset of the opening fence. */
79
+ start: number;
80
+ }
81
+
82
+ /**
83
+ * An open list level — the column of its markers, mirroring the sync lexer's stack.
84
+ *
85
+ * @nodocs
86
+ */
87
+ export interface MdzListLevel {
88
+ indent: number;
89
+ ordered: boolean;
90
+ }
91
+
92
+ /**
93
+ * An open blockquote — the streaming analogue of the sync lexer's quote
94
+ * sub-lex. A quote's content is a mini-document behind the per-line prefix,
95
+ * so the quote owns a **nested parser state**: the outer parser strips one
96
+ * prefix level per line and feeds the remainder (including each line's
97
+ * terminating newline) to `inner`, whose opcodes pass through with positions
98
+ * remapped to source offsets. Nesting recurses — `inner` opens its own quote
99
+ * for deeper prefixes — so the outer parser tracks at most one quote
100
+ * directly. The id counter is shared (`inner.next_id` seeds from the outer's
101
+ * and syncs back at close), so pass-through opcodes need no id remapping.
102
+ *
103
+ * @nodocs
104
+ */
105
+ export interface MdzBlockquoteState {
106
+ /** The Blockquote frame's node id. */
107
+ id: MdzNodeId;
108
+ /** The nested parser state for the quote's content document. */
109
+ inner: MdzStreamParserState;
110
+ /**
111
+ * Affine segments mapping inner-document offsets to global source
112
+ * offsets — one per quote line; the line's content and terminating
113
+ * newline are contiguous in both coordinate spaces.
114
+ */
115
+ segments: Array<{inner_start: number; source_start: number}>;
116
+ /** Total bytes fed to the inner document so far. */
117
+ inner_len: number;
118
+ /**
119
+ * Inner-coordinate end of each live text node, tracked so forwarded
120
+ * `append_text` opcodes can carry explicit remapped `end`s — an implied
121
+ * extend-by-content-length is wrong across the prefix gap at line
122
+ * boundaries.
123
+ */
124
+ text_inner_ends: Map<MdzNodeId, number>;
125
+ /** The attach item's marker indent for in-item quotes; -1 at top level. */
126
+ item_indent: number;
127
+ }
128
+
129
+ /**
130
+ * Counts of open inline containers by type, letting `find_open` answer the
131
+ * common "nothing open" case in O(1) instead of walking a stack that can grow
132
+ * arbitrarily deep with unclosed optimistic tags (tag-dense single-paragraph
133
+ * input leaks one `<Tag>` frame per line). Only the types `find_open` is
134
+ * queried with are counted. The counts are run-scoped for free: every run
135
+ * frame below the current one sealed by reverting its inlines (decrementing
136
+ * their counts), so all counted entries sit above the innermost run boundary
137
+ * (Paragraph, Heading, or ListItem).
138
+ *
139
+ * @nodocs
140
+ */
141
+ export interface OpenInlineCounts {
142
+ Bold: number;
143
+ Italic: number;
144
+ Strikethrough: number;
145
+ Link: number;
146
+ }
147
+
148
+ /**
149
+ * Pending auto-link URL/path state for text-first rendering.
150
+ * When set, URL chars flow as visible text. On terminator, a `wrap` opcode
151
+ * retroactively wraps the text node in a Link.
152
+ *
153
+ * For URLs, `confirmed` starts false during speculative prefix matching
154
+ * (chars stream as text while we verify `https://` or `http://`).
155
+ * For paths, `confirmed` starts true (prefix already validated by hold).
156
+ *
157
+ * @nodocs
158
+ */
159
+ export interface PendingUrl {
160
+ url_text: string;
161
+ start: number;
162
+ link_type: 'external' | 'internal';
163
+ /** Whether the URL/path prefix has been fully confirmed. */
164
+ confirmed: boolean;
165
+ /** Prefix match tracking — only used when `!confirmed`. */
166
+ viable_https: boolean;
167
+ viable_http: boolean;
168
+ }
169
+
170
+ /**
171
+ * Mutable state for the streaming parser. One instance per `MdzStreamParser`.
172
+ * Handlers in sibling modules take this as their first parameter — the streaming
173
+ * parser uses free functions, not class methods, so state crosses module
174
+ * boundaries.
175
+ *
176
+ * @nodocs
177
+ */
178
+ export interface MdzStreamParserState {
179
+ buffer: string;
180
+ pos: number;
181
+ opcodes: Array<MdzOpcode>;
182
+ next_id: MdzNodeId;
183
+ stack: Array<StackEntry>;
184
+ column: number;
185
+ prev_char: number;
186
+ active_text_id: MdzNodeId | null;
187
+ accumulated_text: string;
188
+ accumulated_text_start: number;
189
+ /**
190
+ * Global byte offset just past the accumulated text's source span. Equals
191
+ * `accumulated_text_start + accumulated_text.length` except when list
192
+ * continuation lines strip structural indent — content is then shorter
193
+ * than its source span, and flushes carry this so consumers keep
194
+ * source-accurate node `end`s.
195
+ */
196
+ accumulated_text_end: number;
197
+ codeblock: CodeblockState | null;
198
+ /**
199
+ * Open list levels, deepest last — the streaming analogue of the sync
200
+ * lexer's level stack in `#tokenize_list`. Non-empty exactly while
201
+ * List/ListItem frames are on `stack`; newlines then route to the list
202
+ * line classifier instead of the top-level paragraph-break scan.
203
+ */
204
+ list_levels: Array<MdzListLevel>;
205
+ /**
206
+ * The open blockquote, or null. While set, all input routes through the
207
+ * quote (prefix stripping + nested-parser feeding) — see `MdzBlockquoteState`.
208
+ */
209
+ blockquote: MdzBlockquoteState | null;
210
+ /**
211
+ * Whether the innermost open ListItem's first inline run is current.
212
+ * Content then flows directly into the ListItem frame — the sync AST
213
+ * keeps an item's first run as direct children — so `ensure_paragraph`
214
+ * is suppressed. Cleared when the run ends (blank line, dedent, block
215
+ * child); later runs in the item open `Paragraph` block children.
216
+ */
217
+ in_list_item_run: boolean;
218
+ /**
219
+ * Source end (just past the marker chars) of the most recently opened
220
+ * empty list item — the close opcode's `end` when an item closes
221
+ * childless, mirroring the sync parser's `open.end` fallback. One slot
222
+ * suffices: only empty items can close childless, and at most one can be
223
+ * open untouched (anything emitted after it opened becomes its child;
224
+ * a sibling closes it before the next empty item opens).
225
+ */
226
+ last_empty_item_end: number;
227
+ /** Global byte offset of the start of `buffer`. */
228
+ base_offset: number;
229
+ /**
230
+ * Per-needle search memo for `buffer_index_of`, keyed by needle.
231
+ * Created lazily — only `code_search_limit`'s cold path uses it, and the
232
+ * per-parser `Map` allocation is measurable on tiny inputs.
233
+ */
234
+ search_memo: Map<string, BufferSearchMemo> | null;
235
+ /** Open-container counts for `find_open`'s O(1) "nothing open" early exit. */
236
+ open_counts: OpenInlineCounts;
237
+ /** Whether we're inside a heading (newline ends it). */
238
+ in_heading: boolean;
239
+ /** Whether we're inside an optimistic inline Code container. */
240
+ in_code: boolean;
241
+ /** Cached flag: whether a Paragraph is open on the stack. */
242
+ in_paragraph: boolean;
243
+ pending_url: PendingUrl | null;
244
+ /**
245
+ * Stack of text segments for heading ID computation.
246
+ * Each open container inside a heading pushes a new segment.
247
+ * On close: pop and append to parent (children's text is part of heading).
248
+ * On revert: pop, prepend replacement_text, append to parent
249
+ * (document order: delimiter text comes before children's text).
250
+ */
251
+ heading_text_parts: Array<string>;
252
+ /**
253
+ * When true, absorb the blank region (newlines and whitespace-only lines)
254
+ * at the current position before normal processing. Set after block-element
255
+ * closes (codeblock, heading, HR) and paragraph breaks; stays set across
256
+ * chunks until a content line arrives — needed for char-by-char streaming
257
+ * where post-block blanks arrive in later chunks. Consumed by
258
+ * `skip_blank_region` in `mdz_stream_parser.ts`.
259
+ */
260
+ skip_blank_lines: boolean;
261
+ /**
262
+ * Stack index of the innermost open `Paragraph`, or `-1` when none.
263
+ * Lets `mark_paragraph_non_whitespace` skip the stack walk that runs on
264
+ * every non-whitespace emit. Updated only on Paragraph push/pop (block
265
+ * boundaries — only one of `Paragraph`/`Heading` can be on the stack
266
+ * at a time, so the cache is stable across inline pushes/pops).
267
+ */
268
+ paragraph_stack_idx: number;
269
+ /**
270
+ * Id of the most-recent `text`/`append_text` emission, used by
271
+ * `trim_trailing_newline` to target the right opcode after `take_opcodes()`
272
+ * has drained the queue. `null` after a structural emit (open/close/revert)
273
+ * sealed the prior text run.
274
+ */
275
+ last_text_id: MdzNodeId | null;
276
+ /** Whether the most recent text emission's content ended in `'\n'`. */
277
+ last_text_ended_with_newline: boolean;
278
+ /**
279
+ * Whether the most recent text emission was a `text` opcode (not
280
+ * `append_text`) with content exactly `'\n'` — trimming the trailing \n
281
+ * would leave the text node empty, so `active_text_id` must be cleared
282
+ * to keep subsequent content from merging into a deleted node.
283
+ */
284
+ last_text_was_singleton_newline: boolean;
285
+ }
286
+
287
+ /** @nodocs */
288
+ export const create_state = (): MdzStreamParserState => ({
289
+ buffer: '',
290
+ pos: 0,
291
+ opcodes: [],
292
+ next_id: 0,
293
+ stack: [],
294
+ column: 0,
295
+ prev_char: -1,
296
+ active_text_id: null,
297
+ accumulated_text: '',
298
+ accumulated_text_start: 0,
299
+ accumulated_text_end: 0,
300
+ codeblock: null,
301
+ list_levels: [],
302
+ blockquote: null,
303
+ in_list_item_run: false,
304
+ last_empty_item_end: 0,
305
+ base_offset: 0,
306
+ search_memo: null,
307
+ open_counts: {Bold: 0, Italic: 0, Strikethrough: 0, Link: 0},
308
+ in_heading: false,
309
+ in_code: false,
310
+ in_paragraph: false,
311
+ pending_url: null,
312
+ heading_text_parts: [],
313
+ // matches mdz_parse: leading blank lines before any block are skipped, not
314
+ // preserved as paragraph content
315
+ skip_blank_lines: true,
316
+ paragraph_stack_idx: -1,
317
+ last_text_id: null,
318
+ last_text_ended_with_newline: false,
319
+ last_text_was_singleton_newline: false,
320
+ });
321
+
322
+ /** @nodocs */
323
+ export const alloc_id = (state: MdzStreamParserState): MdzNodeId => state.next_id++;
324
+
325
+ /**
326
+ * Push a new container frame onto the stack. Fills the boilerplate fields
327
+ * (`has_children`, `has_non_whitespace_content`) so call sites only spell out
328
+ * what varies per container type. Keeping the object literal in one place
329
+ * also gives V8 a single monomorphic creation site for `StackEntry`.
330
+ *
331
+ * @nodocs
332
+ */
333
+ export const push_stack_entry = (
334
+ state: MdzStreamParserState,
335
+ id: MdzNodeId,
336
+ node_type: MdzNodeTypeContainer,
337
+ start: number,
338
+ delimiter: string = '',
339
+ tag_name?: string,
340
+ ): void => {
341
+ state.stack.push({
342
+ id,
343
+ node_type,
344
+ delimiter,
345
+ tag_name,
346
+ has_children: false,
347
+ has_non_whitespace_content: false,
348
+ start,
349
+ });
350
+ if (node_type === 'Paragraph') {
351
+ state.paragraph_stack_idx = state.stack.length - 1;
352
+ } else {
353
+ bump_open_count(state, node_type, 1);
354
+ }
355
+ };
356
+
357
+ /**
358
+ * Adjust the `open_counts` entry for a counted container type; no-op for
359
+ * uncounted types. Static-key accesses keep the property loads monomorphic —
360
+ * a keyed `counts[type]` access measurably regresses hot push/pop paths.
361
+ *
362
+ * @mutates `state.open_counts`
363
+ */
364
+ const bump_open_count = (
365
+ state: MdzStreamParserState,
366
+ node_type: MdzNodeTypeContainer,
367
+ delta: number,
368
+ ): void => {
369
+ const counts = state.open_counts;
370
+ if (node_type === 'Bold') counts.Bold += delta;
371
+ else if (node_type === 'Italic') counts.Italic += delta;
372
+ else if (node_type === 'Strikethrough') counts.Strikethrough += delta;
373
+ else if (node_type === 'Link') counts.Link += delta;
374
+ };
375
+
376
+ /**
377
+ * Pop the top stack entry, keeping `open_counts` in sync. All pops of inline
378
+ * containers must go through this — the only direct stack removal elsewhere
379
+ * is `revert_failed_close`'s mid-stack splice, which adjusts the count itself.
380
+ *
381
+ * @mutates `state`
382
+ * @nodocs
383
+ */
384
+ export const pop_stack_entry = (state: MdzStreamParserState): StackEntry => {
385
+ const entry = state.stack.pop()!;
386
+ bump_open_count(state, entry.node_type, -1);
387
+ return entry;
388
+ };
389
+
390
+ /**
391
+ * Mark the innermost open `Paragraph` as having non-whitespace content,
392
+ * via the `paragraph_stack_idx` cache (O(1) instead of an O(stack) walk).
393
+ *
394
+ * No-op when no Paragraph is open — that includes the Heading case, since
395
+ * block boundaries are mutually exclusive (a Heading on stack means
396
+ * `paragraph_stack_idx === -1`).
397
+ */
398
+ const mark_paragraph_non_whitespace = (state: MdzStreamParserState): void => {
399
+ if (state.paragraph_stack_idx === -1) return;
400
+ state.stack[state.paragraph_stack_idx]!.has_non_whitespace_content = true;
401
+ };
402
+
403
+ /**
404
+ * Global byte offset for a local buffer position.
405
+ *
406
+ * @nodocs
407
+ */
408
+ export const offset = (state: MdzStreamParserState, pos: number = state.pos): number =>
409
+ state.base_offset + pos;
410
+
411
+ /**
412
+ * Seal the prior text run so `trim_trailing_newline` can no longer target it.
413
+ * Called when a structural opcode (open/close/revert) ends the run.
414
+ *
415
+ * @mutates `state`
416
+ */
417
+ const seal_text_run = (state: MdzStreamParserState): void => {
418
+ state.last_text_id = null;
419
+ state.last_text_ended_with_newline = false;
420
+ state.last_text_was_singleton_newline = false;
421
+ };
422
+
423
+ /**
424
+ * Memoized `String#indexOf` over `state.buffer` against the given memo
425
+ * record, taking and returning **local** buffer positions.
426
+ *
427
+ * The streaming analogue of the sync lexer's memoized `#index_of`: a failed
428
+ * search would otherwise rescan the remaining buffer on every candidate,
429
+ * making inputs dense in unclosed candidates quadratic. Unlike the sync case
430
+ * the buffer mutates — `feed()` appends and the parser compacts consumed
431
+ * bytes from the front — so the memo lives in global byte coordinates
432
+ * (compaction shifts local positions but global content is immutable) and a
433
+ * not-found result records how far it scanned (`searched_to`). After an
434
+ * append, only the new tail is rescanned, backed up by `needle.length - 1`
435
+ * bytes to catch needles straddling the old boundary.
436
+ *
437
+ * A memoized found result earlier than `from` falls through to a fresh scan,
438
+ * same as the sync memo — the parser's scan positions are monotonic, so each
439
+ * needle's missing-suffix region is still scanned at most once per append.
440
+ *
441
+ * @mutates `memo`
442
+ */
443
+ const memo_index_of = (
444
+ state: MdzStreamParserState,
445
+ memo: BufferSearchMemo,
446
+ needle: string,
447
+ from: number,
448
+ ): number => {
449
+ const global_from = state.base_offset + from;
450
+ const global_end = state.base_offset + state.buffer.length;
451
+ if (memo.from <= global_from) {
452
+ if (memo.result >= global_from) return memo.result - state.base_offset;
453
+ if (memo.result === -1) {
454
+ if (memo.searched_to >= global_end) return -1; // nothing appended since
455
+ // rescan only the appended tail, overlapping the old boundary so a
456
+ // straddling needle isn't missed
457
+ const rescan_from = Math.max(global_from, memo.searched_to - (needle.length - 1));
458
+ const result = state.buffer.indexOf(needle, rescan_from - state.base_offset);
459
+ memo.from = global_from;
460
+ memo.result = result === -1 ? -1 : state.base_offset + result;
461
+ memo.searched_to = global_end;
462
+ return result;
463
+ }
464
+ // found result already behind `from` — fall through to a fresh scan
465
+ }
466
+ const result = state.buffer.indexOf(needle, from);
467
+ memo.from = global_from;
468
+ memo.result = result === -1 ? -1 : state.base_offset + result;
469
+ memo.searched_to = global_end;
470
+ return result;
471
+ };
472
+
473
+ /**
474
+ * Memoized buffer search for an arbitrary needle, backed by the per-needle
475
+ * `search_memo` map (created on first use). Used for cold-path scans with
476
+ * dynamic needles — the stack-delimiter searches in `code_search_limit`,
477
+ * where unclosed-tag delimiters absent from the rest of the input would
478
+ * otherwise rescan to the buffer end on every backtick candidate.
479
+ *
480
+ * @mutates `state.search_memo`
481
+ * @nodocs
482
+ */
483
+ export const buffer_index_of = (
484
+ state: MdzStreamParserState,
485
+ needle: string,
486
+ from: number,
487
+ ): number => {
488
+ const map = (state.search_memo ??= new Map());
489
+ let memo = map.get(needle);
490
+ if (memo === undefined) {
491
+ // "scanned [0, 0), found nothing" — memo_index_of handles the full scan
492
+ memo = {from: 0, result: -1, searched_to: 0};
493
+ map.set(needle, memo);
494
+ }
495
+ return memo_index_of(state, memo, needle, from);
496
+ };
497
+
498
+ /**
499
+ * Emit an opcode into the pending batch (drained by `take_opcodes`),
500
+ * maintaining the `has_children`/non-whitespace bookkeeping on open frames.
501
+ *
502
+ * @nodocs
503
+ */
504
+ export const emit = (state: MdzStreamParserState, op: MdzOpcode): void => {
505
+ state.opcodes.push(op);
506
+ // mark the innermost container as having children
507
+ if (op.type === 'text' || op.type === 'append_text' || op.type === 'void') {
508
+ const top = state.stack[state.stack.length - 1];
509
+ if (top) {
510
+ top.has_children = true;
511
+ // propagate non-whitespace marker to the nearest Paragraph so
512
+ // whitespace-only paragraphs can be discarded at close
513
+ if (op.type === 'void' || has_non_whitespace(op.content)) {
514
+ mark_paragraph_non_whitespace(state);
515
+ }
516
+ }
517
+ } else if (op.type === 'open') {
518
+ // opening a child container counts as content for the parent
519
+ const parent = state.stack[state.stack.length - 1];
520
+ if (parent) {
521
+ parent.has_children = true;
522
+ mark_paragraph_non_whitespace(state);
523
+ }
524
+ } else if (op.type === 'wrap') {
525
+ // retroactive Link wrap of a text node — the resulting Link is non-whitespace
526
+ mark_paragraph_non_whitespace(state);
527
+ }
528
+ // track the most recent text run so `trim_trailing_newline` can target it
529
+ // after `take_opcodes()` has drained the queue
530
+ if (op.type === 'text') {
531
+ state.last_text_id = op.id;
532
+ state.last_text_ended_with_newline = op.content.endsWith('\n');
533
+ state.last_text_was_singleton_newline = op.content === '\n';
534
+ } else if (op.type === 'append_text') {
535
+ state.last_text_id = op.id;
536
+ state.last_text_ended_with_newline = op.content.endsWith('\n');
537
+ // append_text builds on a prior text node; the underlying node can't be
538
+ // singleton even if this appended chunk is just '\n'
539
+ state.last_text_was_singleton_newline = false;
540
+ } else if (op.type === 'open' || op.type === 'close' || op.type === 'revert') {
541
+ seal_text_run(state);
542
+ }
543
+ // track text content for heading ID computation
544
+ if (state.in_heading && state.heading_text_parts.length > 0) {
545
+ if (op.type === 'text' || op.type === 'append_text') {
546
+ state.heading_text_parts[state.heading_text_parts.length - 1] += op.content;
547
+ } else if (op.type === 'open') {
548
+ // child container: push a new segment for its text content
549
+ state.heading_text_parts.push('');
550
+ } else if (op.type === 'close') {
551
+ // normal close: pop child's text and merge into parent
552
+ if (state.heading_text_parts.length > 1) {
553
+ const child_text = state.heading_text_parts.pop()!;
554
+ state.heading_text_parts[state.heading_text_parts.length - 1] += child_text;
555
+ }
556
+ } else if (op.type === 'revert') {
557
+ // revert: pop child's text, prepend replacement (document order), merge into parent
558
+ if (state.heading_text_parts.length > 1) {
559
+ const child_text = state.heading_text_parts.pop()!;
560
+ state.heading_text_parts[state.heading_text_parts.length - 1] +=
561
+ (op.replacement_text || '') + child_text;
562
+ }
563
+ }
564
+ }
565
+ };
566
+
567
+ /**
568
+ * Accumulate text, tracking the source span (start of the first character, end past the last).
569
+ *
570
+ * @nodocs
571
+ */
572
+ export const accumulate_text = (
573
+ state: MdzStreamParserState,
574
+ text: string,
575
+ start_offset: number,
576
+ ): void => {
577
+ if (state.accumulated_text.length === 0) {
578
+ state.accumulated_text_start = start_offset;
579
+ }
580
+ state.accumulated_text += text;
581
+ state.accumulated_text_end = start_offset + text.length;
582
+ };
583
+
584
+ /**
585
+ * Extend the accumulated text's source span past content that was stripped
586
+ * from it — a list continuation line's structural indent. The sync parser
587
+ * attributes the stripped region to the preceding text token, so node `end`s
588
+ * stay source-accurate across both pipelines.
589
+ *
590
+ * @mutates `state`
591
+ * @nodocs
592
+ */
593
+ export const extend_accumulated_end = (state: MdzStreamParserState, end: number): void => {
594
+ state.accumulated_text_end = end;
595
+ };
596
+
597
+ /**
598
+ * Flush accumulated text as a text or append_text opcode. Opcode `end`s come
599
+ * from the tracked source span, which can outgrow the content length when
600
+ * list continuation lines strip structural indent.
601
+ *
602
+ * @nodocs
603
+ */
604
+ export const flush_text = (state: MdzStreamParserState): void => {
605
+ if (state.accumulated_text.length === 0) return;
606
+ if (state.active_text_id !== null) {
607
+ emit(state, {
608
+ type: 'append_text',
609
+ id: state.active_text_id,
610
+ content: state.accumulated_text,
611
+ end: state.accumulated_text_end,
612
+ });
613
+ } else {
614
+ ensure_paragraph(state);
615
+ const id = alloc_id(state);
616
+ const start = state.accumulated_text_start;
617
+ emit(state, {
618
+ type: 'text',
619
+ id,
620
+ content: state.accumulated_text,
621
+ text_type: 'Text',
622
+ start,
623
+ end: state.accumulated_text_end,
624
+ });
625
+ state.active_text_id = id;
626
+ }
627
+ state.accumulated_text = '';
628
+ };
629
+
630
+ /**
631
+ * Open a Paragraph frame unless a run frame is already current — an open
632
+ * paragraph or heading, or a list item's first inline run (which flows
633
+ * directly into the ListItem frame, matching the sync AST's direct-children
634
+ * shape).
635
+ *
636
+ * @nodocs
637
+ */
638
+ export const ensure_paragraph = (state: MdzStreamParserState): void => {
639
+ if (state.in_heading || state.in_paragraph || state.in_list_item_run) return;
640
+ const id = alloc_id(state);
641
+ const start = offset(state);
642
+ emit(state, {type: 'open', id, node_type: 'Paragraph', start});
643
+ push_stack_entry(state, id, 'Paragraph', start);
644
+ state.in_paragraph = true;
645
+ };
646
+
647
+ /**
648
+ * Whether a stack frame bounds an inline run — inline pairs never cross a
649
+ * Paragraph, Heading, or ListItem frame (an item's first run is a
650
+ * paragraph-equivalent region; a List frame is never the innermost frame
651
+ * under inline content, so checking it is unnecessary).
652
+ *
653
+ * @nodocs
654
+ */
655
+ export const is_run_boundary = (node_type: MdzNodeTypeContainer): boolean =>
656
+ node_type === 'Paragraph' || node_type === 'Heading' || node_type === 'ListItem';
657
+
658
+ /**
659
+ * Find the innermost open container of a given type.
660
+ * Returns stack index, or -1 if not found.
661
+ * Does not cross run boundaries (Paragraph, Heading, ListItem).
662
+ *
663
+ * The "nothing open" case is O(1) via `open_counts` — without it, every
664
+ * candidate char in a deep-stack paragraph (unclosed optimistic tags leak
665
+ * one frame per line) walks to the block boundary, going quadratic.
666
+ *
667
+ * @nodocs
668
+ */
669
+ export const find_open = (state: MdzStreamParserState, type: keyof OpenInlineCounts): number => {
670
+ const counts = state.open_counts;
671
+ const count =
672
+ type === 'Bold'
673
+ ? counts.Bold
674
+ : type === 'Italic'
675
+ ? counts.Italic
676
+ : type === 'Strikethrough'
677
+ ? counts.Strikethrough
678
+ : counts.Link;
679
+ if (count === 0) return -1;
680
+ for (let i = state.stack.length - 1; i >= 0; i--) {
681
+ const entry = state.stack[i]!;
682
+ if (entry.node_type === type) return i;
683
+ if (is_run_boundary(entry.node_type)) return -1;
684
+ }
685
+ return -1;
686
+ };
687
+
688
+ /**
689
+ * Revert all stack entries above the given index. Every inline open is
690
+ * optimistic, so anything above a known frame is revertable by construction.
691
+ *
692
+ * @nodocs
693
+ */
694
+ export const revert_above = (state: MdzStreamParserState, target_idx: number): void => {
695
+ while (state.stack.length - 1 > target_idx) {
696
+ const entry = pop_stack_entry(state);
697
+ if (entry.node_type === 'Code') state.in_code = false;
698
+ emit(state, {
699
+ type: 'revert',
700
+ id: entry.id,
701
+ replacement_text: entry.delimiter,
702
+ start: entry.start,
703
+ });
704
+ }
705
+ state.active_text_id = null;
706
+ };
707
+
708
+ /**
709
+ * Revert an open `Italic` whose first closer candidate failed its boundary
710
+ * check (italic is the only inline with closer-boundary rules). One-shot
711
+ * parsing greedy-rejects the opener at the first invalid closer, so once a
712
+ * failed candidate is seen, the optimistic open is known wrong — reverting
713
+ * immediately (instead of leaving the container open for a later closer)
714
+ * keeps chunked output identical to the one-shot parse.
715
+ *
716
+ * Unlike `revert_above`, this reverts a possibly
717
+ * **mid-stack** entry: containers opened above it (e.g. a Bold inside the
718
+ * failed Italic) stay open — the one-shot parse opens them at top level, and
719
+ * the consumers' revert handlers re-parent them identically (the reverted
720
+ * node is still its parent's last child; see `mdz_opcodes_to_nodes` and
721
+ * `MdzStreamState`).
722
+ *
723
+ * Bypasses `emit` for the opcode push: `emit`'s heading-segment handling
724
+ * assumes the reverted container is the innermost open one, which doesn't
725
+ * hold mid-stack — the segment merge here targets the reverted frame's own
726
+ * segment, keeping the segments of still-open frames above it live.
727
+ *
728
+ * @mutates `state`
729
+ * @nodocs
730
+ */
731
+ export const revert_failed_close = (state: MdzStreamParserState, stack_idx: number): void => {
732
+ flush_text(state);
733
+ const entry = state.stack[stack_idx]!;
734
+ const frames_above = state.stack.length - 1 - stack_idx;
735
+ state.stack.splice(stack_idx, 1);
736
+ bump_open_count(state, entry.node_type, -1);
737
+ state.opcodes.push({
738
+ type: 'revert',
739
+ id: entry.id,
740
+ replacement_text: entry.delimiter,
741
+ start: entry.start,
742
+ });
743
+ // structural opcode seals the prior text run (mirrors `emit`)
744
+ seal_text_run(state);
745
+ // heading slug bookkeeping: fold the reverted frame's segment into its
746
+ // parent segment, delimiter text first (document order)
747
+ if (state.in_heading && state.heading_text_parts.length > 1) {
748
+ const seg_idx = state.heading_text_parts.length - 1 - frames_above;
749
+ if (seg_idx >= 1) {
750
+ const seg = state.heading_text_parts.splice(seg_idx, 1)[0]!;
751
+ state.heading_text_parts[seg_idx - 1] += entry.delimiter + seg;
752
+ }
753
+ }
754
+ state.active_text_id = null;
755
+ };
756
+
757
+ /**
758
+ * Trim a trailing newline from paragraph content.
759
+ * Checks unflushed accumulated text first; otherwise emits a `trim_text`
760
+ * opcode targeting the most recent text/append_text via the tracking fields
761
+ * on `state` (which survive `take_opcodes()` drains). The opcode stream
762
+ * stays append-only — no retroactive opcode mutation.
763
+ *
764
+ * @nodocs
765
+ */
766
+ export const trim_trailing_newline = (state: MdzStreamParserState): void => {
767
+ if (state.accumulated_text.endsWith('\n')) {
768
+ state.accumulated_text = state.accumulated_text.slice(0, -1);
769
+ return;
770
+ }
771
+ if (state.last_text_id === null || !state.last_text_ended_with_newline) return;
772
+ // emit trim opcode; consumer adjusts content and removes empty nodes
773
+ emit(state, {type: 'trim_text', id: state.last_text_id, count: 1});
774
+ // if this trim empties a singleton text node, clear active_text_id so
775
+ // subsequent content doesn't merge into a deleted node via append_text
776
+ if (state.last_text_was_singleton_newline) {
777
+ state.active_text_id = null;
778
+ }
779
+ // after trim, the prior text no longer ends in \n — guard against re-trim
780
+ state.last_text_ended_with_newline = false;
781
+ state.last_text_was_singleton_newline = false;
782
+ };
783
+
784
+ /** @nodocs */
785
+ export const close_paragraph = (state: MdzStreamParserState): void => {
786
+ // find and close the paragraph on the stack
787
+ for (let i = state.stack.length - 1; i >= 0; i--) {
788
+ if (state.stack[i]!.node_type === 'Paragraph') {
789
+ // trim trailing newline from paragraph content
790
+ trim_trailing_newline(state);
791
+ revert_above(state, i);
792
+ const entry = pop_stack_entry(state);
793
+ // drop whitespace-only paragraphs to match `mdz_parse`'s output;
794
+ // consumers (`mdz_opcodes_to_nodes`, `MdzStreamState`) honor `discard`
795
+ // by removing the node and its descendants from the tree
796
+ const discard = !entry.has_non_whitespace_content;
797
+ emit(
798
+ state,
799
+ discard
800
+ ? {type: 'close', id: entry.id, end: offset(state), discard: true}
801
+ : {type: 'close', id: entry.id, end: offset(state)},
802
+ );
803
+ state.active_text_id = null;
804
+ state.in_paragraph = false;
805
+ state.paragraph_stack_idx = -1;
806
+ return;
807
+ }
808
+ }
809
+ };
810
+
811
+ /** @nodocs */
812
+ export const close_heading = (state: MdzStreamParserState): void => {
813
+ // find the heading frame and revert the open inlines above it (before
814
+ // clearing in_heading, so revert replacement text is captured in
815
+ // heading_text_parts)
816
+ let heading_idx = -1;
817
+ for (let i = state.stack.length - 1; i >= 0; i--) {
818
+ if (state.stack[i]!.node_type === 'Heading') {
819
+ heading_idx = i;
820
+ break;
821
+ }
822
+ }
823
+ if (heading_idx !== -1) {
824
+ revert_above(state, heading_idx);
825
+ }
826
+ // compute heading ID from accumulated text before clearing heading state
827
+ const heading_text = state.heading_text_parts.join('');
828
+ state.heading_text_parts = [];
829
+ state.in_heading = false;
830
+ if (heading_idx === -1) return;
831
+ const heading_id = mdz_heading_id_from_text(heading_text);
832
+ // the heading is now the top of the stack
833
+ const entry = pop_stack_entry(state);
834
+ emit(state, {type: 'close', id: entry.id, end: offset(state), heading_id});
835
+ state.active_text_id = null;
836
+ };
837
+
838
+ /**
839
+ * Close a codeblock left open at EOF. Raw mode is absolute — an unclosed
840
+ * fence consumes to EOF as content — so this is a plain close after trimming
841
+ * one trailing newline, matching closed fences' content shape (which
842
+ * excludes the newline before the closer line) and the sync parser's
843
+ * unclosed-fence trim.
844
+ *
845
+ * @nodocs
846
+ */
847
+ export const close_codeblock_at_eof = (state: MdzStreamParserState): void => {
848
+ if (!state.codeblock) return;
849
+ // in-item content emits line-by-line with '\n' separators and never gains
850
+ // a trailing newline, so only top-level bulk emission needs the trim
851
+ if (state.codeblock.fence_indent === 0) {
852
+ trim_trailing_newline(state);
853
+ }
854
+ emit(state, {type: 'close', id: state.codeblock.id, end: offset(state)});
855
+ state.codeblock = null;
856
+ };
857
+
858
+ /**
859
+ * Seal the innermost ListItem's first inline run: flush text and revert the
860
+ * optimistic inlines above the ListItem frame. The item itself stays open —
861
+ * block children (nested lists, paragraphs, code blocks) may follow.
862
+ *
863
+ * @nodocs
864
+ */
865
+ export const seal_list_item_run = (state: MdzStreamParserState): void => {
866
+ if (!state.in_list_item_run) return;
867
+ flush_text(state);
868
+ for (let i = state.stack.length - 1; i >= 0; i--) {
869
+ if (state.stack[i]!.node_type === 'ListItem') {
870
+ revert_above(state, i);
871
+ break;
872
+ }
873
+ }
874
+ state.in_list_item_run = false;
875
+ state.active_text_id = null;
876
+ };
877
+
878
+ /**
879
+ * Close the current inline run inside a list, whichever frame holds it: a
880
+ * `Paragraph` block child closes, an item's first run seals. No-op when no
881
+ * run is current (post-blank, post-block-child).
882
+ *
883
+ * @nodocs
884
+ */
885
+ export const close_list_run = (state: MdzStreamParserState): void => {
886
+ if (state.in_paragraph) {
887
+ flush_text(state);
888
+ close_paragraph(state);
889
+ } else {
890
+ seal_list_item_run(state);
891
+ }
892
+ };
893
+
894
+ /**
895
+ * Close the deepest open level's current item. The run must already be
896
+ * sealed/closed, leaving the ListItem frame on top of the stack. A childless
897
+ * close means an empty item — its `end` is the marker end recorded at open
898
+ * (the sync parser's `open.end` fallback); otherwise consumers derive the
899
+ * node's `end` from its children, so the offset here is cosmetic.
900
+ *
901
+ * @nodocs
902
+ */
903
+ export const close_list_item = (state: MdzStreamParserState): void => {
904
+ const entry = pop_stack_entry(state);
905
+ emit(state, {
906
+ type: 'close',
907
+ id: entry.id,
908
+ end: entry.has_children ? offset(state) : state.last_empty_item_end,
909
+ });
910
+ state.active_text_id = null;
911
+ };
912
+
913
+ /**
914
+ * Close the deepest open level: its current item, then its list.
915
+ *
916
+ * @nodocs
917
+ */
918
+ export const close_list_level = (state: MdzStreamParserState): void => {
919
+ close_list_item(state);
920
+ const entry = pop_stack_entry(state);
921
+ emit(state, {type: 'close', id: entry.id, end: offset(state)});
922
+ state.list_levels.pop();
923
+ };
924
+
925
+ /**
926
+ * Close every open list level — the whole stack — sealing the item's first
927
+ * run if current. A current `Paragraph` block child must be closed by the
928
+ * caller first (`close_paragraph` in `finish()`, `close_list_run` in the
929
+ * classifier).
930
+ *
931
+ * @nodocs
932
+ */
933
+ export const close_all_list_levels = (state: MdzStreamParserState): void => {
934
+ if (state.list_levels.length === 0) return;
935
+ seal_list_item_run(state);
936
+ while (state.list_levels.length > 0) {
937
+ close_list_level(state);
938
+ }
939
+ };
940
+
941
+ /** @nodocs */
942
+ export const handle_paragraph_break = (state: MdzStreamParserState): void => {
943
+ flush_text(state);
944
+ // `close_paragraph` reverts everything above the paragraph frame itself.
945
+ // Its trailing-newline trim runs before those reverts, but the ordering is
946
+ // unobservable here: content at a paragraph break never ends in `\n` (the
947
+ // break's first newline is absorbed by `skip_blank_region`, and a lone held
948
+ // `\n` at buffer end never accumulates), so there's never a trim to emit.
949
+ close_paragraph(state);
950
+ // the break's newlines and blank lines are absorbed by `skip_blank_region`
951
+ // at the top of the process loop, which also carries the absorb across
952
+ // chunks — otherwise a blank run split mid-stream would leak a leading
953
+ // `\n` into the next paragraph's text
954
+ state.skip_blank_lines = true;
955
+ state.column = 0;
956
+ state.prev_char = NEWLINE;
957
+ };