@fuzdev/mdz 0.2.0 → 0.3.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/dist/Mdz.svelte +15 -7
- package/dist/Mdz.svelte.d.ts +9 -2
- package/dist/Mdz.svelte.d.ts.map +1 -1
- package/dist/MdzNodeView.svelte +118 -112
- package/dist/MdzNodeView.svelte.d.ts +6 -2
- package/dist/MdzNodeView.svelte.d.ts.map +1 -1
- package/dist/MdzStream.svelte +3 -3
- package/dist/MdzStream.svelte.d.ts.map +1 -1
- package/dist/MdzStreamNodeView.svelte +117 -114
- package/dist/MdzStreamNodeView.svelte.d.ts +6 -2
- package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -1
- package/dist/mdz.d.ts +6 -0
- package/dist/mdz.d.ts.map +1 -1
- package/dist/mdz.js +8 -0
- package/dist/mdz_debug_work.d.ts +38 -0
- package/dist/mdz_debug_work.d.ts.map +1 -0
- package/dist/mdz_debug_work.js +37 -0
- package/dist/mdz_helpers.d.ts +61 -0
- package/dist/mdz_helpers.d.ts.map +1 -1
- package/dist/mdz_helpers.js +72 -0
- package/dist/mdz_lexer.d.ts +12 -0
- package/dist/mdz_lexer.d.ts.map +1 -1
- package/dist/mdz_lexer.js +164 -24
- package/dist/mdz_stream_parser_inline.d.ts.map +1 -1
- package/dist/mdz_stream_parser_inline.js +19 -17
- package/dist/mdz_stream_parser_link.d.ts.map +1 -1
- package/dist/mdz_stream_parser_link.js +88 -56
- package/dist/mdz_stream_parser_list.d.ts.map +1 -1
- package/dist/mdz_stream_parser_list.js +10 -2
- package/dist/mdz_stream_parser_state.d.ts +29 -0
- package/dist/mdz_stream_parser_state.d.ts.map +1 -1
- package/dist/mdz_stream_parser_state.js +52 -3
- package/dist/mdz_stream_parser_table.d.ts +3 -2
- package/dist/mdz_stream_parser_table.d.ts.map +1 -1
- package/dist/mdz_stream_parser_table.js +18 -7
- package/dist/mdz_stream_parser_text.d.ts.map +1 -1
- package/dist/mdz_stream_parser_text.js +35 -24
- package/dist/mdz_to_svelte.d.ts.map +1 -1
- package/dist/mdz_to_svelte.js +28 -31
- package/dist/mdz_token_parser.d.ts.map +1 -1
- package/dist/mdz_token_parser.js +15 -16
- package/dist/svelte_preprocess_mdz.js +31 -37
- package/package.json +7 -4
- package/src/lib/mdz.ts +9 -0
- package/src/lib/mdz_debug_work.ts +38 -0
- package/src/lib/mdz_helpers.ts +97 -0
- package/src/lib/mdz_lexer.ts +168 -28
- package/src/lib/mdz_stream_parser_inline.ts +21 -17
- package/src/lib/mdz_stream_parser_link.ts +89 -56
- package/src/lib/mdz_stream_parser_list.ts +10 -4
- package/src/lib/mdz_stream_parser_state.ts +76 -4
- package/src/lib/mdz_stream_parser_table.ts +18 -7
- package/src/lib/mdz_stream_parser_text.ts +38 -37
- package/src/lib/mdz_to_svelte.ts +23 -31
- package/src/lib/mdz_token_parser.ts +17 -20
- package/src/lib/svelte_preprocess_mdz.ts +34 -35
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
A_UPPER,
|
|
11
11
|
LEFT_BRACKET,
|
|
12
12
|
LEFT_PAREN,
|
|
13
|
-
|
|
13
|
+
MAX_INLINE_NESTING_DEPTH,
|
|
14
14
|
RIGHT_ANGLE,
|
|
15
15
|
RIGHT_BRACKET,
|
|
16
16
|
RIGHT_PAREN,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type TryResult,
|
|
29
29
|
accumulate_text,
|
|
30
30
|
alloc_id,
|
|
31
|
+
buffer_index_of,
|
|
31
32
|
emit,
|
|
32
33
|
ensure_paragraph,
|
|
33
34
|
flush_text,
|
|
@@ -37,11 +38,20 @@ import {
|
|
|
37
38
|
push_stack_entry,
|
|
38
39
|
revert_above,
|
|
39
40
|
} from './mdz_stream_parser_state.ts';
|
|
41
|
+
import {consume_delimiter_as_text} from './mdz_stream_parser_text.ts';
|
|
42
|
+
import {mdz_debug_work} from './mdz_debug_work.ts';
|
|
40
43
|
|
|
41
44
|
// -- Links --
|
|
42
45
|
|
|
43
46
|
/** @nodocs */
|
|
44
47
|
export const try_link_open = (state: MdzStreamParserState): boolean => {
|
|
48
|
+
// nesting-depth cap: too deep to open — render `[` literal, matching the
|
|
49
|
+
// sync lexer's `#tokenize_markdown_link` cap (keeps the two parsers agreeing
|
|
50
|
+
// on where deep `[` stops nesting)
|
|
51
|
+
if (state.open_link_tag_depth >= MAX_INLINE_NESTING_DEPTH) {
|
|
52
|
+
consume_delimiter_as_text(state, '[');
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
45
55
|
flush_text(state);
|
|
46
56
|
ensure_paragraph(state);
|
|
47
57
|
|
|
@@ -97,65 +107,69 @@ export const try_complete_link = (state: MdzStreamParserState, link_stack_idx: n
|
|
|
97
107
|
return true;
|
|
98
108
|
}
|
|
99
109
|
|
|
100
|
-
// found ]( —
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
end: offset(state, i + 1),
|
|
138
|
-
reference,
|
|
139
|
-
link_type,
|
|
140
|
-
});
|
|
141
|
-
state.active_text_id = null;
|
|
142
|
-
state.pos = i + 1;
|
|
143
|
-
state.column += i + 1 - bracket_pos;
|
|
144
|
-
state.prev_char = RIGHT_PAREN;
|
|
110
|
+
// found ]( — find the reference's terminator: `)` completes, a space or
|
|
111
|
+
// newline aborts. Memoized searches (see `buffer_index_of`) — a link whose
|
|
112
|
+
// URL arrives incrementally re-enters here on every feed with `pos` still
|
|
113
|
+
// at the `]`, and a raw scan would re-cover the growing reference each
|
|
114
|
+
// time, going quadratic on long streamed URLs.
|
|
115
|
+
const ref_start = bracket_pos + 2;
|
|
116
|
+
const close_pos = buffer_index_of(state, ')', ref_start);
|
|
117
|
+
const space_pos = buffer_index_of(state, ' ', ref_start);
|
|
118
|
+
const newline_pos = buffer_index_of(state, '\n', ref_start);
|
|
119
|
+
let invalid_pos = space_pos;
|
|
120
|
+
if (newline_pos !== -1 && (invalid_pos === -1 || newline_pos < invalid_pos)) {
|
|
121
|
+
invalid_pos = newline_pos;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (close_pos === -1) {
|
|
125
|
+
// didn't find ) — need more input or invalid
|
|
126
|
+
if (invalid_pos === -1) return false; // need more input
|
|
127
|
+
// found invalid char before any ) — revert
|
|
128
|
+
abort_link_to_text(state, link_stack_idx, bracket_pos);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (invalid_pos !== -1 && invalid_pos < close_pos) {
|
|
132
|
+
// invalid character in URL (simplified check)
|
|
133
|
+
abort_link_to_text(state, link_stack_idx, bracket_pos);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const i = close_pos;
|
|
138
|
+
const reference = state.buffer.slice(ref_start, i);
|
|
139
|
+
// validate reference: non-empty and only valid path chars
|
|
140
|
+
if (!reference.trim()) {
|
|
141
|
+
abort_link_to_text(state, link_stack_idx, bracket_pos);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
for (let j = 0; j < reference.length; j++) {
|
|
145
|
+
if (!is_valid_path_char(reference.charCodeAt(j))) {
|
|
146
|
+
abort_link_to_text(state, link_stack_idx, bracket_pos);
|
|
145
147
|
return true;
|
|
146
148
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
}
|
|
150
|
+
// Reject unsafe protocols (javascript:, data:, etc.) at parse time.
|
|
151
|
+
// `is_valid_path_char` permits `:` so we have to filter explicitly.
|
|
152
|
+
if (!mdz_is_safe_reference(reference)) {
|
|
153
|
+
abort_link_to_text(state, link_stack_idx, bracket_pos);
|
|
154
|
+
return true;
|
|
152
155
|
}
|
|
153
156
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// success — close the link with reference
|
|
158
|
+
flush_text(state);
|
|
159
|
+
revert_above(state, link_stack_idx);
|
|
160
|
+
const entry = pop_stack_entry(state);
|
|
161
|
+
const link_type = mdz_is_url(reference) ? 'external' : 'internal';
|
|
162
|
+
emit(state, {
|
|
163
|
+
type: 'close',
|
|
164
|
+
id: entry.id,
|
|
165
|
+
end: offset(state, i + 1),
|
|
166
|
+
reference,
|
|
167
|
+
link_type,
|
|
168
|
+
});
|
|
169
|
+
state.active_text_id = null;
|
|
170
|
+
state.pos = i + 1;
|
|
171
|
+
state.column += i + 1 - bracket_pos;
|
|
172
|
+
state.prev_char = RIGHT_PAREN;
|
|
159
173
|
return true;
|
|
160
174
|
};
|
|
161
175
|
|
|
@@ -176,7 +190,19 @@ export const try_tag_open = (state: MdzStreamParserState): TryResult => {
|
|
|
176
190
|
return 'not_match';
|
|
177
191
|
}
|
|
178
192
|
|
|
193
|
+
// nesting-depth cap: `<letter…` is a tag open, but we're too deep to nest —
|
|
194
|
+
// render `<` literal, matching the sync lexer's `#tokenize_tag` cap. Checked
|
|
195
|
+
// after the closer/hold cases above (a `</…>` closer still closes; a `<` at
|
|
196
|
+
// the buffer end still holds), so only genuine opens are suppressed.
|
|
197
|
+
if (state.open_link_tag_depth >= MAX_INLINE_NESTING_DEPTH) {
|
|
198
|
+
consume_delimiter_as_text(state, '<');
|
|
199
|
+
return 'consumed';
|
|
200
|
+
}
|
|
201
|
+
|
|
179
202
|
// collect tag name
|
|
203
|
+
// TODO the name/whitespace scans rescan from `start` on every held retry
|
|
204
|
+
// of an unclosed tag's open tail — needs a hold watermark; a length cap would
|
|
205
|
+
// diverge from the sync parser on pathological multi-KB "tag names"
|
|
180
206
|
const name_start = i;
|
|
181
207
|
while (i < state.buffer.length && is_tag_name_char(state.buffer.charCodeAt(i))) {
|
|
182
208
|
i++;
|
|
@@ -255,9 +281,16 @@ export const try_close_tag = (state: MdzStreamParserState): TryResult => {
|
|
|
255
281
|
const name = state.buffer.slice(name_start, i);
|
|
256
282
|
i++; // past >
|
|
257
283
|
|
|
284
|
+
// O(1) bail when no tag of this name is open — without it, every `</name>`
|
|
285
|
+
// candidate walks the whole inline stack, which grows one frame per leaked
|
|
286
|
+
// unclosed `<Tag>` and goes quadratic on tag-dense input (the same shape
|
|
287
|
+
// `open_counts` guards for the delimiter inlines)
|
|
288
|
+
if (!state.open_tag_counts?.get(name)) return 'not_match';
|
|
289
|
+
|
|
258
290
|
// find matching open tag on stack
|
|
259
291
|
let found_idx = -1;
|
|
260
292
|
for (let j = state.stack.length - 1; j >= 0; j--) {
|
|
293
|
+
if (mdz_debug_work.enabled) mdz_debug_work.total++; // frames walked (mismatched-tags guard)
|
|
261
294
|
const entry = state.stack[j]!;
|
|
262
295
|
if (
|
|
263
296
|
(entry.node_type === 'Element' || entry.node_type === 'Component') &&
|
|
@@ -418,10 +418,16 @@ export const process_list_line = (
|
|
|
418
418
|
|
|
419
419
|
if (marker !== null && marker.empty) {
|
|
420
420
|
// empty item: only at an indent exactly matching an open level of the
|
|
421
|
-
// same marker type — anything else degrades below
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
)
|
|
421
|
+
// same marker type — anything else degrades below (manual loop like
|
|
422
|
+
// `list_continuation_target` — no per-line closure allocation)
|
|
423
|
+
let level_idx = -1;
|
|
424
|
+
for (let li = 0; li < state.list_levels.length; li++) {
|
|
425
|
+
const l = state.list_levels[li]!;
|
|
426
|
+
if (l.indent === indent && l.ordered === marker.ordered) {
|
|
427
|
+
level_idx = li;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
425
431
|
if (level_idx !== -1) {
|
|
426
432
|
close_list_run(state);
|
|
427
433
|
while (state.list_levels.length - 1 > level_idx) {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type {MdzNodeTypeContainer, MdzNodeId, MdzOpcode} from './mdz_opcodes.ts';
|
|
12
|
+
import type {MdzTableCellParser} from './mdz.ts';
|
|
12
13
|
import {NEWLINE, has_non_whitespace, mdz_heading_id_from_text} from './mdz_helpers.ts';
|
|
14
|
+
import {mdz_debug_work} from './mdz_debug_work.ts';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Tri-state result for `try_*` parser handlers.
|
|
@@ -214,8 +216,9 @@ export interface MdzStreamParserState {
|
|
|
214
216
|
* is `null` for a top-level table (column-0 rows) or the enclosing list item's
|
|
215
217
|
* marker indent for a table nested as a block child (rows skip the indent and
|
|
216
218
|
* a dedent to that indent ends the table, returning control to the list).
|
|
219
|
+
* `cell_parser` is the table's shared cell parser, rebound per row.
|
|
217
220
|
*/
|
|
218
|
-
table: {id: MdzNodeId; item_indent: number | null} | null;
|
|
221
|
+
table: {id: MdzNodeId; item_indent: number | null; cell_parser: MdzTableCellParser} | null;
|
|
219
222
|
/**
|
|
220
223
|
* Whether the innermost open ListItem's first inline run is current.
|
|
221
224
|
* Content then flows directly into the ListItem frame — the sync AST
|
|
@@ -243,6 +246,32 @@ export interface MdzStreamParserState {
|
|
|
243
246
|
search_memo: Map<string, BufferSearchMemo> | null;
|
|
244
247
|
/** Open-container counts for `find_open`'s O(1) "nothing open" early exit. */
|
|
245
248
|
open_counts: OpenInlineCounts;
|
|
249
|
+
/**
|
|
250
|
+
* Count of open `Link`/`Element`/`Component` frames in the current run — the
|
|
251
|
+
* streaming analogue of the sync lexer's `#inline_depth`. Kept as an O(1)
|
|
252
|
+
* counter (not a stack walk, which would reintroduce the deep-stack
|
|
253
|
+
* quadratic `open_counts` exists to avoid) so `try_link_open`/`try_tag_open`
|
|
254
|
+
* can cap nesting at `MAX_INLINE_NESTING_DEPTH` — rendering a deeper `[`/`<`
|
|
255
|
+
* literal, matching the sync cap on pure/symmetric nesting. It counts
|
|
256
|
+
* **optimistic** opens, though, so it diverges from the sync `#inline_depth`
|
|
257
|
+
* (which the sync tag path only bumps after a closer precheck) on an
|
|
258
|
+
* asymmetric cap-saturating prefix — 100+ *unclosed* `<a>` before a valid
|
|
259
|
+
* `[x](/u)` saturates this counter and suppresses the trailing link that sync
|
|
260
|
+
* still forms. Adversarial-only, documented not fixed (streaming can't
|
|
261
|
+
* precheck closers): see the flip-at-cap case in `mdz_nesting_cap.test.ts`.
|
|
262
|
+
* Run-scoped for free like `open_counts`: a
|
|
263
|
+
* run close reverts every inline frame above it, decrementing back to zero.
|
|
264
|
+
*/
|
|
265
|
+
open_link_tag_depth: number;
|
|
266
|
+
/**
|
|
267
|
+
* Per-name counts of open Element/Component frames, the tag analogue of
|
|
268
|
+
* `open_counts`: `try_close_tag` bails in O(1) when the closing name isn't
|
|
269
|
+
* open, instead of walking a stack that grows one frame per leaked unclosed
|
|
270
|
+
* `<Tag>`. Lazily created on the first tag open — tag-free inputs never
|
|
271
|
+
* allocate the map. Run-scoped like `open_counts`: run closes revert every
|
|
272
|
+
* inline frame above them, decrementing the counts back to zero.
|
|
273
|
+
*/
|
|
274
|
+
open_tag_counts: Map<string, number> | null;
|
|
246
275
|
/** Whether we're inside a heading (newline ends it). */
|
|
247
276
|
in_heading: boolean;
|
|
248
277
|
/** Whether we're inside an optimistic inline Code container. */
|
|
@@ -315,6 +344,8 @@ export const create_state = (): MdzStreamParserState => ({
|
|
|
315
344
|
base_offset: 0,
|
|
316
345
|
search_memo: null,
|
|
317
346
|
open_counts: {Bold: 0, Italic: 0, Strikethrough: 0, Link: 0},
|
|
347
|
+
open_link_tag_depth: 0,
|
|
348
|
+
open_tag_counts: null,
|
|
318
349
|
in_heading: false,
|
|
319
350
|
in_code: false,
|
|
320
351
|
in_paragraph: false,
|
|
@@ -362,6 +393,13 @@ export const push_stack_entry = (
|
|
|
362
393
|
} else {
|
|
363
394
|
bump_open_count(state, node_type, 1);
|
|
364
395
|
}
|
|
396
|
+
if (tag_name !== undefined) {
|
|
397
|
+
const counts = (state.open_tag_counts ??= new Map());
|
|
398
|
+
counts.set(tag_name, (counts.get(tag_name) ?? 0) + 1);
|
|
399
|
+
}
|
|
400
|
+
// Link/Element/Component contribute to the nesting-depth cap (tag_name is
|
|
401
|
+
// set for Element/Component only, so `Link || tag_name` is exactly the three)
|
|
402
|
+
if (node_type === 'Link' || tag_name !== undefined) state.open_link_tag_depth++;
|
|
365
403
|
};
|
|
366
404
|
|
|
367
405
|
/**
|
|
@@ -394,6 +432,16 @@ const bump_open_count = (
|
|
|
394
432
|
export const pop_stack_entry = (state: MdzStreamParserState): StackEntry => {
|
|
395
433
|
const entry = state.stack.pop()!;
|
|
396
434
|
bump_open_count(state, entry.node_type, -1);
|
|
435
|
+
if (entry.tag_name !== undefined) {
|
|
436
|
+
const counts = state.open_tag_counts!;
|
|
437
|
+
// drop zero-count keys so a long stream of many distinct tag names can't
|
|
438
|
+
// grow the map unboundedly — the `!counts.get(name)` bail in `try_close_tag`
|
|
439
|
+
// treats a missing key and a 0 count identically, so this is behavior-neutral
|
|
440
|
+
const remaining = counts.get(entry.tag_name)! - 1;
|
|
441
|
+
if (remaining === 0) counts.delete(entry.tag_name);
|
|
442
|
+
else counts.set(entry.tag_name, remaining);
|
|
443
|
+
}
|
|
444
|
+
if (entry.node_type === 'Link' || entry.tag_name !== undefined) state.open_link_tag_depth--;
|
|
397
445
|
return entry;
|
|
398
446
|
};
|
|
399
447
|
|
|
@@ -465,7 +513,11 @@ const memo_index_of = (
|
|
|
465
513
|
// rescan only the appended tail, overlapping the old boundary so a
|
|
466
514
|
// straddling needle isn't missed
|
|
467
515
|
const rescan_from = Math.max(global_from, memo.searched_to - (needle.length - 1));
|
|
468
|
-
const
|
|
516
|
+
const local_from = rescan_from - state.base_offset;
|
|
517
|
+
const result = state.buffer.indexOf(needle, local_from);
|
|
518
|
+
if (mdz_debug_work.enabled) {
|
|
519
|
+
mdz_debug_work.total += (result === -1 ? state.buffer.length : result) - local_from;
|
|
520
|
+
}
|
|
469
521
|
memo.from = global_from;
|
|
470
522
|
memo.result = result === -1 ? -1 : state.base_offset + result;
|
|
471
523
|
memo.searched_to = global_end;
|
|
@@ -474,6 +526,9 @@ const memo_index_of = (
|
|
|
474
526
|
// found result already behind `from` — fall through to a fresh scan
|
|
475
527
|
}
|
|
476
528
|
const result = state.buffer.indexOf(needle, from);
|
|
529
|
+
if (mdz_debug_work.enabled) {
|
|
530
|
+
mdz_debug_work.total += (result === -1 ? state.buffer.length : result) - from;
|
|
531
|
+
}
|
|
477
532
|
memo.from = global_from;
|
|
478
533
|
memo.result = result === -1 ? -1 : state.base_offset + result;
|
|
479
534
|
memo.searched_to = global_end;
|
|
@@ -519,8 +574,13 @@ export const emit = (state: MdzStreamParserState, op: MdzOpcode): void => {
|
|
|
519
574
|
if (top) {
|
|
520
575
|
top.has_children = true;
|
|
521
576
|
// propagate non-whitespace marker to the nearest Paragraph so
|
|
522
|
-
// whitespace-only paragraphs can be discarded at close
|
|
523
|
-
|
|
577
|
+
// whitespace-only paragraphs can be discarded at close — the O(1)
|
|
578
|
+
// no-paragraph check runs first so codeblock/table-cell emissions
|
|
579
|
+
// (never inside a Paragraph) skip the content scan entirely
|
|
580
|
+
if (
|
|
581
|
+
op.type === 'void' ||
|
|
582
|
+
(state.paragraph_stack_idx !== -1 && has_non_whitespace(op.content))
|
|
583
|
+
) {
|
|
524
584
|
mark_paragraph_non_whitespace(state);
|
|
525
585
|
}
|
|
526
586
|
}
|
|
@@ -744,6 +804,18 @@ export const revert_failed_close = (state: MdzStreamParserState, stack_idx: numb
|
|
|
744
804
|
const frames_above = state.stack.length - 1 - stack_idx;
|
|
745
805
|
state.stack.splice(stack_idx, 1);
|
|
746
806
|
bump_open_count(state, entry.node_type, -1);
|
|
807
|
+
if (entry.tag_name !== undefined) {
|
|
808
|
+
const counts = state.open_tag_counts!;
|
|
809
|
+
// drop zero-count keys so a long stream of many distinct tag names can't
|
|
810
|
+
// grow the map unboundedly — the `!counts.get(name)` bail in `try_close_tag`
|
|
811
|
+
// treats a missing key and a 0 count identically, so this is behavior-neutral
|
|
812
|
+
const remaining = counts.get(entry.tag_name)! - 1;
|
|
813
|
+
if (remaining === 0) counts.delete(entry.tag_name);
|
|
814
|
+
else counts.set(entry.tag_name, remaining);
|
|
815
|
+
}
|
|
816
|
+
// only ever reverts an Italic today, but keep the depth counter honest if a
|
|
817
|
+
// Link/Element/Component is ever failed-closed mid-stack
|
|
818
|
+
if (entry.node_type === 'Link' || entry.tag_name !== undefined) state.open_link_tag_depth--;
|
|
747
819
|
state.opcodes.push({
|
|
748
820
|
type: 'revert',
|
|
749
821
|
id: entry.id,
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* whole header emits at once. Body rows then stream one per line. Because a row
|
|
8
8
|
* is always fully buffered before it emits (no within-row streaming), each
|
|
9
9
|
* cell's inline content is parsed with the sync reference (`MdzTableCellParser`,
|
|
10
|
-
* one `MdzLexer`/`MdzTokenParser` shared across the
|
|
11
|
-
* as opcodes — guaranteeing sync↔stream parity
|
|
10
|
+
* one `MdzLexer`/`MdzTokenParser` shared across the whole table's cells,
|
|
11
|
+
* rebound per row) and replayed as opcodes — guaranteeing sync↔stream parity
|
|
12
|
+
* by construction.
|
|
12
13
|
*
|
|
13
14
|
* @module
|
|
14
15
|
*/
|
|
@@ -72,7 +73,11 @@ export const try_table_start = (state: MdzStreamParserState, forced: boolean): T
|
|
|
72
73
|
const table_start = offset(state, start);
|
|
73
74
|
emit(state, {type: 'open', id: table_id, node_type: 'Table', start: table_start, align});
|
|
74
75
|
push_stack_entry(state, table_id, 'Table', table_start);
|
|
75
|
-
state.table = {
|
|
76
|
+
state.table = {
|
|
77
|
+
id: table_id,
|
|
78
|
+
item_indent: null,
|
|
79
|
+
cell_parser: new MdzTableCellParser(state.buffer),
|
|
80
|
+
};
|
|
76
81
|
|
|
77
82
|
emit_table_row(state, header_cells, true, start, header_end);
|
|
78
83
|
|
|
@@ -280,7 +285,11 @@ export const open_table_in_item = (
|
|
|
280
285
|
align: head.align,
|
|
281
286
|
});
|
|
282
287
|
push_stack_entry(state, table_id, 'Table', table_start);
|
|
283
|
-
state.table = {
|
|
288
|
+
state.table = {
|
|
289
|
+
id: table_id,
|
|
290
|
+
item_indent: marker_indent,
|
|
291
|
+
cell_parser: new MdzTableCellParser(state.buffer),
|
|
292
|
+
};
|
|
284
293
|
emit_table_row(state, head.header_cells, true, header_first, head.header_end);
|
|
285
294
|
// leave the cursor on the delimiter's newline (or EOF) — the in-item body-row
|
|
286
295
|
// handler reads from the line after it and hands back on this same convention
|
|
@@ -301,9 +310,11 @@ const emit_table_row = (
|
|
|
301
310
|
const row_off = offset(state, row_start);
|
|
302
311
|
emit(state, {type: 'open', id: row_id, node_type: 'TableRow', start: row_off, header});
|
|
303
312
|
push_stack_entry(state, row_id, 'TableRow', row_off);
|
|
304
|
-
// one lexer/parser shared across the
|
|
305
|
-
//
|
|
306
|
-
|
|
313
|
+
// one lexer/parser shared across the whole table rather than allocated per
|
|
314
|
+
// row (or per cell) — rebound per row because the buffer string changes
|
|
315
|
+
// across feeds; the lexer's search memo survives when it doesn't
|
|
316
|
+
const cell_parser = state.table!.cell_parser;
|
|
317
|
+
cell_parser.rebind(state.buffer);
|
|
307
318
|
for (const cell of cells) {
|
|
308
319
|
emit_table_cell(state, cell_parser, cell.start, cell.end);
|
|
309
320
|
}
|
|
@@ -5,21 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
ASTERISK,
|
|
9
|
-
BACKTICK,
|
|
10
8
|
HTTPS_PREFIX_LENGTH,
|
|
11
|
-
H_LOWER,
|
|
12
|
-
H_UPPER,
|
|
13
|
-
LEFT_ANGLE,
|
|
14
|
-
LEFT_BRACKET,
|
|
15
9
|
NEWLINE,
|
|
16
|
-
PERIOD,
|
|
17
|
-
RIGHT_BRACKET,
|
|
18
|
-
SLASH,
|
|
19
10
|
SPACE,
|
|
20
11
|
TAB,
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
TEXT_CLASS_NEWLINE,
|
|
13
|
+
TEXT_CLASS_PATH,
|
|
14
|
+
TEXT_CLASS_STOP,
|
|
15
|
+
TEXT_CLASS_URL,
|
|
16
|
+
TEXT_SCAN_CLASS,
|
|
23
17
|
is_word_char,
|
|
24
18
|
match_url_prefix_case_insensitive,
|
|
25
19
|
} from './mdz_helpers.ts';
|
|
@@ -29,6 +23,7 @@ import {
|
|
|
29
23
|
ensure_paragraph,
|
|
30
24
|
offset,
|
|
31
25
|
} from './mdz_stream_parser_state.ts';
|
|
26
|
+
import {mdz_debug_work} from './mdz_debug_work.ts';
|
|
32
27
|
|
|
33
28
|
/**
|
|
34
29
|
* Consume a delimiter at the current position as literal text — the shared
|
|
@@ -60,35 +55,41 @@ export const consume_text_run = (state: MdzStreamParserState): void => {
|
|
|
60
55
|
state.pos++;
|
|
61
56
|
while (state.pos < state.buffer.length) {
|
|
62
57
|
const c = state.buffer.charCodeAt(state.pos);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// URL/path: only break when actually at a URL or path start.
|
|
73
|
-
// Most h/H/./slash in prose are not URLs/paths — continuing the scan
|
|
74
|
-
// avoids the full dispatch cycle through process_loop → process_inline.
|
|
75
|
-
// For `h`/`H` at a word boundary, also break when there isn't enough
|
|
76
|
-
// buffer left to confirm `https://` / `http://` — process_inline's
|
|
77
|
-
// speculator then carries the prefix match across chunk boundaries.
|
|
78
|
-
// Scheme matching is case-insensitive (RFC 3986).
|
|
79
|
-
((c === H_LOWER || c === H_UPPER) &&
|
|
80
|
-
(match_url_prefix_case_insensitive(state.buffer, state.pos) > 0 ||
|
|
81
|
-
(state.buffer.length - state.pos < HTTPS_PREFIX_LENGTH &&
|
|
82
|
-
!is_word_char(state.buffer.charCodeAt(state.pos - 1))))) ||
|
|
83
|
-
((c === SLASH || c === PERIOD) &&
|
|
84
|
-
(state.buffer.charCodeAt(state.pos - 1) === SPACE ||
|
|
85
|
-
state.buffer.charCodeAt(state.pos - 1) === NEWLINE ||
|
|
86
|
-
state.buffer.charCodeAt(state.pos - 1) === TAB))
|
|
87
|
-
) {
|
|
88
|
-
break;
|
|
58
|
+
// one table load classifies the char; zero (the common case) continues
|
|
59
|
+
// the run without any further checks. `TEXT_SCAN_CLASS` is shared with
|
|
60
|
+
// the sync lexer's `#tokenize_text` (`mdz_lexer.ts`); the dispatch bodies
|
|
61
|
+
// intentionally differ — that one handles `\|` escapes and list-run
|
|
62
|
+
// strip, this one URL speculation across chunk boundaries.
|
|
63
|
+
const char_class = c < 128 ? TEXT_SCAN_CLASS[c]! : 0;
|
|
64
|
+
if (char_class === 0) {
|
|
65
|
+
state.pos++;
|
|
66
|
+
continue;
|
|
89
67
|
}
|
|
68
|
+
if ((char_class & (TEXT_CLASS_STOP | TEXT_CLASS_NEWLINE)) !== 0) break;
|
|
69
|
+
if ((char_class & TEXT_CLASS_URL) !== 0) {
|
|
70
|
+
// URL: only break when actually at a URL start. Most h/H in prose are
|
|
71
|
+
// not URLs — continuing the scan avoids the full dispatch cycle
|
|
72
|
+
// through process_loop → process_inline. At a word boundary, also
|
|
73
|
+
// break when there isn't enough buffer left to confirm `https://` /
|
|
74
|
+
// `http://` — process_inline's speculator then carries the prefix
|
|
75
|
+
// match across chunk boundaries. Scheme matching is case-insensitive
|
|
76
|
+
// (RFC 3986).
|
|
77
|
+
if (
|
|
78
|
+
match_url_prefix_case_insensitive(state.buffer, state.pos) > 0 ||
|
|
79
|
+
(state.buffer.length - state.pos < HTTPS_PREFIX_LENGTH &&
|
|
80
|
+
!is_word_char(state.buffer.charCodeAt(state.pos - 1)))
|
|
81
|
+
) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
} else if ((char_class & TEXT_CLASS_PATH) !== 0) {
|
|
85
|
+
// path (`/` or `.`): only break at a plausible path start
|
|
86
|
+
const prev = state.buffer.charCodeAt(state.pos - 1);
|
|
87
|
+
if (prev === SPACE || prev === NEWLINE || prev === TAB) break;
|
|
88
|
+
}
|
|
89
|
+
// backslash (or a non-starting path char) — ordinary text
|
|
90
90
|
state.pos++;
|
|
91
91
|
}
|
|
92
|
+
if (mdz_debug_work.enabled) mdz_debug_work.total += state.pos - start;
|
|
92
93
|
accumulate_text(state, state.buffer.slice(start, state.pos), offset(state, start));
|
|
93
94
|
state.prev_char = state.buffer.charCodeAt(state.pos - 1);
|
|
94
95
|
state.column += state.pos - start;
|
package/src/lib/mdz_to_svelte.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {escape_svelte_text} from '@fuzdev/fuz_util/svelte_preprocess_helpers.ts'
|
|
|
13
13
|
import {escape_js_string} from '@fuzdev/fuz_util/string.ts';
|
|
14
14
|
|
|
15
15
|
import type {MdzNode, MdzNodeTableRow} from './mdz.ts';
|
|
16
|
-
import {
|
|
16
|
+
import {mdz_is_void_element, mdz_classify_link} from './mdz_helpers.ts';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Result of converting `MdzNode` arrays to Svelte markup.
|
|
@@ -131,26 +131,21 @@ export const mdz_to_svelte = (
|
|
|
131
131
|
|
|
132
132
|
case 'Link': {
|
|
133
133
|
const children_markup = render_nodes(node.children);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
node.reference.startsWith('?') ||
|
|
143
|
-
node.reference.startsWith('.') ||
|
|
144
|
-
// Bare references (e.g. `foo`) aren't absolute — resolve() throws on them
|
|
145
|
-
!node.reference.startsWith('/')
|
|
146
|
-
) {
|
|
147
|
-
return `<a href={'${escape_js_string(node.reference)}'}>${children_markup}</a>`;
|
|
148
|
-
}
|
|
134
|
+
// safety gate + resolve-vs-raw classification shared with the runtime
|
|
135
|
+
// views via `mdz_classify_link`, so the three renderers can't drift
|
|
136
|
+
// (an unsafe `javascript:`/`data:` ref renders as plain children, no
|
|
137
|
+
// `<a>` — defense in depth, since `mdz_to_svelte` is a public entry
|
|
138
|
+
// callable with hand-built trees that never went through `mdz_parse`)
|
|
139
|
+
const link = mdz_classify_link(node.reference, node.link_type, base);
|
|
140
|
+
if (link.kind === 'unsafe') return children_markup;
|
|
141
|
+
if (link.kind === 'resolve') {
|
|
149
142
|
imports.set('resolve', {path: '$app/paths', kind: 'named'});
|
|
150
|
-
return `<a href={resolve('${escape_js_string(
|
|
143
|
+
return `<a href={resolve('${escape_js_string(link.href)}')}>${children_markup}</a>`;
|
|
144
|
+
}
|
|
145
|
+
if (link.kind === 'external') {
|
|
146
|
+
return `<a href={'${escape_js_string(link.href)}'} target="_blank" rel="noopener">${children_markup}</a>`;
|
|
151
147
|
}
|
|
152
|
-
|
|
153
|
-
return `<a href={'${escape_js_string(node.reference)}'} target="_blank" rel="noopener">${children_markup}</a>`;
|
|
148
|
+
return `<a href={'${escape_js_string(link.href)}'}>${children_markup}</a>`;
|
|
154
149
|
}
|
|
155
150
|
|
|
156
151
|
case 'Paragraph':
|
|
@@ -184,19 +179,16 @@ export const mdz_to_svelte = (
|
|
|
184
179
|
}
|
|
185
180
|
return `<tr>${cells}</tr>`;
|
|
186
181
|
};
|
|
182
|
+
// by grammar the header row is always first (and the only one) —
|
|
183
|
+
// read it by index and skip it in the body, mirroring the node
|
|
184
|
+
// views (a `.filter()` pair re-partitions every row; see
|
|
185
|
+
// `MdzNodeView.svelte`'s "Table rendering de-quadratified" note)
|
|
187
186
|
let out = '<table>';
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
out += '</thead>';
|
|
194
|
-
}
|
|
195
|
-
if (body_rows.length > 0) {
|
|
196
|
-
out += '<tbody>';
|
|
197
|
-
for (const row of body_rows) out += render_row(row, 'td');
|
|
198
|
-
out += '</tbody>';
|
|
199
|
-
}
|
|
187
|
+
const header_row = node.children[0]?.header ? node.children[0] : undefined;
|
|
188
|
+
if (header_row) out += '<thead>' + render_row(header_row, 'th') + '</thead>';
|
|
189
|
+
let body = '';
|
|
190
|
+
for (const row of node.children) if (!row.header) body += render_row(row, 'td');
|
|
191
|
+
if (body) out += '<tbody>' + body + '</tbody>';
|
|
200
192
|
return out + '</table>';
|
|
201
193
|
}
|
|
202
194
|
|
|
@@ -46,6 +46,21 @@ import {
|
|
|
46
46
|
type MdzTokenTableCellOpen,
|
|
47
47
|
} from './mdz_lexer.ts';
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Whether a token type ends the current inline context — the block-boundary
|
|
51
|
+
* set shared by the delimiter-pair and link children loops (an unclosed
|
|
52
|
+
* inline never swallows block structure).
|
|
53
|
+
*/
|
|
54
|
+
const token_is_block_boundary = (type: MdzToken['type']): boolean =>
|
|
55
|
+
type === 'paragraph_break' ||
|
|
56
|
+
type === 'heading_start' ||
|
|
57
|
+
type === 'hr' ||
|
|
58
|
+
type === 'codeblock' ||
|
|
59
|
+
type === 'list_open' ||
|
|
60
|
+
type === 'list_item_close' ||
|
|
61
|
+
type === 'blockquote_open' ||
|
|
62
|
+
type === 'table_open';
|
|
63
|
+
|
|
49
64
|
/**
|
|
50
65
|
* Builds an `MdzNode[]` tree from a lexed `MdzToken[]` stream.
|
|
51
66
|
* Used by `mdz_parse`, which should be preferred for simple usage.
|
|
@@ -490,16 +505,7 @@ export class MdzTokenParser {
|
|
|
490
505
|
this.#index++;
|
|
491
506
|
return {type: node_type, children, start, end: t.end};
|
|
492
507
|
}
|
|
493
|
-
if (
|
|
494
|
-
t.type === 'paragraph_break' ||
|
|
495
|
-
t.type === 'heading_start' ||
|
|
496
|
-
t.type === 'hr' ||
|
|
497
|
-
t.type === 'codeblock' ||
|
|
498
|
-
t.type === 'list_open' ||
|
|
499
|
-
t.type === 'list_item_close' ||
|
|
500
|
-
t.type === 'blockquote_open' ||
|
|
501
|
-
t.type === 'table_open'
|
|
502
|
-
) {
|
|
508
|
+
if (token_is_block_boundary(t.type)) {
|
|
503
509
|
break;
|
|
504
510
|
}
|
|
505
511
|
const node = this.#parse_inline();
|
|
@@ -539,16 +545,7 @@ export class MdzTokenParser {
|
|
|
539
545
|
// No link_ref - treat as text
|
|
540
546
|
return {type: 'Text', content: '[', start, end: start + 1};
|
|
541
547
|
}
|
|
542
|
-
if (
|
|
543
|
-
t.type === 'paragraph_break' ||
|
|
544
|
-
t.type === 'heading_start' ||
|
|
545
|
-
t.type === 'hr' ||
|
|
546
|
-
t.type === 'codeblock' ||
|
|
547
|
-
t.type === 'list_open' ||
|
|
548
|
-
t.type === 'list_item_close' ||
|
|
549
|
-
t.type === 'blockquote_open' ||
|
|
550
|
-
t.type === 'table_open'
|
|
551
|
-
) {
|
|
548
|
+
if (token_is_block_boundary(t.type)) {
|
|
552
549
|
break;
|
|
553
550
|
}
|
|
554
551
|
const node = this.#parse_inline();
|