svelte-streamdown 4.1.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,13 @@
6
6
  type StreamdownProps
7
7
  } from './context.svelte.js';
8
8
  import { mergeTheme, shadcnTheme } from './theme.js';
9
- import { parseBlocks, createParseBlocksCache } from './marked/index.js';
9
+ import {
10
+ parseBlocks,
11
+ createParseBlocksCache,
12
+ compileTags,
13
+ DEFAULT_TAGS
14
+ } from './marked/index.js';
15
+ import { normalizeHtmlIndentation as dedentHtml } from './utils/normalize-html-indentation.js';
10
16
  import { hasUnclosedFence } from './utils/fence.js';
11
17
 
12
18
  let {
@@ -30,6 +36,7 @@
30
36
  controls,
31
37
  codeBlockMaxHeight,
32
38
  tableMaxHeight,
39
+ lineNumbers = false,
33
40
  animation,
34
41
  element = $bindable(),
35
42
  icons,
@@ -38,6 +45,9 @@
38
45
  sources,
39
46
  inlineCitationsMode = 'carousel',
40
47
  mdxComponents,
48
+ customTags,
49
+ literalTagContent,
50
+ normalizeHtmlIndentation = false,
41
51
  components,
42
52
  static: isStatic,
43
53
  ...snippets
@@ -59,6 +69,20 @@
59
69
  // the last couple of blocks instead of the whole document.
60
70
  const blocksCache = createParseBlocksCache();
61
71
 
72
+ // One compile per allowlist identity, shared by the lexer and the streaming
73
+ // completer so the two can never disagree about what counts as a tag.
74
+ const tags = $derived.by(() => {
75
+ const names = [...(customTags ?? []), ...Object.keys(mdxComponents ?? {})];
76
+ // With nothing to compile, share DEFAULT_TAGS: the lexer options cache is
77
+ // keyed on this object, so a private copy per instance would rebuild and
78
+ // retain its own ~20 tokenizer registrations for no reason.
79
+ return names.length === 0 && !literalTagContent?.length
80
+ ? DEFAULT_TAGS
81
+ : compileTags(names, literalTagContent);
82
+ });
83
+
84
+ const source = $derived(normalizeHtmlIndentation ? dedentHtml(content) : content);
85
+
62
86
  streamdown = new StreamdownContext({
63
87
  get element() {
64
88
  return element;
@@ -176,6 +200,7 @@
176
200
  tableCopy: table.copy,
177
201
  tableDownload: table.download,
178
202
  tableDownloadFilename: table.filename ?? 'table',
203
+ tableFullscreen: table.enabled && tableSection.fullscreen !== false,
179
204
  tableCsvSeparator: tableSection.csvSeparator ?? ',',
180
205
  mermaid: mermaid.enabled,
181
206
  mermaidDownload: mermaid.download,
@@ -188,6 +213,9 @@
188
213
  get codeBlockMaxHeight() {
189
214
  return codeBlockMaxHeight;
190
215
  },
216
+ get lineNumbers() {
217
+ return lineNumbers;
218
+ },
191
219
  get tableMaxHeight() {
192
220
  return tableMaxHeight;
193
221
  },
@@ -203,6 +231,9 @@
203
231
  get mdxComponents() {
204
232
  return mdxComponents;
205
233
  },
234
+ get tags() {
235
+ return tags;
236
+ },
206
237
  get components() {
207
238
  return components;
208
239
  }
@@ -211,7 +242,7 @@
211
242
  const id = $props.id();
212
243
 
213
244
  const blocks = $derived(
214
- isStatic ? content : parseBlocks(content, streamdown.extensions, blocksCache)
245
+ isStatic ? source : parseBlocks(source, streamdown.extensions, blocksCache, tags)
215
246
  );
216
247
 
217
248
  // Only the tail of a live stream can be mid-fence — a static render is finished
@@ -223,12 +254,13 @@
223
254
 
224
255
  <div bind:this={element} class={className}>
225
256
  {#if isStatic}
226
- <Block static={isStatic} block={content} />
257
+ <Block static={isStatic} block={source} />
227
258
  {:else}
228
259
  {#each blocks as block, index (`${id}-block-${index}`)}
229
260
  <Block
230
261
  static={isStatic}
231
262
  {block}
263
+ live={index === blocks.length - 1}
232
264
  incomplete={lastBlockIncomplete && index === blocks.length - 1}
233
265
  />
234
266
  {/each}
@@ -237,6 +269,29 @@
237
269
 
238
270
  <style global>
239
271
  :global {
272
+ /* Line numbers: a counter on the line span that already exists (why it is
273
+ here and not in the theme: see resolveLineNumbers). CSS ships as a string
274
+ at runtime, so keep this short. */
275
+ [data-streamdown-code] pre[data-line-numbers] > code > span {
276
+ counter-increment: sd-line;
277
+ }
278
+
279
+ [data-streamdown-code] pre[data-line-numbers] > code > span::before {
280
+ content: counter(sd-line);
281
+ }
282
+
283
+ /* Lives here, not in Mermaid.svelte: the table overlay needs it on pages
284
+ that never render a diagram. */
285
+ [data-expanded='true'] {
286
+ position: fixed;
287
+ top: 16px;
288
+ left: 16px;
289
+ width: calc(100vw - 32px);
290
+ height: calc(100vh - 32px);
291
+ z-index: 2147483647;
292
+ margin: 0px;
293
+ }
294
+
240
295
  @keyframes sd-fade {
241
296
  from {
242
297
  opacity: 0;
@@ -28,6 +28,9 @@ export type Translations = {
28
28
  tableFormatHtml: string;
29
29
  tableFormatCsv: string;
30
30
  tableFormatTsv: string;
31
+ tableFullscreen: string;
32
+ exitTableFullscreen: string;
33
+ table: string;
31
34
  downloadDiagram: string;
32
35
  downloadDiagramPng: string;
33
36
  downloadDiagramSvg: string;
@@ -48,6 +51,8 @@ export type Translations = {
48
51
  };
49
52
  export declare const defaultTranslations: Translations;
50
53
  export interface StreamdownContext extends Omit<StreamdownProps, keyof Snippets | 'class' | 'theme' | 'highlightTheme' | 'inlineCitationsMode'> {
54
+ /** `customTags` + `mdxComponents` keys compiled once; lexer and completer share it. */
55
+ tags: TagMatchers;
51
56
  snippets: Snippets;
52
57
  highlightTheme: HighlightTheme;
53
58
  /** False while rendering a bulk update (a replacement or a paste-sized append); Block captures it per update. */
@@ -72,10 +77,11 @@ export declare class StreamdownContext<Source extends Record<string, any> = Reco
72
77
  snippets: Snippets<Source>;
73
78
  highlightTheme: HighlightTheme;
74
79
  animateUpdate: boolean;
80
+ tags: TagMatchers;
75
81
  });
76
82
  }
77
83
  export declare const useStreamdown: () => StreamdownContext<Record<string, any>>;
78
- import type { AlertToken, CodeToken, MathToken, SubSupToken, TableToken, THead, TBody, TFoot, THeadRow, TRow, TD, TH, Extension, GenericToken, CitationToken, MdxToken } from './marked/index.js';
84
+ import type { AlertToken, CodeToken, MathToken, SubSupToken, TableToken, THead, TBody, TFoot, THeadRow, TRow, TD, TH, Extension, GenericToken, CitationToken, MdxToken, TagMatchers } from './marked/index.js';
79
85
  import type { Tokens } from 'marked';
80
86
  import type { CsvSeparator } from './utils/table-export.js';
81
87
  import type { ListItemToken, ListToken } from './marked/marked-list.js';
@@ -151,6 +157,7 @@ export type TableControls = boolean | {
151
157
  enabled?: boolean;
152
158
  copy?: boolean;
153
159
  download?: DownloadControl<TableToken>;
160
+ fullscreen?: boolean;
154
161
  csvSeparator?: CsvSeparator;
155
162
  };
156
163
  export type MermaidControls = boolean | {
@@ -173,6 +180,7 @@ export type ResolvedControls = {
173
180
  tableCopy: boolean;
174
181
  tableDownload: boolean;
175
182
  tableDownloadFilename: string | ((token: TableToken) => string);
183
+ tableFullscreen: boolean;
176
184
  tableCsvSeparator: CsvSeparator;
177
185
  mermaid: boolean;
178
186
  mermaidDownload: boolean;
@@ -206,6 +214,13 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
206
214
  [K in keyof Translations]?: Partial<Translations[K]>;
207
215
  };
208
216
  controls?: Controls;
217
+ /**
218
+ * Number the lines of every code block. Default false — upstream defaults it on,
219
+ * but turning it on here would change every existing render. A fence can flip it
220
+ * either way with a `lineNumbers` / `noLineNumbers` meta word, and start at N with
221
+ * `startLine=N`.
222
+ */
223
+ lineNumbers?: boolean;
209
224
  codeBlockMaxHeight?: string;
210
225
  tableMaxHeight?: string;
211
226
  renderHtml?: boolean | ((token: Tokens.HTML | Tokens.Tag) => string);
@@ -221,6 +236,7 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
221
236
  copy?: Snippet;
222
237
  download?: Snippet;
223
238
  fullscreen?: Snippet;
239
+ close?: Snippet;
224
240
  zoomIn?: Snippet;
225
241
  zoomOut?: Snippet;
226
242
  fitView?: Snippet;
@@ -234,6 +250,21 @@ export type StreamdownProps<Source extends Record<string, any> = Record<string,
234
250
  check?: Snippet;
235
251
  };
236
252
  extensions?: Extension[];
253
+ /**
254
+ * Extra tag names the MDX tokenizer accepts on top of PascalCase, so
255
+ * `<ai-thinking>**bold**</ai-thinking>` becomes a component with parsed
256
+ * children instead of a literal html block. Keys of `mdxComponents` are
257
+ * allowed automatically; nothing else lowercase is, so real HTML is untouched.
258
+ */
259
+ customTags?: string[];
260
+ /** Tags whose children render verbatim — no Markdown, `**` and `_` left alone. */
261
+ literalTagContent?: string[];
262
+ /**
263
+ * Dedent pretty-printed HTML before parsing, so a nested `<div>` indented four
264
+ * spaces after a blank line is not read as an indented code block. Off by
265
+ * default: dedenting is lossy, and `<pre>`/`<code>` bodies are never touched.
266
+ */
267
+ normalizeHtmlIndentation?: boolean;
237
268
  children?: Snippet<[{
238
269
  streamdown: StreamdownContext;
239
270
  token: GenericToken;
@@ -19,6 +19,9 @@ export const defaultTranslations = {
19
19
  tableFormatHtml: 'HTML',
20
20
  tableFormatCsv: 'CSV',
21
21
  tableFormatTsv: 'TSV',
22
+ tableFullscreen: 'Expand table',
23
+ exitTableFullscreen: 'Collapse table',
24
+ table: 'Table',
22
25
  downloadDiagram: 'Download diagram',
23
26
  downloadDiagramPng: 'PNG',
24
27
  downloadDiagramSvg: 'SVG',
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ export { default as Streamdown } from './Streamdown.svelte';
2
2
  export { useStreamdown, defaultTranslations, type StreamdownProps, type Translations, type Controls, type CodeControls, type TableControls, type MermaidControls, type ResolvedControls } from './context.svelte.js';
3
3
  export { extractTableData, tableDataToCSV, tableDataToTSV, tableDataToMarkdown, tableDataToHTML, type TableData, type CsvSeparator } from './utils/table-export.js';
4
4
  export { theme, shadcnTheme, mergeTheme, type Theme } from './theme.js';
5
- export { type CodeToken, type Extension, type StreamdownToken, lex, parseBlocks } from './marked/index.js';
6
- export { parseIncompleteMarkdown, type Plugin, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
5
+ export { type CodeToken, type Extension, type StreamdownToken, type TagMatchers, compileTags, lex, parseBlocks } from './marked/index.js';
6
+ export { normalizeHtmlIndentation } from './utils/normalize-html-indentation.js';
7
+ export { parseIncompleteMarkdown, type Plugin, type CompleterOptions, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
7
8
  export { defineLanguage, type LanguageDefinition } from '@tanstack/highlight';
8
9
  export type { HighlightTheme } from '@tanstack/highlight/theme';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { default as Streamdown } from './Streamdown.svelte';
2
2
  export { useStreamdown, defaultTranslations } from './context.svelte.js';
3
3
  export { extractTableData, tableDataToCSV, tableDataToTSV, tableDataToMarkdown, tableDataToHTML } from './utils/table-export.js';
4
4
  export { theme, shadcnTheme, mergeTheme } from './theme.js';
5
- export { lex, parseBlocks } from './marked/index.js';
5
+ export { compileTags, lex, parseBlocks } from './marked/index.js';
6
+ export { normalizeHtmlIndentation } from './utils/normalize-html-indentation.js';
6
7
  export { parseIncompleteMarkdown, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
7
8
  export { defineLanguage } from '@tanstack/highlight';
@@ -10,7 +10,7 @@ import { type TableToken, type THead, type TBody, type TFoot, type THeadRow, typ
10
10
  import { type DescriptionDetailToken, type DescriptionListToken, type DescriptionTermToken, type DescriptionToken } from './marked-dl.js';
11
11
  import { type AlignToken } from './marked-align.js';
12
12
  import { type CitationToken } from './marked-citations.js';
13
- import { type MdxToken } from './marked-mdx.js';
13
+ import { type MdxToken, type TagMatchers } from './marked-mdx.js';
14
14
  export type GenericToken = {
15
15
  type: string;
16
16
  raw: string;
@@ -32,7 +32,7 @@ export type CodeToken = Tokens.Code & {
32
32
  };
33
33
  export type StreamdownToken = Exclude<MarkedToken, Tokens.List | Tokens.ListItem | Tokens.Table | Tokens.Code> | CodeToken | ListToken | ListItemToken | MathToken | AlertToken | FootnoteToken | SubSupToken | BrToken | HrToken | TableToken | THead | TBody | TFoot | THeadRow | TRow | TH | TD | DescriptionListToken | DescriptionToken | DescriptionDetailToken | DescriptionTermToken | AlignToken | CitationToken | MdxToken;
34
34
  export type { TableToken, THead, TBody, TFoot, THeadRow, TRow, TH, TD } from './marked-table.js';
35
- export declare const lex: (markdown: string, extensions?: Extension[]) => StreamdownToken[];
35
+ export declare const lex: (markdown: string, extensions?: Extension[], tags?: TagMatchers) => StreamdownToken[];
36
36
  /**
37
37
  * Opaque incremental state for `parseBlocks`. Create one per Streamdown
38
38
  * instance (or per simulated stream) and pass it on every call: append-only
@@ -65,5 +65,6 @@ export type ParseBlocksCache = {
65
65
  lastUpdate: 'first' | 'stream' | 'bulk';
66
66
  };
67
67
  export declare const createParseBlocksCache: () => ParseBlocksCache;
68
- export declare const parseBlocks: (markdown: string, extensions?: Extension[], cache?: ParseBlocksCache) => string[];
69
- export type { MathToken, AlertToken, FootnoteToken, SubSupToken, BrToken, HrToken, AlignToken, CitationToken, MdxToken };
68
+ export declare const parseBlocks: (markdown: string, extensions?: Extension[], cache?: ParseBlocksCache, tags?: TagMatchers) => string[];
69
+ export { compileTags, DEFAULT_TAGS } from './marked-mdx.js';
70
+ export type { TagMatchers, MathToken, AlertToken, FootnoteToken, SubSupToken, BrToken, HrToken, AlignToken, CitationToken, MdxToken };
@@ -10,13 +10,14 @@ import { markedTable } from './marked-table.js';
10
10
  import { markedDl } from './marked-dl.js';
11
11
  import { markedAlign } from './marked-align.js';
12
12
  import { markedCitations } from './marked-citations.js';
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 = [
13
+ import { markedMdx, DEFAULT_TAGS } from './marked-mdx.js';
14
+ // Default plugin sets, in registration order. Built behind the options cache
15
+ // below, so the options object (and the regexes/closures inside each plugin) is
16
+ // built once per allowlist, not per chunk. The tokenizers are stateless at
17
+ // creation time — per-document state lives on the Lexer instance (e.g. footnotes
18
+ // maps, reference-link defs), so a fresh Lexer per call keeps documents isolated
19
+ // while the options are shared.
20
+ const defaultLexExtensions = (tags) => [
20
21
  markedHr,
21
22
  markedTable,
22
23
  ...markedFootnote(),
@@ -29,15 +30,15 @@ const DEFAULT_LEX_EXTENSIONS = [
29
30
  markedDl,
30
31
  markedAlign,
31
32
  markedCitations,
32
- markedMdx
33
+ markedMdx(tags)
33
34
  ];
34
- const DEFAULT_BLOCK_EXTENSIONS = [
35
+ const defaultBlockExtensions = (tags) => [
35
36
  markedHr,
36
37
  ...markedFootnote(),
37
38
  markedDl,
38
39
  markedTable,
39
40
  markedAlign,
40
- markedMdx
41
+ markedMdx(tags)
41
42
  ];
42
43
  class StreamdownTokenizer extends Tokenizer {
43
44
  /**
@@ -104,38 +105,25 @@ const parseExtensions = (...extensions) => {
104
105
  });
105
106
  return options;
106
107
  };
107
- // Options objects are reusable across Lexer instances; cache them per
108
- // user-extension array (props are referentially stable across chunks) so the hot
109
- // path skips rebuilding ~20 tokenizer registrations on every streamed chunk. The
110
- // cached object carries one shared Tokenizer whose `lexer` back-pointer marked
111
- // re-stamps per Lexer construction — safe only because lexing is synchronous and
112
- // never re-entrant across documents.
113
- const DEFAULT_LEX_OPTIONS = parseExtensions(...DEFAULT_LEX_EXTENSIONS);
114
- const DEFAULT_BLOCK_OPTIONS = parseExtensions(...DEFAULT_BLOCK_EXTENSIONS);
115
- const lexOptionsCache = new WeakMap();
116
- const blockOptionsCache = new WeakMap();
117
- const getLexOptions = (extensions) => {
118
- if (extensions.length === 0)
119
- return DEFAULT_LEX_OPTIONS;
120
- let options = lexOptionsCache.get(extensions);
121
- if (!options) {
122
- options = parseExtensions(...DEFAULT_LEX_EXTENSIONS, ...extensions);
123
- lexOptionsCache.set(extensions, options);
124
- }
125
- return options;
126
- };
127
- const getBlockOptions = (extensions) => {
128
- if (extensions.length === 0)
129
- return DEFAULT_BLOCK_OPTIONS;
130
- let options = blockOptionsCache.get(extensions);
131
- if (!options) {
132
- options = parseExtensions(...DEFAULT_BLOCK_EXTENSIONS, ...extensions.filter(({ level, applyInBlockParsing }) => level === 'block' && applyInBlockParsing));
133
- blockOptionsCache.set(extensions, options);
134
- }
108
+ // Stand-in key for "no user extensions" so both cache levels stay WeakMaps —
109
+ // the `extensions = []` default allocates a fresh array on every call.
110
+ const NO_EXTENSIONS = [];
111
+ const cached = (cache, tags, extensions, build) => {
112
+ let byExtensions = cache.get(tags);
113
+ if (!byExtensions)
114
+ cache.set(tags, (byExtensions = new WeakMap()));
115
+ const key = extensions.length === 0 ? NO_EXTENSIONS : extensions;
116
+ let options = byExtensions.get(key);
117
+ if (!options)
118
+ byExtensions.set(key, (options = build()));
135
119
  return options;
136
120
  };
137
- export const lex = (markdown, extensions = []) => {
138
- return new Lexer(getLexOptions(extensions))
121
+ const lexOptionsCache = new WeakMap();
122
+ const blockOptionsCache = new WeakMap();
123
+ const getLexOptions = (extensions, tags) => cached(lexOptionsCache, tags, extensions, () => parseExtensions(...defaultLexExtensions(tags), ...extensions));
124
+ const getBlockOptions = (extensions, tags) => cached(blockOptionsCache, tags, extensions, () => parseExtensions(...defaultBlockExtensions(tags), ...extensions.filter(({ level, applyInBlockParsing }) => level === 'block' && applyInBlockParsing)));
125
+ export const lex = (markdown, extensions = [], tags = DEFAULT_TAGS) => {
126
+ return new Lexer(getLexOptions(extensions, tags))
139
127
  .lex(markdown)
140
128
  .filter((token) => token.type !== 'space' && token.type !== 'footnote');
141
129
  };
@@ -182,7 +170,7 @@ class SplitLexer extends Lexer {
182
170
  return tokens;
183
171
  }
184
172
  }
185
- const blockTokensOf = (markdown, extensions) => new SplitLexer(getBlockOptions(extensions)).blockTokens(markdown, []);
173
+ const blockTokensOf = (markdown, extensions, tags) => new SplitLexer(getBlockOptions(extensions, tags)).blockTokens(markdown, []);
186
174
  /**
187
175
  * Is `markdown` an append to `cache.content`?
188
176
  *
@@ -237,7 +225,7 @@ const updateKind = (isAppend, previousLength, length) => {
237
225
  return previousLength === 0 ? 'first' : 'bulk';
238
226
  return length - previousLength > BULK_APPEND_CHARS ? 'bulk' : 'stream';
239
227
  };
240
- export const parseBlocks = (markdown, extensions = [], cache) => {
228
+ export const parseBlocks = (markdown, extensions = [], cache, tags = DEFAULT_TAGS) => {
241
229
  // Whether this call extends the content the cache already described — decided
242
230
  // by the same probe the fast path uses, so the contiguity fallback below still
243
231
  // counts as an append for the animation's purposes.
@@ -257,7 +245,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
257
245
  const offset = cache.offsets[cut];
258
246
  if (appendable(markdown, cache, cut, offset)) {
259
247
  isAppend = true;
260
- const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
248
+ const tailTokens = blockTokensOf(markdown.slice(offset), extensions, tags);
261
249
  let tailLength = 0;
262
250
  for (const token of tailTokens)
263
251
  tailLength += token.raw.length;
@@ -295,7 +283,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
295
283
  }
296
284
  }
297
285
  // Full parse (first call, non-append update, or contiguity fallback).
298
- const tokens = blockTokensOf(markdown, extensions);
286
+ const tokens = blockTokensOf(markdown, extensions, tags);
299
287
  if (cache) {
300
288
  cache.raws.length = 0;
301
289
  cache.keep.length = 0;
@@ -329,3 +317,4 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
329
317
  }
330
318
  return blocks;
331
319
  };
320
+ export { compileTags, DEFAULT_TAGS } from './marked-mdx.js';
@@ -9,4 +9,30 @@ export type MdxToken = {
9
9
  tokens?: Token[];
10
10
  text?: string;
11
11
  };
12
- export declare const markedMdx: Extension;
12
+ /**
13
+ * The tag vocabulary the mdx tokenizer and the streaming completer share,
14
+ * compiled once per allowlist (upstream fb9f97c/b392fbe). PascalCase is always
15
+ * in it; lowercase/hyphenated names only ever come from `customTags` /
16
+ * `mdxComponents`, so this can never swallow a real HTML tag the way a blanket
17
+ * `/^<([a-zA-Z][\w-]*)/` would.
18
+ */
19
+ export type TagMatchers = {
20
+ /** `<Tag …/>` */
21
+ selfClosing: RegExp;
22
+ /** `<Tag …>` */
23
+ openTag: RegExp;
24
+ /** `</Tag>` */
25
+ closeTag: RegExp;
26
+ /** `<Tag …>…</Tag>`, both ends on one line */
27
+ complete: RegExp;
28
+ /** a bare allowed tag name, anchored */
29
+ name: RegExp;
30
+ /** the non-PascalCase names, kept as strings so a half-typed one can be prefix-matched */
31
+ names: string[];
32
+ /** tags whose children are emitted as one literal text token */
33
+ literal: Set<string>;
34
+ };
35
+ export declare const compileTags: (customTags?: string[], literalTagContent?: string[]) => TagMatchers;
36
+ /** PascalCase only — what every call that passes no allowlist gets. */
37
+ export declare const DEFAULT_TAGS: TagMatchers;
38
+ export declare const markedMdx: (tags?: TagMatchers) => Extension;
@@ -1,4 +1,26 @@
1
1
  import { Lexer } from 'marked';
2
+ const escapeName = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ // Attribute names take hyphens (`data-id`), not just word characters.
4
+ const ATTRS = '((?:\\s+[\\w-]+=(?:"[^"]*"|{[^}]*}))*)';
5
+ export const compileTags = (customTags, literalTagContent) => {
6
+ // A tag whose content is literal is a custom tag by definition: listing it
7
+ // once in `literalTagContent` must be enough, without repeating it in
8
+ // `customTags`.
9
+ const allowed = [...new Set([...(customTags ?? []), ...(literalTagContent ?? [])])];
10
+ const names = ['[A-Z][a-zA-Z0-9]*', ...allowed.map(escapeName)].join('|');
11
+ const tag = `(${names})`;
12
+ return {
13
+ selfClosing: new RegExp(`^<${tag}${ATTRS}\\s*/>`),
14
+ openTag: new RegExp(`^<${tag}${ATTRS}\\s*>`),
15
+ closeTag: new RegExp(`^</${tag}>`),
16
+ complete: new RegExp(`^<${tag}${ATTRS}\\s*>.*?</\\1>`),
17
+ name: new RegExp(`^(?:${names})$`),
18
+ names: allowed,
19
+ literal: new Set(literalTagContent ?? [])
20
+ };
21
+ };
22
+ /** PascalCase only — what every call that passes no allowlist gets. */
23
+ export const DEFAULT_TAGS = compileTags();
2
24
  const defaultLexer = new Lexer({ gfm: true });
3
25
  const defaultTokenizer = defaultLexer.options.tokenizer;
4
26
  /**
@@ -8,7 +30,7 @@ const defaultTokenizer = defaultLexer.options.tokenizer;
8
30
  function parseAttributes(attributeString) {
9
31
  const attributes = {};
10
32
  // Pattern: attr="value" or attr={value}
11
- const attrPattern = /(\w+)=(?:"([^"]*)"|{([^}]*)})/g;
33
+ const attrPattern = /([\w-]+)=(?:"([^"]*)"|{([^}]*)})/g;
12
34
  let match;
13
35
  while ((match = attrPattern.exec(attributeString)) !== null) {
14
36
  const [, name, stringValue, expressionValue] = match;
@@ -38,16 +60,17 @@ function parseAttributes(attributeString) {
38
60
  }
39
61
  return attributes;
40
62
  }
41
- export const markedMdx = {
63
+ export const markedMdx = (tags = DEFAULT_TAGS) => ({
42
64
  name: 'mdx',
43
65
  level: 'block',
44
66
  applyInBlockParsing: true,
45
67
  tokenizer(src) {
46
- // Match MDX component tags (must start with capital letter)
68
+ // Match a tag from the allowlist (PascalCase plus anything `customTags` /
69
+ // `mdxComponents` added).
47
70
  // Self-closing: <Component attr="value" />
48
71
  // With children: <Component attr="value">content</Component>
49
72
  // First try self-closing tag
50
- const selfClosingMatch = src.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*\/>/);
73
+ const selfClosingMatch = src.match(tags.selfClosing);
51
74
  if (selfClosingMatch) {
52
75
  const [raw, tagName, attributeString] = selfClosingMatch;
53
76
  const attributes = parseAttributes(attributeString);
@@ -60,7 +83,7 @@ export const markedMdx = {
60
83
  };
61
84
  }
62
85
  // Try paired tag with children
63
- const openTagMatch = src.match(/^<([A-Z][a-zA-Z0-9]*)((?:\s+\w+=(?:"[^"]*"|{[^}]*}))*)\s*>/);
86
+ const openTagMatch = src.match(tags.openTag);
64
87
  if (openTagMatch) {
65
88
  const [openTag, tagName, attributeString] = openTagMatch;
66
89
  const attributes = parseAttributes(attributeString);
@@ -106,8 +129,14 @@ export const markedMdx = {
106
129
  const contentStart = openTag.length;
107
130
  const content = src.substring(contentStart, closingIndex);
108
131
  const raw = src.substring(0, closingIndex + closingTag.length);
109
- // Parse children as markdown
110
- const tokens = content.trim() ? this.lexer.blockTokens(content.trim(), []) : [];
132
+ // Parse children as markdown — unless the tag is in `literalTagContent`,
133
+ // where the inner text is data, not Markdown (upstream b392fbe): one
134
+ // text token, nothing escaped, `**` and `_` left alone.
135
+ const tokens = tags.literal.has(tagName)
136
+ ? [{ type: 'text', raw: content, text: content }]
137
+ : content.trim()
138
+ ? this.lexer.blockTokens(content.trim(), [])
139
+ : [];
111
140
  return {
112
141
  type: 'mdx',
113
142
  raw,
@@ -121,4 +150,4 @@ export const markedMdx = {
121
150
  }
122
151
  return undefined;
123
152
  }
124
- };
153
+ });
package/dist/theme.d.ts CHANGED
@@ -44,6 +44,7 @@ export declare const theme: {
44
44
  language: string;
45
45
  pre: string;
46
46
  line: string;
47
+ lineNumber: string;
47
48
  };
48
49
  codespan: {
49
50
  base: string;
@@ -68,6 +69,7 @@ export declare const theme: {
68
69
  table: {
69
70
  base: string;
70
71
  table: string;
72
+ expanded: string;
71
73
  };
72
74
  thead: {
73
75
  base: string;
@@ -196,6 +198,7 @@ export declare const shadcnTheme: {
196
198
  language: string;
197
199
  pre: string;
198
200
  line: string;
201
+ lineNumber: string;
199
202
  };
200
203
  codespan: {
201
204
  base: string;
@@ -220,6 +223,7 @@ export declare const shadcnTheme: {
220
223
  table: {
221
224
  base: string;
222
225
  table: string;
226
+ expanded: string;
223
227
  };
224
228
  thead: {
225
229
  base: string;
@@ -353,6 +357,7 @@ export declare const mergeTheme: (customTheme?: DeepPartialTheme, baseTheme?: "t
353
357
  language: string;
354
358
  pre: string;
355
359
  line: string;
360
+ lineNumber: string;
356
361
  };
357
362
  codespan: {
358
363
  base: string;
@@ -377,6 +382,7 @@ export declare const mergeTheme: (customTheme?: DeepPartialTheme, baseTheme?: "t
377
382
  table: {
378
383
  base: string;
379
384
  table: string;
385
+ expanded: string;
380
386
  };
381
387
  thead: {
382
388
  base: string;
package/dist/theme.js CHANGED
@@ -44,7 +44,10 @@ export const theme = {
44
44
  buttons: 'flex items-center gap-2',
45
45
  language: 'ml-1 font-mono lowercase',
46
46
  pre: 'overflow-x-auto font-mono p-0 bg-gray-100/40',
47
- line: 'block'
47
+ line: 'block',
48
+ // Gutter appearance only; the counter itself is a global rule in
49
+ // Streamdown.svelte (see the comment there) so numbers work without Tailwind.
50
+ lineNumber: 'before:inline-block before:w-8 before:pr-3 before:text-right before:text-gray-400 before:select-none'
48
51
  },
49
52
  codespan: {
50
53
  base: 'bg-gray-100 rounded px-1.5 py-0.5 font-mono text-[0.9em]'
@@ -68,7 +71,10 @@ export const theme = {
68
71
  },
69
72
  table: {
70
73
  base: 'overflow-x-auto max-w-full my-4 border border-gray-200 rounded-lg',
71
- table: 'w-full border-collapse min-w-full'
74
+ table: 'w-full border-collapse min-w-full',
75
+ // Added to `base` while fullscreen; the fixed positioning itself comes from
76
+ // the global [data-expanded='true'] rule.
77
+ expanded: 'overflow-auto bg-white p-2 pt-10 shadow-2xl'
72
78
  },
73
79
  thead: {
74
80
  base: 'bg-gray-200/80'
@@ -196,7 +202,8 @@ export const shadcnTheme = {
196
202
  buttons: 'flex items-center gap-2',
197
203
  language: 'ml-1 font-mono lowercase',
198
204
  pre: 'overflow-x-auto font-mono p-0 bg-muted/40',
199
- line: 'block '
205
+ line: 'block ',
206
+ lineNumber: 'before:inline-block before:w-8 before:pr-3 before:text-right before:text-muted-foreground before:select-none'
200
207
  },
201
208
  codespan: {
202
209
  base: 'bg-muted rounded px-1.5 py-0.5 font-mono text-foreground text-[0.9em]'
@@ -220,7 +227,8 @@ export const shadcnTheme = {
220
227
  },
221
228
  table: {
222
229
  base: 'overflow-x-auto max-w-full my-4 rounded-lg border border-border',
223
- table: 'w-full border-collapse min-w-full'
230
+ table: 'w-full border-collapse min-w-full',
231
+ expanded: 'overflow-auto bg-background p-2 pt-10 shadow-2xl'
224
232
  },
225
233
  thead: {
226
234
  base: 'bg-muted/80'
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The fullscreen half of the mermaid expand behaviour (Mermaid.svelte:264-267)
3
+ * without panzoom's `expand()`, which is entangled with `zoomToFit` and a FLIP
4
+ * animation a table has no use for: state, Escape and focus only. The
5
+ * `position: fixed` comes from the global `[data-expanded='true']` rule in
6
+ * Streamdown.svelte.
7
+ *
8
+ * The flag lives in the caller (so the markup can drive `role`/`aria-*`/the
9
+ * expanded class from it); this only drives it.
10
+ *
11
+ * // ponytail: no open/close animation. Upgrade path: measure the target rect
12
+ * // either side of `set()` and run the same first/last invert panzoom does,
13
+ * // once that block is untangled from the zoom state it reads today.
14
+ */
15
+ export declare const useExpand: (opts: {
16
+ expanded: boolean;
17
+ getTarget: () => HTMLElement | null | undefined;
18
+ }) => {
19
+ readonly expanded: boolean;
20
+ toggle: (from?: HTMLElement | null) => void;
21
+ };