@vectojs/markdown 0.7.0 → 0.9.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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Incremental block lexing for a growing (append-only) Markdown source.
3
+ *
4
+ * ## The problem this solves
5
+ *
6
+ * `marked` has no incremental lexing API, so the obvious streaming strategy is
7
+ * to re-lex the whole accumulated document on every chunk. That is O(n²) over a
8
+ * stream, and measured in `comparisons/stream-markdown-smd` it dominates
9
+ * everything else: a 25 070-char document delivered in 784 chunks cost 434 ms in
10
+ * Chrome 150 while lexing the finished document **once** cost 0.975 ms. The
11
+ * parser is linear; the strategy was quadratic.
12
+ *
13
+ * This module keeps a **stable block boundary** — a character offset before
14
+ * which the token list can no longer change — and re-lexes only the text after
15
+ * it, splicing the result onto the already-stable token prefix. The cost per
16
+ * chunk becomes O(unstable tail) instead of O(document).
17
+ *
18
+ * ## The correctness contract
19
+ *
20
+ * {@link lexFull} and {@link lexAppend} must return a token list **deeply
21
+ * identical** to `marked.lexer(source)` for the same source. Speed is
22
+ * secondary: a boundary chosen one line too early silently corrupts the token
23
+ * stream, which is a far worse failure than being slow. `incrementalLex.test.ts`
24
+ * enforces this by streaming a corpus one character at a time and comparing
25
+ * against a full lex at **every** intermediate length.
26
+ *
27
+ * ## Why the boundary rule is what it is
28
+ *
29
+ * The rule: cut immediately after a `space` token that has **at least one token
30
+ * following it**, and never when a link reference definition exists.
31
+ *
32
+ * Three properties of `marked`'s block lexer (18.0.7) make that safe, and each
33
+ * was measured exhaustively rather than reasoned about:
34
+ *
35
+ * 1. **A pushed `space` token always means a blank line.** A lone `\n` is merged
36
+ * into the preceding token's `raw` instead of being pushed, so a `space`
37
+ * token in the list is a real block separator — never a single line ending.
38
+ * 2. **For every built-in rule, only the token adjacent to the end of the source
39
+ * can still change.** With a token following the `space`, the construct
40
+ * before that `space` is committed. This is what rules out the interesting
41
+ * failures: an indented code block or a loose list *can* absorb a blank line
42
+ * and keep going, and a `paragraph` can still acquire a setext underline —
43
+ * but only while it is the last thing in the source. A brute-force sweep over
44
+ * 14 documents × every prefix length × every cut index found the
45
+ * `nFollow >= 1` form safe for every predecessor type (`blockquote`, `code`,
46
+ * `heading`, `hr`, `html`, `list`, `paragraph`, `table`) and the
47
+ * `nFollow == 0` form unsafe for `code`, `list` and `paragraph` — hence the
48
+ * one-token lag.
49
+ *
50
+ * Two things break that property, and both were found by fuzzing rather than
51
+ * by reading the rules:
52
+ *
53
+ * **A `list` reaches past a blank line to absorb a following list item.**
54
+ * Measured: `'1. ordered\n2. second\n\n\n1.'` is a *single* `list` token with
55
+ * `loose: true`, where a splice at the blank line yields
56
+ * `[list(loose:false), space, list]`. Three tokens against one, and the
57
+ * `loose` flag differs, which changes rendering. So a cut whose next token is
58
+ * a `list` is only taken once a *further* token exists after it — at which
59
+ * point the list can no longer grow. This is {@link cutIsSettled}.
60
+ *
61
+ * **Our own `blockMath` extension breaks locality in both directions**, which
62
+ * is why {@link hasBlockMathOpener} degrades outright:
63
+ * - *Forward*: the tokenizer is `/^ {0,3}\$\$([\s\S]+?)\$\$[ \t]*(?:\n|$)/`
64
+ * and `[\s\S]+?` crosses blank lines, so an unterminated `$$` reaches
65
+ * arbitrarily far ahead. Measured: `'$$\nopen\n\npara\n'` lexes to
66
+ * `[paragraph, space, paragraph]`, and appending `'\n$$\n'` collapses all
67
+ * three into one `blockMath` token.
68
+ * - *Backward*: `blockTokens` clips the text handed to the paragraph
69
+ * tokenizer whenever an extension's `startBlock` hook reports a position,
70
+ * and sets a flag that merges the **next** paragraph into the clipped one.
71
+ * Since `blockMath` supplies `start()`, a `$$` anywhere ahead retroactively
72
+ * re-groups paragraphs already emitted. Measured on
73
+ * `'Term\n: definition-ish\n| partial | table |\n| --- |\n\nAfter.\n'`
74
+ * plus a trailing `'\n$$\nx\n'`: without the extension it is
75
+ * `paragraph, paragraph`; with it registered the two become **one** merged
76
+ * paragraph. Capping the boundary cannot fix a backwards reach, so any
77
+ * line-start `$$` degrades the instance instead. That is only correct
78
+ * because `start()` returns `undefined` when no line-start `$$` exists, so
79
+ * with none present the clip never fires and the merge is impossible.
80
+ *
81
+ * Any future block-level extension that supplies `start()` or whose tokenizer
82
+ * can span a blank line needs the same treatment.
83
+ * 3. **Link reference definitions break prefix reuse entirely.** `marked`
84
+ * collects every `def` while block-lexing and only then resolves reflinks
85
+ * across the *whole* document, so a definition arriving late retroactively
86
+ * changes inline tokens that are already emitted, and one inside the stable
87
+ * prefix is invisible to a suffix lex. Both directions are unfixable by
88
+ * boundary placement, so an instance that sees any definition degrades to
89
+ * full lexing permanently. The sweep confirms it: `def` was the one
90
+ * predecessor type unsafe at `nFollow >= 1` (21/21).
91
+ *
92
+ * Degrading is always available and always correct, so a carriage return takes
93
+ * it: `marked` normalises CR internally, which desyncs every raw-length offset
94
+ * from the source those offsets are supposed to index.
95
+ *
96
+ * ## Why the boundary is verified rather than trusted
97
+ *
98
+ * Offsets are derived by summing `raw` lengths, which assumes `raw` strings tile
99
+ * their source. They usually do, but not always: measured against marked 18.0.7,
100
+ * a source ending in a bare list marker (`"- a\n- "`) lexes to raw `"- a\n-\n"`,
101
+ * because the list tokenizer trims the final item and re-adds a newline. So
102
+ * every advance is **verified** — the text being declared stable must equal the
103
+ * concatenated `raw` of the tokens covering it — and an advance that fails
104
+ * verification is simply not taken. That case is transient (the next chunk
105
+ * completes the item and it tiles again), so declining costs one chunk of window
106
+ * growth, where degrading would have cost the whole rest of the stream.
107
+ */
108
+ import { type TokensList } from 'marked';
109
+ /**
110
+ * Everything needed to extend a lex without redoing it.
111
+ *
112
+ * `source` and `tail` are both carried because they serve different masters:
113
+ * `source` is what a full lex needs if this instance ever degrades (and what the
114
+ * caller reconciles its own length check against), while `tail` is the unstable
115
+ * suffix that is actually re-lexed each chunk. Keeping `tail` separately is what
116
+ * makes the per-chunk string work O(tail) — deriving it as
117
+ * `source.slice(stableOffset)` each time would force the engine to flatten the
118
+ * concatenation rope, putting an O(document) memcpy back into the hot path.
119
+ */
120
+ export interface IncrementalLexCache {
121
+ /** Full accumulated source these tokens describe. */
122
+ readonly source: string;
123
+ /** `source.slice(stableOffset)` — the part still subject to change. */
124
+ readonly tail: string;
125
+ /** Complete token list for `source`. */
126
+ readonly tokens: TokensList;
127
+ /** Number of leading tokens that can no longer change. */
128
+ readonly stableCount: number;
129
+ /** Character offset in `source` at which the stable prefix ends. */
130
+ readonly stableOffset: number;
131
+ /** Once set, this instance always full-lexes. Never clears. */
132
+ readonly degraded: boolean;
133
+ /** Why it degraded, for tests and diagnostics. `null` while incremental. */
134
+ readonly degradedReason: DegradeReason | null;
135
+ }
136
+ /** Why an instance gave up on incremental lexing. */
137
+ export type DegradeReason =
138
+ /** A link reference definition exists; see the module comment. */
139
+ 'link-definition'
140
+ /** A carriage return desyncs `raw`-length offsets from source offsets. */
141
+ | 'carriage-return'
142
+ /** A line-start `$$` lets `blockMath` reach outside its own token. */
143
+ | 'block-math';
144
+ export interface IncrementalLexResult {
145
+ /** Deeply identical to `marked.lexer(source)`. */
146
+ readonly tokens: TokensList;
147
+ readonly cache: IncrementalLexCache;
148
+ /**
149
+ * Characters actually handed to `marked.lexer()`. Equal to `source.length` for
150
+ * a full lex and to the unstable tail otherwise — so the ratio against the
151
+ * document length is the direct measure of what the boundary saved.
152
+ */
153
+ readonly charsLexed: number;
154
+ /** Leading tokens taken from the cache rather than re-lexed. */
155
+ readonly reusedTokens: number;
156
+ }
157
+ /**
158
+ * Lex `source` from scratch and prepare to extend it incrementally.
159
+ *
160
+ * Used for the first request for an instance, and for anything that is not an
161
+ * append: a `setContent()`, or a resync.
162
+ */
163
+ export declare function lexFull(source: string): IncrementalLexResult;
164
+ /**
165
+ * Extend a previous lex with appended text, re-lexing only the unstable tail.
166
+ *
167
+ * The caller must guarantee `append` extends exactly `prev.source`. This does not
168
+ * verify that, because the verification would be an O(document) comparison per
169
+ * chunk and would defeat the purpose; the worker enforces it structurally by
170
+ * owning the cache and by checking the caller's `expectedLength` first.
171
+ */
172
+ export declare function lexAppend(prev: IncrementalLexCache, append: string): IncrementalLexResult;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export * from './Markdown';
2
+ export { parseFrontMatterFields, scanFrontMatter } from './frontMatter';
3
+ export type { FrontMatterScan } from './frontMatter';
2
4
  export type { IncompleteMarkdownMode, StreamController, StreamControllerOptions, StreamControllerState, StreamPacingOptions, } from './StreamController';