@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,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"}
|