@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,490 @@
1
+ /**
2
+ * Block-element handlers for the streaming mdz parser.
3
+ *
4
+ * Each `try_*` returns {@link TryResult} so the orchestrator in
5
+ * {@link ./mdz_stream_parser.ts} can fall through or wait for more input.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import {
11
+ BACKTICK,
12
+ HASH,
13
+ HR_HYPHEN_COUNT,
14
+ HYPHEN,
15
+ MAX_HEADING_LEVEL,
16
+ MIN_CODEBLOCK_BACKTICKS,
17
+ NEWLINE,
18
+ SPACE,
19
+ is_line_whitespace,
20
+ } from './mdz_helpers.js';
21
+ import {
22
+ type CodeblockState,
23
+ type MdzStreamParserState,
24
+ type TryResult,
25
+ alloc_id,
26
+ close_paragraph,
27
+ emit,
28
+ flush_text,
29
+ offset,
30
+ push_stack_entry,
31
+ } from './mdz_stream_parser_state.js';
32
+
33
+ /**
34
+ * Try to parse a heading at column 0.
35
+ *
36
+ * @nodocs
37
+ */
38
+ export const try_heading = (state: MdzStreamParserState): TryResult => {
39
+ const start = state.pos;
40
+ let i = start;
41
+
42
+ // count hashes
43
+ let hash_count = 0;
44
+ while (
45
+ i < state.buffer.length &&
46
+ state.buffer.charCodeAt(i) === HASH &&
47
+ hash_count <= MAX_HEADING_LEVEL
48
+ ) {
49
+ hash_count++;
50
+ i++;
51
+ }
52
+
53
+ if (hash_count === 0 || hash_count > MAX_HEADING_LEVEL) return 'not_match';
54
+
55
+ // need at least one more char to check for space
56
+ if (i >= state.buffer.length) return 'need_more';
57
+
58
+ // must have space after hashes
59
+ if (state.buffer.charCodeAt(i) !== SPACE) return 'not_match';
60
+ i++; // consume space
61
+
62
+ // must have at least one non-whitespace char before the newline (mirrors
63
+ // the sync lexer's has-content probe) — hold until it can be decided. A
64
+ // line-bounded hold: the heading opens the moment its first content char
65
+ // arrives. In forced mode the orchestrator treats `need_more` as text.
66
+ let j = i;
67
+ while (j < state.buffer.length && is_line_whitespace(state.buffer.charCodeAt(j))) {
68
+ j++;
69
+ }
70
+ if (j >= state.buffer.length) return 'need_more';
71
+ if (state.buffer.charCodeAt(j) === NEWLINE) return 'not_match'; // no content — literal text
72
+
73
+ // close any open paragraph first
74
+ flush_text(state);
75
+ close_paragraph(state);
76
+
77
+ const id = alloc_id(state);
78
+ const heading_start = offset(state, start);
79
+ emit(state, {
80
+ type: 'open',
81
+ id,
82
+ node_type: 'Heading',
83
+ start: heading_start,
84
+ level: hash_count as 1 | 2 | 3 | 4 | 5 | 6,
85
+ });
86
+ push_stack_entry(state, id, 'Heading', heading_start);
87
+ state.in_heading = true;
88
+ state.heading_text_parts = [''];
89
+ state.pos = i;
90
+ state.column = i - start;
91
+ state.prev_char = SPACE;
92
+ return 'consumed';
93
+ };
94
+
95
+ /**
96
+ * Try to parse a horizontal rule at column 0.
97
+ * When `forced` is true, treats buffer end as EOF (no more input coming).
98
+ *
99
+ * @nodocs
100
+ */
101
+ export const try_hr = (state: MdzStreamParserState, forced = false): TryResult => {
102
+ const start = state.pos;
103
+ let i = start;
104
+
105
+ // must have exactly 3 hyphens
106
+ if (i + HR_HYPHEN_COUNT > state.buffer.length) return 'need_more';
107
+ for (let j = 0; j < HR_HYPHEN_COUNT; j++) {
108
+ if (state.buffer.charCodeAt(i + j) !== HYPHEN) return 'not_match';
109
+ }
110
+ i += HR_HYPHEN_COUNT;
111
+
112
+ // after hyphens, only line whitespace allowed before newline/EOF
113
+ while (i < state.buffer.length) {
114
+ const c = state.buffer.charCodeAt(i);
115
+ if (c === NEWLINE) break;
116
+ if (!is_line_whitespace(c)) return 'not_match';
117
+ i++;
118
+ }
119
+
120
+ // if buffer ends without newline/EOF confirmation, need more input (unless forced)
121
+ if (i >= state.buffer.length && !forced) {
122
+ return 'need_more';
123
+ }
124
+
125
+ flush_text(state);
126
+ close_paragraph(state);
127
+
128
+ const id = alloc_id(state);
129
+ emit(state, {
130
+ type: 'void',
131
+ id,
132
+ node_type: 'Hr',
133
+ start: offset(state, start),
134
+ end: offset(state, i),
135
+ });
136
+ // `i` sits at the line's newline, or at the buffer end — in forced mode
137
+ // always the buffer end (a newline would have resolved this line during
138
+ // `feed`, so a forced HR is only ever the document's final line). Trailing
139
+ // newline and blank lines absorb at the process-loop top.
140
+ state.pos = i;
141
+ state.skip_blank_lines = true;
142
+ state.column = 0;
143
+ state.prev_char = NEWLINE;
144
+ return 'consumed';
145
+ };
146
+
147
+ /**
148
+ * Try to open a code block at column 0. A complete opener line is decisive —
149
+ * raw mode is absolute, so fence-ness never depends on a closer arriving:
150
+ * an unclosed fence consumes to EOF as content. Any open paragraph closes
151
+ * first (fences interrupt paragraphs, matching the sync parser). The only
152
+ * hold is the opener line itself; an opener cut off at EOF without its
153
+ * newline stays text (the orchestrator treats `need_more` as text in forced
154
+ * mode).
155
+ *
156
+ * @nodocs
157
+ */
158
+ export const try_codeblock_open = (state: MdzStreamParserState): TryResult => {
159
+ const start = state.pos;
160
+ let i = start;
161
+
162
+ // count backticks
163
+ let backtick_count = 0;
164
+ while (i < state.buffer.length && state.buffer.charCodeAt(i) === BACKTICK) {
165
+ backtick_count++;
166
+ i++;
167
+ }
168
+
169
+ if (backtick_count < MIN_CODEBLOCK_BACKTICKS) {
170
+ // if buffer ended mid-backtick-sequence, need more input
171
+ if (i >= state.buffer.length) return 'need_more';
172
+ // definitely not enough backticks
173
+ return 'not_match';
174
+ }
175
+
176
+ // find end of opening fence line (need newline)
177
+ const lang_start = i;
178
+ while (i < state.buffer.length) {
179
+ const c = state.buffer.charCodeAt(i);
180
+ if (c === NEWLINE || is_line_whitespace(c)) break;
181
+ i++;
182
+ }
183
+ const lang = i > lang_start ? state.buffer.slice(lang_start, i) : null;
184
+
185
+ // skip trailing whitespace on opening fence line
186
+ while (i < state.buffer.length && is_line_whitespace(state.buffer.charCodeAt(i))) {
187
+ i++;
188
+ }
189
+
190
+ // must find newline
191
+ if (i >= state.buffer.length) return 'need_more';
192
+ if (state.buffer.charCodeAt(i) !== NEWLINE) return 'not_match';
193
+ i++; // consume newline
194
+
195
+ flush_text(state);
196
+ close_paragraph(state);
197
+
198
+ const id = alloc_id(state);
199
+ const cb_start = offset(state, start);
200
+ emit(state, {type: 'open', id, node_type: 'Codeblock', start: cb_start, lang});
201
+ state.codeblock = {id, backtick_count, text_id: null, fence_indent: 0, start: cb_start};
202
+ state.pos = i;
203
+ state.column = 0;
204
+ state.prev_char = NEWLINE;
205
+ return 'consumed';
206
+ };
207
+
208
+ /**
209
+ * Emit codeblock content as `text` (first chunk) or `append_text` (subsequent
210
+ * chunks), updating `cb.text_id` on first emit. `content_start` is the local
211
+ * buffer position where the content slice began — only used to compute the
212
+ * global byte offset for the first `text` opcode.
213
+ *
214
+ * @nodocs
215
+ */
216
+ export const emit_codeblock_text = (
217
+ state: MdzStreamParserState,
218
+ cb: CodeblockState,
219
+ content: string,
220
+ content_start: number,
221
+ ): void => {
222
+ if (cb.text_id !== null) {
223
+ emit(state, {type: 'append_text', id: cb.text_id, content});
224
+ } else {
225
+ const id = alloc_id(state);
226
+ const text_start = offset(state, content_start);
227
+ emit(state, {
228
+ type: 'text',
229
+ id,
230
+ content,
231
+ text_type: 'Text',
232
+ start: text_start,
233
+ end: text_start + content.length,
234
+ });
235
+ cb.text_id = id;
236
+ }
237
+ };
238
+
239
+ /**
240
+ * Drain the remaining buffer as codeblock content at EOF, looking for a closing
241
+ * fence whose terminator is EOF rather than `\n`. Matches the sync lexer's
242
+ * `#match_fence`, which allows a fence at the end of the document.
243
+ *
244
+ * If a fence is found, the codeblock closes normally. Otherwise the remaining
245
+ * buffer becomes content and `close_codeblock_at_eof` (in
246
+ * `mdz_stream_parser_state.ts`) emits the plain close after process completes
247
+ * (an unclosed fence consumes to EOF — never reverted).
248
+ *
249
+ * @nodocs
250
+ */
251
+ export const process_codeblock_forced = (state: MdzStreamParserState): void => {
252
+ const cb = state.codeblock!;
253
+ const start = state.pos;
254
+
255
+ while (state.pos < state.buffer.length) {
256
+ if (state.column === 0) {
257
+ const fence_match = match_codeblock_close(state, cb.backtick_count, true);
258
+ if (fence_match !== -1) {
259
+ // emit content up to the closing fence (excluding trailing newline before fence)
260
+ let content_end = state.pos;
261
+ if (content_end > start && state.buffer.charCodeAt(content_end - 1) === NEWLINE) {
262
+ content_end--;
263
+ }
264
+ const content = state.buffer.slice(start, content_end);
265
+ if (content.length > 0) {
266
+ emit_codeblock_text(state, cb, content, start);
267
+ }
268
+
269
+ // the closer here is always EOF-terminated (`fence_match` is the
270
+ // buffer end): a newline-terminated closer resolves during `feed`,
271
+ // whose hold retains at most a trailing newline plus a partial
272
+ // fence line. So — like the non-forced path's `fence_match - 1` —
273
+ // the close `end` excludes any line terminator
274
+ emit(state, {type: 'close', id: cb.id, end: offset(state, fence_match)});
275
+ state.pos = fence_match;
276
+ state.codeblock = null;
277
+ return;
278
+ }
279
+ }
280
+
281
+ const c = state.buffer.charCodeAt(state.pos);
282
+ if (c === NEWLINE) {
283
+ state.column = 0;
284
+ } else {
285
+ state.column++;
286
+ }
287
+ state.pos++;
288
+ }
289
+
290
+ // no closing fence — dump the rest as codeblock content; caller's
291
+ // `close_codeblock_at_eof` then closes the codeblock at EOF
292
+ const remaining = state.buffer.slice(start, state.pos);
293
+ if (remaining.length > 0) {
294
+ emit_codeblock_text(state, cb, remaining, start);
295
+ }
296
+ };
297
+
298
+ /**
299
+ * Process bytes while in codeblock mode.
300
+ * Returns true if processing should continue, false if more input needed.
301
+ *
302
+ * @nodocs
303
+ */
304
+ export const process_codeblock = (state: MdzStreamParserState): boolean => {
305
+ const cb = state.codeblock!;
306
+ const start = state.pos;
307
+
308
+ while (state.pos < state.buffer.length) {
309
+ // check for closing fence at column 0
310
+ if (state.column === 0) {
311
+ const fence_match = match_codeblock_close(state, cb.backtick_count);
312
+ if (fence_match === -2) {
313
+ // need more input — hold from this position (don't consume potential fence chars)
314
+ break;
315
+ }
316
+ if (fence_match !== -1) {
317
+ // emit content up to the closing fence (excluding trailing newline before fence)
318
+ let content_end = state.pos;
319
+ if (content_end > start && state.buffer.charCodeAt(content_end - 1) === NEWLINE) {
320
+ content_end--;
321
+ }
322
+ const content = state.buffer.slice(start, content_end);
323
+ if (content.length > 0) {
324
+ emit_codeblock_text(state, cb, content, start);
325
+ }
326
+
327
+ // codeblock end = position of newline after closing fence (exclusive)
328
+ emit(state, {type: 'close', id: cb.id, end: offset(state, fence_match - 1)});
329
+ state.pos = fence_match;
330
+ state.codeblock = null;
331
+ state.column = 0;
332
+ state.prev_char = NEWLINE;
333
+ // trailing newlines and blank lines absorb at the process-loop top
334
+ state.skip_blank_lines = true;
335
+ return true;
336
+ }
337
+ }
338
+
339
+ // not a closing fence — advance
340
+ const c = state.buffer.charCodeAt(state.pos);
341
+ if (c === NEWLINE) {
342
+ state.column = 0;
343
+ } else {
344
+ state.column++;
345
+ }
346
+ state.pos++;
347
+ }
348
+
349
+ // buffer exhausted or broke for potential fence — emit content so far, hold trailing data.
350
+ // Hold trailing \n (could be before a closing fence in next chunk), and also hold
351
+ // \n + backticks when we broke mid-fence-check (the \n precedes the backticks and
352
+ // would be stripped by fence close, so must not be emitted as content yet).
353
+ let emit_end = state.pos;
354
+ if (emit_end > start && state.buffer.charCodeAt(emit_end - 1) === NEWLINE) {
355
+ emit_end--;
356
+ state.pos = emit_end; // leave \n (+ any held backticks after it) in buffer
357
+ }
358
+ const content = state.buffer.slice(start, emit_end);
359
+ if (content.length > 0) {
360
+ emit_codeblock_text(state, cb, content, start);
361
+ }
362
+ return false; // need more input
363
+ };
364
+
365
+ /**
366
+ * Process buffered lines while inside an in-item code block
367
+ * (`fence_indent > 0`). Operates on complete lines only — a partial line at
368
+ * the buffer end holds until its newline arrives (line-bounded, like the
369
+ * opener hold), keeping the per-line indent strip and closer probe
370
+ * deterministic across chunk boundaries. Content lines strip
371
+ * `min(fence_indent, line_indent)` and emit joined by `'\n'` separators
372
+ * (first line bare, later lines prefixed), so emitted content never carries
373
+ * the region's final newline — `close_codeblock_at_eof` therefore skips its
374
+ * trailing-newline trim for in-item fences.
375
+ *
376
+ * A closing fence — any indent <= the opener's, a run of >= the opener's
377
+ * backticks, only whitespace to the line end — closes the block with `pos`
378
+ * left at its terminating newline, so the list line classifier dispatches
379
+ * what follows (mirroring the sync lexer's loop shape). In forced mode EOF
380
+ * terminates closer and content lines.
381
+ *
382
+ * Returns `false` when more input is needed; never in forced mode.
383
+ *
384
+ * @nodocs
385
+ */
386
+ export const process_codeblock_in_item = (
387
+ state: MdzStreamParserState,
388
+ forced: boolean,
389
+ ): boolean => {
390
+ const cb = state.codeblock!;
391
+ while (state.pos < state.buffer.length) {
392
+ const line_start = state.pos;
393
+ let j = line_start;
394
+ while (j < state.buffer.length && is_line_whitespace(state.buffer.charCodeAt(j))) {
395
+ j++;
396
+ }
397
+ if (j >= state.buffer.length && !forced) return false; // partial line — hold
398
+ const line_indent = j - line_start;
399
+ // closer probe
400
+ if (
401
+ line_indent <= cb.fence_indent &&
402
+ j < state.buffer.length &&
403
+ state.buffer.charCodeAt(j) === BACKTICK
404
+ ) {
405
+ let k = j;
406
+ let count = 0;
407
+ while (k < state.buffer.length && state.buffer.charCodeAt(k) === BACKTICK) {
408
+ count++;
409
+ k++;
410
+ }
411
+ if (k >= state.buffer.length && !forced) return false; // run could still grow
412
+ if (count >= cb.backtick_count) {
413
+ let m = k;
414
+ while (m < state.buffer.length && is_line_whitespace(state.buffer.charCodeAt(m))) {
415
+ m++;
416
+ }
417
+ if (m >= state.buffer.length && !forced) return false; // trailing content could disqualify
418
+ if (m >= state.buffer.length || state.buffer.charCodeAt(m) === NEWLINE) {
419
+ emit(state, {type: 'close', id: cb.id, end: offset(state, m)});
420
+ state.codeblock = null;
421
+ state.pos = m; // at the closer's newline (or EOF)
422
+ state.column = m - line_start;
423
+ return true;
424
+ }
425
+ }
426
+ // not a closer — the line is content
427
+ }
428
+ // content line — emit it whole once its newline arrives (or at EOF when forced)
429
+ const nl = state.buffer.indexOf('\n', j);
430
+ if (nl === -1 && !forced) return false; // hold the partial line
431
+ const line_end = nl === -1 ? state.buffer.length : nl;
432
+ const strip = line_start + Math.min(cb.fence_indent, line_indent);
433
+ const content = state.buffer.slice(strip, line_end);
434
+ emit_codeblock_text(state, cb, cb.text_id !== null ? '\n' + content : content, strip);
435
+ state.pos = nl === -1 ? line_end : line_end + 1;
436
+ state.column = 0;
437
+ state.prev_char = NEWLINE;
438
+ }
439
+ return true;
440
+ };
441
+
442
+ /**
443
+ * Check if the buffer at current position has a codeblock closing fence —
444
+ * a run of at least `backtick_count` backticks with only whitespace to end
445
+ * of line. Returns the position after the fence (including trailing newline)
446
+ * on success, `-1` when definitely not a fence, or `-2` when more input is
447
+ * needed to decide.
448
+ *
449
+ * When `forced` is true, EOF after the fence counts as a valid terminator
450
+ * (matches `mdz_parse`'s handling of code blocks at end of input).
451
+ *
452
+ * @nodocs
453
+ */
454
+ export const match_codeblock_close = (
455
+ state: MdzStreamParserState,
456
+ backtick_count: number,
457
+ forced: boolean = false,
458
+ ): number => {
459
+ let i = state.pos;
460
+
461
+ // count backticks
462
+ let count = 0;
463
+ while (i < state.buffer.length && state.buffer.charCodeAt(i) === BACKTICK) {
464
+ count++;
465
+ i++;
466
+ }
467
+ if (count < backtick_count) {
468
+ // buffer ended mid-backtick-sequence — could still grow into a fence
469
+ if (!forced && i >= state.buffer.length) return -2;
470
+ return -1;
471
+ }
472
+
473
+ // trailing whitespace allowed
474
+ while (i < state.buffer.length && is_line_whitespace(state.buffer.charCodeAt(i))) {
475
+ i++;
476
+ }
477
+
478
+ // must be followed by newline or EOF
479
+ if (i < state.buffer.length && state.buffer.charCodeAt(i) === NEWLINE) {
480
+ return i + 1;
481
+ }
482
+ if (i >= state.buffer.length) {
483
+ // in forced mode, EOF terminates the fence
484
+ if (forced) return i;
485
+ // enough backticks but buffer ended — the run could still grow, or
486
+ // whitespace/newline/disqualifying content could follow
487
+ return -2;
488
+ }
489
+ return -1;
490
+ };