svelte-streamdown 4.1.0 → 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 +149 -47
- package/dist/Block.svelte +19 -8
- package/dist/Block.svelte.d.ts +2 -0
- package/dist/Elements/Code.svelte +15 -4
- package/dist/Elements/Code.svelte.d.ts +2 -0
- package/dist/Elements/Element.svelte +32 -11
- package/dist/Elements/Element.svelte.d.ts +1 -0
- package/dist/Elements/Mermaid.svelte +5 -11
- package/dist/Elements/Mermaid.svelte.d.ts +1 -0
- package/dist/Elements/TableDownload.svelte +44 -3
- package/dist/Elements/TableDownload.svelte.d.ts +3 -1
- package/dist/Elements/fallbacks/CodeFallback.svelte +16 -6
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -2
- package/dist/Elements/icons.d.ts +1 -0
- package/dist/Elements/icons.js +8 -0
- package/dist/Streamdown.svelte +68 -6
- package/dist/context.svelte.d.ts +37 -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 +11 -4
- package/dist/marked/index.js +54 -45
- 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/fence.d.ts +12 -1
- package/dist/utils/fence.js +34 -17
- 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 +173 -69
- package/package.json +1 -1
|
@@ -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
|
-
import { trackFence } from './fence.js';
|
|
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
|
|
@@ -196,10 +256,12 @@ export class IncompleteMarkdownParser {
|
|
|
196
256
|
// Close inner blocks (code/math) before alignment wrappers.
|
|
197
257
|
let result = text;
|
|
198
258
|
if (state.blockingContexts.has('code')) {
|
|
199
|
-
// Close with the fence that was opened
|
|
200
|
-
// '```',
|
|
259
|
+
// Close with the fence that was opened, at the depth it was opened: a
|
|
260
|
+
// '~~~' block is not closed by '```', a longer run needs a closer at
|
|
261
|
+
// least as long, and a fence inside a list item needs its closer inside
|
|
262
|
+
// the item — one at column 0 leaves the block open and starts a new one.
|
|
201
263
|
const fence = state.openFence;
|
|
202
|
-
result += '\n' + (fence ?
|
|
264
|
+
result += '\n' + (fence ? closingFence(fence) : '```');
|
|
203
265
|
}
|
|
204
266
|
if (state.blockingContexts.has('math')) {
|
|
205
267
|
if (state.mathCloser === '\\]') {
|
|
@@ -841,24 +903,21 @@ export class IncompleteMarkdownParser {
|
|
|
841
903
|
skipInBlockTypes: ['code', 'math', 'center', 'right'],
|
|
842
904
|
preprocess: ({ text, state }) => {
|
|
843
905
|
// Track MDX component states across the entire text
|
|
906
|
+
const tags = state.tags;
|
|
844
907
|
const lines = text.split('\n');
|
|
845
908
|
const openTags = [];
|
|
846
|
-
let mdxLineStates = [];
|
|
847
909
|
for (let i = 0; i < lines.length; i++) {
|
|
848
910
|
// Lines inside code fences or math blocks are opaque content: MDX-looking
|
|
849
911
|
// tags there must not open/close/track components.
|
|
850
912
|
const lineCtx = state.lineContexts?.[i];
|
|
851
913
|
if (lineCtx?.code || lineCtx?.math) {
|
|
852
|
-
mdxLineStates[i] = { inMdx: false, incompletePositions: [] };
|
|
853
914
|
continue;
|
|
854
915
|
}
|
|
855
916
|
const line = lines[i];
|
|
856
|
-
let inMdx = false;
|
|
857
|
-
let incompletePositions = [];
|
|
858
917
|
// Find all MDX tags in the line
|
|
859
918
|
let searchPos = 0;
|
|
860
919
|
while (searchPos < line.length) {
|
|
861
|
-
// Look for opening bracket
|
|
920
|
+
// Look for an opening bracket that starts an allowlisted tag
|
|
862
921
|
const tagStart = line.indexOf('<', searchPos);
|
|
863
922
|
if (tagStart === -1 || tagStart >= line.length - 1)
|
|
864
923
|
break;
|
|
@@ -866,7 +925,7 @@ export class IncompleteMarkdownParser {
|
|
|
866
925
|
// Closing tag for a component opened on an earlier line. Handled
|
|
867
926
|
// inside the scan so a close that is part of a same-line complete
|
|
868
927
|
// pair (consumed below) is never double-counted against the stack.
|
|
869
|
-
const closeTagMatch = line.substring(tagStart).match(
|
|
928
|
+
const closeTagMatch = line.substring(tagStart).match(tags.closeTag);
|
|
870
929
|
if (closeTagMatch) {
|
|
871
930
|
const tagName = closeTagMatch[1];
|
|
872
931
|
// Pop the innermost same-name open (LIFO) so the auto-appended
|
|
@@ -880,82 +939,38 @@ export class IncompleteMarkdownParser {
|
|
|
880
939
|
searchPos = tagStart + closeTagMatch[0].length;
|
|
881
940
|
continue;
|
|
882
941
|
}
|
|
883
|
-
//
|
|
884
|
-
if (!/[
|
|
942
|
+
// Every allowlisted name starts with a letter; cheap gate.
|
|
943
|
+
if (!/[a-zA-Z]/.test(nextChar)) {
|
|
885
944
|
searchPos = tagStart + 1;
|
|
886
945
|
continue;
|
|
887
946
|
}
|
|
888
947
|
// Try to match complete self-closing tag
|
|
889
|
-
const selfClosingMatch = line
|
|
890
|
-
.substring(tagStart)
|
|
891
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*\/>/);
|
|
948
|
+
const selfClosingMatch = line.substring(tagStart).match(tags.selfClosing);
|
|
892
949
|
if (selfClosingMatch) {
|
|
893
950
|
searchPos = tagStart + selfClosingMatch[0].length;
|
|
894
951
|
continue;
|
|
895
952
|
}
|
|
896
953
|
// Try to match complete opening tag with immediate closing
|
|
897
|
-
const completeMatch = line
|
|
898
|
-
.substring(tagStart)
|
|
899
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*>.*?<\/\1>/);
|
|
954
|
+
const completeMatch = line.substring(tagStart).match(tags.complete);
|
|
900
955
|
if (completeMatch) {
|
|
901
956
|
searchPos = tagStart + completeMatch[0].length;
|
|
902
957
|
continue;
|
|
903
958
|
}
|
|
904
959
|
// Try to match opening tag
|
|
905
|
-
const openTagMatch = line
|
|
906
|
-
.substring(tagStart)
|
|
907
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*>/);
|
|
960
|
+
const openTagMatch = line.substring(tagStart).match(tags.openTag);
|
|
908
961
|
if (openTagMatch) {
|
|
909
962
|
const tagName = openTagMatch[1];
|
|
910
963
|
openTags.push({ tagName, lineIndex: i });
|
|
911
|
-
inMdx = true;
|
|
912
964
|
searchPos = tagStart + openTagMatch[0].length;
|
|
913
965
|
continue;
|
|
914
966
|
}
|
|
915
|
-
//
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)[^>]*\/$/);
|
|
919
|
-
if (incompleteSelfClosing) {
|
|
920
|
-
incompletePositions.push(tagStart);
|
|
921
|
-
break; // This is at the end of the line
|
|
922
|
-
}
|
|
923
|
-
// Check for incomplete tag (no closing >) - only at end of line
|
|
924
|
-
const incompleteTag = line
|
|
925
|
-
.substring(tagStart)
|
|
926
|
-
.match(/^<([A-Z][a-zA-Z0-9]*)(?:\s+[^>]*)?$/);
|
|
927
|
-
if (incompleteTag) {
|
|
928
|
-
incompletePositions.push(tagStart);
|
|
929
|
-
break; // This is at the end of the line
|
|
930
|
-
}
|
|
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.
|
|
931
970
|
searchPos = tagStart + 1;
|
|
932
971
|
}
|
|
933
|
-
mdxLineStates[i] = { inMdx, incompletePositions };
|
|
934
972
|
}
|
|
935
|
-
return {
|
|
936
|
-
text,
|
|
937
|
-
state: {
|
|
938
|
-
mdxUnclosedTags: openTags,
|
|
939
|
-
mdxLineStates
|
|
940
|
-
}
|
|
941
|
-
};
|
|
942
|
-
},
|
|
943
|
-
handler: ({ line, state }) => {
|
|
944
|
-
// Remove incomplete MDX syntax (don't render it)
|
|
945
|
-
const lineStates = state.mdxLineStates || [];
|
|
946
|
-
const currentState = lineStates[state.currentLine];
|
|
947
|
-
if (currentState?.incompletePositions && currentState.incompletePositions.length > 0) {
|
|
948
|
-
// Process incomplete positions from right to left to preserve indices
|
|
949
|
-
let result = line;
|
|
950
|
-
for (let i = currentState.incompletePositions.length - 1; i >= 0; i--) {
|
|
951
|
-
const pos = currentState.incompletePositions[i];
|
|
952
|
-
const before = result.substring(0, pos);
|
|
953
|
-
// Simply remove the incomplete MDX tag
|
|
954
|
-
result = before;
|
|
955
|
-
}
|
|
956
|
-
return result;
|
|
957
|
-
}
|
|
958
|
-
return line;
|
|
973
|
+
return { text, state: { mdxUnclosedTags: openTags } };
|
|
959
974
|
},
|
|
960
975
|
postprocess: ({ text, state }) => {
|
|
961
976
|
// Complete unclosed MDX components at the end
|
|
@@ -977,15 +992,104 @@ export class IncompleteMarkdownParser {
|
|
|
977
992
|
// Legacy function for backward compatibility
|
|
978
993
|
const defaultPlugins = IncompleteMarkdownParser.createDefaultPlugins();
|
|
979
994
|
const defaultParser = new IncompleteMarkdownParser(defaultPlugins);
|
|
980
|
-
export const parseIncompleteMarkdown = (text) => {
|
|
995
|
+
export const parseIncompleteMarkdown = (text, options) => {
|
|
981
996
|
if (!text || typeof text !== 'string') {
|
|
982
997
|
return text;
|
|
983
998
|
}
|
|
984
|
-
return defaultParser.parse(text);
|
|
999
|
+
return defaultParser.parse(text, options);
|
|
985
1000
|
};
|
|
986
1001
|
// Utility functions
|
|
987
1002
|
// Full test for the comparisonOperator plugin, whose `pattern` only gates it.
|
|
988
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));
|
|
989
1093
|
// Emphasis/code markers, as a set so inlineCitation can scan a cell for them
|
|
990
1094
|
// without building a substring per bracket.
|
|
991
1095
|
const formattingChars = new Set(['*', '~', '`', '_']);
|