svelte-streamdown 4.1.0 → 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.
Files changed (37) hide show
  1. package/README.md +149 -47
  2. package/dist/Block.svelte +19 -8
  3. package/dist/Block.svelte.d.ts +2 -0
  4. package/dist/Elements/Code.svelte +15 -4
  5. package/dist/Elements/Code.svelte.d.ts +2 -0
  6. package/dist/Elements/Element.svelte +32 -11
  7. package/dist/Elements/Element.svelte.d.ts +1 -0
  8. package/dist/Elements/Mermaid.svelte +5 -11
  9. package/dist/Elements/Mermaid.svelte.d.ts +1 -0
  10. package/dist/Elements/TableDownload.svelte +44 -3
  11. package/dist/Elements/TableDownload.svelte.d.ts +3 -1
  12. package/dist/Elements/fallbacks/CodeFallback.svelte +16 -6
  13. package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -2
  14. package/dist/Elements/icons.d.ts +1 -0
  15. package/dist/Elements/icons.js +8 -0
  16. package/dist/Streamdown.svelte +68 -6
  17. package/dist/context.svelte.d.ts +37 -1
  18. package/dist/context.svelte.js +3 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.js +2 -1
  21. package/dist/marked/index.d.ts +11 -4
  22. package/dist/marked/index.js +54 -45
  23. package/dist/marked/marked-mdx.d.ts +27 -1
  24. package/dist/marked/marked-mdx.js +37 -8
  25. package/dist/theme.d.ts +6 -0
  26. package/dist/theme.js +12 -4
  27. package/dist/utils/expand.svelte.d.ts +21 -0
  28. package/dist/utils/expand.svelte.js +46 -0
  29. package/dist/utils/fence.d.ts +12 -1
  30. package/dist/utils/fence.js +34 -17
  31. package/dist/utils/line-numbers.d.ts +23 -0
  32. package/dist/utils/line-numbers.js +30 -0
  33. package/dist/utils/normalize-html-indentation.d.ts +10 -0
  34. package/dist/utils/normalize-html-indentation.js +52 -0
  35. package/dist/utils/parse-incomplete-markdown.d.ts +20 -6
  36. package/dist/utils/parse-incomplete-markdown.js +173 -69
  37. package/package.json +1 -1
@@ -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
- }
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()));
125
119
  return options;
126
120
  };
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
- }
135
- return options;
136
- };
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
  };
@@ -145,7 +133,8 @@ export const createParseBlocksCache = () => ({
145
133
  keep: [],
146
134
  offsets: [0],
147
135
  keptBefore: [0],
148
- blocks: []
136
+ blocks: [],
137
+ lastUpdate: 'first'
149
138
  });
150
139
  // Number of trailing rendered blocks that stay "live" (re-lexed every chunk).
151
140
  // 2 covers constructs that merge backward as they stream in — e.g. a paragraph
@@ -181,7 +170,7 @@ class SplitLexer extends Lexer {
181
170
  return tokens;
182
171
  }
183
172
  }
