svelte-streamdown 3.0.1 → 3.1.1

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 = '',
@@ -127,11 +127,15 @@
127
127
  },
128
128
  get controls() {
129
129
  const codeControls = controls?.code ?? true;
130
- 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;
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
- 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
+ );
158
167
  </script>
159
168
 
160
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();
@@ -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);
@@ -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 and merge consecutive empty cells
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
- const textToAppend = cell.text.slice(0, -1).trim();
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
- const textToAppend = cell.text.slice(0, -1).trim();
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
- // If no target was found but it's a rowspan cell, clean the ^ indicator
206
- if (!targetFound && cell.rowspan > 0) {
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
package/dist/theme.js CHANGED
@@ -81,7 +81,7 @@ export const theme = {
81
81
  base: 'bg-gray-100/50 border-t border-gray-300'
82
82
  },
83
83
  tr: {
84
- base: 'border-gray-200 border-b hover:bg-gray-100/50 transition-colors'
84
+ base: 'border-gray-200 not-last:border-b hover:bg-gray-100/50 transition-colors'
85
85
  },
86
86
  td: {
87
87
  base: 'px-4 py-3 text-sm min-w-[200px] max-w-[400px] break-words'
@@ -234,7 +234,7 @@ export const shadcnTheme = {
234
234
  base: 'bg-muted/50 border-t border-border'
235
235
  },
236
236
  tr: {
237
- base: 'border-border border-b hover:bg-muted/50 transition-colors'
237
+ base: 'border-border not-last:border-b hover:bg-muted/50 transition-colors'
238
238
  },
239
239
  td: {
240
240
  base: 'px-4 py-3 text-sm text-foreground min-w-[200px] max-w-[400px] break-words'