svelte-streamdown 4.1.1 → 4.2.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/README.md +146 -47
- package/dist/Block.svelte +9 -2
- package/dist/Block.svelte.d.ts +2 -0
- package/dist/Elements/Code.svelte +9 -1
- package/dist/Elements/Element.svelte +20 -5
- package/dist/Elements/Mermaid.svelte +1 -9
- package/dist/Elements/TableDownload.svelte +44 -3
- package/dist/Elements/TableDownload.svelte.d.ts +3 -1
- package/dist/Elements/fallbacks/CodeFallback.svelte +11 -3
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +2 -2
- package/dist/Elements/icons.d.ts +1 -0
- package/dist/Elements/icons.js +8 -0
- package/dist/Streamdown.svelte +58 -3
- package/dist/context.svelte.d.ts +32 -1
- package/dist/context.svelte.js +3 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/marked/index.d.ts +5 -4
- package/dist/marked/index.js +33 -44
- package/dist/marked/marked-mdx.d.ts +27 -1
- package/dist/marked/marked-mdx.js +37 -8
- package/dist/theme.d.ts +6 -0
- package/dist/theme.js +12 -4
- package/dist/utils/expand.svelte.d.ts +21 -0
- package/dist/utils/expand.svelte.js +46 -0
- package/dist/utils/line-numbers.d.ts +23 -0
- package/dist/utils/line-numbers.js +30 -0
- package/dist/utils/normalize-html-indentation.d.ts +10 -0
- package/dist/utils/normalize-html-indentation.js +52 -0
- package/dist/utils/parse-incomplete-markdown.d.ts +20 -6
- package/dist/utils/parse-incomplete-markdown.js +167 -65
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useKeyDown } from './useKeyDown.svelte.js';
|
|
2
|
+
/**
|
|
3
|
+
* The fullscreen half of the mermaid expand behaviour (Mermaid.svelte:264-267)
|
|
4
|
+
* without panzoom's `expand()`, which is entangled with `zoomToFit` and a FLIP
|
|
5
|
+
* animation a table has no use for: state, Escape and focus only. The
|
|
6
|
+
* `position: fixed` comes from the global `[data-expanded='true']` rule in
|
|
7
|
+
* Streamdown.svelte.
|
|
8
|
+
*
|
|
9
|
+
* The flag lives in the caller (so the markup can drive `role`/`aria-*`/the
|
|
10
|
+
* expanded class from it); this only drives it.
|
|
11
|
+
*
|
|
12
|
+
* // ponytail: no open/close animation. Upgrade path: measure the target rect
|
|
13
|
+
* // either side of `set()` and run the same first/last invert panzoom does,
|
|
14
|
+
* // once that block is untangled from the zoom state it reads today.
|
|
15
|
+
*/
|
|
16
|
+
export const useExpand = (opts) => {
|
|
17
|
+
// The control that opened the overlay, so focus can return to it on close.
|
|
18
|
+
let trigger = null;
|
|
19
|
+
const set = (next) => {
|
|
20
|
+
const target = opts.getTarget();
|
|
21
|
+
opts.expanded = next;
|
|
22
|
+
if (target)
|
|
23
|
+
target.dataset.expanded = String(next);
|
|
24
|
+
if (next)
|
|
25
|
+
target?.focus();
|
|
26
|
+
else
|
|
27
|
+
trigger?.focus();
|
|
28
|
+
};
|
|
29
|
+
useKeyDown({
|
|
30
|
+
keys: ['Escape'],
|
|
31
|
+
get isActive() {
|
|
32
|
+
return opts.expanded;
|
|
33
|
+
},
|
|
34
|
+
callback: () => set(false)
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
get expanded() {
|
|
38
|
+
return opts.expanded;
|
|
39
|
+
},
|
|
40
|
+
toggle: (from) => {
|
|
41
|
+
if (!opts.expanded)
|
|
42
|
+
trigger = from ?? null;
|
|
43
|
+
set(!opts.expanded);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-number options carried by a fence's info string. `token.lang` is the first
|
|
3
|
+
* word and `token.meta` the rest since 4.0.1, so this only ever sees the rest —
|
|
4
|
+
* `startLine=10 noLineNumbers` out of a ```ts fence.
|
|
5
|
+
*
|
|
6
|
+
* `lineNumbers` / `noLineNumbers` are upstream streamdown's per-block escape
|
|
7
|
+
* hatches; ours flip the `lineNumbers` prop either way because our prop defaults
|
|
8
|
+
* to false (upstream defaults to on).
|
|
9
|
+
*
|
|
10
|
+
* The rendering side is CSS counters on the per-line span that is already there
|
|
11
|
+
* (`counter-increment` + a `::before` printing `counter(sd-line)`, in the global
|
|
12
|
+
* block of Streamdown.svelte; `counter-reset` inline on the <pre> so `startLine`
|
|
13
|
+
* can move it): no element per line, and a pseudo-element's content is never part
|
|
14
|
+
* of a selection, so copy/download — which read the token text anyway — can never
|
|
15
|
+
* pick numbers up. The counter rule cannot live in the theme like the gutter's
|
|
16
|
+
* width and colour (`theme.code.lineNumber`), because a Tailwind
|
|
17
|
+
* `before:content-[counter(sd-line)]` class only exists if the consumer's
|
|
18
|
+
* Tailwind scanned this package.
|
|
19
|
+
*/
|
|
20
|
+
export declare const resolveLineNumbers: (meta: string | undefined, enabled: boolean) => {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
start: number;
|
|
23
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-number options carried by a fence's info string. `token.lang` is the first
|
|
3
|
+
* word and `token.meta` the rest since 4.0.1, so this only ever sees the rest —
|
|
4
|
+
* `startLine=10 noLineNumbers` out of a ```ts fence.
|
|
5
|
+
*
|
|
6
|
+
* `lineNumbers` / `noLineNumbers` are upstream streamdown's per-block escape
|
|
7
|
+
* hatches; ours flip the `lineNumbers` prop either way because our prop defaults
|
|
8
|
+
* to false (upstream defaults to on).
|
|
9
|
+
*
|
|
10
|
+
* The rendering side is CSS counters on the per-line span that is already there
|
|
11
|
+
* (`counter-increment` + a `::before` printing `counter(sd-line)`, in the global
|
|
12
|
+
* block of Streamdown.svelte; `counter-reset` inline on the <pre> so `startLine`
|
|
13
|
+
* can move it): no element per line, and a pseudo-element's content is never part
|
|
14
|
+
* of a selection, so copy/download — which read the token text anyway — can never
|
|
15
|
+
* pick numbers up. The counter rule cannot live in the theme like the gutter's
|
|
16
|
+
* width and colour (`theme.code.lineNumber`), because a Tailwind
|
|
17
|
+
* `before:content-[counter(sd-line)]` class only exists if the consumer's
|
|
18
|
+
* Tailwind scanned this package.
|
|
19
|
+
*/
|
|
20
|
+
export const resolveLineNumbers = (meta, enabled) => {
|
|
21
|
+
// The tests are case-sensitive and `noLineNumbers` spells the word with a
|
|
22
|
+
// capital L, so the positive test can never match it; checked first anyway.
|
|
23
|
+
const on = meta
|
|
24
|
+
? /\bnoLineNumbers\b/.test(meta)
|
|
25
|
+
? false
|
|
26
|
+
: /\blineNumbers\b/.test(meta) || enabled
|
|
27
|
+
: enabled;
|
|
28
|
+
const start = meta?.match(/\bstartLine=(\d+)\b/);
|
|
29
|
+
return { enabled: on, start: start ? Number(start[1]) : 1 };
|
|
30
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Four leading spaces after a blank line is an indented code block, HTML or not.
|
|
3
|
+
* A pretty-printed document therefore splits into html / code / html and the
|
|
4
|
+
* indented part renders as source in a code box (upstream 7f9127b).
|
|
5
|
+
*
|
|
6
|
+
* This strips the indentation off tag lines only. It is lossy inside `<pre>` and
|
|
7
|
+
* `<code>`, where whitespace is content, so those bodies are skipped and the
|
|
8
|
+
* whole thing is behind the opt-in `normalizeHtmlIndentation` prop.
|
|
9
|
+
*/
|
|
10
|
+
export declare const normalizeHtmlIndentation: (content: string) => string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Four leading spaces after a blank line is an indented code block, HTML or not.
|
|
3
|
+
* A pretty-printed document therefore splits into html / code / html and the
|
|
4
|
+
* indented part renders as source in a code box (upstream 7f9127b).
|
|
5
|
+
*
|
|
6
|
+
* This strips the indentation off tag lines only. It is lossy inside `<pre>` and
|
|
7
|
+
* `<code>`, where whitespace is content, so those bodies are skipped and the
|
|
8
|
+
* whole thing is behind the opt-in `normalizeHtmlIndentation` prop.
|
|
9
|
+
*/
|
|
10
|
+
// The indentation of a line whose first non-space character opens a tag, a
|
|
11
|
+
// closing tag, or a comment/doctype. Anchored, and the tag itself is a lookahead
|
|
12
|
+
// so the match is exactly the whitespace to drop.
|
|
13
|
+
const INDENTED_TAG = /^[ \t]+(?=<[a-zA-Z!/])/;
|
|
14
|
+
// Inside these, indentation is content. `<pre class=…>` and `<code>` both match.
|
|
15
|
+
const LITERAL_OPEN = /<(?:pre|code)[\s/>]/gi;
|
|
16
|
+
const LITERAL_CLOSE = /<\/(?:pre|code)>/gi;
|
|
17
|
+
const count = (line, re) => {
|
|
18
|
+
re.lastIndex = 0;
|
|
19
|
+
let n = 0;
|
|
20
|
+
while (re.exec(line))
|
|
21
|
+
n++;
|
|
22
|
+
return n;
|
|
23
|
+
};
|
|
24
|
+
export const normalizeHtmlIndentation = (content) => {
|
|
25
|
+
// Only documents that start with a tag; anything else is Markdown whose
|
|
26
|
+
// indentation we have no business touching. Returned by identity so a Svelte
|
|
27
|
+
// `$derived` over it does not churn.
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (i < content.length && (content[i] === ' ' || content[i] === '\n' || content[i] === '\t'))
|
|
30
|
+
i++;
|
|
31
|
+
if (content[i] !== '<')
|
|
32
|
+
return content;
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
let literalDepth = 0;
|
|
35
|
+
let changed = false;
|
|
36
|
+
for (let l = 0; l < lines.length; l++) {
|
|
37
|
+
const line = lines[l];
|
|
38
|
+
if (literalDepth === 0) {
|
|
39
|
+
const match = INDENTED_TAG.exec(line);
|
|
40
|
+
if (match) {
|
|
41
|
+
lines[l] = line.slice(match[0].length);
|
|
42
|
+
changed = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (line.indexOf('<') !== -1) {
|
|
46
|
+
literalDepth += count(line, LITERAL_OPEN) - count(line, LITERAL_CLOSE);
|
|
47
|
+
if (literalDepth < 0)
|
|
48
|
+
literalDepth = 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return changed ? lines.join('\n') : content;
|
|
52
|
+
};
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { type OpenFence } from './fence.js';
|
|
2
|
+
import { type TagMatchers } from '../marked/marked-mdx.js';
|
|
3
|
+
/** Per-call knobs `Block.svelte` threads down from the Streamdown context. */
|
|
4
|
+
export type CompleterOptions = {
|
|
5
|
+
/** The tag allowlist the lexer uses, so the completer never disagrees with it. */
|
|
6
|
+
tags?: TagMatchers;
|
|
7
|
+
/**
|
|
8
|
+
* Is this the block still being streamed into? Only there may a half-typed
|
|
9
|
+
* HTML tag be stripped — a sealed paragraph reading `if a <b then` must keep
|
|
10
|
+
* its tail forever (upstream 3e6a77d strips unconditionally; we do not).
|
|
11
|
+
*/
|
|
12
|
+
live?: boolean;
|
|
13
|
+
};
|
|
2
14
|
export interface Plugin {
|
|
3
15
|
name: string;
|
|
4
16
|
pattern?: RegExp;
|
|
@@ -24,6 +36,8 @@ interface HandlerPayload {
|
|
|
24
36
|
}
|
|
25
37
|
interface ParseState {
|
|
26
38
|
currentLine: number;
|
|
39
|
+
/** How many lines the text has, so a plugin can tell the last one. */
|
|
40
|
+
lineCount: number;
|
|
27
41
|
context: 'normal' | 'list' | 'blockquote' | 'descriptionList';
|
|
28
42
|
blockingContexts: Set<'code' | 'math' | 'center' | 'right'>;
|
|
29
43
|
/** Delimiter that closes the open math block: '$$' or '\]'. */
|
|
@@ -40,18 +54,18 @@ interface ParseState {
|
|
|
40
54
|
tagName: string;
|
|
41
55
|
lineIndex: number;
|
|
42
56
|
}>;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
/** Tag allowlist for this call (see CompleterOptions). */
|
|
58
|
+
tags: TagMatchers;
|
|
59
|
+
/** Is this the block still streaming (see CompleterOptions)? */
|
|
60
|
+
live: boolean;
|
|
47
61
|
}
|
|
48
62
|
export declare class IncompleteMarkdownParser {
|
|
49
63
|
private plugins;
|
|
50
64
|
private state;
|
|
51
65
|
setState: (state: Partial<ParseState>) => void;
|
|
52
66
|
constructor(plugins?: Plugin[]);
|
|
53
|
-
parse(text: string): string;
|
|
67
|
+
parse(text: string, options?: CompleterOptions): string;
|
|
54
68
|
static createDefaultPlugins(): Plugin[];
|
|
55
69
|
}
|
|
56
|
-
export declare const parseIncompleteMarkdown: (text: string) => string;
|
|
70
|
+
export declare const parseIncompleteMarkdown: (text: string, options?: CompleterOptions) => string;
|
|
57
71
|
export {};
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { closingFence, trackFence } from './fence.js';
|
|
2
|
+
import { DEFAULT_TAGS } from '../marked/marked-mdx.js';
|
|
2
3
|
export class IncompleteMarkdownParser {
|
|
3
4
|
plugins = [];
|
|
4
5
|
state = {
|
|
5
6
|
currentLine: 0,
|
|
7
|
+
lineCount: 0,
|
|
6
8
|
context: 'normal',
|
|
7
9
|
blockingContexts: new Set(),
|
|
8
|
-
lineContexts: []
|
|
10
|
+
lineContexts: [],
|
|
11
|
+
tags: DEFAULT_TAGS,
|
|
12
|
+
live: true
|
|
9
13
|
};
|
|
10
14
|
setState = (state) => {
|
|
11
15
|
this.state = { ...this.state, ...state };
|
|
@@ -14,15 +18,18 @@ export class IncompleteMarkdownParser {
|
|
|
14
18
|
this.plugins = plugins;
|
|
15
19
|
}
|
|
16
20
|
// Main parsing methods
|
|
17
|
-
parse(text) {
|
|
21
|
+
parse(text, options) {
|
|
18
22
|
if (!text || typeof text !== 'string') {
|
|
19
23
|
return text;
|
|
20
24
|
}
|
|
21
25
|
this.state = {
|
|
22
26
|
currentLine: 0,
|
|
27
|
+
lineCount: 0,
|
|
23
28
|
context: 'normal',
|
|
24
29
|
blockingContexts: new Set(),
|
|
25
|
-
lineContexts: []
|
|
30
|
+
lineContexts: [],
|
|
31
|
+
tags: options?.tags ?? DEFAULT_TAGS,
|
|
32
|
+
live: options?.live ?? true
|
|
26
33
|
};
|
|
27
34
|
let result = text;
|
|
28
35
|
// Execute preprocess hooks for all plugins
|
|
@@ -50,6 +57,7 @@ export class IncompleteMarkdownParser {
|
|
|
50
57
|
// Split into lines for processing
|
|
51
58
|
const lines = result.split('\n');
|
|
52
59
|
const processedLines = [...lines];
|
|
60
|
+
this.state.lineCount = processedLines.length;
|
|
53
61
|
// Process each line with each plugin
|
|
54
62
|
for (let i = 0; i < processedLines.length; i++) {
|
|
55
63
|
this.state.currentLine = i;
|
|
@@ -99,7 +107,59 @@ export class IncompleteMarkdownParser {
|
|
|
99
107
|
static createDefaultPlugins() {
|
|
100
108
|
return [
|
|
101
109
|
{
|
|
102
|
-
//
|
|
110
|
+
// `Hello <div cla` and `<div>content</di` render as literal source on
|
|
111
|
+
// every chunk until the `>` lands (upstream 3e6a77d). Strip that tail —
|
|
112
|
+
// but only on the block still being streamed and only when the name is
|
|
113
|
+
// really a tag. Upstream strips `/<[a-zA-Z\/][^>]*$/` unconditionally,
|
|
114
|
+
// which on our per-block completer would eat the tail of a sealed
|
|
115
|
+
// paragraph reading `if a <b then` for good.
|
|
116
|
+
// Registered first so the mdx plugin below never sees a half-typed tag.
|
|
117
|
+
name: 'incompleteHtmlTag',
|
|
118
|
+
// Cheap gate: a line with no '<' can never match.
|
|
119
|
+
pattern: /</,
|
|
120
|
+
skipInBlockTypes: ['code', 'math'],
|
|
121
|
+
handler: ({ line, state }) => {
|
|
122
|
+
// Only the streaming tail: the live block's LAST line. An unfinished
|
|
123
|
+
// tag on an earlier line can never complete, and `live` is merely
|
|
124
|
+
// "last block", so `Use the <div element to wrap it.` in a finished
|
|
125
|
+
// document must keep its text. The patterns below are `$`-anchored,
|
|
126
|
+
// so matching on the last line means matching at the end of the text.
|
|
127
|
+
if (!state.live || state.currentLine !== state.lineCount - 1)
|
|
128
|
+
return line;
|
|
129
|
+
// The incomplete tag is the last '<' with no '>' after it, so anchor
|
|
130
|
+
// there instead of letting `[^>]*$` backtrack from every '<' on the
|
|
131
|
+
// line (that is quadratic on a line full of complete tags).
|
|
132
|
+
const start = line.lastIndexOf('<');
|
|
133
|
+
if (start === -1)
|
|
134
|
+
return line;
|
|
135
|
+
// `</` with the name not yet typed is unambiguous — no prose ends a
|
|
136
|
+
// line that way — and it is the one frame a name check cannot catch.
|
|
137
|
+
if (start === line.length - 2 && line[start + 1] === '/') {
|
|
138
|
+
return isWithinCompleteInlineCode(line, start) ? line : line.slice(0, start);
|
|
139
|
+
}
|
|
140
|
+
const match = incompleteHtmlTag.exec(line.slice(start));
|
|
141
|
+
if (!match)
|
|
142
|
+
return line;
|
|
143
|
+
const name = match[1];
|
|
144
|
+
if (!isTagPrefix(name, state.tags))
|
|
145
|
+
return line;
|
|
146
|
+
// One-letter lowercase elements (a, b, i, p, q, s, u) are also how prose
|
|
147
|
+
// looks: `if a <b then` on the last line of a finished document must keep
|
|
148
|
+
// its text, and `live` cannot tell finished from streaming. Such a letter
|
|
149
|
+
// only counts as a tag once an attribute has started (`<a href="`). An
|
|
150
|
+
// uppercase letter (`<C`) can only be a component prefix, never prose.
|
|
151
|
+
if (name.length === 1 && name === name.toLowerCase() && !match[0].includes('='))
|
|
152
|
+
return line;
|
|
153
|
+
// A '<' inside a closed code span is content, not a tag (a725579).
|
|
154
|
+
if (isWithinCompleteInlineCode(line, start))
|
|
155
|
+
return line;
|
|
156
|
+
// No trimEnd (upstream does): 4.1.1 cut PascalCase tags at exactly
|
|
157
|
+
// this offset, so `Some text <Comp` keeps rendering as `Some text `.
|
|
158
|
+
return line.slice(0, start);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
// In a list item a '>' before a number is a comparison
|
|
103
163
|
// ('- > 25: rich'), but marked reads it as a nested blockquote. Escaping it
|
|
104
164
|
// keeps the text, and the escape renders as a plain '>' (4fffb9f).
|
|
105
165
|
// `pattern` is only a cheap gate — a line whose first non-space character is
|
|
@@ -843,24 +903,21 @@ export class IncompleteMarkdownParser {
|
|
|
843
903
|
skipInBlockTypes: ['code', 'math', 'center', 'right'],
|
|
844
904
|
preprocess: ({ text, state }) => {
|
|
845
905
|
// Track MDX component states across the entire text
|
|
906
|
+
const tags = state.tags;
|
|
846
907
|
const lines = text.split('\n');
|
|
847
908
|
const openTags = [];
|
|
848
|
-
let mdxLineStates = [];
|
|
849
909
|
for (let i = 0; i < lines.length; i++) {
|
|
850
910
|
// Lines inside code fences or math blocks are opaque content: MDX-looking
|
|
851
911
|
// tags there must not open/close/track components.
|
|
852
912
|
const lineCtx = state.lineContexts?.[i];
|
|
853
913
|
if (lineCtx?.code || lineCtx?.math) {
|
|
854
|
-
mdxLineStates[i] = { inMdx: false, incompletePositions: [] };
|
|
855
914
|
continue;
|
|
856
915
|
}
|
|
857
916
|
const line = lines[i];
|
|
858
|
-
let inMdx = false;
|
|
859
|
-
let incompletePositions = [];
|
|
860
917
|
// Find all MDX tags in the line
|
|
861
918
|
let searchPos = 0;
|
|
862
919
|
while (searchPos < line.length) {
|
|
863
|
-
// Look for opening bracket
|
|
920
|
+
// Look for an opening bracket that starts an allowlisted tag
|
|
864
921
|
const tagStart = line.indexOf('<', searchPos);
|
|
865
922
|
if (tagStart === -1 || tagStart >= line.length - 1)
|
|
866
923
|
break;
|
|
@@ -868,7 +925,7 @@ export class IncompleteMarkdownParser {
|
|
|
868
925
|
// Closing tag for a component opened on an earlier line. Handled
|
|
869
926
|
// inside the scan so a close that is part of a same-line complete
|
|
870
927
|
// pair (consumed below) is never double-counted against the stack.
|
|
871
|
-
const closeTagMatch = line.substring(tagStart).match(
|
|
928
|
+
const closeTagMatch = line.substring(tagStart).match(tags.closeTag);
|
|
872
929
|
if (closeTagMatch) {
|
|
873
930
|
const tagName = closeTagMatch[1];
|
|
874
931
|
// Pop the innermost same-name open (LIFO) so the auto-appended
|
|
@@ -882,82 +939,38 @@ export class IncompleteMarkdownParser {
|
|
|
882
939
|
searchPos = tagStart + closeTagMatch[0].length;
|
|
883
940
|
continue;
|
|
884
941
|
}
|
|
885
|
-
//
|
|
886
|
-
if (!/[
|
|
942
|
+
// Every allowlisted name starts with a letter; cheap gate.
|
|
943
|
+
if (!/[a-zA-Z]/.test(nextChar)) {
|
|
887
944
|
searchPos = tagStart + 1;
|
|
888
945
|
continue;
|
|
889
946
|
}
|
|
890
947
|
// Try to match complete self-closing tag
|
|
891
|
-
const selfClosingMatch = line
|
|
892
|
-
.substring(tagStart)
|
|
893
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*\/>/);
|
|
948
|
+
const selfClosingMatch = line.substring(tagStart).match(tags.selfClosing);
|
|
894
949
|
if (selfClosingMatch) {
|
|
895
950
|
searchPos = tagStart + selfClosingMatch[0].length;
|
|
896
951
|
continue;
|
|
897
952
|
}
|
|
898
953
|
// Try to match complete opening tag with immediate closing
|
|
899
|
-
const completeMatch = line
|
|
900
|
-
.substring(tagStart)
|
|
901
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*>.*?<\/\1>/);
|
|
954
|
+
const completeMatch = line.substring(tagStart).match(tags.complete);
|
|
902
955
|
if (completeMatch) {
|
|
903
956
|
searchPos = tagStart + completeMatch[0].length;
|
|
904
957
|
continue;
|
|
905
958
|
}
|
|
906
959
|
// Try to match opening tag
|
|
907
|
-
const openTagMatch = line
|
|
908
|
-
.substring(tagStart)
|
|
909
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*>/);
|
|
960
|
+
const openTagMatch = line.substring(tagStart).match(tags.openTag);
|
|
910
961
|
if (openTagMatch) {
|
|
911
962
|
const tagName = openTagMatch[1];
|
|
912
963
|
openTags.push({ tagName, lineIndex: i });
|
|
913
|
-
inMdx = true;
|
|
914
964
|
searchPos = tagStart + openTagMatch[0].length;
|
|
915
965
|
continue;
|
|
916
966
|
}
|
|
917
|
-
//
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)[^>]*\/$/);
|
|
921
|
-
if (incompleteSelfClosing) {
|
|
922
|
-
incompletePositions.push(tagStart);
|
|
923
|
-
break; // This is at the end of the line
|
|
924
|
-
}
|
|
925
|
-
// Check for incomplete tag (no closing >) - only at end of line
|
|
926
|
-
const incompleteTag = line
|
|
927
|
-
.substring(tagStart)
|
|
928
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?$/);
|
|
929
|
-
if (incompleteTag) {
|
|
930
|
-
incompletePositions.push(tagStart);
|
|
931
|
-
break; // This is at the end of the line
|
|
932
|
-
}
|
|
967
|
+
// A tag cut off before its `>` is the last thing on the line (the
|
|
968
|
+
// incompleteHtmlTag plugin, which ran first, strips it); scanning on
|
|
969
|
+
// from the next character finds nothing and ends the loop.
|
|
933
970
|
searchPos = tagStart + 1;
|
|
934
971
|
}
|
|
935
|
-
mdxLineStates[i] = { inMdx, incompletePositions };
|
|
936
972
|
}
|
|
937
|
-
return {
|
|
938
|
-
text,
|
|
939
|
-
state: {
|
|
940
|
-
mdxUnclosedTags: openTags,
|
|
941
|
-
mdxLineStates
|
|
942
|
-
}
|
|
943
|
-
};
|
|
944
|
-
},
|
|
945
|
-
handler: ({ line, state }) => {
|
|
946
|
-
// Remove incomplete MDX syntax (don't render it)
|
|
947
|
-
const lineStates = state.mdxLineStates || [];
|
|
948
|
-
const currentState = lineStates[state.currentLine];
|
|
949
|
-
if (currentState?.incompletePositions && currentState.incompletePositions.length > 0) {
|
|
950
|
-
// Process incomplete positions from right to left to preserve indices
|
|
951
|
-
let result = line;
|
|
952
|
-
for (let i = currentState.incompletePositions.length - 1; i >= 0; i--) {
|
|
953
|
-
const pos = currentState.incompletePositions[i];
|
|
954
|
-
const before = result.substring(0, pos);
|
|
955
|
-
// Simply remove the incomplete MDX tag
|
|
956
|
-
result = before;
|
|
957
|
-
}
|
|
958
|
-
return result;
|
|
959
|
-
}
|
|
960
|
-
return line;
|
|
973
|
+
return { text, state: { mdxUnclosedTags: openTags } };
|
|
961
974
|
},
|
|
962
975
|
postprocess: ({ text, state }) => {
|
|
963
976
|
// Complete unclosed MDX components at the end
|
|
@@ -979,15 +992,104 @@ export class IncompleteMarkdownParser {
|
|
|
979
992
|
// Legacy function for backward compatibility
|
|
980
993
|
const defaultPlugins = IncompleteMarkdownParser.createDefaultPlugins();
|
|
981
994
|
const defaultParser = new IncompleteMarkdownParser(defaultPlugins);
|
|
982
|
-
export const parseIncompleteMarkdown = (text) => {
|
|
995
|
+
export const parseIncompleteMarkdown = (text, options) => {
|
|
983
996
|
if (!text || typeof text !== 'string') {
|
|
984
997
|
return text;
|
|
985
998
|
}
|
|
986
|
-
return defaultParser.parse(text);
|
|
999
|
+
return defaultParser.parse(text, options);
|
|
987
1000
|
};
|
|
988
1001
|
// Utility functions
|
|
989
1002
|
// Full test for the comparisonOperator plugin, whose `pattern` only gates it.
|
|
990
1003
|
const listItemComparison = /^(\s*(?:[-*+]|\d+[.)]) +)>(?==?\s*\$?\d)/;
|
|
1004
|
+
// An opening or closing tag that never got its '>', anchored at the line's last
|
|
1005
|
+
// '<': the name (hyphens allowed, so `</ai-thinking` is caught too), then any
|
|
1006
|
+
// number of COMPLETE attributes, then at most one still being typed. Upstream
|
|
1007
|
+
// accepts any `[^>]*` tail, which also swallows ordinary prose — `if a <b then
|
|
1008
|
+
// c` and `Use the <div element to wrap it.` both end in a `<name` with no '>'
|
|
1009
|
+
// after it. Requiring every attribute but the last to carry a quoted value is
|
|
1010
|
+
// what tells a half-typed tag from a sentence.
|
|
1011
|
+
const incompleteHtmlTag = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s+[\w-]+=(?:"[^"]*"|'[^']*'|\{[^}]*\}))*(?:\s+[\w-]*(?:=(?:"[^"]*|'[^']*|\{[^}]*)?)?)?\s*\/?$/;
|
|
1012
|
+
/**
|
|
1013
|
+
* The HTML element names a half-typed tag may be stripped for. Deliberately a
|
|
1014
|
+
* short list of what an LLM actually emits rather than the full HTML vocabulary:
|
|
1015
|
+
* every name here is one the user will never mean literally at the end of a
|
|
1016
|
+
* streamed line, which is what makes the strip safe. Anything else must come in
|
|
1017
|
+
* through `customTags` / `mdxComponents`.
|
|
1018
|
+
*/
|
|
1019
|
+
const htmlElements = [
|
|
1020
|
+
'a',
|
|
1021
|
+
'abbr',
|
|
1022
|
+
'b',
|
|
1023
|
+
'blockquote',
|
|
1024
|
+
'br',
|
|
1025
|
+
'button',
|
|
1026
|
+
'code',
|
|
1027
|
+
'col',
|
|
1028
|
+
'colgroup',
|
|
1029
|
+
'dd',
|
|
1030
|
+
'del',
|
|
1031
|
+
'details',
|
|
1032
|
+
'div',
|
|
1033
|
+
'dl',
|
|
1034
|
+
'dt',
|
|
1035
|
+
'em',
|
|
1036
|
+
'figcaption',
|
|
1037
|
+
'figure',
|
|
1038
|
+
'h1',
|
|
1039
|
+
'h2',
|
|
1040
|
+
'h3',
|
|
1041
|
+
'h4',
|
|
1042
|
+
'h5',
|
|
1043
|
+
'h6',
|
|
1044
|
+
'hr',
|
|
1045
|
+
'i',
|
|
1046
|
+
'iframe',
|
|
1047
|
+
'img',
|
|
1048
|
+
'input',
|
|
1049
|
+
'ins',
|
|
1050
|
+
'kbd',
|
|
1051
|
+
'label',
|
|
1052
|
+
'li',
|
|
1053
|
+
'mark',
|
|
1054
|
+
'ol',
|
|
1055
|
+
'p',
|
|
1056
|
+
'picture',
|
|
1057
|
+
'pre',
|
|
1058
|
+
'q',
|
|
1059
|
+
's',
|
|
1060
|
+
'samp',
|
|
1061
|
+
'section',
|
|
1062
|
+
'small',
|
|
1063
|
+
'source',
|
|
1064
|
+
'span',
|
|
1065
|
+
'strong',
|
|
1066
|
+
'sub',
|
|
1067
|
+
'summary',
|
|
1068
|
+
'sup',
|
|
1069
|
+
'table',
|
|
1070
|
+
'tbody',
|
|
1071
|
+
'td',
|
|
1072
|
+
'tfoot',
|
|
1073
|
+
'th',
|
|
1074
|
+
'thead',
|
|
1075
|
+
'tr',
|
|
1076
|
+
'u',
|
|
1077
|
+
'ul',
|
|
1078
|
+
'video'
|
|
1079
|
+
];
|
|
1080
|
+
// A tag arrives one character at a time, so `<d`, `<di` and `<div` must all be
|
|
1081
|
+
// recognised or the raw text flashes for a chunk or two anyway. Every prefix of
|
|
1082
|
+
// every name above, precomputed — ~250 entries, one Set lookup per probe.
|
|
1083
|
+
const htmlPrefixes = new Set();
|
|
1084
|
+
for (const element of htmlElements) {
|
|
1085
|
+
for (let i = 1; i <= element.length; i++)
|
|
1086
|
+
htmlPrefixes.add(element.slice(0, i));
|
|
1087
|
+
}
|
|
1088
|
+
/** Could `name` still grow into a tag we are allowed to strip? */
|
|
1089
|
+
const isTagPrefix = (name, tags) => htmlPrefixes.has(name.toLowerCase()) ||
|
|
1090
|
+
// PascalCase is prefix-closed by construction, so this covers `<MyComp` too.
|
|
1091
|
+
tags.name.test(name) ||
|
|
1092
|
+
tags.names.some((tag) => tag.startsWith(name));
|
|
991
1093
|
// Emphasis/code markers, as a set so inlineCitation can scan a cell for them
|
|
992
1094
|
// without building a substring per bracket.
|
|
993
1095
|
const formattingChars = new Set(['*', '~', '`', '_']);
|