svelte-streamdown 3.0.0 → 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 +260 -139
- package/dist/Elements/FootnoteRef.svelte +1 -1
- package/dist/Elements/Mermaid.svelte +1 -1
- package/dist/Streamdown.svelte +17 -4
- 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-footnotes.js +7 -6
- 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 = '',
|
|
@@ -40,7 +40,11 @@
|
|
|
40
40
|
const darkMode = useDarkMode();
|
|
41
41
|
|
|
42
42
|
const shikiThemedTheme = $derived(
|
|
43
|
-
shikiThemes
|
|
43
|
+
shikiThemes
|
|
44
|
+
? Object.keys(shikiThemes)[0] || 'github-light'
|
|
45
|
+
: darkMode.current
|
|
46
|
+
? 'github-dark'
|
|
47
|
+
: 'github-light'
|
|
44
48
|
);
|
|
45
49
|
|
|
46
50
|
const mermaidThemedTheme = $derived(
|
|
@@ -123,11 +127,15 @@
|
|
|
123
127
|
},
|
|
124
128
|
get controls() {
|
|
125
129
|
const codeControls = controls?.code ?? true;
|
|
126
|
-
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;
|
|
127
134
|
const tableControls = controls?.table ?? true;
|
|
128
135
|
return {
|
|
129
136
|
code: codeControls,
|
|
130
137
|
mermaid: mermaidControls,
|
|
138
|
+
mermaidMouseWheelZoom,
|
|
131
139
|
table: tableControls
|
|
132
140
|
};
|
|
133
141
|
},
|
|
@@ -150,7 +158,12 @@
|
|
|
150
158
|
|
|
151
159
|
const id = $props.id();
|
|
152
160
|
|
|
153
|
-
|
|
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
|
+
);
|
|
154
167
|
</script>
|
|
155
168
|
|
|
156
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();
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {} from './index.js';
|
|
2
2
|
import { StreamdownContext } from '../context.svelte.js';
|
|
3
3
|
import { getContext } from 'svelte';
|
|
4
|
+
const footnoteRegex = /^\[\^([^\]\n]+)\]:(?:[ \t]+|\n|$)([^\n]*(?:\n(?:[ \t]+[^\n]*)?)*)/;
|
|
5
|
+
const footnoteRefRegex = /^\[\^([^\]\n]+)\]/;
|
|
6
|
+
const footNoteLastLineRegex = /^[ \t]*?[>\-*][ ]|[`]{3,}$|^[ \t]*?[|].+[|]$/;
|
|
4
7
|
const safeGetContext = () => {
|
|
5
8
|
try {
|
|
6
9
|
return getContext('streamdown');
|
|
@@ -32,18 +35,16 @@ export function markedFootnote() {
|
|
|
32
35
|
level: 'block',
|
|
33
36
|
tokenizer(src) {
|
|
34
37
|
const maps = ensureMaps(this);
|
|
35
|
-
const match =
|
|
38
|
+
const match = footnoteRegex.exec(src);
|
|
36
39
|
if (match) {
|
|
37
40
|
const [raw, label, text = ''] = match;
|
|
38
41
|
let content = text.split('\n').reduce((acc, curr) => {
|
|
39
|
-
return acc + '\n' + curr.replace(/^
|
|
42
|
+
return acc + '\n' + curr.replace(/^[ \t]+/, '');
|
|
40
43
|
}, '');
|
|
41
44
|
const contentLastLine = content.trimEnd().split('\n').pop();
|
|
42
45
|
content +=
|
|
43
46
|
// add lines after list, blockquote, codefence, and table
|
|
44
|
-
contentLastLine &&
|
|
45
|
-
? '\n\n'
|
|
46
|
-
: '';
|
|
47
|
+
contentLastLine && footNoteLastLineRegex.test(contentLastLine) ? '\n\n' : '';
|
|
47
48
|
const lines = content.split('\n');
|
|
48
49
|
const token = {
|
|
49
50
|
type: 'footnote',
|
|
@@ -66,7 +67,7 @@ export function markedFootnote() {
|
|
|
66
67
|
level: 'inline',
|
|
67
68
|
tokenizer(src) {
|
|
68
69
|
const maps = ensureMaps(this);
|
|
69
|
-
const match =
|
|
70
|
+
const match = footnoteRefRegex.exec(src);
|
|
70
71
|
if (match) {
|
|
71
72
|
const [raw, label] = match;
|
|
72
73
|
const footnote = maps.footnotes.get(label);
|
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);
|