@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,651 @@
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
+ import { NEWLINE, has_non_whitespace, mdz_heading_id_from_text } from './mdz_helpers.js';
11
+ /** @nodocs */
12
+ export const create_state = () => ({
13
+ buffer: '',
14
+ pos: 0,
15
+ opcodes: [],
16
+ next_id: 0,
17
+ stack: [],
18
+ column: 0,
19
+ prev_char: -1,
20
+ active_text_id: null,
21
+ accumulated_text: '',
22
+ accumulated_text_start: 0,
23
+ accumulated_text_end: 0,
24
+ codeblock: null,
25
+ list_levels: [],
26
+ blockquote: null,
27
+ in_list_item_run: false,
28
+ last_empty_item_end: 0,
29
+ base_offset: 0,
30
+ search_memo: null,
31
+ open_counts: { Bold: 0, Italic: 0, Strikethrough: 0, Link: 0 },
32
+ in_heading: false,
33
+ in_code: false,
34
+ in_paragraph: false,
35
+ pending_url: null,
36
+ heading_text_parts: [],
37
+ // matches mdz_parse: leading blank lines before any block are skipped, not
38
+ // preserved as paragraph content
39
+ skip_blank_lines: true,
40
+ paragraph_stack_idx: -1,
41
+ last_text_id: null,
42
+ last_text_ended_with_newline: false,
43
+ last_text_was_singleton_newline: false,
44
+ });
45
+ /** @nodocs */
46
+ export const alloc_id = (state) => state.next_id++;
47
+ /**
48
+ * Push a new container frame onto the stack. Fills the boilerplate fields
49
+ * (`has_children`, `has_non_whitespace_content`) so call sites only spell out
50
+ * what varies per container type. Keeping the object literal in one place
51
+ * also gives V8 a single monomorphic creation site for `StackEntry`.
52
+ *
53
+ * @nodocs
54
+ */
55
+ export const push_stack_entry = (state, id, node_type, start, delimiter = '', tag_name) => {
56
+ state.stack.push({
57
+ id,
58
+ node_type,
59
+ delimiter,
60
+ tag_name,
61
+ has_children: false,
62
+ has_non_whitespace_content: false,
63
+ start,
64
+ });
65
+ if (node_type === 'Paragraph') {
66
+ state.paragraph_stack_idx = state.stack.length - 1;
67
+ }
68
+ else {
69
+ bump_open_count(state, node_type, 1);
70
+ }
71
+ };
72
+ /**
73
+ * Adjust the `open_counts` entry for a counted container type; no-op for
74
+ * uncounted types. Static-key accesses keep the property loads monomorphic —
75
+ * a keyed `counts[type]` access measurably regresses hot push/pop paths.
76
+ *
77
+ * @mutates `state.open_counts`
78
+ */
79
+ const bump_open_count = (state, node_type, delta) => {
80
+ const counts = state.open_counts;
81
+ if (node_type === 'Bold')
82
+ counts.Bold += delta;
83
+ else if (node_type === 'Italic')
84
+ counts.Italic += delta;
85
+ else if (node_type === 'Strikethrough')
86
+ counts.Strikethrough += delta;
87
+ else if (node_type === 'Link')
88
+ counts.Link += delta;
89
+ };
90
+ /**
91
+ * Pop the top stack entry, keeping `open_counts` in sync. All pops of inline
92
+ * containers must go through this — the only direct stack removal elsewhere
93
+ * is `revert_failed_close`'s mid-stack splice, which adjusts the count itself.
94
+ *
95
+ * @mutates `state`
96
+ * @nodocs
97
+ */
98
+ export const pop_stack_entry = (state) => {
99
+ const entry = state.stack.pop();
100
+ bump_open_count(state, entry.node_type, -1);
101
+ return entry;
102
+ };
103
+ /**
104
+ * Mark the innermost open `Paragraph` as having non-whitespace content,
105
+ * via the `paragraph_stack_idx` cache (O(1) instead of an O(stack) walk).
106
+ *
107
+ * No-op when no Paragraph is open — that includes the Heading case, since
108
+ * block boundaries are mutually exclusive (a Heading on stack means
109
+ * `paragraph_stack_idx === -1`).
110
+ */
111
+ const mark_paragraph_non_whitespace = (state) => {
112
+ if (state.paragraph_stack_idx === -1)
113
+ return;
114
+ state.stack[state.paragraph_stack_idx].has_non_whitespace_content = true;
115
+ };
116
+ /**
117
+ * Global byte offset for a local buffer position.
118
+ *
119
+ * @nodocs
120
+ */
121
+ export const offset = (state, pos = state.pos) => state.base_offset + pos;
122
+ /**
123
+ * Seal the prior text run so `trim_trailing_newline` can no longer target it.
124
+ * Called when a structural opcode (open/close/revert) ends the run.
125
+ *
126
+ * @mutates `state`
127
+ */
128
+ const seal_text_run = (state) => {
129
+ state.last_text_id = null;
130
+ state.last_text_ended_with_newline = false;
131
+ state.last_text_was_singleton_newline = false;
132
+ };
133
+ /**
134
+ * Memoized `String#indexOf` over `state.buffer` against the given memo
135
+ * record, taking and returning **local** buffer positions.
136
+ *
137
+ * The streaming analogue of the sync lexer's memoized `#index_of`: a failed
138
+ * search would otherwise rescan the remaining buffer on every candidate,
139
+ * making inputs dense in unclosed candidates quadratic. Unlike the sync case
140
+ * the buffer mutates — `feed()` appends and the parser compacts consumed
141
+ * bytes from the front — so the memo lives in global byte coordinates
142
+ * (compaction shifts local positions but global content is immutable) and a
143
+ * not-found result records how far it scanned (`searched_to`). After an
144
+ * append, only the new tail is rescanned, backed up by `needle.length - 1`
145
+ * bytes to catch needles straddling the old boundary.
146
+ *
147
+ * A memoized found result earlier than `from` falls through to a fresh scan,
148
+ * same as the sync memo — the parser's scan positions are monotonic, so each
149
+ * needle's missing-suffix region is still scanned at most once per append.
150
+ *
151
+ * @mutates `memo`
152
+ */
153
+ const memo_index_of = (state, memo, needle, from) => {
154
+ const global_from = state.base_offset + from;
155
+ const global_end = state.base_offset + state.buffer.length;
156
+ if (memo.from <= global_from) {
157
+ if (memo.result >= global_from)
158
+ return memo.result - state.base_offset;
159
+ if (memo.result === -1) {
160
+ if (memo.searched_to >= global_end)
161
+ return -1; // nothing appended since
162
+ // rescan only the appended tail, overlapping the old boundary so a
163
+ // straddling needle isn't missed
164
+ const rescan_from = Math.max(global_from, memo.searched_to - (needle.length - 1));
165
+ const result = state.buffer.indexOf(needle, rescan_from - state.base_offset);
166
+ memo.from = global_from;
167
+ memo.result = result === -1 ? -1 : state.base_offset + result;
168
+ memo.searched_to = global_end;
169
+ return result;
170
+ }
171
+ // found result already behind `from` — fall through to a fresh scan
172
+ }
173
+ const result = state.buffer.indexOf(needle, from);
174
+ memo.from = global_from;
175
+ memo.result = result === -1 ? -1 : state.base_offset + result;
176
+ memo.searched_to = global_end;
177
+ return result;
178
+ };
179
+ /**
180
+ * Memoized buffer search for an arbitrary needle, backed by the per-needle
181
+ * `search_memo` map (created on first use). Used for cold-path scans with
182
+ * dynamic needles — the stack-delimiter searches in `code_search_limit`,
183
+ * where unclosed-tag delimiters absent from the rest of the input would
184
+ * otherwise rescan to the buffer end on every backtick candidate.
185
+ *
186
+ * @mutates `state.search_memo`
187
+ * @nodocs
188
+ */
189
+ export const buffer_index_of = (state, needle, from) => {
190
+ const map = (state.search_memo ??= new Map());
191
+ let memo = map.get(needle);
192
+ if (memo === undefined) {
193
+ // "scanned [0, 0), found nothing" — memo_index_of handles the full scan
194
+ memo = { from: 0, result: -1, searched_to: 0 };
195
+ map.set(needle, memo);
196
+ }
197
+ return memo_index_of(state, memo, needle, from);
198
+ };
199
+ /**
200
+ * Emit an opcode into the pending batch (drained by `take_opcodes`),
201
+ * maintaining the `has_children`/non-whitespace bookkeeping on open frames.
202
+ *
203
+ * @nodocs
204
+ */
205
+ export const emit = (state, op) => {
206
+ state.opcodes.push(op);
207
+ // mark the innermost container as having children
208
+ if (op.type === 'text' || op.type === 'append_text' || op.type === 'void') {
209
+ const top = state.stack[state.stack.length - 1];
210
+ if (top) {
211
+ top.has_children = true;
212
+ // propagate non-whitespace marker to the nearest Paragraph so
213
+ // whitespace-only paragraphs can be discarded at close
214
+ if (op.type === 'void' || has_non_whitespace(op.content)) {
215
+ mark_paragraph_non_whitespace(state);
216
+ }
217
+ }
218
+ }
219
+ else if (op.type === 'open') {
220
+ // opening a child container counts as content for the parent
221
+ const parent = state.stack[state.stack.length - 1];
222
+ if (parent) {
223
+ parent.has_children = true;
224
+ mark_paragraph_non_whitespace(state);
225
+ }
226
+ }
227
+ else if (op.type === 'wrap') {
228
+ // retroactive Link wrap of a text node — the resulting Link is non-whitespace
229
+ mark_paragraph_non_whitespace(state);
230
+ }
231
+ // track the most recent text run so `trim_trailing_newline` can target it
232
+ // after `take_opcodes()` has drained the queue
233
+ if (op.type === 'text') {
234
+ state.last_text_id = op.id;
235
+ state.last_text_ended_with_newline = op.content.endsWith('\n');
236
+ state.last_text_was_singleton_newline = op.content === '\n';
237
+ }
238
+ else if (op.type === 'append_text') {
239
+ state.last_text_id = op.id;
240
+ state.last_text_ended_with_newline = op.content.endsWith('\n');
241
+ // append_text builds on a prior text node; the underlying node can't be
242
+ // singleton even if this appended chunk is just '\n'
243
+ state.last_text_was_singleton_newline = false;
244
+ }
245
+ else if (op.type === 'open' || op.type === 'close' || op.type === 'revert') {
246
+ seal_text_run(state);
247
+ }
248
+ // track text content for heading ID computation
249
+ if (state.in_heading && state.heading_text_parts.length > 0) {
250
+ if (op.type === 'text' || op.type === 'append_text') {
251
+ state.heading_text_parts[state.heading_text_parts.length - 1] += op.content;
252
+ }
253
+ else if (op.type === 'open') {
254
+ // child container: push a new segment for its text content
255
+ state.heading_text_parts.push('');
256
+ }
257
+ else if (op.type === 'close') {
258
+ // normal close: pop child's text and merge into parent
259
+ if (state.heading_text_parts.length > 1) {
260
+ const child_text = state.heading_text_parts.pop();
261
+ state.heading_text_parts[state.heading_text_parts.length - 1] += child_text;
262
+ }
263
+ }
264
+ else if (op.type === 'revert') {
265
+ // revert: pop child's text, prepend replacement (document order), merge into parent
266
+ if (state.heading_text_parts.length > 1) {
267
+ const child_text = state.heading_text_parts.pop();
268
+ state.heading_text_parts[state.heading_text_parts.length - 1] +=
269
+ (op.replacement_text || '') + child_text;
270
+ }
271
+ }
272
+ }
273
+ };
274
+ /**
275
+ * Accumulate text, tracking the source span (start of the first character, end past the last).
276
+ *
277
+ * @nodocs
278
+ */
279
+ export const accumulate_text = (state, text, start_offset) => {
280
+ if (state.accumulated_text.length === 0) {
281
+ state.accumulated_text_start = start_offset;
282
+ }
283
+ state.accumulated_text += text;
284
+ state.accumulated_text_end = start_offset + text.length;
285
+ };
286
+ /**
287
+ * Extend the accumulated text's source span past content that was stripped
288
+ * from it — a list continuation line's structural indent. The sync parser
289
+ * attributes the stripped region to the preceding text token, so node `end`s
290
+ * stay source-accurate across both pipelines.
291
+ *
292
+ * @mutates `state`
293
+ * @nodocs
294
+ */
295
+ export const extend_accumulated_end = (state, end) => {
296
+ state.accumulated_text_end = end;
297
+ };
298
+ /**
299
+ * Flush accumulated text as a text or append_text opcode. Opcode `end`s come
300
+ * from the tracked source span, which can outgrow the content length when
301
+ * list continuation lines strip structural indent.
302
+ *
303
+ * @nodocs
304
+ */
305
+ export const flush_text = (state) => {
306
+ if (state.accumulated_text.length === 0)
307
+ return;
308
+ if (state.active_text_id !== null) {
309
+ emit(state, {
310
+ type: 'append_text',
311
+ id: state.active_text_id,
312
+ content: state.accumulated_text,
313
+ end: state.accumulated_text_end,
314
+ });
315
+ }
316
+ else {
317
+ ensure_paragraph(state);
318
+ const id = alloc_id(state);
319
+ const start = state.accumulated_text_start;
320
+ emit(state, {
321
+ type: 'text',
322
+ id,
323
+ content: state.accumulated_text,
324
+ text_type: 'Text',
325
+ start,
326
+ end: state.accumulated_text_end,
327
+ });
328
+ state.active_text_id = id;
329
+ }
330
+ state.accumulated_text = '';
331
+ };
332
+ /**
333
+ * Open a Paragraph frame unless a run frame is already current — an open
334
+ * paragraph or heading, or a list item's first inline run (which flows
335
+ * directly into the ListItem frame, matching the sync AST's direct-children
336
+ * shape).
337
+ *
338
+ * @nodocs
339
+ */
340
+ export const ensure_paragraph = (state) => {
341
+ if (state.in_heading || state.in_paragraph || state.in_list_item_run)
342
+ return;
343
+ const id = alloc_id(state);
344
+ const start = offset(state);
345
+ emit(state, { type: 'open', id, node_type: 'Paragraph', start });
346
+ push_stack_entry(state, id, 'Paragraph', start);
347
+ state.in_paragraph = true;
348
+ };
349
+ /**
350
+ * Whether a stack frame bounds an inline run — inline pairs never cross a
351
+ * Paragraph, Heading, or ListItem frame (an item's first run is a
352
+ * paragraph-equivalent region; a List frame is never the innermost frame
353
+ * under inline content, so checking it is unnecessary).
354
+ *
355
+ * @nodocs
356
+ */
357
+ export const is_run_boundary = (node_type) => node_type === 'Paragraph' || node_type === 'Heading' || node_type === 'ListItem';
358
+ /**
359
+ * Find the innermost open container of a given type.
360
+ * Returns stack index, or -1 if not found.
361
+ * Does not cross run boundaries (Paragraph, Heading, ListItem).
362
+ *
363
+ * The "nothing open" case is O(1) via `open_counts` — without it, every
364
+ * candidate char in a deep-stack paragraph (unclosed optimistic tags leak
365
+ * one frame per line) walks to the block boundary, going quadratic.
366
+ *
367
+ * @nodocs
368
+ */
369
+ export const find_open = (state, type) => {
370
+ const counts = state.open_counts;
371
+ const count = type === 'Bold'
372
+ ? counts.Bold
373
+ : type === 'Italic'
374
+ ? counts.Italic
375
+ : type === 'Strikethrough'
376
+ ? counts.Strikethrough
377
+ : counts.Link;
378
+ if (count === 0)
379
+ return -1;
380
+ for (let i = state.stack.length - 1; i >= 0; i--) {
381
+ const entry = state.stack[i];
382
+ if (entry.node_type === type)
383
+ return i;
384
+ if (is_run_boundary(entry.node_type))
385
+ return -1;
386
+ }
387
+ return -1;
388
+ };
389
+ /**
390
+ * Revert all stack entries above the given index. Every inline open is
391
+ * optimistic, so anything above a known frame is revertable by construction.
392
+ *
393
+ * @nodocs
394
+ */
395
+ export const revert_above = (state, target_idx) => {
396
+ while (state.stack.length - 1 > target_idx) {
397
+ const entry = pop_stack_entry(state);
398
+ if (entry.node_type === 'Code')
399
+ state.in_code = false;
400
+ emit(state, {
401
+ type: 'revert',
402
+ id: entry.id,
403
+ replacement_text: entry.delimiter,
404
+ start: entry.start,
405
+ });
406
+ }
407
+ state.active_text_id = null;
408
+ };
409
+ /**
410
+ * Revert an open `Italic` whose first closer candidate failed its boundary
411
+ * check (italic is the only inline with closer-boundary rules). One-shot
412
+ * parsing greedy-rejects the opener at the first invalid closer, so once a
413
+ * failed candidate is seen, the optimistic open is known wrong — reverting
414
+ * immediately (instead of leaving the container open for a later closer)
415
+ * keeps chunked output identical to the one-shot parse.
416
+ *
417
+ * Unlike `revert_above`, this reverts a possibly
418
+ * **mid-stack** entry: containers opened above it (e.g. a Bold inside the
419
+ * failed Italic) stay open — the one-shot parse opens them at top level, and
420
+ * the consumers' revert handlers re-parent them identically (the reverted
421
+ * node is still its parent's last child; see `mdz_opcodes_to_nodes` and
422
+ * `MdzStreamState`).
423
+ *
424
+ * Bypasses `emit` for the opcode push: `emit`'s heading-segment handling
425
+ * assumes the reverted container is the innermost open one, which doesn't
426
+ * hold mid-stack — the segment merge here targets the reverted frame's own
427
+ * segment, keeping the segments of still-open frames above it live.
428
+ *
429
+ * @mutates `state`
430
+ * @nodocs
431
+ */
432
+ export const revert_failed_close = (state, stack_idx) => {
433
+ flush_text(state);
434
+ const entry = state.stack[stack_idx];
435
+ const frames_above = state.stack.length - 1 - stack_idx;
436
+ state.stack.splice(stack_idx, 1);
437
+ bump_open_count(state, entry.node_type, -1);
438
+ state.opcodes.push({
439
+ type: 'revert',
440
+ id: entry.id,
441
+ replacement_text: entry.delimiter,
442
+ start: entry.start,
443
+ });
444
+ // structural opcode seals the prior text run (mirrors `emit`)
445
+ seal_text_run(state);
446
+ // heading slug bookkeeping: fold the reverted frame's segment into its
447
+ // parent segment, delimiter text first (document order)
448
+ if (state.in_heading && state.heading_text_parts.length > 1) {
449
+ const seg_idx = state.heading_text_parts.length - 1 - frames_above;
450
+ if (seg_idx >= 1) {
451
+ const seg = state.heading_text_parts.splice(seg_idx, 1)[0];
452
+ state.heading_text_parts[seg_idx - 1] += entry.delimiter + seg;
453
+ }
454
+ }
455
+ state.active_text_id = null;
456
+ };
457
+ /**
458
+ * Trim a trailing newline from paragraph content.
459
+ * Checks unflushed accumulated text first; otherwise emits a `trim_text`
460
+ * opcode targeting the most recent text/append_text via the tracking fields
461
+ * on `state` (which survive `take_opcodes()` drains). The opcode stream
462
+ * stays append-only — no retroactive opcode mutation.
463
+ *
464
+ * @nodocs
465
+ */
466
+ export const trim_trailing_newline = (state) => {
467
+ if (state.accumulated_text.endsWith('\n')) {
468
+ state.accumulated_text = state.accumulated_text.slice(0, -1);
469
+ return;
470
+ }
471
+ if (state.last_text_id === null || !state.last_text_ended_with_newline)
472
+ return;
473
+ // emit trim opcode; consumer adjusts content and removes empty nodes
474
+ emit(state, { type: 'trim_text', id: state.last_text_id, count: 1 });
475
+ // if this trim empties a singleton text node, clear active_text_id so
476
+ // subsequent content doesn't merge into a deleted node via append_text
477
+ if (state.last_text_was_singleton_newline) {
478
+ state.active_text_id = null;
479
+ }
480
+ // after trim, the prior text no longer ends in \n — guard against re-trim
481
+ state.last_text_ended_with_newline = false;
482
+ state.last_text_was_singleton_newline = false;
483
+ };
484
+ /** @nodocs */
485
+ export const close_paragraph = (state) => {
486
+ // find and close the paragraph on the stack
487
+ for (let i = state.stack.length - 1; i >= 0; i--) {
488
+ if (state.stack[i].node_type === 'Paragraph') {
489
+ // trim trailing newline from paragraph content
490
+ trim_trailing_newline(state);
491
+ revert_above(state, i);
492
+ const entry = pop_stack_entry(state);
493
+ // drop whitespace-only paragraphs to match `mdz_parse`'s output;
494
+ // consumers (`mdz_opcodes_to_nodes`, `MdzStreamState`) honor `discard`
495
+ // by removing the node and its descendants from the tree
496
+ const discard = !entry.has_non_whitespace_content;
497
+ emit(state, discard
498
+ ? { type: 'close', id: entry.id, end: offset(state), discard: true }
499
+ : { type: 'close', id: entry.id, end: offset(state) });
500
+ state.active_text_id = null;
501
+ state.in_paragraph = false;
502
+ state.paragraph_stack_idx = -1;
503
+ return;
504
+ }
505
+ }
506
+ };
507
+ /** @nodocs */
508
+ export const close_heading = (state) => {
509
+ // find the heading frame and revert the open inlines above it (before
510
+ // clearing in_heading, so revert replacement text is captured in
511
+ // heading_text_parts)
512
+ let heading_idx = -1;
513
+ for (let i = state.stack.length - 1; i >= 0; i--) {
514
+ if (state.stack[i].node_type === 'Heading') {
515
+ heading_idx = i;
516
+ break;
517
+ }
518
+ }
519
+ if (heading_idx !== -1) {
520
+ revert_above(state, heading_idx);
521
+ }
522
+ // compute heading ID from accumulated text before clearing heading state
523
+ const heading_text = state.heading_text_parts.join('');
524
+ state.heading_text_parts = [];
525
+ state.in_heading = false;
526
+ if (heading_idx === -1)
527
+ return;
528
+ const heading_id = mdz_heading_id_from_text(heading_text);
529
+ // the heading is now the top of the stack
530
+ const entry = pop_stack_entry(state);
531
+ emit(state, { type: 'close', id: entry.id, end: offset(state), heading_id });
532
+ state.active_text_id = null;
533
+ };
534
+ /**
535
+ * Close a codeblock left open at EOF. Raw mode is absolute — an unclosed
536
+ * fence consumes to EOF as content — so this is a plain close after trimming
537
+ * one trailing newline, matching closed fences' content shape (which
538
+ * excludes the newline before the closer line) and the sync parser's
539
+ * unclosed-fence trim.
540
+ *
541
+ * @nodocs
542
+ */
543
+ export const close_codeblock_at_eof = (state) => {
544
+ if (!state.codeblock)
545
+ return;
546
+ // in-item content emits line-by-line with '\n' separators and never gains
547
+ // a trailing newline, so only top-level bulk emission needs the trim
548
+ if (state.codeblock.fence_indent === 0) {
549
+ trim_trailing_newline(state);
550
+ }
551
+ emit(state, { type: 'close', id: state.codeblock.id, end: offset(state) });
552
+ state.codeblock = null;
553
+ };
554
+ /**
555
+ * Seal the innermost ListItem's first inline run: flush text and revert the
556
+ * optimistic inlines above the ListItem frame. The item itself stays open —
557
+ * block children (nested lists, paragraphs, code blocks) may follow.
558
+ *
559
+ * @nodocs
560
+ */
561
+ export const seal_list_item_run = (state) => {
562
+ if (!state.in_list_item_run)
563
+ return;
564
+ flush_text(state);
565
+ for (let i = state.stack.length - 1; i >= 0; i--) {
566
+ if (state.stack[i].node_type === 'ListItem') {
567
+ revert_above(state, i);
568
+ break;
569
+ }
570
+ }
571
+ state.in_list_item_run = false;
572
+ state.active_text_id = null;
573
+ };
574
+ /**
575
+ * Close the current inline run inside a list, whichever frame holds it: a
576
+ * `Paragraph` block child closes, an item's first run seals. No-op when no
577
+ * run is current (post-blank, post-block-child).
578
+ *
579
+ * @nodocs
580
+ */
581
+ export const close_list_run = (state) => {
582
+ if (state.in_paragraph) {
583
+ flush_text(state);
584
+ close_paragraph(state);
585
+ }
586
+ else {
587
+ seal_list_item_run(state);
588
+ }
589
+ };
590
+ /**
591
+ * Close the deepest open level's current item. The run must already be
592
+ * sealed/closed, leaving the ListItem frame on top of the stack. A childless
593
+ * close means an empty item — its `end` is the marker end recorded at open
594
+ * (the sync parser's `open.end` fallback); otherwise consumers derive the
595
+ * node's `end` from its children, so the offset here is cosmetic.
596
+ *
597
+ * @nodocs
598
+ */
599
+ export const close_list_item = (state) => {
600
+ const entry = pop_stack_entry(state);
601
+ emit(state, {
602
+ type: 'close',
603
+ id: entry.id,
604
+ end: entry.has_children ? offset(state) : state.last_empty_item_end,
605
+ });
606
+ state.active_text_id = null;
607
+ };
608
+ /**
609
+ * Close the deepest open level: its current item, then its list.
610
+ *
611
+ * @nodocs
612
+ */
613
+ export const close_list_level = (state) => {
614
+ close_list_item(state);
615
+ const entry = pop_stack_entry(state);
616
+ emit(state, { type: 'close', id: entry.id, end: offset(state) });
617
+ state.list_levels.pop();
618
+ };
619
+ /**
620
+ * Close every open list level — the whole stack — sealing the item's first
621
+ * run if current. A current `Paragraph` block child must be closed by the
622
+ * caller first (`close_paragraph` in `finish()`, `close_list_run` in the
623
+ * classifier).
624
+ *
625
+ * @nodocs
626
+ */
627
+ export const close_all_list_levels = (state) => {
628
+ if (state.list_levels.length === 0)
629
+ return;
630
+ seal_list_item_run(state);
631
+ while (state.list_levels.length > 0) {
632
+ close_list_level(state);
633
+ }
634
+ };
635
+ /** @nodocs */
636
+ export const handle_paragraph_break = (state) => {
637
+ flush_text(state);
638
+ // `close_paragraph` reverts everything above the paragraph frame itself.
639
+ // Its trailing-newline trim runs before those reverts, but the ordering is
640
+ // unobservable here: content at a paragraph break never ends in `\n` (the
641
+ // break's first newline is absorbed by `skip_blank_region`, and a lone held
642
+ // `\n` at buffer end never accumulates), so there's never a trim to emit.
643
+ close_paragraph(state);
644
+ // the break's newlines and blank lines are absorbed by `skip_blank_region`
645
+ // at the top of the process loop, which also carries the absorb across
646
+ // chunks — otherwise a blank run split mid-stream would leak a leading
647
+ // `\n` into the next paragraph's text
648
+ state.skip_blank_lines = true;
649
+ state.column = 0;
650
+ state.prev_char = NEWLINE;
651
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Plain-text run consumption for the streaming mdz parser.
3
+ *
4
+ * @module
5
+ */
6
+ import { type MdzStreamParserState } from './mdz_stream_parser_state.js';
7
+ /**
8
+ * Consume a delimiter at the current position as literal text — the shared
9
+ * tail of every "this delimiter doesn't open/close anything here" decision
10
+ * (failed boundary, EOF degrade, greedy-rejected closer, lone `*`/`]`).
11
+ *
12
+ * @mutates `state`
13
+ * @nodocs
14
+ */
15
+ export declare const consume_delimiter_as_text: (state: MdzStreamParserState, delimiter: string) => void;
16
+ /**
17
+ * Consume a run of plain text characters. Scans ahead to the next
18
+ * structurally interesting character and accumulates the whole run
19
+ * as a single slice, avoiding per-character string concatenation
20
+ * and dispatch overhead.
21
+ *
22
+ * @nodocs
23
+ */
24
+ export declare const consume_text_run: (state: MdzStreamParserState) => void;
25
+ //# sourceMappingURL=mdz_stream_parser_text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mdz_stream_parser_text.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/mdz_stream_parser_text.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAqBH,OAAO,EACN,KAAK,oBAAoB,EAIzB,MAAM,8BAA8B,CAAC;AAEtC;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,GAAI,OAAO,oBAAoB,EAAE,WAAW,MAAM,KAAG,IAM1F,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,oBAAoB,KAAG,IAsC9D,CAAC"}