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.
- package/README.md +149 -47
- package/dist/Block.svelte +19 -8
- package/dist/Block.svelte.d.ts +2 -0
- package/dist/Elements/Code.svelte +15 -4
- package/dist/Elements/Code.svelte.d.ts +2 -0
- package/dist/Elements/Element.svelte +32 -11
- package/dist/Elements/Element.svelte.d.ts +1 -0
- package/dist/Elements/Mermaid.svelte +5 -11
- package/dist/Elements/Mermaid.svelte.d.ts +1 -0
- package/dist/Elements/TableDownload.svelte +44 -3
- package/dist/Elements/TableDownload.svelte.d.ts +3 -1
- package/dist/Elements/fallbacks/CodeFallback.svelte +16 -6
- package/dist/Elements/fallbacks/CodeFallback.svelte.d.ts +3 -2
- package/dist/Elements/icons.d.ts +1 -0
- package/dist/Elements/icons.js +8 -0
- package/dist/Streamdown.svelte +68 -6
- package/dist/context.svelte.d.ts +37 -1
- package/dist/context.svelte.js +3 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/marked/index.d.ts +11 -4
- package/dist/marked/index.js +54 -45
- package/dist/marked/marked-mdx.d.ts +27 -1
- package/dist/marked/marked-mdx.js +37 -8
- package/dist/theme.d.ts +6 -0
- package/dist/theme.js +12 -4
- package/dist/utils/expand.svelte.d.ts +21 -0
- package/dist/utils/expand.svelte.js +46 -0
- package/dist/utils/fence.d.ts +12 -1
- package/dist/utils/fence.js +34 -17
- package/dist/utils/line-numbers.d.ts +23 -0
- package/dist/utils/line-numbers.js +30 -0
- package/dist/utils/normalize-html-indentation.d.ts +10 -0
- package/dist/utils/normalize-html-indentation.js +52 -0
- package/dist/utils/parse-incomplete-markdown.d.ts +20 -6
- package/dist/utils/parse-incomplete-markdown.js +173 -69
- package/package.json +1 -1
package/dist/marked/index.js
CHANGED
|
@@ -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.
|
|
15
|
-
// (and the regexes/closures inside each plugin) is
|
|
16
|
-
//
|
|
17
|
-
// the Lexer instance (e.g. footnotes
|
|
18
|
-
// Lexer per call keeps documents isolated
|
|
19
|
-
|
|
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
|
|
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
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/utils/fence.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
/**
|
|
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.
|
package/dist/utils/fence.js
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
const
|
|
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,
|
|
19
|
-
// info string
|
|
20
|
-
|
|
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] === '`' &&
|
|
25
|
-
return
|
|
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;
|