184
- const blockTokensOf = (markdown, extensions) => new SplitLexer(getBlockOptions(extensions)).blockTokens(markdown, []);
173
+ const blockTokensOf = (markdown, extensions, tags) => new SplitLexer(getBlockOptions(extensions, tags)).blockTokens(markdown, []);
185
174
  /**
186
175
  * Is `markdown` an append to `cache.content`?
187
176
  *
@@ -225,7 +214,23 @@ const appendable = (markdown, cache, cut, offset) => {
225
214
  }
226
215
  return markdown.charCodeAt(offset - 1) === content.charCodeAt(offset - 1);
227
216
  };
228
- export const parseBlocks = (markdown, extensions = [], cache) => {
217
+ // An append this large in a single update is a paste or a "show all", not a
218
+ // streamed chunk. Real streams arrive in tens of characters, and even a client
219
+ // that batches renders to one frame at ~1000 tokens/s adds a few hundred; the
220
+ // harm — thousands of spans starting a CSS animation in the same frame — only
221
+ // begins well past this. ponytail: heuristic; a prop if anyone needs to tune it.
222
+ const BULK_APPEND_CHARS = 2048;
223
+ const updateKind = (isAppend, previousLength, length) => {
224
+ if (!isAppend)
225
+ return previousLength === 0 ? 'first' : 'bulk';
226
+ return length - previousLength > BULK_APPEND_CHARS ? 'bulk' : 'stream';
227
+ };
228
+ export const parseBlocks = (markdown, extensions = [], cache, tags = DEFAULT_TAGS) => {
229
+ // Whether this call extends the content the cache already described — decided
230
+ // by the same probe the fast path uses, so the contiguity fallback below still
231
+ // counts as an append for the animation's purposes.
232
+ let isAppend = false;
233
+ const previousLength = cache?.content.length ?? 0;
229
234
  if (cache && cache.content.length > 0 && markdown.length > cache.content.length) {
230
235
  // Append-only update: seal everything except the last SEAL_SLACK rendered
231
236
  // blocks and re-lex only the tail. offsets[] are prefix sums over raws, so
@@ -239,7 +244,8 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
239
244
  }
240
245
  const offset = cache.offsets[cut];
241
246
  if (appendable(markdown, cache, cut, offset)) {
242
- const tailTokens = blockTokensOf(markdown.slice(offset), extensions);
247
+ isAppend = true;
248
+ const tailTokens = blockTokensOf(markdown.slice(offset), extensions, tags);
243
249
  let tailLength = 0;
244
250
  for (const token of tailTokens)
245
251
  tailLength += token.raw.length;
@@ -268,6 +274,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
268
274
  cache.keptBefore.push(kept);
269
275
  }
270
276
  cache.content = markdown;
277
+ cache.lastUpdate = updateKind(isAppend, previousLength, markdown.length);
271
278
  // Copy out: callers (Svelte `$derived`, the perf harness) diff block
272
279
  // lists by identity, so handing back the persistent array would read as
273
280
  // "nothing changed". slice() is a memcpy with no per-element callback.
@@ -276,7 +283,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
276
283
  }
277
284
  }
278
285
  // Full parse (first call, non-append update, or contiguity fallback).
279
- const tokens = blockTokensOf(markdown, extensions);
286
+ const tokens = blockTokensOf(markdown, extensions, tags);
280
287
  if (cache) {
281
288
  cache.raws.length = 0;
282
289
  cache.keep.length = 0;
@@ -298,6 +305,7 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
298
305
  }
299
306
  cache.keptBefore.push(kept);
300
307
  }
308
+ cache.lastUpdate = updateKind(isAppend, previousLength, markdown.length);
301
309
  // Only trust the cache for future appends if raws reconstruct the input.
302
310
  cache.content = pos === markdown.length ? markdown : '';
303
311
  return cache.blocks.slice();
@@ -309,3 +317,4 @@ export const parseBlocks = (markdown, extensions = [], cache) => {
309
317
  }
310
318
  return blocks;
311
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
+ };
@@ -0,0 +1,46 @@
1
+ import { useKeyDown } from './useKeyDown.svelte.js';
2
+ /**
3
+ * The fullscreen half of the mermaid expand behaviour (Mermaid.svelte:264-267)
4
+ * without panzoom's `expand()`, which is entangled with `zoomToFit` and a FLIP
5
+ * animation a table has no use for: state, Escape and focus only. The
6
+ * `position: fixed` comes from the global `[data-expanded='true']` rule in
7
+ * Streamdown.svelte.
8
+ *
9
+ * The flag lives in the caller (so the markup can drive `role`/`aria-*`/the
10
+ * expanded class from it); this only drives it.
11
+ *
12
+ * // ponytail: no open/close animation. Upgrade path: measure the target rect
13
+ * // either side of `set()` and run the same first/last invert panzoom does,
14
+ * // once that block is untangled from the zoom state it reads today.
15
+ */
16
+ export const useExpand = (opts) => {
17
+ // The control that opened the overlay, so focus can return to it on close.
18
+ let trigger = null;
19
+ const set = (next) => {
20
+ const target = opts.getTarget();
21
+ opts.expanded = next;
22
+ if (target)
23
+ target.dataset.expanded = String(next);
24
+ if (next)
25
+ target?.focus();
26
+ else
27
+ trigger?.focus();
28
+ };
29
+ useKeyDown({
30
+ keys: ['Escape'],
31
+ get isActive() {
32
+ return opts.expanded;
33
+ },
34
+ callback: () => set(false)
35
+ });
36
+ return {
37
+ get expanded() {
38
+ return opts.expanded;
39
+ },
40
+ toggle: (from) => {
41
+ if (!opts.expanded)
42
+ trigger = from ?? null;
43
+ set(!opts.expanded);
44
+ }
45
+ };
46
+ };
@@ -1,7 +1,12 @@
1
- /** An open code fence: the character it was opened with, and its run length. */
1
+ /**
2
+ * An open code fence: the character it was opened with, its run length, and
3
+ * the prefix the opener sat behind (indentation, blockquote markers, a list
4
+ * marker). The prefix is what a closer's indentation is measured against.
5
+ */
2
6
  export type OpenFence = {
3
7
  char: string;
4
8
  length: number;
9
+ prefix: string;
5
10
  } | null;
