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.
@@ -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 ? Object.keys(shikiThemes)[0] || 'github-light' : darkMode.current ? 'github-dark' : 'github-light'
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 mermaidControls = controls?.mermaid ?? true;
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
- const blocks = $derived(isStatic ? content : parseBlocks(content, streamdown.extensions));
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}>
@@ -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);
@@ -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
- export declare const parseBlocks: (markdown: string, extensions?: Extension[]) => string[];
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 };
@@ -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(parseExtensions(markedHr, markedTable, ...markedFootnote(), markedAlert, ...markedMath, markedSub, markedSup, markedList, markedBr, markedDl, markedAlign, markedCitations, markedMdx, ...extensions))
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 parseBlocks = (markdown, extensions = []) => {
52
- const blockLexer = new Lexer(parseExtensions(markedHr, ...markedFootnote(), markedDl, markedTable, markedAlign, markedMdx, ...extensions.filter(({ level, applyInBlockParsing }) => level === 'block' && applyInBlockParsing)));
53
- return blockLexer.blockTokens(markdown, []).reduce((acc, block) => {
54
- if (block.type === 'space' || block.type === 'footnote') {
55
- return acc;
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
- else {
58
- acc.push(block.raw);
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
- return acc;
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 matchedVariant = variants.find((type) => new RegExp(createSyntaxPattern(type), 'i').test(('text' in token && token.text) || ''));
22
- if (!matchedVariant) {
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 alertPattern = new RegExp(`[\\*_]*\\[!${matchedVariant.toUpperCase()}\\][\\*_]*`, 'g');
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
- const centerMatch = src.match(/^\[center\]\n([\s\S]*?)\n\[\/center\]/);
7
- const rightMatch = src.match(/^\[right\]\n([\s\S]*?)\n\[\/right\]/);
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
- return src.indexOf('[') === -1 ? -1 : 0;
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) => {
@@ -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 rule = /^(?:[ \t]*:[^:\n]+:[ \t]?[^\n]*(?:\n|$))+/;
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 = /^\s*:([^:\n]+):([^:\n]*)(?:\n|$)/.exec(line);
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 = /^\[\^([^\]\n]+)\]:(?:[ \t]+|[\n]*?|$)([^\n]*?(?:\n|$)(?:\n*?[ ]{4,}[^\n]*)*)/.exec(src);
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(/^(?:[ ]{4}|[\t])/, '');
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 && /^[ \t]*?[>\-*][ ]|[`]{3,}$|^[ \t]*?[|].+[|]$/.test(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 = /^\[\^([^\]\n]+)\]/.exec(src);
70
+ const match = footnoteRefRegex.exec(src);
70
71
  if (match) {
71
72
  const [raw, label] = match;
72
73
  const footnote = maps.footnotes.get(label);
@@ -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, with optional spaces between
7
- // Must be at start of string and match entire line
8
- const match = src.match(/^[ \t]*(-[ \t]*-[ \t]*-+|_[ \t]*_[ \t]*_+|\*[ \t]*\*[ \t]*\*+)[ \t]*(?:\n|$)/);
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 = new RegExp(rule).exec(src);
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 (bullet.match(new RegExp(`^${romanUpper}[.)]$`))) {
108
+ if (ROMAN_UPPER_RE.test(bullet)) {
67
109
  type = 'upper-roman';
68
110
  bull = `${romanUpper}\\${bullet.slice(-1)}`;
69
111
  }
70
- else if (bullet.match(new RegExp(`^${romanLower}[.)]$`))) {
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 (bullet.match(/^[a-z][.)]$/)) {
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 (bullet.match(/^[A-Z][.)]$/)) {
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 = new RegExp(`^( {0,3}${bull})([\t ][^\\n]*|[\t ])?(?:\\n|$)`);
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 nextBulletRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|(?:\\d{1,9}|[a-zA-Z]|${romanUpper}|${romanLower})[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`);
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 (fencesBeginRegex.test(nextLineWithoutTabs) ||
150
- headingBeginRegex.test(nextLineWithoutTabs) ||
151
- htmlBeginRegex.test(nextLineWithoutTabs) ||
152
- nextBulletRegex.test(nextLineWithoutTabs) ||
153
- hrRegex.test(nextLineWithoutTabs))
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
- if (possibleMath.match(inlineRule)) {
52
- const match = possibleMath.match(inlineRule);
53
- if (match) {
54
- const content = match[2];
55
- const dollarCount = match[1]; // '$' or '$$'
56
- // Only apply currency detection to single dollars
57
- // Double dollars ($$) indicate explicit math intent
58
- if (dollarCount === '$' && isCurrencyPattern(content, src, currentIndex)) {
59
- // This looks like currency with single dollars, skip it
60
- index += dollarIndex + 1;
61
- searchSrc = src.substring(index);
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);