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