6
11
  /**
7
12
  * Fence tracking, one line at a time: returns the fence state after `line`.
@@ -9,6 +14,12 @@ export type OpenFence = {
9
14
  * "am I inside a fence" answer can never drift between them.
10
15
  */
11
16
  export declare const trackFence: (line: string, open: OpenFence) => OpenFence;
17
+ /**
18
+ * The line that closes `open`, placed at the opener's depth so it closes the
19
+ * fence where it lives. A list marker in the prefix is blanked to spaces: a
20
+ * closer must sit inside the item, and a repeated marker would start a new one.
21
+ */
22
+ export declare const closingFence: (open: NonNullable<OpenFence>) => string;
12
23
  /**
13
24
  * True when `raw` ends inside a fenced block — i.e. the fence is still being
14
25
  * streamed. Only the last block of a live document can be in this state.
@@ -1,31 +1,48 @@
1
- // Up to 3 spaces of indentation per CommonMark; blockquote markers are stripped
2
- // too because a fence can be quoted ("> ```") inside a blockquote or an alert,
3
- // and so is a leading list marker, because "- ```js" opens a fence in the item.
4
- const FENCE_LINE = /^[ \t]{0,3}(?:>[ \t]*)*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?(`{3,}|~{3,})(.*)$/;
1
+ // An opener may sit behind up to 3 spaces of indentation per CommonMark, behind
2
+ // blockquote markers ("> ```" inside a blockquote or an alert), and behind a
3
+ // list marker, because "- ```js" opens a fence in the item.
4
+ const OPENER_LINE = /^([ \t]{0,3}(?:>[ \t]*)*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?)(`{3,}|~{3,})(.*)$/;
5
+ // A closer is measured against the opener, not against column 0: it may be
6
+ // indented up to 3 spaces more than the opener was. That is what makes a fence
7
+ // inside a list item close — "10. ```js" opens at column 4, so its " ```"
8
+ // closer is at 4, which a fixed 0–3 limit rejected.
9
+ const CLOSER_LINE = /^([ \t]*(?:>[ \t]*)*)(`{3,}|~{3,})[ \t]*$/;
5
10
  /**
6
11
  * Fence tracking, one line at a time: returns the fence state after `line`.
7
12
  * The completer's block scan and `hasUnclosedFence` both walk with this, so the
8
13
  * "am I inside a fence" answer can never drift between them.
9
14
  */
10
15
  export const trackFence = (line, open) => {
11
- const match = FENCE_LINE.exec(line);
12
- if (!match) {
13
- return open;
14
- }
15
- const run = match[1];
16
- const info = match[2];
17
16
  if (open) {
18
- // A closer uses the opener's character, is at least as long, and carries no
19
- // info string so a shorter fence line inside a longer block is content.
20
- return run[0] === open.char && run.length >= open.length && info.trim() === '' ? null : open;
17
+ // A closer uses the opener's character, is at least as long, carries no
18
+ // info string, and is not indented deeper than the opener plus 3 so a
19
+ // shorter or deeper fence line inside the block is content.
20
+ const match = CLOSER_LINE.exec(line);
21
+ if (!match)
22
+ return open;
23
+ const run = match[2];
24
+ const closes = run[0] === open.char &&
25
+ run.length >= open.length &&
26
+ match[1].length <= open.prefix.length + 3;
27
+ return closes ? null : open;
21
28
  }
29
+ const match = OPENER_LINE.exec(line);
30
+ if (!match)
31
+ return null;
32
+ const run = match[2];
22
33
  // A backtick fence's info string cannot contain a backtick (marked's own rule),
23
34
  // so such a line opens nothing.
24
- if (run[0] === '`' && info.includes('`')) {
25
- return open;
26
- }
27
- return { char: run[0], length: run.length };
35
+ if (run[0] === '`' && match[3].includes('`'))
36
+ return null;
37
+ return { char: run[0], length: run.length, prefix: match[1] };
28
38
  };
