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