@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,766 @@
1
+ /**
2
+ * Streaming opcode parser for mdz.
3
+ *
4
+ * Fed chunks of text (e.g., from LLM output), emits opcodes as rendering
5
+ * instructions. Makes optimistic assumptions about ambiguous syntax and
6
+ * emits `revert` opcodes to correct when wrong. Never re-parses —
7
+ * corrections are bounded, explicit opcodes (`revert`, `wrap`, `trim_text`).
8
+ *
9
+ * The design is derived from
10
+ * {@link https://bsky.app/profile/pngwn.at/post/3mi527zntb22n @pngwn.at}'s
11
+ * ideas: restrict the syntax so streaming is tractable, render
12
+ * optimistically and correct when wrong, emit serializable opcodes to avoid
13
+ * re-parsing, and keep the opcodes target-agnostic so any renderer can
14
+ * consume them. mdz diverges in one respect: the Svelte consumer
15
+ * (`MdzStreamState`) does build a reactive tree from opcodes — the platform
16
+ * dictates this — but mutations are fine-grained via `$state`, not diffed.
17
+ *
18
+ * The parser is split across sibling modules: this file holds the public
19
+ * `MdzStreamParser` class and the `process_loop` / `process_inline`
20
+ * orchestrators. Per-category handlers (block / inline / link / url / text)
21
+ * live in `mdz_stream_parser_*.ts` as free functions taking the shared
22
+ * `MdzStreamParserState` as first argument.
23
+ *
24
+ * Usage:
25
+ * ```ts
26
+ * const parser = new MdzStreamParser();
27
+ * parser.feed('hello **bold');
28
+ * const ops1 = parser.take_opcodes(); // open Paragraph, text "hello ", open Bold, text "bold"
29
+ * parser.feed('** world');
30
+ * const ops2 = parser.take_opcodes(); // close Bold, text " world"
31
+ * parser.finish();
32
+ * const ops3 = parser.take_opcodes(); // close Paragraph
33
+ * ```
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import type {MdzOpcode} from './mdz_opcodes.js';
39
+ import {
40
+ BACKTICK,
41
+ ASTERISK,
42
+ UNDERSCORE,
43
+ TILDE,
44
+ NEWLINE,
45
+ HYPHEN,
46
+ HASH,
47
+ LEFT_ANGLE,
48
+ RIGHT_ANGLE,
49
+ SLASH,
50
+ LEFT_BRACKET,
51
+ RIGHT_BRACKET,
52
+ H_LOWER,
53
+ H_UPPER,
54
+ PERIOD,
55
+ SPACE,
56
+ ZERO,
57
+ NINE,
58
+ is_word_char,
59
+ is_line_whitespace,
60
+ mdz_match_blockquote_prefix,
61
+ mdz_blockquote_line_has_content,
62
+ mdz_blockquote_strip_width,
63
+ mdz_remap_segments,
64
+ } from './mdz_helpers.js';
65
+ import {
66
+ type MdzStreamParserState,
67
+ type TryResult,
68
+ accumulate_text,
69
+ alloc_id,
70
+ close_all_list_levels,
71
+ close_codeblock_at_eof,
72
+ close_heading,
73
+ close_paragraph,
74
+ create_state,
75
+ emit,
76
+ find_open,
77
+ flush_text,
78
+ handle_paragraph_break,
79
+ offset,
80
+ pop_stack_entry,
81
+ push_stack_entry,
82
+ revert_failed_close,
83
+ } from './mdz_stream_parser_state.js';
84
+ import {
85
+ process_codeblock,
86
+ process_codeblock_forced,
87
+ process_codeblock_in_item,
88
+ try_codeblock_open,
89
+ try_heading,
90
+ try_hr,
91
+ } from './mdz_stream_parser_block.js';
92
+ import {process_list_line, try_list_start} from './mdz_stream_parser_list.js';
93
+ import {
94
+ check_close_word_boundary,
95
+ close_delimiter,
96
+ process_code_content,
97
+ try_code,
98
+ try_double_delimiter,
99
+ try_italic,
100
+ } from './mdz_stream_parser_inline.js';
101
+ import {
102
+ try_close_tag,
103
+ try_complete_link,
104
+ try_link_open,
105
+ try_tag_open,
106
+ } from './mdz_stream_parser_link.js';
107
+ import {
108
+ complete_pending_url,
109
+ process_url_content,
110
+ start_speculative_url,
111
+ try_auto_path_absolute,
112
+ try_auto_path_relative,
113
+ try_auto_url_forced,
114
+ } from './mdz_stream_parser_url.js';
115
+ import {consume_delimiter_as_text, consume_text_run} from './mdz_stream_parser_text.js';
116
+
117
+ /**
118
+ * Streaming opcode parser for mdz content.
119
+ * Feed chunks via `feed()`, retrieve opcodes via `take_opcodes()`, call `finish()` at end.
120
+ *
121
+ * The opcode sequence is not deterministic across chunk boundaries — the same input
122
+ * fed in different chunk sizes may produce different `text`/`append_text` splits and
123
+ * different optimistic/revert sequences. The final tree (via `mdz_opcodes_to_nodes`)
124
+ * matches the one-shot result: optimistic opens are corrected by `revert` opcodes
125
+ * at the first failed closer, at run close (paragraph, heading, or list-item
126
+ * run), or at EOF, and at EOF the delimiter-paired opens
127
+ * (bold/italic/strikethrough) and inline-code candidates are gated on a closer
128
+ * scan of the complete final buffer, so held tails parse like the one-shot
129
+ * parse. The residual divergence classes are:
130
+ *
131
+ * - **Backtick-adjacent chunking** — an inline-code candidate held across
132
+ * chunks can make its text-vs-code decision bounded by a wrongly-optimistic
133
+ * italic (opened before its failed closer was visible), where the one-shot
134
+ * parse greedy-rejects that italic and scans unbounded — `` _`__` ``
135
+ * chunked at the italic stays flat text where one-shot parses the code span.
136
+ * Italic is the only wedge (the only delimiter whose one-shot form rejects
137
+ * on a failed first closer); see `try_code`'s hold and `code_search_limit`.
138
+ * - **Optimistic inline code unclosed at EOF** — a `` ` `` that opened
139
+ * optimistically consumes its tail as raw code text, so formatting inside it
140
+ * never forms (`` h `x **b** z `` stays flat where one-shot parses the bold);
141
+ * the parser never re-parses, so the EOF revert can only flatten it to text.
142
+ * - **Link/tag opens at EOF** — `finish()` doesn't open links or tags, so a held
143
+ * tail containing complete `[text](url)` / `<Tag>…</Tag>` syntax parses flat.
144
+ * - **Block elements interrupt optimistic inlines** — at column 0 a
145
+ * heading/HR/fence/list/blockquote line interrupts the open paragraph even when an
146
+ * optimistic inline container spans it (`**a\n# h\nb**` parses the heading
147
+ * here; the one-shot parse, knowing the closer exists, swallows the line as
148
+ * bold text). Inherent: the swallow is only correct when a closer eventually
149
+ * arrives, which streaming can't know — interrupting matches the one-shot
150
+ * parse on the no-closer flip side (`**a\n# h`) and renders blocks promptly.
151
+ *
152
+ * Lifecycle: one parser instance per stream — `feed()` any number of times,
153
+ * `finish()` exactly once, then a final `take_opcodes()`. There is no
154
+ * `reset()`; calling `feed()` or `finish()` after `finish()` is undefined.
155
+ * To restart, construct a new parser (and a new consumer — see
156
+ * `MdzStreamState`).
157
+ */
158
+ export class MdzStreamParser {
159
+ #state: MdzStreamParserState = create_state();
160
+
161
+ /**
162
+ * Feed a chunk of text to the parser.
163
+ * Opcodes are accumulated and retrieved via `take_opcodes()`.
164
+ */
165
+ feed(chunk: string): void {
166
+ feed_state(this.#state, chunk);
167
+ }
168
+
169
+ /**
170
+ * Signal end of input. Resolves all pending state: closes open blocks,
171
+ * reverts unclosed optimistic opens, trims trailing newlines.
172
+ *
173
+ * Trailing-newline trimming is handled in one place: `trim_trailing_newline()`
174
+ * called at the top of `close_paragraph()` and `close_codeblock_at_eof()`,
175
+ * before either function reverts its inner stack. The trim sees the
176
+ * just-flushed text node's `last_text_id` (or a still-accumulated `\n`) and
177
+ * emits a `trim_text` opcode. Revert opcodes only fire after.
178
+ *
179
+ * Optimistic-container revert is handled by `close_paragraph` and
180
+ * `close_heading` (each reverts everything above its own block frame via
181
+ * `revert_above`), so no separate revert pass is needed — optimistic
182
+ * containers can only exist inside an open Paragraph or Heading (parser
183
+ * invariant).
184
+ */
185
+ finish(): void {
186
+ finish_state(this.#state);
187
+ }
188
+
189
+ /**
190
+ * Drain and return all accumulated opcodes.
191
+ * Destructive — empties the internal queue. The returned array is owned by the caller.
192
+ */
193
+ take_opcodes(): Array<MdzOpcode> {
194
+ const ops = this.#state.opcodes;
195
+ this.#state.opcodes = [];
196
+ return ops;
197
+ }
198
+ }
199
+
200
+ // -- Drive functions --
201
+ // The bodies of `feed`/`finish` as free functions over the state, so the
202
+ // blockquote machinery can drive its nested inner parser through the same
203
+ // code paths (nested parsing is recursive, which keeps these and
204
+ // `process_loop` in one module).
205
+
206
+ /** Feed a chunk to a parser state — the body of `MdzStreamParser#feed`. */
207
+ const feed_state = (state: MdzStreamParserState, chunk: string): void => {
208
+ state.buffer += chunk;
209
+ process_loop(state, false);
210
+ // flush any accumulated text so the renderer shows it immediately
211
+ flush_text(state);
212
+ // drain processed bytes from the buffer
213
+ if (state.pos > 0) {
214
+ state.base_offset += state.pos;
215
+ state.buffer = state.buffer.slice(state.pos);
216
+ state.pos = 0;
217
+ }
218
+ };
219
+
220
+ /** Resolve all pending state at end of input — the body of `MdzStreamParser#finish`. */
221
+ const finish_state = (state: MdzStreamParserState): void => {
222
+ // process any remaining buffer — `forced=true` treats ambiguous trailing
223
+ // bytes as concrete content rather than waiting for more input
224
+ if (state.buffer.length > 0) {
225
+ process_loop(state, true);
226
+ }
227
+ // close an open blockquote — finishes its nested inner document (an
228
+ // unclosed fence inside ends at the quote's EOF, which is this) and
229
+ // cascades recursively through deeper quotes
230
+ if (state.blockquote) {
231
+ finish_blockquote(state);
232
+ }
233
+ // finalize pending URL if any (text-first auto-links)
234
+ if (state.pending_url !== null) {
235
+ flush_text(state);
236
+ complete_pending_url(state);
237
+ }
238
+ flush_text(state);
239
+ // close heading if open — internally reverts its inner optimistic stack
240
+ if (state.in_heading) {
241
+ close_heading(state);
242
+ }
243
+ // close paragraph if open (a top-level paragraph or a list item's
244
+ // Paragraph block child) — internally trims trailing \n, reverts its
245
+ // inner optimistic stack, then pops
246
+ close_paragraph(state);
247
+ // close codeblock if open — raw mode is absolute, so an unclosed fence
248
+ // consumed to EOF and gets a plain close (top-level trims trailing \n)
249
+ if (state.codeblock) {
250
+ close_codeblock_at_eof(state);
251
+ }
252
+ // EOF ends any open list: seal the item run, close items and lists
253
+ close_all_list_levels(state);
254
+ };
255
+
256
+ // -- Blockquotes --
257
+ // A quote's content is a mini-document behind the per-line prefix, parsed by
258
+ // a nested parser state: the outer parser classifies each line's prefix
259
+ // (line-bounded holds), strips one level, and feeds the remainder — including
260
+ // each line's terminating newline — to the inner document, forwarding its
261
+ // opcodes with positions remapped to source offsets. Deeper prefixes nest by
262
+ // recursion (the inner parser opens its own quote), so the outer tracks at
263
+ // most one quote. The id counter is shared, so ids pass through unmapped.
264
+
265
+ /**
266
+ * Open a blockquote at `state.pos` (the validated prefix's first `>`).
267
+ * `item_indent` is -1 for top-level quotes or the attach item's marker
268
+ * indent for in-item quotes (block children of a list item).
269
+ */
270
+ const open_blockquote = (state: MdzStreamParserState, item_indent: number): void => {
271
+ const id = alloc_id(state);
272
+ const start = offset(state);
273
+ emit(state, {type: 'open', id, node_type: 'Blockquote', start});
274
+ push_stack_entry(state, id, 'Blockquote', start);
275
+ const inner = create_state();
276
+ inner.next_id = state.next_id;
277
+ const strip = state.pos + mdz_blockquote_strip_width(state.buffer, state.pos);
278
+ state.blockquote = {
279
+ id,
280
+ inner,
281
+ segments: [{inner_start: 0, source_start: offset(state, strip)}],
282
+ inner_len: 0,
283
+ text_inner_ends: new Map(),
284
+ item_indent,
285
+ };
286
+ state.pos = strip;
287
+ state.prev_char = SPACE;
288
+ state.active_text_id = null;
289
+ };
290
+
291
+ /** Feed a slice of quote content to the inner document and forward its opcodes. */
292
+ const feed_blockquote_inner = (state: MdzStreamParserState, chunk: string): void => {
293
+ const blockquote = state.blockquote!;
294
+ feed_state(blockquote.inner, chunk);
295
+ blockquote.inner_len += chunk.length;
296
+ forward_inner_opcodes(state);
297
+ };
298
+
299
+ /**
300
+ * Drain the inner document's opcodes into the outer stream, remapping
301
+ * positions from inner-document offsets to source offsets. Ids pass through
302
+ * unmapped (the counter is shared). `append_text` always gains an explicit
303
+ * remapped `end` — the implied extend-by-content-length is wrong across the
304
+ * prefix gap at line boundaries — via per-node inner-end tracking.
305
+ */
306
+ const forward_inner_opcodes = (state: MdzStreamParserState): void => {
307
+ const blockquote = state.blockquote!;
308
+ const ops = blockquote.inner.opcodes;
309
+ if (ops.length === 0) return;
310
+ blockquote.inner.opcodes = [];
311
+ const {segments, text_inner_ends} = blockquote;
312
+ for (const op of ops) {
313
+ switch (op.type) {
314
+ case 'open':
315
+ case 'revert':
316
+ op.start = mdz_remap_segments(segments, op.start);
317
+ break;
318
+ case 'close':
319
+ op.end = mdz_remap_segments(segments, op.end);
320
+ break;
321
+ case 'text':
322
+ text_inner_ends.set(op.id, op.end);
323
+ op.start = mdz_remap_segments(segments, op.start);
324
+ op.end = mdz_remap_segments(segments, op.end);
325
+ break;
326
+ case 'append_text': {
327
+ const inner_end = op.end ?? text_inner_ends.get(op.id)! + op.content.length;
328
+ text_inner_ends.set(op.id, inner_end);
329
+ op.end = mdz_remap_segments(segments, inner_end);
330
+ break;
331
+ }
332
+ case 'trim_text': {
333
+ const inner_end = text_inner_ends.get(op.id);
334
+ if (inner_end !== undefined) text_inner_ends.set(op.id, inner_end - op.count);
335
+ break;
336
+ }
337
+ case 'void':
338
+ op.start = mdz_remap_segments(segments, op.start);
339
+ op.end = mdz_remap_segments(segments, op.end);
340
+ break;
341
+ case 'wrap':
342
+ op.start = mdz_remap_segments(segments, op.start);
343
+ op.end = mdz_remap_segments(segments, op.end);
344
+ break;
345
+ }
346
+ emit(state, op);
347
+ }
348
+ };
349
+
350
+ /**
351
+ * Close the open blockquote: finish the inner document (its EOF — unclosed
352
+ * fences and open structure inside resolve here, recursing through deeper
353
+ * quotes), forward the final opcodes, sync the shared id counter back, and
354
+ * close the Blockquote frame.
355
+ */
356
+ const finish_blockquote = (state: MdzStreamParserState): void => {
357
+ const blockquote = state.blockquote!;
358
+ finish_state(blockquote.inner);
359
+ forward_inner_opcodes(state);
360
+ state.next_id = blockquote.inner.next_id;
361
+ state.blockquote = null;
362
+ const entry = pop_stack_entry(state);
363
+ emit(state, {type: 'close', id: entry.id, end: offset(state)});
364
+ state.active_text_id = null;
365
+ };
366
+
367
+ /**
368
+ * Try to open a blockquote at a column-0 `>` — a valid prefix with
369
+ * non-whitespace content after it on the same line (no empty start,
370
+ * mirroring lists; the hold is line-bounded). Interrupts a current
371
+ * paragraph, like every block construct.
372
+ */
373
+ const try_blockquote_start = (state: MdzStreamParserState, forced: boolean): TryResult => {
374
+ const prefix = mdz_match_blockquote_prefix(state.buffer, state.pos, forced);
375
+ if (prefix === 'pending') return 'need_more';
376
+ if (prefix === null) return 'not_match';
377
+ const has_content = mdz_blockquote_line_has_content(state.buffer, prefix.content_start, forced);
378
+ if (has_content === 'pending') return 'need_more';
379
+ if (!has_content) return 'not_match';
380
+ flush_text(state);
381
+ close_paragraph(state);
382
+ open_blockquote(state, -1);
383
+ return 'consumed';
384
+ };
385
+
386
+ /**
387
+ * Process input while a quote is open: pass content through to the inner
388
+ * document mid-line, and classify each following line's prefix at its
389
+ * newline — continue (feed the held newline, strip one level), or end the
390
+ * quote (blank line, missing or mis-positioned prefix, EOF). Returns false
391
+ * when the buffer ends in an unresolved line and more input is needed; all
392
+ * holds are line-bounded.
393
+ */
394
+ const process_blockquote = (state: MdzStreamParserState, forced: boolean): boolean => {
395
+ const blockquote = state.blockquote!;
396
+ const {buffer} = state;
397
+ // mid-line: feed content through to the line's newline (or buffer end —
398
+ // content streams as it arrives; `pos` is the fed watermark)
399
+ if (state.pos < buffer.length && buffer.charCodeAt(state.pos) !== NEWLINE) {
400
+ let i = state.pos;
401
+ while (i < buffer.length && buffer.charCodeAt(i) !== NEWLINE) {
402
+ i++;
403
+ }
404
+ feed_blockquote_inner(state, buffer.slice(state.pos, i));
405
+ state.pos = i;
406
+ }
407
+ if (state.pos >= buffer.length) return true; // everything fed; more may arrive
408
+
409
+ // at the line's newline: the next line decides continue-vs-end before the
410
+ // held newline feeds (it joins the inner document either way — quote lines
411
+ // are newline-terminated — but a continuation also needs its strip point)
412
+ const nl_pos = state.pos;
413
+ const line_start = nl_pos + 1;
414
+ let first = line_start;
415
+ if (blockquote.item_indent >= 0) {
416
+ while (first < buffer.length && is_line_whitespace(buffer.charCodeAt(first))) {
417
+ first++;
418
+ }
419
+ }
420
+ if (first >= buffer.length && !forced) return false; // unresolved line — hold
421
+ const indent = first - line_start;
422
+ if (
423
+ first < buffer.length &&
424
+ buffer.charCodeAt(first) === RIGHT_ANGLE &&
425
+ (blockquote.item_indent < 0 ? indent === 0 : indent > blockquote.item_indent)
426
+ ) {
427
+ const prefix = mdz_match_blockquote_prefix(buffer, first, forced);
428
+ if (prefix === 'pending') return false;
429
+ if (prefix !== null) {
430
+ // the quote continues: feed the held newline, then resume past the
431
+ // stripped prefix level (a bare prefix line contributes only its
432
+ // newline — the next iteration classifies again from there)
433
+ feed_blockquote_inner(state, '\n');
434
+ const strip = first + mdz_blockquote_strip_width(buffer, first);
435
+ blockquote.segments.push({
436
+ inner_start: blockquote.inner_len,
437
+ source_start: offset(state, strip),
438
+ });
439
+ state.pos = strip;
440
+ state.column = strip - line_start;
441
+ state.prev_char = NEWLINE;
442
+ return true;
443
+ }
444
+ }
445
+ // the line doesn't continue the quote — it ends here. The last quote
446
+ // line's newline joins the inner document; the ending line re-dispatches
447
+ // outside: the list classifier owns it inside an open list (blank
448
+ // containment, markers, list end), the main loop otherwise.
449
+ feed_blockquote_inner(state, '\n');
450
+ finish_blockquote(state);
451
+ if (state.list_levels.length === 0) {
452
+ state.pos = line_start;
453
+ state.column = 0;
454
+ state.prev_char = NEWLINE;
455
+ state.skip_blank_lines = true;
456
+ }
457
+ return true;
458
+ };
459
+
460
+ // -- Processing loop --
461
+
462
+ /**
463
+ * Absorb the blank region at the current position: newlines and
464
+ * whitespace-only lines. Runs while `skip_blank_lines` is set — at document
465
+ * start, after block-element closes, and after paragraph breaks. Clears the
466
+ * flag when a content line is reached (its leading whitespace stays in the
467
+ * text); leaves it set when the buffer is consumed entirely, carrying the
468
+ * absorb across chunks.
469
+ *
470
+ * Returns `false` when the buffer ends in an unresolved partial line
471
+ * (whitespace so far — could still gain content) and more input is needed;
472
+ * `state.pos` stays at the partial line's start. In forced mode a trailing
473
+ * whitespace run is a blank line at EOF and is consumed.
474
+ */
475
+ const skip_blank_region = (state: MdzStreamParserState, forced: boolean): boolean => {
476
+ while (state.pos < state.buffer.length) {
477
+ const char_code = state.buffer.charCodeAt(state.pos);
478
+ if (char_code === NEWLINE) {
479
+ state.pos++;
480
+ state.column = 0;
481
+ state.prev_char = NEWLINE;
482
+ continue;
483
+ }
484
+ if (!is_line_whitespace(char_code)) {
485
+ state.skip_blank_lines = false;
486
+ return true;
487
+ }
488
+ // whitespace at line start — blank only if the rest of the line is whitespace
489
+ let i = state.pos;
490
+ while (i < state.buffer.length && is_line_whitespace(state.buffer.charCodeAt(i))) {
491
+ i++;
492
+ }
493
+ if (i >= state.buffer.length) {
494
+ if (forced) {
495
+ // trailing whitespace-only line at EOF
496
+ state.pos = i;
497
+ return true;
498
+ }
499
+ return false; // partial line — need more input to classify it
500
+ }
501
+ if (state.buffer.charCodeAt(i) !== NEWLINE) {
502
+ // line has content — its leading whitespace is text
503
+ state.skip_blank_lines = false;
504
+ return true;
505
+ }
506
+ state.pos = i; // blank line — newline consumed next iteration
507
+ }
508
+ return true; // consumed everything; flag stays set for the next chunk
509
+ };
510
+
511
+ /**
512
+ * Core processing loop. When `forced` is false (normal streaming), returns
513
+ * when more input is needed. When `forced` is true (EOF), treats buffer end
514
+ * as content end and never waits for more input.
515
+ */
516
+ const process_loop = (state: MdzStreamParserState, forced: boolean): void => {
517
+ while (state.pos < state.buffer.length) {
518
+ // absorb the blank region at start of input or after a block boundary
519
+ // (paragraph break, heading/HR/codeblock close in this or a prior chunk)
520
+ if (state.skip_blank_lines) {
521
+ if (!skip_blank_region(state, forced)) return; // need more input
522
+ continue;
523
+ }
524
+ // open blockquote — all input routes through the quote (prefix
525
+ // classification + nested-parser feeding) until a line breaks it
526
+ if (state.blockquote) {
527
+ if (!process_blockquote(state, forced)) return; // need more input
528
+ continue;
529
+ }
530
+ // codeblock mode — in-item fences (`fence_indent > 0`) stream complete
531
+ // lines with per-line indent stripping; top-level fences keep the raw
532
+ // char-walk fast path
533
+ if (state.codeblock) {
534
+ if (state.codeblock.fence_indent > 0) {
535
+ if (!process_codeblock_in_item(state, forced)) return; // need more input
536
+ continue;
537
+ }
538
+ if (forced) {
539
+ // consumes to the buffer end by construction — content runs to
540
+ // EOF, or an EOF-terminated closer ends the block (see
541
+ // `process_codeblock_forced`) — so nothing is left to process
542
+ process_codeblock_forced(state);
543
+ return;
544
+ }
545
+ if (!process_codeblock(state)) return; // need more input
546
+ continue;
547
+ }
548
+
549
+ // optimistic inline code mode — raw scanning for close/revert
550
+ if (state.in_code) {
551
+ process_code_content(state);
552
+ continue;
553
+ }
554
+
555
+ // text-first URL/path scanning mode
556
+ if (state.pending_url !== null) {
557
+ process_url_content(state, forced);
558
+ continue;
559
+ }
560
+
561
+ const char_code = state.buffer.charCodeAt(state.pos);
562
+
563
+ // newline handling
564
+ if (char_code === NEWLINE) {
565
+ if (state.in_heading) {
566
+ // newline ends heading; trailing blanks absorb at the loop top
567
+ flush_text(state);
568
+ close_heading(state);
569
+ state.pos++;
570
+ state.skip_blank_lines = true;
571
+ state.column = 0;
572
+ state.prev_char = NEWLINE;
573
+ continue;
574
+ }
575
+ // inside an open list, every line classifies through the list line
576
+ // classifier (continuation / marker / blank containment / list end);
577
+ // a deeper quote-opener line signals back so the quote opens here
578
+ if (state.list_levels.length > 0) {
579
+ const r = process_list_line(state, forced);
580
+ if (r === 'blockquote') {
581
+ open_blockquote(state, state.list_levels[state.list_levels.length - 1]!.indent);
582
+ continue;
583
+ }
584
+ if (!r) return; // need more input
585
+ continue;
586
+ }
587
+ // scan past line whitespace: a following blank line is a paragraph break
588
+ let after = state.pos + 1;
589
+ while (after < state.buffer.length && is_line_whitespace(state.buffer.charCodeAt(after))) {
590
+ after++;
591
+ }
592
+ if (after >= state.buffer.length && !forced) {
593
+ // hold trailing `\n` + whitespace — could become a blank line
594
+ return;
595
+ }
596
+ if (after < state.buffer.length && state.buffer.charCodeAt(after) === NEWLINE) {
597
+ handle_paragraph_break(state);
598
+ continue;
599
+ }
600
+ // single newline: accumulate as text (any scanned whitespace is the
601
+ // next line's leading content and accumulates via the normal text path)
602
+ accumulate_text(state, '\n', offset(state));
603
+ state.pos++;
604
+ state.column = 0;
605
+ state.prev_char = NEWLINE;
606
+ continue;
607
+ }
608
+
609
+ // block elements at column 0
610
+ if (state.column === 0 && !state.in_heading) {
611
+ if (char_code === HASH) {
612
+ const r = try_heading(state);
613
+ if (r === 'consumed') continue;
614
+ if (!forced && r === 'need_more') return;
615
+ } else if (char_code === HYPHEN) {
616
+ const r = try_hr(state, forced);
617
+ if (r === 'consumed') continue;
618
+ if (!forced && r === 'need_more') return;
619
+ // not an HR — could start an unordered list
620
+ const lr = try_list_start(state, forced);
621
+ if (lr === 'consumed') continue;
622
+ if (!forced && lr === 'need_more') return;
623
+ } else if (char_code === BACKTICK) {
624
+ const r = try_codeblock_open(state);
625
+ if (r === 'consumed') continue;
626
+ if (!forced && r === 'need_more') return;
627
+ } else if (char_code >= ZERO && char_code <= NINE) {
628
+ const r = try_list_start(state, forced);
629
+ if (r === 'consumed') continue;
630
+ if (!forced && r === 'need_more') return;
631
+ } else if (char_code === RIGHT_ANGLE) {
632
+ const r = try_blockquote_start(state, forced);
633
+ if (r === 'consumed') continue;
634
+ if (!forced && r === 'need_more') return;
635
+ }
636
+ }
637
+
638
+ // inline processing
639
+ if (!process_inline(state, forced)) return; // need more input
640
+ }
641
+ };
642
+
643
+ // -- Inline processing --
644
+
645
+ /**
646
+ * Process one inline element. Returns false if more input is needed.
647
+ * When `forced` is true (EOF processing), open decisions run against the
648
+ * complete final buffer instead of waiting for more input: bold, italic,
649
+ * and strikethrough open only when their confirmed closer is already
650
+ * present, and an inline-code candidate degrades to literal text when its
651
+ * closer isn't (at EOF it never can be). Link and tag opens are skipped
652
+ * entirely — held `[text](url)` / `<Tag>` tails parse flat (an accepted
653
+ * divergence; see the `MdzStreamParser` class TSDoc). All closing
654
+ * constructs and auto-links still run.
655
+ * Never returns false in forced mode.
656
+ */
657
+ const process_inline = (state: MdzStreamParserState, forced: boolean): boolean => {
658
+ const char_code = state.buffer.charCodeAt(state.pos);
659
+
660
+ // check for closing delimiters first (if matching open exists)
661
+ if (char_code === ASTERISK || char_code === TILDE) {
662
+ if (
663
+ state.pos + 1 < state.buffer.length &&
664
+ state.buffer.charCodeAt(state.pos + 1) === char_code
665
+ ) {
666
+ const node_type = char_code === ASTERISK ? 'Bold' : 'Strikethrough';
667
+ const open_idx = find_open(state, node_type);
668
+ if (open_idx !== -1) {
669
+ return close_delimiter(state, open_idx);
670
+ }
671
+ return try_double_delimiter(state, char_code === ASTERISK ? '**' : '~~', node_type, forced);
672
+ }
673
+
674
+ if (!forced) {
675
+ // could be start of the doubled delimiter — need next char to decide
676
+ if (state.pos + 1 >= state.buffer.length) return false; // hold for next chunk
677
+ // single char is text (next char doesn't double it)
678
+ consume_delimiter_as_text(state, char_code === ASTERISK ? '*' : '~');
679
+ return true;
680
+ }
681
+ // forced with a lone delimiter char: falls through to the text handler
682
+ }
683
+
684
+ if (char_code === UNDERSCORE) {
685
+ const open_idx = find_open(state, 'Italic');
686
+ if (open_idx !== -1) {
687
+ // hold: the char after a potential closer decides its boundary
688
+ if (!forced && state.pos + 1 >= state.buffer.length) return false;
689
+ if (check_close_word_boundary(state)) {
690
+ return close_delimiter(state, open_idx);
691
+ }
692
+ // failed closer: the one-shot parse greedy-rejects the opener at
693
+ // the first invalid closer, so the optimistic open is known wrong
694
+ revert_failed_close(state, open_idx);
695
+ // fall through: this same delimiter may be a fresh opener, matching
696
+ // the one-shot parse, which continues after the rejected opener
697
+ }
698
+ return try_italic(state, forced);
699
+ }
700
+
701
+ if (char_code === BACKTICK) {
702
+ return try_code(state, forced);
703
+ }
704
+
705
+ if (char_code === LEFT_BRACKET && !forced) {
706
+ return try_link_open(state);
707
+ }
708
+
709
+ if (char_code === RIGHT_BRACKET && !forced) {
710
+ const link_idx = find_open(state, 'Link');
711
+ if (link_idx !== -1) {
712
+ return try_complete_link(state, link_idx);
713
+ }
714
+ // no open link — treat as text
715
+ consume_delimiter_as_text(state, ']');
716
+ return true;
717
+ }
718
+
719
+ if (char_code === LEFT_ANGLE) {
720
+ // closing tags are handled even in forced mode
721
+ if (state.pos + 1 < state.buffer.length && state.buffer.charCodeAt(state.pos + 1) === SLASH) {
722
+ const result = try_close_tag(state);
723
+ if (result === 'consumed') return true;
724
+ // in forced mode, need_more falls through to text
725
+ if (!forced && result === 'need_more') return false;
726
+ // not_match — fall through
727
+ }
728
+ if (!forced) {
729
+ const tag_result = try_tag_open(state);
730
+ if (tag_result === 'consumed') return true;
731
+ if (tag_result === 'need_more') return false;
732
+ // not_match — fall through
733
+ }
734
+ // not a valid tag — fall through to text
735
+ }
736
+
737
+ // auto-detected URLs (text-first speculative prefix matching).
738
+ // Fires on `h` and `H` — scheme matching is case-insensitive (RFC 3986).
739
+ if (char_code === H_LOWER || char_code === H_UPPER) {
740
+ if (forced) {
741
+ const result = try_auto_url_forced(state);
742
+ if (result === 'consumed') return true;
743
+ if (result === 'need_more') return false;
744
+ } else if (state.prev_char === -1 || !is_word_char(state.prev_char)) {
745
+ // word boundary — start speculative URL prefix matching
746
+ start_speculative_url(state);
747
+ return true;
748
+ }
749
+ }
750
+
751
+ // auto-detected paths
752
+ if (char_code === SLASH) {
753
+ const result = try_auto_path_absolute(state, forced);
754
+ if (result === 'consumed') return true;
755
+ if (result === 'need_more') return false;
756
+ }
757
+ if (char_code === PERIOD) {
758
+ const result = try_auto_path_relative(state, forced);
759
+ if (result === 'consumed') return true;
760
+ if (result === 'need_more') return false;
761
+ }
762
+
763
+ // plain text
764
+ consume_text_run(state);
765
+ return true;
766
+ };