39
+ /**
40
+ * The line that closes `open`, placed at the opener's depth so it closes the
41
+ * fence where it lives. A list marker in the prefix is blanked to spaces: a
42
+ * closer must sit inside the item, and a repeated marker would start a new one.
43
+ */
44
+ export const closingFence = (open) => open.prefix.replace(/[-*+]|\d{1,9}[.)]/g, (marker) => ' '.repeat(marker.length)) +
45
+ open.char.repeat(open.length);
29
46
  /**
30
47
  * True when `raw` ends inside a fenced block — i.e. the fence is still being
31
48
  * streamed. Only the last block of a live document can be in this state.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Line-number options carried by a fence's info string. `token.lang` is the first
3
+ * word and `token.meta` the rest since 4.0.1, so this only ever sees the rest —
4
+ * `startLine=10 noLineNumbers` out of a ```ts fence.
5
+ *
6
+ * `lineNumbers` / `noLineNumbers` are upstream streamdown's per-block escape
7
+ * hatches; ours flip the `lineNumbers` prop either way because our prop defaults
8
+ * to false (upstream defaults to on).
9
+ *
10
+ * The rendering side is CSS counters on the per-line span that is already there
11
+ * (`counter-increment` + a `::before` printing `counter(sd-line)`, in the global
12
+ * block of Streamdown.svelte; `counter-reset` inline on the <pre> so `startLine`
13
+ * can move it): no element per line, and a pseudo-element's content is never part
14
+ * of a selection, so copy/download — which read the token text anyway — can never
15
+ * pick numbers up. The counter rule cannot live in the theme like the gutter's
16
+ * width and colour (`theme.code.lineNumber`), because a Tailwind
17
+ * `before:content-[counter(sd-line)]` class only exists if the consumer's
18
+ * Tailwind scanned this package.
19
+ */
20
+ export declare const resolveLineNumbers: (meta: string | undefined, enabled: boolean) => {
21
+ enabled: boolean;
22
+ start: number;
23
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Line-number options carried by a fence's info string. `token.lang` is the first
3
+ * word and `token.meta` the rest since 4.0.1, so this only ever sees the rest —
4
+ * `startLine=10 noLineNumbers` out of a ```ts fence.
5
+ *
6
+ * `lineNumbers` / `noLineNumbers` are upstream streamdown's per-block escape
7
+ * hatches; ours flip the `lineNumbers` prop either way because our prop defaults
8
+ * to false (upstream defaults to on).
9
+ *
10
+ * The rendering side is CSS counters on the per-line span that is already there
11
+ * (`counter-increment` + a `::before` printing `counter(sd-line)`, in the global
12
+ * block of Streamdown.svelte; `counter-reset` inline on the <pre> so `startLine`
13
+ * can move it): no element per line, and a pseudo-element's content is never part
14
+ * of a selection, so copy/download — which read the token text anyway — can never
15
+ * pick numbers up. The counter rule cannot live in the theme like the gutter's
16
+ * width and colour (`theme.code.lineNumber`), because a Tailwind
17
+ * `before:content-[counter(sd-line)]` class only exists if the consumer's
18
+ * Tailwind scanned this package.
19
+ */
20
+ export const resolveLineNumbers = (meta, enabled) => {
21
+ // The tests are case-sensitive and `noLineNumbers` spells the word with a
22
+ // capital L, so the positive test can never match it; checked first anyway.
23
+ const on = meta
24
+ ? /\bnoLineNumbers\b/.test(meta)
25
+ ? false
26
+ : /\blineNumbers\b/.test(meta) || enabled
27
+ : enabled;
28
+ const start = meta?.match(/\bstartLine=(\d+)\b/);
29
+ return { enabled: on, start: start ? Number(start[1]) : 1 };
30
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Four leading spaces after a blank line is an indented code block, HTML or not.
3
+ * A pretty-printed document therefore splits into html / code / html and the
4
+ * indented part renders as source in a code box (upstream 7f9127b).
5
+ *
6
+ * This strips the indentation off tag lines only. It is lossy inside `<pre>` and
7
+ * `<code>`, where whitespace is content, so those bodies are skipped and the
8
+ * whole thing is behind the opt-in `normalizeHtmlIndentation` prop.
9
+ */
10
+ export declare const normalizeHtmlIndentation: (content: string) => string;