svelte-streamdown 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +259 -139
- package/dist/Elements/FootnoteRef.svelte +0 -2
- package/dist/Elements/Mermaid.svelte +1 -1
- package/dist/Streamdown.svelte +12 -3
- package/dist/context.svelte.d.ts +5 -1
- package/dist/marked/index.d.ts +15 -1
- package/dist/marked/index.js +123 -10
- package/dist/marked/marked-alert.js +12 -3
- package/dist/marked/marked-align.js +9 -5
- package/dist/marked/marked-citations.js +4 -3
- package/dist/marked/marked-dl.js +8 -3
- package/dist/marked/marked-hr.js +4 -3
- package/dist/marked/marked-list.js +55 -18
- package/dist/marked/marked-math.js +12 -14
- package/dist/marked/marked-table.js +14 -34
- package/dist/utils/parse-incomplete-markdown.js +127 -66
- package/dist/utils/url.js +6 -0
- package/package.json +11 -1
package/dist/Streamdown.svelte
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import Block from './Block.svelte';
|
|
3
3
|
import { StreamdownContext, type StreamdownProps } from './context.svelte.js';
|
|
4
4
|
import { mergeTheme, shadcnTheme } from './theme.js';
|
|
5
|
-
import { parseBlocks } from './marked/index.js';
|
|
5
|
+
import { parseBlocks, createParseBlocksCache } from './marked/index.js';
|
|
6
6
|
|
|
7
7
|
let {
|
|
8
8
|
content = '',
|
|
@@ -127,11 +127,15 @@
|
|
|
127
127
|
},
|
|
128
128
|
get controls() {
|
|
129
129
|
const codeControls = controls?.code ?? true;
|
|
130
|
-
const
|
|
130
|
+
const mermaid = controls?.mermaid;
|
|
131
|
+
const isMermaidObject = typeof mermaid === 'object' && mermaid !== null;
|
|
132
|
+
const mermaidControls = isMermaidObject ? (mermaid.enabled ?? true) : (mermaid ?? true);
|
|
133
|
+
const mermaidMouseWheelZoom = isMermaidObject ? (mermaid.mouseWheelZoom ?? true) : true;
|
|
131
134
|
const tableControls = controls?.table ?? true;
|
|
132
135
|
return {
|
|
133
136
|
code: codeControls,
|
|
134
137
|
mermaid: mermaidControls,
|
|
138
|
+
mermaidMouseWheelZoom,
|
|
135
139
|
table: tableControls
|
|
136
140
|
};
|
|
137
141
|
},
|
|
@@ -154,7 +158,12 @@
|
|
|
154
158
|
|
|
155
159
|
const id = $props.id();
|
|
156
160
|
|
|
157
|
-
|
|
161
|
+
// Per-instance incremental state: append-only content updates re-lex only
|
|
162
|
+
// the last couple of blocks instead of the whole document.
|
|
163
|
+
const blocksCache = createParseBlocksCache();
|
|
164
|
+
const blocks = $derived(
|
|
165
|
+
isStatic ? content : parseBlocks(content, streamdown.extensions, blocksCache)
|
|
166
|
+
);
|
|
158
167
|
</script>
|
|
159
168
|
|
|
160
169
|
<div bind:this={element} class={className}>
|
package/dist/context.svelte.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets
|
|
|
11
11
|
controls: {
|
|
12
12
|
code: boolean;
|
|
13
13
|
mermaid: boolean;
|
|
14
|
+
mermaidMouseWheelZoom: boolean;
|
|
14
15
|
table: boolean;
|
|
15
16
|
};
|
|
16
17
|
inlineCitationsMode: 'list' | 'carousel';
|
|
@@ -123,7 +124,10 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
|
|
|
123
124
|
};
|
|
124
125
|
controls?: {
|
|
125
126
|
code?: boolean;
|
|
126
|
-
mermaid?: boolean
|
|
127
|
+
mermaid?: boolean | {
|
|
128
|
+
enabled?: boolean;
|
|
129
|
+
mouseWheelZoom?: boolean;
|
|
130
|
+
};
|
|
127
131
|
table?: boolean;
|
|
128
132
|
};
|
|
129
133
|
renderHtml?: boolean | ((token: Tokens.HTML | Tokens.Tag) => string);
|
package/dist/marked/index.d.ts
CHANGED
|
@@ -26,5 +26,19 @@ export type Extension = {
|
|
|
26
26
|
export type StreamdownToken = Exclude<MarkedToken, Tokens.List | Tokens.ListItem | Tokens.Table> | ListToken | ListItemToken | MathToken | AlertToken | FootnoteToken | SubSupToken | BrToken | HrToken | TableToken | THead | TBody | TFoot | THeadRow | TRow | TH | TD | DescriptionListToken | DescriptionToken | DescriptionDetailToken | DescriptionTermToken | AlignToken | CitationToken | MdxToken;
|
|
27
27
|
export type { TableToken, THead, TBody, TFoot, THeadRow, TRow, TH, TD } from './marked-table.js';
|
|
28
28
|
export declare const lex: (markdown: string, extensions?: Extension[]) => StreamdownToken[];
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Opaque incremental state for `parseBlocks`. Create one per Streamdown
|
|
31
|
+
* instance (or per simulated stream) and pass it on every call: append-only
|
|
32
|
+
* content updates then re-lex only the last couple of blocks instead of the
|
|
33
|
+
* whole document. Any non-append update falls back to a full parse.
|
|
34
|
+
*/
|
|
35
|
+
export type ParseBlocksCache = {
|
|
36
|
+
content: string;
|
|
37
|
+
/** every block token's raw (including space/footnote tokens) in document order */
|
|
38
|
+
raws: string[];
|
|
39
|
+
/** parallel to raws: whether the token is part of the rendered block list */
|
|
40
|
+
keep: boolean[];
|
|
41
|
+
};
|
|
42
|
+
export declare const createParseBlocksCache: () => ParseBlocksCache;
|
|
43
|
+
export declare const parseBlocks: (markdown: string, extensions?: Extension[], cache?: ParseBlocksCache) => string[];
|
|
30
44
|
export type { MathToken, AlertToken, FootnoteToken, SubSupToken, BrToken, HrToken, AlignToken, CitationToken, MdxToken };
|
package/dist/marked/index.js
CHANGED
|
@@ -11,6 +11,34 @@ import { markedDl } from './marked-dl.js';
|
|
|
11
11
|
import { markedAlign } from './marked-align.js';
|
|
12
12
|
import { markedCitations } from './marked-citations.js';
|
|
13
13
|
import { markedMdx } from './marked-mdx.js';
|
|
14
|
+
// Default plugin sets, in registration order. Hoisted so the options object
|
|
15
|
+
// (and the regexes/closures inside each plugin) is built once, not per chunk.
|
|
16
|
+
// The tokenizers are stateless at creation time — per-document state lives on
|
|
17
|
+
// the Lexer instance (e.g. footnotes maps, reference-link defs), so a fresh
|
|
18
|
+
// Lexer per call keeps documents isolated while the options are shared.
|
|
19
|
+
const DEFAULT_LEX_EXTENSIONS = [
|
|
20
|
+
markedHr,
|
|
21
|
+
markedTable,
|
|
22
|
+
...markedFootnote(),
|
|
23
|
+
markedAlert,
|
|
24
|
+
...markedMath,
|
|
25
|
+
markedSub,
|
|
26
|
+
markedSup,
|
|
27
|
+
markedList,
|
|
28
|
+
markedBr,
|
|
29
|
+
markedDl,
|
|
30
|
+
markedAlign,
|
|
31
|
+
markedCitations,
|
|
32
|
+
markedMdx
|
|
33
|
+
];
|
|
34
|
+
const DEFAULT_BLOCK_EXTENSIONS = [
|
|
35
|
+
markedHr,
|
|
36
|
+
...markedFootnote(),
|
|
37
|
+
markedDl,
|
|
38
|
+
markedTable,
|
|
39
|
+
markedAlign,
|
|
40
|
+
markedMdx
|
|
41
|
+
];
|
|
14
42
|
const parseExtensions = (...extensions) => {
|
|
15
43
|
const options = {
|
|
16
44
|
gfm: true,
|
|
@@ -43,20 +71,105 @@ const parseExtensions = (...extensions) => {
|
|
|
43
71
|
});
|
|
44
72
|
return options;
|
|
45
73
|
};
|
|
74
|
+
// Options objects are stateless and reusable across Lexer instances; cache them
|
|
75
|
+
// per user-extension array (props are referentially stable across chunks) so the
|
|
76
|
+
// hot path skips rebuilding ~20 tokenizer registrations on every streamed chunk.
|
|
77
|
+
const DEFAULT_LEX_OPTIONS = parseExtensions(...DEFAULT_LEX_EXTENSIONS);
|
|
78
|
+
const DEFAULT_BLOCK_OPTIONS = parseExtensions(...DEFAULT_BLOCK_EXTENSIONS);
|
|
79
|
+
const lexOptionsCache = new WeakMap();
|
|
80
|
+
const blockOptionsCache = new WeakMap();
|
|
81
|
+
const getLexOptions = (extensions) => {
|
|
82
|
+
if (extensions.length === 0)
|
|
83
|
+
return DEFAULT_LEX_OPTIONS;
|
|
84
|
+
let options = lexOptionsCache.get(extensions);
|
|
85
|
+
if (!options) {
|
|
86
|
+
options = parseExtensions(...DEFAULT_LEX_EXTENSIONS, ...extensions);
|
|
87
|
+
lexOptionsCache.set(extensions, options);
|
|
88
|
+
}
|
|
89
|
+
return options;
|
|
90
|
+
};
|
|
91
|
+
const getBlockOptions = (extensions) => {
|
|
92
|
+
if (extensions.length === 0)
|
|
93
|
+
return DEFAULT_BLOCK_OPTIONS;
|
|
94
|
+
let options = blockOptionsCache.get(extensions);
|
|
95
|
+
if (!options) {
|
|
96
|
+
options = parseExtensions(...DEFAULT_BLOCK_EXTENSIONS, ...extensions.filter(({ level, applyInBlockParsing }) => level === 'block' && applyInBlockParsing));
|
|
97
|
+
blockOptionsCache.set(extensions, options);
|
|
98
|
+
}
|
|
99
|
+
return options;
|
|
100
|
+
};
|
|
46
101
|
export const lex = (markdown, extensions = []) => {
|
|
47
|
-
return new Lexer(
|
|
102
|
+
return new Lexer(getLexOptions(extensions))
|
|
48
103
|
.lex(markdown)
|
|
49
104
|
.filter((token) => token.type !== 'space' && token.type !== 'footnote');
|
|
50
105
|
};
|
|
51
|
-
export const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
106
|
+
export const createParseBlocksCache = () => ({
|
|
107
|
+
content: '',
|
|
108
|
+
raws: [],
|
|
109
|
+
keep: []
|
|
110
|
+
});
|
|
111
|
+
// Number of trailing rendered blocks that stay "live" (re-lexed every chunk).
|
|
112
|
+
// 2 covers constructs that merge backward as they stream in — e.g. a paragraph
|
|
113
|
+
// line becoming a table once its delimiter row arrives, or a setext heading.
|
|
114
|
+
const SEAL_SLACK = 2;
|
|
115
|
+
const blockTokensOf = (markdown, extensions) => new Lexer(getBlockOptions(extensions)).blockTokens(markdown, []);
|
|
116
|
+
export const parseBlocks = (markdown, extensions = [], cache) => {
|
|
117
|
+
if (cache &&
|
|
118
|
+
cache.content.length > 0 &&
|
|
119
|
+
markdown.length > cache.content.length &&
|
|
120
|
+
markdown.startsWith(cache.content)) {
|
|
121
|
+
// Append-only update: seal everything except the last SEAL_SLACK rendered
|
|
122
|
+
// blocks and re-lex only the tail. cache.raws concatenates exactly to
|
|
123
|
+
// cache.content (verified by length below), so summed lengths are offsets.
|
|
124
|
+
let cut = cache.raws.length;
|
|
125
|
+
let liveBlocks = 0;
|
|
126
|
+
while (cut > 0 && liveBlocks < SEAL_SLACK) {
|
|
127
|
+
cut--;
|
|
128
|
+
if (cache.keep[cut])
|
|
129
|
+
liveBlocks++;
|
|
130
|
+
}
|
|
131
|
+
let offset = 0;
|
|
132
|
+
for (let i = 0; i < cut; i++)
|
|
133
|
+
offset += cache.raws[i].length;
|
|
134
|
+
const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
|
|
135
|
+
let tailLength = 0;
|
|
136
|
+
for (const token of tailTokens)
|
|
137
|
+
tailLength += token.raw.length;
|
|
138
|
+
// Contiguity guard: if the lexer normalized the tail (so raws no longer
|
|
139
|
+
// reconstruct the input), the offsets cannot be trusted — full reparse.
|
|
140
|
+
if (offset + tailLength === markdown.length) {
|
|
141
|
+
cache.raws.length = cut;
|
|
142
|
+
cache.keep.length = cut;
|
|
143
|
+
for (const token of tailTokens) {
|
|
144
|
+
cache.raws.push(token.raw);
|
|
145
|
+
cache.keep.push(token.type !== 'space' && token.type !== 'footnote');
|
|
146
|
+
}
|
|
147
|
+
cache.content = markdown;
|
|
148
|
+
return cache.raws.filter((_, i) => cache.keep[i]);
|
|
56
149
|
}
|
|
57
|
-
|
|
58
|
-
|
|
150
|
+
}
|
|
151
|
+
// Full parse (first call, non-append update, or contiguity fallback).
|
|
152
|
+
const tokens = blockTokensOf(markdown, extensions);
|
|
153
|
+
const blocks = [];
|
|
154
|
+
if (cache) {
|
|
155
|
+
cache.raws = [];
|
|
156
|
+
cache.keep = [];
|
|
157
|
+
let total = 0;
|
|
158
|
+
for (const token of tokens) {
|
|
159
|
+
const keep = token.type !== 'space' && token.type !== 'footnote';
|
|
160
|
+
cache.raws.push(token.raw);
|
|
161
|
+
cache.keep.push(keep);
|
|
162
|
+
total += token.raw.length;
|
|
163
|
+
if (keep)
|
|
164
|
+
blocks.push(token.raw);
|
|
59
165
|
}
|
|
60
|
-
|
|
61
|
-
|
|
166
|
+
// Only trust the cache for future appends if raws reconstruct the input.
|
|
167
|
+
cache.content = total === markdown.length ? markdown : '';
|
|
168
|
+
return blocks;
|
|
169
|
+
}
|
|
170
|
+
for (const token of tokens) {
|
|
171
|
+
if (token.type !== 'space' && token.type !== 'footnote')
|
|
172
|
+
blocks.push(token.raw);
|
|
173
|
+
}
|
|
174
|
+
return blocks;
|
|
62
175
|
};
|
|
@@ -3,6 +3,14 @@ const variants = ['note', 'tip', 'important', 'warning', 'caution'];
|
|
|
3
3
|
export function createSyntaxPattern(type) {
|
|
4
4
|
return `^\\s*[\\*_]*\\[!${type.toUpperCase()}\\][\\*_]*\\s*`;
|
|
5
5
|
}
|
|
6
|
+
// Precomputed once per variant instead of recompiling on every blockquote:
|
|
7
|
+
// - syntax: detects the `[!NOTE]` marker (case-insensitive)
|
|
8
|
+
// - strip: removes the marker (global) when building the alert token
|
|
9
|
+
const VARIANT_PATTERNS = variants.map((type) => ({
|
|
10
|
+
type,
|
|
11
|
+
syntax: new RegExp(createSyntaxPattern(type), 'i'),
|
|
12
|
+
strip: new RegExp(`[\\*_]*\\[!${type.toUpperCase()}\\][\\*_]*`, 'g')
|
|
13
|
+
}));
|
|
6
14
|
const defaultLexer = new Lexer({ gfm: true });
|
|
7
15
|
const defaultTokenizer = defaultLexer.options.tokenizer;
|
|
8
16
|
export const markedAlert = {
|
|
@@ -18,8 +26,8 @@ export const markedAlert = {
|
|
|
18
26
|
}
|
|
19
27
|
};
|
|
20
28
|
export function processAlertToken(token, tokenizer) {
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
29
|
+
const matched = VARIANT_PATTERNS.find((v) => v.syntax.test(('text' in token && token.text) || ''));
|
|
30
|
+
if (!matched) {
|
|
23
31
|
Object.assign(token, {
|
|
24
32
|
tokens: token.tokens
|
|
25
33
|
.map((token) => {
|
|
@@ -29,7 +37,8 @@ export function processAlertToken(token, tokenizer) {
|
|
|
29
37
|
});
|
|
30
38
|
return;
|
|
31
39
|
}
|
|
32
|
-
const
|
|
40
|
+
const matchedVariant = matched.type;
|
|
41
|
+
const alertPattern = matched.strip;
|
|
33
42
|
const tokens = token.tokens
|
|
34
43
|
.map((token) => {
|
|
35
44
|
let cleanedRaw = token.raw;
|
|
@@ -2,11 +2,15 @@ export const markedAlign = {
|
|
|
2
2
|
name: 'align',
|
|
3
3
|
level: 'block',
|
|
4
4
|
tokenizer(src) {
|
|
5
|
-
// Check if the source starts with [center] or [right] blocks
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// Check if the source starts with [center] or [right] blocks.
|
|
6
|
+
// A block ends at its explicit closing tag, or right before the opening
|
|
7
|
+
// tag of the next alignment block (blocks don't nest — they become
|
|
8
|
+
// siblings). The content group is optional so empty blocks
|
|
9
|
+
// ([center]\n[/center]) tokenize too.
|
|
10
|
+
const centerMatch = src.match(/^\[center\]\n(?:([\s\S]*?)\n)?(?:\[\/center\]|(?=\[(?:center|right)\]\n))/);
|
|
11
|
+
const rightMatch = src.match(/^\[right\]\n(?:([\s\S]*?)\n)?(?:\[\/right\]|(?=\[(?:center|right)\]\n))/);
|
|
8
12
|
if (centerMatch) {
|
|
9
|
-
const text = centerMatch[1];
|
|
13
|
+
const text = centerMatch[1] ?? '';
|
|
10
14
|
const raw = centerMatch[0];
|
|
11
15
|
// Tokenize the content inside the alignment block
|
|
12
16
|
const tokens = this.lexer.blockTokens(text, []);
|
|
@@ -19,7 +23,7 @@ export const markedAlign = {
|
|
|
19
23
|
};
|
|
20
24
|
}
|
|
21
25
|
if (rightMatch) {
|
|
22
|
-
const text = rightMatch[1];
|
|
26
|
+
const text = rightMatch[1] ?? '';
|
|
23
27
|
const raw = rightMatch[0];
|
|
24
28
|
// Tokenize the content inside the alignment block
|
|
25
29
|
const tokens = this.lexer.blockTokens(text, []);
|
|
@@ -2,12 +2,13 @@ export const markedCitations = {
|
|
|
2
2
|
name: 'citations',
|
|
3
3
|
level: 'inline',
|
|
4
4
|
start(src) {
|
|
5
|
-
|
|
5
|
+
const i = src.indexOf('[');
|
|
6
|
+
return i === -1 ? -1 : i;
|
|
6
7
|
},
|
|
7
8
|
tokenizer(src) {
|
|
8
9
|
// Match inline citations like [1], [ref], [1] [2], [ref] [ref2], etc.
|
|
9
10
|
// Requires non-empty bracket contents and spaces between adjacent citation brackets
|
|
10
|
-
const match = src.match(/^\[[^\]]+\](?:\s+\[[^\]]+\])*/);
|
|
11
|
+
const match = src.match(/^\[[^\][]+\](?:\s+\[[^\][]+\])*/);
|
|
11
12
|
if (match) {
|
|
12
13
|
// Early exit: if first closing bracket is immediately followed by '[', it's likely link-style syntax
|
|
13
14
|
const firstClosingBracketIndex = src.indexOf(']');
|
|
@@ -20,7 +21,7 @@ export const markedCitations = {
|
|
|
20
21
|
return undefined;
|
|
21
22
|
}
|
|
22
23
|
// Extract all citation keys (anything inside brackets)
|
|
23
|
-
const citations = match[0].match(/\[([^\]]+)\]/g);
|
|
24
|
+
const citations = match[0].match(/\[([^\][]+)\]/g);
|
|
24
25
|
if (citations) {
|
|
25
26
|
// Filter out task list syntax ([ ], [x], [X]) after trimming
|
|
26
27
|
const validCitations = citations.filter((citation) => {
|
package/dist/marked/marked-dl.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
+
// Hoisted to module scope: these are stateless (no `g` flag) and were previously
|
|
2
|
+
// recompiled on every tokenizer call (DL_RULE) and every line of a growing list (DL_LINE_RULE).
|
|
3
|
+
// The detail group must accept colons (`Time: 10:30`) and mirror DL_RULE exactly:
|
|
4
|
+
// any line the block rule consumes must also match here, or it silently vanishes.
|
|
5
|
+
const DL_RULE = /^(?:[ \t]*:[^:\n]+:[ \t]?[^\n]*(?:\n|$))+/;
|
|
6
|
+
const DL_LINE_RULE = /^\s*:([^:\n]+):([^\n]*)(?:\n|$)/;
|
|
1
7
|
export const markedDl = {
|
|
2
8
|
name: 'descriptionList',
|
|
3
9
|
level: 'block', // Is this a block-level or inline-level tokenizer?
|
|
4
10
|
tokenizer(src) {
|
|
5
|
-
const
|
|
6
|
-
const match = rule.exec(src);
|
|
11
|
+
const match = DL_RULE.exec(src);
|
|
7
12
|
if (match) {
|
|
8
13
|
const text = match[0].trim();
|
|
9
14
|
const tokens = [];
|
|
10
15
|
// Parse each line as a description
|
|
11
16
|
const lines = text.split('\n');
|
|
12
17
|
for (const line of lines) {
|
|
13
|
-
const lineMatch =
|
|
18
|
+
const lineMatch = DL_LINE_RULE.exec(line);
|
|
14
19
|
if (lineMatch) {
|
|
15
20
|
const term = lineMatch[1].trim();
|
|
16
21
|
const detail = lineMatch[2].trim();
|
package/dist/marked/marked-hr.js
CHANGED
|
@@ -3,9 +3,10 @@ export const markedHr = {
|
|
|
3
3
|
level: 'block',
|
|
4
4
|
tokenizer(src) {
|
|
5
5
|
// Match horizontal rules according to CommonMark spec:
|
|
6
|
-
// 3 or more matching -, _, or * characters,
|
|
7
|
-
//
|
|
8
|
-
|
|
6
|
+
// 3 or more matching -, _, or * characters, each optionally followed by
|
|
7
|
+
// spaces/tabs ("- - - -" is a thematic break, not a list). Must be at
|
|
8
|
+
// start of string and match the entire line.
|
|
9
|
+
const match = src.match(/^[ \t]*(?:(?:-[ \t]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n|$)/);
|
|
9
10
|
if (match) {
|
|
10
11
|
const raw = match[0].replace(/\n$/, ''); // Remove trailing newline from raw
|
|
11
12
|
return {
|
|
@@ -26,6 +26,38 @@ export const romanLower = '(?:c|xc|l?x{0,3}(?:ix|iv|v?i{0,3}))';
|
|
|
26
26
|
// Fixed regex pattern - carefully balanced parentheses
|
|
27
27
|
export const bulletPattern = `(?:[*+-]|(?:\\d{1,9}|[a-zA-Z]|${romanUpper}|${romanLower})[.)])`;
|
|
28
28
|
export const rule = `^( {0,3}${bulletPattern})([ \\t][^\\n]*|[ \\t])?(?:\\n|$)`;
|
|
29
|
+
// --- Precompiled regexes ---------------------------------------------------
|
|
30
|
+
// These were previously rebuilt on every tokenizer call and, for the boundary
|
|
31
|
+
// set, on every item iteration. They are stateless (no `g` flag) so they are
|
|
32
|
+
// safe to share. This is the bulk of the per-chunk list cost.
|
|
33
|
+
const RULE_RE = new RegExp(rule);
|
|
34
|
+
const ROMAN_UPPER_RE = new RegExp(`^${romanUpper}[.)]$`);
|
|
35
|
+
const ROMAN_LOWER_RE = new RegExp(`^${romanLower}[.)]$`);
|
|
36
|
+
const LOWER_ALPHA_RE = /^[a-z][.)]$/;
|
|
37
|
+
const UPPER_ALPHA_RE = /^[A-Z][.)]$/;
|
|
38
|
+
// The per-item boundary regexes vary only by the indent clamp (0..3); build the
|
|
39
|
+
// four variants once at module load and index into them.
|
|
40
|
+
function buildBoundaryRegexes(maxIndent) {
|
|
41
|
+
return {
|
|
42
|
+
nextBullet: new RegExp(`^ {0,${maxIndent}}(?:[*+-]|(?:\\d{1,9}|[a-zA-Z]|${romanUpper}|${romanLower})[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),
|
|
43
|
+
hr: new RegExp(`^ {0,${maxIndent}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),
|
|
44
|
+
fences: new RegExp(`^ {0,${maxIndent}}(?:\`\`\`|~~~)`),
|
|
45
|
+
heading: new RegExp(`^ {0,${maxIndent}}#`),
|
|
46
|
+
html: new RegExp(`^ {0,${maxIndent}}<[a-z].*>`, 'i')
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const LIST_BOUNDARY_TABLE = [0, 1, 2, 3].map(buildBoundaryRegexes);
|
|
50
|
+
// itemRegex depends only on `bull`, which has a small finite set of shapes;
|
|
51
|
+
// cache the compiled instances instead of recompiling per tokenizer call.
|
|
52
|
+
const itemRegexCache = new Map();
|
|
53
|
+
function getItemRegex(bull) {
|
|
54
|
+
let re = itemRegexCache.get(bull);
|
|
55
|
+
if (!re) {
|
|
56
|
+
re = new RegExp(`^( {0,3}${bull})([\t ][^\\n]*|[\t ])?(?:\\n|$)`);
|
|
57
|
+
itemRegexCache.set(bull, re);
|
|
58
|
+
}
|
|
59
|
+
return re;
|
|
60
|
+
}
|
|
29
61
|
function finalizeList(list, lexer) {
|
|
30
62
|
if (list.tokens.length === 0)
|
|
31
63
|
return;
|
|
@@ -38,11 +70,21 @@ function finalizeList(list, lexer) {
|
|
|
38
70
|
for (const item of list.tokens) {
|
|
39
71
|
lexer.state.top = false;
|
|
40
72
|
item.tokens = lexer.blockTokens(item.text, []);
|
|
73
|
+
// A blank line inside a single item also makes the list loose
|
|
74
|
+
if (!list.loose) {
|
|
75
|
+
const spaceTokens = item.tokens.filter((token) => token.type === 'space');
|
|
76
|
+
if (spaceTokens.length > 0 && spaceTokens.some((token) => /\n.*\n/.test(token.raw))) {
|
|
77
|
+
list.loose = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
41
80
|
}
|
|
42
|
-
// Mark list as loose if needed
|
|
81
|
+
// Mark list as loose if needed and re-tokenize items as block content so
|
|
82
|
+
// their text becomes paragraph tokens instead of inline text
|
|
43
83
|
if (list.loose) {
|
|
44
84
|
for (const item of list.tokens) {
|
|
45
85
|
item.loose = true;
|
|
86
|
+
lexer.state.top = true;
|
|
87
|
+
item.tokens = lexer.blockTokens(item.text, []);
|
|
46
88
|
}
|
|
47
89
|
}
|
|
48
90
|
}
|
|
@@ -53,7 +95,7 @@ export const markedList = {
|
|
|
53
95
|
name: 'list',
|
|
54
96
|
level: 'block',
|
|
55
97
|
tokenizer(src) {
|
|
56
|
-
let cap =
|
|
98
|
+
let cap = RULE_RE.exec(src);
|
|
57
99
|
if (!cap)
|
|
58
100
|
return undefined;
|
|
59
101
|
const bullet = cap[1].trim();
|
|
@@ -63,19 +105,19 @@ export const markedList = {
|
|
|
63
105
|
let expectedValue = null;
|
|
64
106
|
// Detect list type (Roman, alphabetic, numeric)
|
|
65
107
|
if (isOrdered) {
|
|
66
|
-
if (
|
|
108
|
+
if (ROMAN_UPPER_RE.test(bullet)) {
|
|
67
109
|
type = 'upper-roman';
|
|
68
110
|
bull = `${romanUpper}\\${bullet.slice(-1)}`;
|
|
69
111
|
}
|
|
70
|
-
else if (
|
|
112
|
+
else if (ROMAN_LOWER_RE.test(bullet)) {
|
|
71
113
|
type = 'lower-roman';
|
|
72
114
|
bull = `${romanLower}\\${bullet.slice(-1)}`;
|
|
73
115
|
}
|
|
74
|
-
else if (
|
|
116
|
+
else if (LOWER_ALPHA_RE.test(bullet)) {
|
|
75
117
|
type = 'lower-alpha';
|
|
76
118
|
bull = `[a-z]\\${bullet.slice(-1)}`;
|
|
77
119
|
}
|
|
78
|
-
else if (
|
|
120
|
+
else if (UPPER_ALPHA_RE.test(bullet)) {
|
|
79
121
|
type = 'upper-alpha';
|
|
80
122
|
bull = `[A-Z]\\${bullet.slice(-1)}`;
|
|
81
123
|
}
|
|
@@ -85,7 +127,6 @@ export const markedList = {
|
|
|
85
127
|
}
|
|
86
128
|
}
|
|
87
129
|
else {
|
|
88
|
-
bull = this.lexer.options.pedantic ? bullet : '[*+-]';
|
|
89
130
|
bull = this.lexer.options.pedantic ? escapeForRegex(bullet) : '[*+-]';
|
|
90
131
|
}
|
|
91
132
|
const list = {
|
|
@@ -99,7 +140,7 @@ export const markedList = {
|
|
|
99
140
|
};
|
|
100
141
|
// Get next list item
|
|
101
142
|
// Updated regex to properly handle empty list items (space after bullet, then newline)
|
|
102
|
-
const itemRegex =
|
|
143
|
+
const itemRegex = getItemRegex(bull);
|
|
103
144
|
let endsWithBlankLine = false;
|
|
104
145
|
// Check if current bullet point can start a new List Item
|
|
105
146
|
while (src) {
|
|
@@ -137,20 +178,16 @@ export const markedList = {
|
|
|
137
178
|
endEarly = true;
|
|
138
179
|
}
|
|
139
180
|
if (!endEarly) {
|
|
140
|
-
const
|
|
141
|
-
const hrRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`);
|
|
142
|
-
const fencesBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`);
|
|
143
|
-
const headingBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`);
|
|
144
|
-
const htmlBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}<[a-z].*>`, 'i');
|
|
181
|
+
const { nextBullet, hr, fences, heading, html } = LIST_BOUNDARY_TABLE[Math.min(3, indent - 1)];
|
|
145
182
|
// Check if following lines should be included in List Item
|
|
146
183
|
while (src) {
|
|
147
184
|
const rawLine = src.split('\n', 1)[0];
|
|
148
185
|
const nextLineWithoutTabs = rawLine.replace(/\t/g, ' ');
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
186
|
+
if (fences.test(nextLineWithoutTabs) ||
|
|
187
|
+
heading.test(nextLineWithoutTabs) ||
|
|
188
|
+
html.test(nextLineWithoutTabs) ||
|
|
189
|
+
nextBullet.test(nextLineWithoutTabs) ||
|
|
190
|
+
hr.test(nextLineWithoutTabs))
|
|
154
191
|
break;
|
|
155
192
|
if (nextLineWithoutTabs.search(/[^ ]/) >= indent || !nextLineWithoutTabs.trim()) {
|
|
156
193
|
itemContents += '\n' + nextLineWithoutTabs.slice(indent);
|
|
@@ -48,21 +48,19 @@ export const markedMath = [
|
|
|
48
48
|
const currentIndex = index + dollarIndex;
|
|
49
49
|
const possibleMath = src.substring(currentIndex);
|
|
50
50
|
// Check if this could be math (not currency)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
return currentIndex;
|
|
51
|
+
const match = possibleMath.match(inlineRule);
|
|
52
|
+
if (match) {
|
|
53
|
+
const content = match[2];
|
|
54
|
+
const dollarCount = match[1]; // '$' or '$$'
|
|
55
|
+
// Only apply currency detection to single dollars
|
|
56
|
+
// Double dollars ($$) indicate explicit math intent
|
|
57
|
+
if (dollarCount === '$' && isCurrencyPattern(content, src, currentIndex)) {
|
|
58
|
+
// This looks like currency with single dollars, skip it
|
|
59
|
+
index += dollarIndex + 1;
|
|
60
|
+
searchSrc = src.substring(index);
|
|
61
|
+
continue;
|
|
65
62
|
}
|
|
63
|
+
return currentIndex;
|
|
66
64
|
}
|
|
67
65
|
index += dollarIndex + 1;
|
|
68
66
|
searchSrc = src.substring(index);
|
|
@@ -90,12 +90,9 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
|
|
|
90
90
|
const processedCells = [];
|
|
91
91
|
// Track colspan cells that need rowspan
|
|
92
92
|
const colspanCells = new Map();
|
|
93
|
-
// First pass: Process each cell's colspan
|
|
93
|
+
// First pass: Process each cell's colspan
|
|
94
94
|
let cellIndex = 0;
|
|
95
|
-
const mergedIndices = new Set();
|
|
96
95
|
for (i = 0; i < cells.length; i++) {
|
|
97
|
-
if (mergedIndices.has(i))
|
|
98
|
-
continue;
|
|
99
96
|
trimmedCell = cells[i];
|
|
100
97
|
let colspan = 1;
|
|
101
98
|
// Check for colspan marker from consecutive pipes
|
|
@@ -104,24 +101,6 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
|
|
|
104
101
|
trimmedCell = parts[0];
|
|
105
102
|
colspan = parseInt(parts[1], 10);
|
|
106
103
|
}
|
|
107
|
-
else if (!trimmedCell.trim()) {
|
|
108
|
-
// Fallback: merge empty run into previous cell (backward compatibility)
|
|
109
|
-
let run = 1, k = i + 1;
|
|
110
|
-
while (k < cells.length && !cells[k].trim()) {
|
|
111
|
-
run++;
|
|
112
|
-
mergedIndices.add(k++);
|
|
113
|
-
}
|
|
114
|
-
if (processedCells.length) {
|
|
115
|
-
const target = processedCells[processedCells.length - 1];
|
|
116
|
-
const allowed = maxColspan != null ? Math.min(run, Math.max(0, maxColspan - target.colspan)) : run;
|
|
117
|
-
target.colspan += allowed;
|
|
118
|
-
numCols += allowed;
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
else {
|
|
122
|
-
colspan = maxColspan != null ? Math.min(run, maxColspan) : run;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
104
|
if (maxColspan !== null && colspan > maxColspan)
|
|
126
105
|
colspan = maxColspan;
|
|
127
106
|
processedCells[cellIndex] = {
|
|
@@ -141,8 +120,11 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
|
|
|
141
120
|
// Check if it's a rowspan indicator (single ^ at end) vs superscript (^text^)
|
|
142
121
|
const isRowspanIndicator = cellText.slice(-1) === '^' && !cellText.match(/\^[^^\n\r]+\^$/); // Not a superscript pattern ^text^
|
|
143
122
|
if (isRowspanIndicator && prevRow.length > 0) {
|
|
144
|
-
// Clean the ^ indicator from the cell text
|
|
123
|
+
// Clean the ^ indicator from the cell text. A cell that is nothing but
|
|
124
|
+
// carets (the usual `^^` continuation marker) carries no content.
|
|
145
125
|
cell.text = cellText.slice(0, -1).trim();
|
|
126
|
+
if (/^\^*$/.test(cell.text))
|
|
127
|
+
cell.text = '';
|
|
146
128
|
cellText = cell.text;
|
|
147
129
|
let targetFound = false;
|
|
148
130
|
const startPosition = cell.position || 0;
|
|
@@ -161,8 +143,10 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
|
|
|
161
143
|
// If the cell spans exactly match, simple case
|
|
162
144
|
if (cell.colspan === prevCell.colspan && cell.position === prevCell.position) {
|
|
163
145
|
cell.rowSpanTarget = prevCell.rowSpanTarget ?? prevCell;
|
|
164
|
-
// Only append text if it's different from the target cell
|
|
165
|
-
|
|
146
|
+
// Only append text if it's different from the target cell.
|
|
147
|
+
// cell.text was already cleaned of its ^ indicator above —
|
|
148
|
+
// slicing again here used to drop the last real character.
|
|
149
|
+
const textToAppend = cell.text.trim();
|
|
166
150
|
const targetText = cell.rowSpanTarget.text.trim();
|
|
167
151
|
// Don't append if the text is the same or already contained (common case for rowspan indicators)
|
|
168
152
|
if (textToAppend &&
|
|
@@ -188,8 +172,9 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
|
|
|
188
172
|
else {
|
|
189
173
|
// Standard case of single column cell with rowspan
|
|
190
174
|
cell.rowSpanTarget = prevCell.rowSpanTarget ?? prevCell;
|
|
191
|
-
// Only append text if it's different from the target cell
|
|
192
|
-
|
|
175
|
+
// Only append text if it's different from the target cell.
|
|
176
|
+
// cell.text was already cleaned of its ^ indicator above.
|
|
177
|
+
const textToAppend = cell.text.trim();
|
|
193
178
|
const targetText = cell.rowSpanTarget.text.trim();
|
|
194
179
|
// Don't append if the text is the same or already contained (common case for rowspan indicators)
|
|
195
180
|
if (textToAppend && textToAppend !== targetText && !targetText.includes(textToAppend)) {
|
|
@@ -202,13 +187,8 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
|
|
|
202
187
|
}
|
|
203
188
|
}
|
|
204
189
|
}
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
// Only clean if it was actually a rowspan indicator, not superscript
|
|
208
|
-
if (isRowspanIndicator) {
|
|
209
|
-
cell.text = cell.text.slice(0, -1);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
190
|
+
// No target found: cell.text was already cleaned of its ^ indicator
|
|
191
|
+
// above, so the cell simply renders as a normal cell.
|
|
212
192
|
}
|
|
213
193
|
}
|
|
214
194
|
// Process any complex colspan+rowspan combinations we tracked
|