svelte-streamdown 4.0.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/dist/Block.svelte +5 -2
- package/dist/Elements/Citation.svelte +2 -2
- package/dist/Elements/Code.svelte +10 -5
- package/dist/Elements/Code.svelte.d.ts +2 -2
- package/dist/Elements/Element.svelte +8 -5
- package/dist/Elements/Link.svelte +2 -1
- package/dist/Elements/Mermaid.svelte +8 -3
- package/dist/Elements/Mermaid.svelte.d.ts +2 -2
- package/dist/Elements/MermaidDownload.svelte +1 -1
- package/dist/Elements/TableDownload.svelte +13 -5
- package/dist/Elements/fallbacks/CodeFallback.svelte +7 -1
- package/dist/Elements/fallbacks/MermaidFallback.svelte +7 -1
- package/dist/index.d.ts +1 -1
- package/dist/marked/index.d.ts +8 -1
- package/dist/marked/index.js +40 -4
- package/dist/marked/marked-footnotes.js +6 -2
- package/dist/marked/marked-subsup.js +16 -3
- package/dist/utils/parse-incomplete-markdown.js +100 -18
- package/dist/utils/save.js +4 -1
- package/dist/utils/url.js +6 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -512,7 +512,7 @@ Prefixes can also be **protocol-only**, which allows any URL using that protocol
|
|
|
512
512
|
```
|
|
513
513
|
|
|
514
514
|
> [!NOTE]
|
|
515
|
-
> `'*'` allows
|
|
515
|
+
> `'*'` allows every `http:`, `https:`, `mailto:` and `tel:` URL — the protocols a document can legitimately link to. `javascript:`, `data:` and `vbscript:` stay blocked under the wildcard because they execute in the page's origin. A protocol-only prefix only allows that exact protocol, so list each one you want to permit. Only add a protocol you trust — e.g. do not add `'javascript:'`.
|
|
516
516
|
|
|
517
517
|
## 📦 Bundle Optimization
|
|
518
518
|
|
|
@@ -707,9 +707,13 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
|
|
|
707
707
|
| `defaultOrigin` | `string` | - | Default origin for relative URLs |
|
|
708
708
|
| `allowedLinkPrefixes` | `string[]` | `['*']` | Allowed URL prefixes for links |
|
|
709
709
|
| `allowedImagePrefixes` | `string[]` | `['*']` | Allowed URL prefixes for images |
|
|
710
|
-
| `
|
|
711
|
-
| `
|
|
712
|
-
| `
|
|
710
|
+
| `renderHtml` | `boolean \| ((token) => string)` | `false` | Render raw HTML blocks and inline tags. When off, the HTML source is shown as literal text instead of being dropped. Pass a function to sanitize and return the HTML string yourself. |
|
|
711
|
+
| `inlineCitationsMode` | `'list' \| 'carousel'` | `'carousel'` | How an inline citation popover presents its sources |
|
|
712
|
+
| `translations` | `{ alert?: { note?, tip?, warning?, caution?, important? } }` | - | Override the built-in alert titles |
|
|
713
|
+
| `icons` | `Partial<Record<IconName, Snippet>>` | - | Replace any built-in icon (`copy`, `check`, `download`, `fullscreen`, `zoomIn`, `zoomOut`, `fitView`, `chevronLeft`, `chevronRight`, `note`, `tip`, `warning`, `caution`, `important`) with your own snippet |
|
|
714
|
+
| `static` | `boolean` | `false` | Render finished content: skips the incomplete-markdown pass and the streaming animation |
|
|
715
|
+
| `element` | `HTMLElement` | - | `bind:element` to get the wrapper node |
|
|
716
|
+
| `streamdown` | `StreamdownContext` | - | `bind:streamdown` to read the resolved context (theme, controls, footnotes, sources) |
|
|
713
717
|
| `theme` | `DeepPartial<Theme>` | - | Custom theme overrides |
|
|
714
718
|
| `baseTheme` | `'tailwind' \| 'shadcn'` | `'tailwind'` | Base theme to use before applying overrides |
|
|
715
719
|
| `mergeTheme` | `boolean` | `true` | Whether to merge theme with base theme |
|
|
@@ -720,7 +724,7 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
|
|
|
720
724
|
| `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
|
|
721
725
|
| `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
|
|
722
726
|
| `animation.enabled` | `boolean` | `false` | Enable/disable animations |
|
|
723
|
-
| `animation.type` | `'fade' \| 'blur' \| '
|
|
727
|
+
| `animation.type` | `'fade' \| 'blur' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
|
|
724
728
|
| `animation.duration` | `number` | `500` | Animation duration in milliseconds |
|
|
725
729
|
| `animation.timingFunction` | `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
|
|
726
730
|
| `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
|
|
@@ -739,7 +743,7 @@ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, r
|
|
|
739
743
|
|
|
740
744
|
**Lists**: `ul`, `ol`, `li`
|
|
741
745
|
|
|
742
|
-
**Code**: `code`, `
|
|
746
|
+
**Code**: `code`, `codespan`
|
|
743
747
|
|
|
744
748
|
**Tables**: `table`, `thead`, `tbody`, `tr`, `th`, `td`, `tfoot`
|
|
745
749
|
|
|
@@ -1103,6 +1107,10 @@ pnpm dev
|
|
|
1103
1107
|
# Run tests
|
|
1104
1108
|
pnpm test
|
|
1105
1109
|
|
|
1110
|
+
# Run the browser (component) tests — needs a Chromium binary:
|
|
1111
|
+
# pnpm exec playwright install chromium
|
|
1112
|
+
pnpm test:browser
|
|
1113
|
+
|
|
1106
1114
|
# Build for production
|
|
1107
1115
|
pnpm build
|
|
1108
1116
|
```
|
package/dist/Block.svelte
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { parseIncompleteMarkdown } from './utils/parse-incomplete-markdown.js';
|
|
2
|
+
import { parseIncompleteMarkdown as completeMarkdown } from './utils/parse-incomplete-markdown.js';
|
|
3
3
|
import Element from './Elements/Element.svelte';
|
|
4
4
|
import { lex, type StreamdownToken } from './marked/index.js';
|
|
5
5
|
import AnimatedText from './AnimatedText.svelte';
|
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
} = $props();
|
|
16
16
|
|
|
17
17
|
const streamdown = useStreamdown();
|
|
18
|
+
// The old code never consulted `streamdown.parseIncompleteMarkdown`; the import
|
|
19
|
+
// is aliased so the context flag and the helper cannot be confused.
|
|
20
|
+
const complete = $derived(!isStatic && streamdown.parseIncompleteMarkdown !== false);
|
|
18
21
|
const tokens = $derived(
|
|
19
|
-
lex(
|
|
22
|
+
lex(complete ? completeMarkdown(block.trim()) : block, streamdown.extensions)
|
|
20
23
|
);
|
|
21
24
|
const insidePopover = getContext('POPOVER');
|
|
22
25
|
</script>
|
|
@@ -174,7 +174,7 @@
|
|
|
174
174
|
style:position="relative"
|
|
175
175
|
style:transition-duration="200ms"
|
|
176
176
|
style:transition-timing-function="ease-in-out"
|
|
177
|
-
aria-label=
|
|
177
|
+
aria-label={'Citations-' + id}
|
|
178
178
|
>
|
|
179
179
|
<div
|
|
180
180
|
bind:this={stepper.stepContainer}
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
style:height="fit-content"
|
|
194
194
|
style:width="100%"
|
|
195
195
|
style:flex-grow="1"
|
|
196
|
-
aria-label=
|
|
196
|
+
aria-label={'Citation-' + id}
|
|
197
197
|
>
|
|
198
198
|
<Slot render={streamdown.snippets.inlineCitationContent} props={{ source, key, token }}>
|
|
199
199
|
{#if url || title}
|
|
@@ -3,22 +3,27 @@
|
|
|
3
3
|
import { save } from '../utils/save.js';
|
|
4
4
|
import { useCopy } from '../utils/copy.svelte.js';
|
|
5
5
|
import { highlightLines, languageExtensionMap } from '../utils/hightlighter.svelte.js';
|
|
6
|
-
import type {
|
|
6
|
+
import type { CodeToken } from '../marked/index.js';
|
|
7
7
|
import { checkIcon, copyIcon, downloadIcon } from './icons.js';
|
|
8
8
|
|
|
9
9
|
const {
|
|
10
10
|
token,
|
|
11
11
|
id
|
|
12
12
|
}: {
|
|
13
|
-
token:
|
|
13
|
+
token: CodeToken;
|
|
14
14
|
id: string;
|
|
15
15
|
} = $props();
|
|
16
16
|
|
|
17
17
|
const streamdown = useStreamdown();
|
|
18
18
|
|
|
19
|
+
// marked keeps the fence's trailing blank lines in `text`; they render as empty
|
|
20
|
+
// lines and, while streaming, flicker in and out on nearly every chunk. Render,
|
|
21
|
+
// copy and download all read this so they can never disagree.
|
|
22
|
+
const code = $derived(token.text.replace(/\n+$/, ''));
|
|
23
|
+
|
|
19
24
|
const copy = useCopy({
|
|
20
25
|
get content() {
|
|
21
|
-
return
|
|
26
|
+
return code;
|
|
22
27
|
}
|
|
23
28
|
});
|
|
24
29
|
|
|
@@ -32,13 +37,13 @@
|
|
|
32
37
|
: 'txt';
|
|
33
38
|
const filename = `file.${extension}`;
|
|
34
39
|
const mimeType = 'text/plain';
|
|
35
|
-
save(filename,
|
|
40
|
+
save(filename, code, mimeType);
|
|
36
41
|
} catch (error) {
|
|
37
42
|
console.error('Failed to download file:', error);
|
|
38
43
|
}
|
|
39
44
|
};
|
|
40
45
|
|
|
41
|
-
const lines = $derived(highlightLines(
|
|
46
|
+
const lines = $derived(highlightLines(code, token.lang, streamdown.highlightLanguages));
|
|
42
47
|
</script>
|
|
43
48
|
|
|
44
49
|
<div
|
|
@@ -288,12 +288,11 @@
|
|
|
288
288
|
{@render children()}
|
|
289
289
|
</dd>
|
|
290
290
|
</Slot>
|
|
291
|
-
{:else if token.type === 'def'}
|
|
292
|
-
<!--
|
|
291
|
+
{:else if token.type === 'def' || token.type === 'space'}
|
|
292
|
+
<!-- Link reference definitions and blank lines produce no output (CommonMark) -->
|
|
293
293
|
{:else if token.type === 'escape'}
|
|
294
|
-
<!--
|
|
295
|
-
{
|
|
296
|
-
<!-- TODO This does not seems to be tokenized for now -->
|
|
294
|
+
<!-- `children` renders token.text, i.e. the escaped character itself -->
|
|
295
|
+
{@render children()}
|
|
297
296
|
{:else if token.type === 'text'}
|
|
298
297
|
{@render children()}
|
|
299
298
|
{:else if token.type === 'html'}
|
|
@@ -301,6 +300,10 @@
|
|
|
301
300
|
{@const content =
|
|
302
301
|
typeof streamdown.renderHtml === 'function' ? streamdown.renderHtml(token) : token.raw}
|
|
303
302
|
{@html content}
|
|
303
|
+
{:else}
|
|
304
|
+
<!-- Without renderHtml the source is shown literally instead of being dropped;
|
|
305
|
+
`children` interpolates it as text, so Svelte escapes it — no XSS surface -->
|
|
306
|
+
{@render children()}
|
|
304
307
|
{/if}
|
|
305
308
|
{:else if token.type === 'mdx'}
|
|
306
309
|
{@const Component = streamdown.mdxComponents?.[token.tagName]}
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
<a
|
|
40
40
|
data-streamdown-link={id}
|
|
41
41
|
class={streamdown.theme.link.base}
|
|
42
|
+
title={token.title}
|
|
42
43
|
{...isRelativeUrl
|
|
43
44
|
? { href: token.href }
|
|
44
45
|
: { href: transformedUrl, target: '_blank', rel: 'noopener noreferrer' }}
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
<span
|
|
51
52
|
data-streamdown-link-blocked={id}
|
|
52
53
|
class={streamdown.theme.link.blocked}
|
|
53
|
-
title={
|
|
54
|
+
title={`Blocked URL: ${token.href}`}
|
|
54
55
|
>
|
|
55
56
|
{@render children()} [blocked]
|
|
56
57
|
</span>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import { useStreamdown } from '../context.svelte.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { CodeToken } from '../marked/index.js';
|
|
5
5
|
import type { MermaidConfig } from 'mermaid';
|
|
6
6
|
import { on } from 'svelte/events';
|
|
7
7
|
import { usePanzoom } from '../utils/panzoom.svelte';
|
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
token,
|
|
15
15
|
id
|
|
16
16
|
}: {
|
|
17
|
-
token:
|
|
17
|
+
token: CodeToken;
|
|
18
18
|
id: string;
|
|
19
19
|
} = $props();
|
|
20
20
|
|
|
21
|
+
// Trailing blank lines are noise for mermaid but they still changed `token.text`
|
|
22
|
+
// on nearly every streamed chunk, which re-ran the whole render. Same trim as
|
|
23
|
+
// Code.svelte.
|
|
24
|
+
const chart = $derived(token.text.replace(/\n+$/, ''));
|
|
25
|
+
|
|
21
26
|
let mermaid = $state<any>(null);
|
|
22
27
|
onMount(async () => {
|
|
23
28
|
mermaid = (await import('mermaid')).default;
|
|
@@ -217,7 +222,7 @@
|
|
|
217
222
|
<div
|
|
218
223
|
style={streamdown.isMounted ? streamdown.animationBlockStyle : ''}
|
|
219
224
|
class={streamdown.theme.mermaid.base}
|
|
220
|
-
{@attach (node) => renderMermaid(
|
|
225
|
+
{@attach (node) => renderMermaid(chart, node)}
|
|
221
226
|
{@attach insider.attach}
|
|
222
227
|
data-expanded={'false'}
|
|
223
228
|
>
|
|
@@ -46,6 +46,15 @@
|
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
// textContent flattens a real <br> element, so a multiline cell collapses to
|
|
50
|
+
// 'Paragraph one.Paragraph two.'. Walk the cell instead and keep the breaks;
|
|
51
|
+
// the quoting below then quotes the newline for us.
|
|
52
|
+
const extractCellText = (node: Node): string => {
|
|
53
|
+
if (node.nodeType === 3) return node.textContent || '';
|
|
54
|
+
if ((node as Element).tagName === 'BR') return '\n';
|
|
55
|
+
return Array.from(node.childNodes).map(extractCellText).join('');
|
|
56
|
+
};
|
|
57
|
+
|
|
49
58
|
const copyOrDownload = (type: 'Markdown' | 'HTML' | 'CSV') => {
|
|
50
59
|
if (type === 'Markdown') {
|
|
51
60
|
copyValue = token.raw;
|
|
@@ -55,7 +64,7 @@
|
|
|
55
64
|
save('table.md', copyValue, 'text/markdown');
|
|
56
65
|
}
|
|
57
66
|
} else if (type === 'HTML') {
|
|
58
|
-
const table = document.querySelector(`[data-streamdown-table
|
|
67
|
+
const table = document.querySelector(`[data-streamdown-table="${id}"]`);
|
|
59
68
|
|
|
60
69
|
if (table) {
|
|
61
70
|
let html = (table.cloneNode(true) as HTMLElement).outerHTML;
|
|
@@ -83,7 +92,7 @@
|
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
} else if (type === 'CSV') {
|
|
86
|
-
const table = document.querySelector(`[data-streamdown-table
|
|
95
|
+
const table = document.querySelector(`[data-streamdown-table="${id}"]`);
|
|
87
96
|
|
|
88
97
|
if (table) {
|
|
89
98
|
const rows = table.querySelectorAll('tr');
|
|
@@ -98,9 +107,8 @@
|
|
|
98
107
|
const colSpan = parseInt(cell.getAttribute('colspan') || '1');
|
|
99
108
|
const rowSpan = parseInt(cell.getAttribute('rowspan') || '1');
|
|
100
109
|
|
|
101
|
-
// Add the cell content
|
|
102
110
|
// Add the cell content, quoting if it contains commas, quotes, or newlines
|
|
103
|
-
const content = cell
|
|
111
|
+
const content = extractCellText(cell);
|
|
104
112
|
const needsQuoting = /[,"\n]/.test(content);
|
|
105
113
|
const escapedContent = content.replace(/"/g, '""');
|
|
106
114
|
rowData.push(needsQuoting ? `"${escapedContent}"` : content);
|
|
@@ -150,7 +158,7 @@
|
|
|
150
158
|
|
|
151
159
|
{#if popover.isOpen}
|
|
152
160
|
<dialog
|
|
153
|
-
id={'table-download-popover'}
|
|
161
|
+
id={'table-download-popover-' + id}
|
|
154
162
|
aria-modal="false"
|
|
155
163
|
transition:scale|global={{ start: 0.95, duration: 100 }}
|
|
156
164
|
{@attach clickOutside.attachment}
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
} = $props();
|
|
12
12
|
|
|
13
13
|
const streamdown = useStreamdown();
|
|
14
|
+
|
|
15
|
+
// Same trim as Code.svelte/Mermaid.svelte: marked keeps the fence's trailing
|
|
16
|
+
// blank lines in `text`, so they render as empty lines and flicker in and out
|
|
17
|
+
// on nearly every streamed chunk. This is the default renderer, so it needs it
|
|
18
|
+
// too.
|
|
19
|
+
const code = $derived(token.text.replace(/\n+$/, ''));
|
|
14
20
|
</script>
|
|
15
21
|
|
|
16
22
|
<div
|
|
@@ -23,7 +29,7 @@
|
|
|
23
29
|
</div>
|
|
24
30
|
<div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
|
|
25
31
|
<pre class={streamdown.theme.code.pre}><code
|
|
26
|
-
>{#each
|
|
32
|
+
>{#each code.split('\n') as line}<span class={streamdown.theme.code.line}
|
|
27
33
|
><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
|
|
28
34
|
>{line.trim().length > 0 ? line : '\u200B'}</span
|
|
29
35
|
></span
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
} = $props();
|
|
12
12
|
|
|
13
13
|
const streamdown = useStreamdown();
|
|
14
|
+
|
|
15
|
+
// Same trim as Code.svelte/Mermaid.svelte: marked keeps the fence's trailing
|
|
16
|
+
// blank lines in `text`, so they render as empty lines and flicker in and out
|
|
17
|
+
// on nearly every streamed chunk. This is the default renderer, so it needs it
|
|
18
|
+
// too.
|
|
19
|
+
const chart = $derived(token.text.replace(/\n+$/, ''));
|
|
14
20
|
</script>
|
|
15
21
|
|
|
16
22
|
<div data-streamdown-mermaid={id}>
|
|
@@ -23,7 +29,7 @@
|
|
|
23
29
|
</div>
|
|
24
30
|
<div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
|
|
25
31
|
<pre class={streamdown.theme.code.pre}><code
|
|
26
|
-
>{#each
|
|
32
|
+
>{#each chart.split('\n') as line}<span class={streamdown.theme.code.line}
|
|
27
33
|
><span style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
|
|
28
34
|
>{line.trim().length > 0 ? line : '\u200B'}</span
|
|
29
35
|
></span
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { default as Streamdown } from './Streamdown.svelte';
|
|
2
2
|
export { useStreamdown, type StreamdownProps } from './context.svelte.js';
|
|
3
3
|
export { theme, shadcnTheme, mergeTheme, type Theme } from './theme.js';
|
|
4
|
-
export { type Extension, type StreamdownToken, lex, parseBlocks } from './marked/index.js';
|
|
4
|
+
export { type CodeToken, type Extension, type StreamdownToken, lex, parseBlocks } from './marked/index.js';
|
|
5
5
|
export { parseIncompleteMarkdown, type Plugin, IncompleteMarkdownParser } from './utils/parse-incomplete-markdown.js';
|
|
6
6
|
export { defineLanguage, type LanguageDefinition } from '@tanstack/highlight';
|
|
7
7
|
export type { HighlightTheme } from '@tanstack/highlight/theme';
|
package/dist/marked/index.d.ts
CHANGED
|
@@ -23,7 +23,14 @@ export type Extension = {
|
|
|
23
23
|
start?: TokenizerStartFunction;
|
|
24
24
|
applyInBlockParsing?: boolean;
|
|
25
25
|
};
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* A fenced code block. `lang` is the first word of the info string; everything
|
|
28
|
+
* after it is `meta` (`undefined` when there is none).
|
|
29
|
+
*/
|
|
30
|
+
export type CodeToken = Tokens.Code & {
|
|
31
|
+
meta?: string;
|
|
32
|
+
};
|
|
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;
|
|
27
34
|
export type { TableToken, THead, TBody, TFoot, THeadRow, TRow, TH, TD } from './marked-table.js';
|
|
28
35
|
export declare const lex: (markdown: string, extensions?: Extension[]) => StreamdownToken[];
|
|
29
36
|
/**
|
package/dist/marked/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Lexer } from 'marked';
|
|
1
|
+
import { Lexer, Tokenizer } from 'marked';
|
|
2
2
|
import { markedAlert } from './marked-alert.js';
|
|
3
3
|
import { markedFootnote } from './marked-footnotes.js';
|
|
4
4
|
import { markedMath } from './marked-math.js';
|
|
@@ -39,9 +39,42 @@ const DEFAULT_BLOCK_EXTENSIONS = [
|
|
|
39
39
|
markedAlign,
|
|
40
40
|
markedMdx
|
|
41
41
|
];
|
|
42
|
+
class StreamdownTokenizer extends Tokenizer {
|
|
43
|
+
/**
|
|
44
|
+
* marked keeps the whole fence info string in `lang`, so ```ts title="x" {1}
|
|
45
|
+
* highlighted as plaintext, labelled the header with the entire string, downloaded
|
|
46
|
+
* as `file.txt` and — worst — missed `token.lang === 'mermaid'` (upstream d4ec6c0).
|
|
47
|
+
* Splitting here rather than walking lex()'s output costs one search per code
|
|
48
|
+
* token instead of a per-block tree walk, and it also reaches fences nested in
|
|
49
|
+
* lists and blockquotes, which a pass over the top-level tokens would not.
|
|
50
|
+
*/
|
|
51
|
+
fences(src) {
|
|
52
|
+
const token = super.fences(src);
|
|
53
|
+
if (token?.lang) {
|
|
54
|
+
const end = token.lang.search(/\s/);
|
|
55
|
+
if (end !== -1) {
|
|
56
|
+
token.meta = token.lang.slice(end + 1).trim() || undefined;
|
|
57
|
+
token.lang = token.lang.slice(0, end);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return token;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `~x~` is a subscript here, not GFM strikethrough (marked-subsup.ts). The old
|
|
64
|
+
* subscript rule shadowed single-tilde del by matching anything; now that it
|
|
65
|
+
* rejects whitespace, del would inherit exactly the sentences 716a5f0 is about
|
|
66
|
+
* (`20~25°C and 30~35°C` → del('~25°C and 30~')). Only `~~` opens a del.
|
|
67
|
+
*/
|
|
68
|
+
del(src, maskedSrc, prevChar) {
|
|
69
|
+
if (src.charCodeAt(0) !== 126 /* ~ */ || src.charCodeAt(1) !== 126)
|
|
70
|
+
return;
|
|
71
|
+
return super.del(src, maskedSrc, prevChar);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
42
74
|
const parseExtensions = (...extensions) => {
|
|
43
75
|
const options = {
|
|
44
76
|
gfm: true,
|
|
77
|
+
tokenizer: new StreamdownTokenizer(),
|
|
45
78
|
extensions: {
|
|
46
79
|
block: [],
|
|
47
80
|
inline: [],
|
|
@@ -71,9 +104,12 @@ const parseExtensions = (...extensions) => {
|
|
|
71
104
|
});
|
|
72
105
|
return options;
|
|
73
106
|
};
|
|
74
|
-
// Options objects are
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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.
|
|
77
113
|
const DEFAULT_LEX_OPTIONS = parseExtensions(...DEFAULT_LEX_EXTENSIONS);
|
|
78
114
|
const DEFAULT_BLOCK_OPTIONS = parseExtensions(...DEFAULT_BLOCK_EXTENSIONS);
|
|
79
115
|
const lexOptionsCache = new WeakMap();
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import {} from './index.js';
|
|
2
2
|
import { StreamdownContext } from '../context.svelte.js';
|
|
3
3
|
import { getContext } from 'svelte';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Footnote identifiers are word characters, `-` and `:` only. `[^\]\n]+` turned
|
|
5
|
+
// every regex character class written in prose (`[^\s]`, `[^,]`) into an empty
|
|
6
|
+
// footnote marker (upstream 9f72224). `:` is kept for the completer's
|
|
7
|
+
// `[^streamdown:footnote]` sentinel, which FootnoteRef.svelte renders as nothing.
|
|
8
|
+
const footnoteRegex = /^\[\^([\w:-]{1,200})\]:(?:[ \t]+|\n|$)([^\n]*(?:\n(?:[ \t]+[^\n]*)?)*)/;
|
|
9
|
+
const footnoteRefRegex = /^\[\^([\w:-]{1,200})\]/;
|
|
6
10
|
const footNoteLastLineRegex = /^[ \t]*?[>\-*][ ]|[`]{3,}$|^[ \t]*?[|].+[|]$/;
|
|
7
11
|
const safeGetContext = () => {
|
|
8
12
|
try {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// A sub/superscript is a single run, never a phrase: no whitespace anywhere in
|
|
2
|
+
// it. The old rules only forbade whitespace at the edges, so `20~25°C and 30~35°C`
|
|
3
|
+
// subscripted half the sentence (upstream 716a5f0).
|
|
4
|
+
const subRule = /^~([^~\s]+)~/; // ~text~
|
|
5
|
+
const supRule = /^\^([^\^\s]+)\^/; // ^text^
|
|
3
6
|
export const markedSub = {
|
|
4
7
|
name: 'sub',
|
|
5
8
|
level: 'inline',
|
|
@@ -7,13 +10,23 @@ export const markedSub = {
|
|
|
7
10
|
const i = src.indexOf('~');
|
|
8
11
|
return i === -1 ? undefined : i;
|
|
9
12
|
},
|
|
10
|
-
tokenizer(src) {
|
|
13
|
+
tokenizer(src, tokens) {
|
|
11
14
|
// marked dispatches every inline extension at every scan position; `start`
|
|
12
15
|
// only clips the text rule, it does not gate the tokenizer. This rule is
|
|
13
16
|
// anchored on a single literal character, so one charCodeAt rejects the
|
|
14
17
|
// ~3.6k non-matching dispatches per 100 KB before the regex engine runs.
|
|
15
18
|
if (src.charCodeAt(0) !== 126 /* ~ */)
|
|
16
19
|
return;
|
|
20
|
+
// A digit right before the opening `~` means a numeric range (`20~25°C`),
|
|
21
|
+
// not a subscript base — chemistry and indices always have a letter or a
|
|
22
|
+
// closing bracket there (`H~2~O`, `x~i+1~`). `src` starts at the marker, so
|
|
23
|
+
// the preceding character is the last one of the previous inline token.
|
|
24
|
+
const prevRaw = tokens[tokens.length - 1]?.raw;
|
|
25
|
+
if (prevRaw) {
|
|
26
|
+
const code = prevRaw.charCodeAt(prevRaw.length - 1);
|
|
27
|
+
if (code >= 48 && code <= 57)
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
17
30
|
const match = src.match(subRule);
|
|
18
31
|
if (match) {
|
|
19
32
|
return {
|
|
@@ -98,6 +98,20 @@ export class IncompleteMarkdownParser {
|
|
|
98
98
|
// Create default plugins that replicate the original handler functions
|
|
99
99
|
static createDefaultPlugins() {
|
|
100
100
|
return [
|
|
101
|
+
{
|
|
102
|
+
// Runs first: in a list item a '>' before a number is a comparison
|
|
103
|
+
// ('- > 25: rich'), but marked reads it as a nested blockquote. Escaping it
|
|
104
|
+
// keeps the text, and the escape renders as a plain '>' (4fffb9f).
|
|
105
|
+
// `pattern` is only a cheap gate — a line whose first non-space character is
|
|
106
|
+
// not a list marker can never match, and bails on that one character.
|
|
107
|
+
name: 'comparisonOperator',
|
|
108
|
+
pattern: /^\s*[-*+\d]/,
|
|
109
|
+
skipInBlockTypes: ['code', 'math'],
|
|
110
|
+
handler: ({ line }) => {
|
|
111
|
+
const match = listItemComparison.exec(line);
|
|
112
|
+
return match ? `${match[1]}\\>${line.slice(match[0].length)}` : line;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
101
115
|
// Block-level plugin that manages blocking contexts
|
|
102
116
|
{
|
|
103
117
|
name: 'contextManager',
|
|
@@ -172,7 +186,9 @@ export class IncompleteMarkdownParser {
|
|
|
172
186
|
result += '\n```';
|
|
173
187
|
}
|
|
174
188
|
if (state.blockingContexts.has('math')) {
|
|
175
|
-
|
|
189
|
+
// The first half of the closing '$$' may already have arrived: adding a
|
|
190
|
+
// whole '\n$$' would leave a stray '$' inside the math and '$$' after it.
|
|
191
|
+
result += result.endsWith('$') && !result.endsWith('$$') ? '$' : '\n$$';
|
|
176
192
|
}
|
|
177
193
|
if (state.blockingContexts.has('center')) {
|
|
178
194
|
result += '\n[/center]';
|
|
@@ -195,6 +211,8 @@ export class IncompleteMarkdownParser {
|
|
|
195
211
|
const tripleAsterisks = (line.match(/\*\*\*/g) || []).length;
|
|
196
212
|
if (tripleAsterisks % 2 === 1) {
|
|
197
213
|
const lastTripleAsteriskIndex = line.lastIndexOf('***');
|
|
214
|
+
if (isWithinCompleteInlineCode(line, lastTripleAsteriskIndex))
|
|
215
|
+
return line;
|
|
198
216
|
const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTripleAsteriskIndex);
|
|
199
217
|
if (isEndingWithTripleAsterisk) {
|
|
200
218
|
return line.substring(0, lastTripleAsteriskIndex);
|
|
@@ -222,6 +240,8 @@ export class IncompleteMarkdownParser {
|
|
|
222
240
|
if (doubleAsteriskMatches % 2 === 1) {
|
|
223
241
|
const isEndingWithDoubleAsterisk = line.endsWith('**');
|
|
224
242
|
const lastDoubleAsteriskIndex = line.lastIndexOf('**');
|
|
243
|
+
if (isWithinCompleteInlineCode(line, lastDoubleAsteriskIndex))
|
|
244
|
+
return line;
|
|
225
245
|
const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleAsteriskIndex);
|
|
226
246
|
if (isEndingWithDoubleAsterisk) {
|
|
227
247
|
return line.substring(0, lastDoubleAsteriskIndex);
|
|
@@ -248,6 +268,8 @@ export class IncompleteMarkdownParser {
|
|
|
248
268
|
if (underscorePairs % 2 === 1) {
|
|
249
269
|
const isEndingWithDoubleUnderscore = line.endsWith('__');
|
|
250
270
|
const lastDoubleUnderscoreIndex = line.lastIndexOf('__');
|
|
271
|
+
if (isWithinCompleteInlineCode(line, lastDoubleUnderscoreIndex))
|
|
272
|
+
return line;
|
|
251
273
|
const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleUnderscoreIndex);
|
|
252
274
|
if (isEndingWithDoubleUnderscore) {
|
|
253
275
|
return line.substring(0, lastDoubleUnderscoreIndex);
|
|
@@ -270,6 +292,8 @@ export class IncompleteMarkdownParser {
|
|
|
270
292
|
if (tildePairs % 2 === 1) {
|
|
271
293
|
const isEndingWithDoubleTilde = line.endsWith('~~');
|
|
272
294
|
const lastDoubleTildeIndex = line.lastIndexOf('~~');
|
|
295
|
+
if (isWithinCompleteInlineCode(line, lastDoubleTildeIndex))
|
|
296
|
+
return line;
|
|
273
297
|
const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastDoubleTildeIndex);
|
|
274
298
|
// Only complete if there's content after the tildes
|
|
275
299
|
const contentAfterTildes = line.substring(lastDoubleTildeIndex + 2, endOfCellOrLine);
|
|
@@ -296,37 +320,43 @@ export class IncompleteMarkdownParser {
|
|
|
296
320
|
}
|
|
297
321
|
// Inline countSingleAsterisks logic
|
|
298
322
|
let singleAsterisks = 0;
|
|
323
|
+
let lastSingleAsterisk = -1;
|
|
299
324
|
for (let i = 0; i < line.length; i++) {
|
|
300
325
|
if (line[i] === '*') {
|
|
301
326
|
const prevChar = i > 0 ? line[i - 1] : '';
|
|
302
327
|
const nextChar = i < line.length - 1 ? line[i + 1] : '';
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
break;
|
|
308
|
-
}
|
|
309
|
-
if (j === 0) {
|
|
310
|
-
lineStartIndex = 0;
|
|
311
|
-
break;
|
|
312
|
-
}
|
|
328
|
+
// Whitespace on both sides means arithmetic ('5 * 0') or a bare list
|
|
329
|
+
// marker, never an emphasis delimiter (c347b53).
|
|
330
|
+
if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar)) {
|
|
331
|
+
continue;
|
|
313
332
|
}
|
|
314
|
-
|
|
315
|
-
if (beforeAsterisk.trim() === '' && (nextChar === ' ' || nextChar === '\t')) {
|
|
333
|
+
if (isWithinCompleteInlineCode(line, i)) {
|
|
316
334
|
continue;
|
|
317
335
|
}
|
|
318
336
|
if (prevChar !== '*' && nextChar !== '*') {
|
|
319
337
|
singleAsterisks++;
|
|
338
|
+
lastSingleAsterisk = i;
|
|
320
339
|
}
|
|
321
340
|
}
|
|
322
341
|
}
|
|
323
342
|
if (singleAsterisks % 2 === 1) {
|
|
343
|
+
// The dangling asterisk is the last counted one. If it cannot OPEN
|
|
344
|
+
// emphasis (nothing or whitespace after it) it is an intraword or
|
|
345
|
+
// trailing closer — '*foo*bar*' is literal, not half of '*foo*bar**'
|
|
346
|
+
// (9f96409).
|
|
347
|
+
if (isSpaceOrEdge(line[lastSingleAsterisk + 1] ?? '')) {
|
|
348
|
+
return line;
|
|
349
|
+
}
|
|
324
350
|
// Inline findFirstSingleAsterisk logic
|
|
325
351
|
let firstSingleAsteriskIndex = -1;
|
|
326
352
|
for (let i = 0; i < line.length; i++) {
|
|
327
353
|
if (line[i] === '*' && line[i - 1] !== '*' && line[i + 1] !== '*') {
|
|
328
354
|
const prevChar = i > 0 ? line[i - 1] : '';
|
|
329
355
|
const nextChar = i < line.length - 1 ? line[i + 1] : '';
|
|
356
|
+
if (isSpaceOrEdge(prevChar) && isSpaceOrEdge(nextChar))
|
|
357
|
+
continue;
|
|
358
|
+
if (isWithinCompleteInlineCode(line, i))
|
|
359
|
+
continue;
|
|
330
360
|
if (/\w/.test(prevChar) && /\w/.test(nextChar))
|
|
331
361
|
continue;
|
|
332
362
|
if (/\w/.test(prevChar) && !/\s/.test(prevChar))
|
|
@@ -391,6 +421,8 @@ export class IncompleteMarkdownParser {
|
|
|
391
421
|
continue;
|
|
392
422
|
if (isWithinMathBlock(line, i))
|
|
393
423
|
continue;
|
|
424
|
+
if (isWithinCompleteInlineCode(line, i))
|
|
425
|
+
continue;
|
|
394
426
|
if (prevChar &&
|
|
395
427
|
nextChar &&
|
|
396
428
|
/[\p{L}\p{N}_]/u.test(prevChar) &&
|
|
@@ -410,7 +442,8 @@ export class IncompleteMarkdownParser {
|
|
|
410
442
|
line[i - 1] !== '_' &&
|
|
411
443
|
line[i + 1] !== '_' &&
|
|
412
444
|
line[i - 1] !== '\\' &&
|
|
413
|
-
!isWithinMathBlock(line, i)
|
|
445
|
+
!isWithinMathBlock(line, i) &&
|
|
446
|
+
!isWithinCompleteInlineCode(line, i)) {
|
|
414
447
|
const prevChar = i > 0 ? line[i - 1] : '';
|
|
415
448
|
const nextChar = i < line.length - 1 ? line[i + 1] : '';
|
|
416
449
|
if (prevChar &&
|
|
@@ -450,11 +483,17 @@ export class IncompleteMarkdownParser {
|
|
|
450
483
|
}
|
|
451
484
|
if (singleTildes % 2 === 1) {
|
|
452
485
|
const lastTildeIndex = line.lastIndexOf('~');
|
|
453
|
-
if (lastTildeIndex !== -1 &&
|
|
486
|
+
if (lastTildeIndex !== -1 &&
|
|
487
|
+
!isWithinMathBlock(line, lastTildeIndex) &&
|
|
488
|
+
!isWithinCompleteInlineCode(line, lastTildeIndex)) {
|
|
454
489
|
const endOfCellOrLine = findEndOfCellOrLineContaining(line, lastTildeIndex);
|
|
455
|
-
// Only complete if there's content after the tilde
|
|
456
490
|
const contentAfterTilde = line.substring(lastTildeIndex + 1, endOfCellOrLine);
|
|
457
|
-
|
|
491
|
+
// A subscript is '~text~' with no whitespace inside (same rule as the
|
|
492
|
+
// lexer), and a digit before the tilde means a range like '20~25°C':
|
|
493
|
+
// closing those would manufacture a subscript nobody typed (716a5f0).
|
|
494
|
+
if (contentAfterTilde.length > 0 &&
|
|
495
|
+
!/\s/.test(contentAfterTilde) &&
|
|
496
|
+
!/\d/.test(line[lastTildeIndex - 1] ?? '')) {
|
|
458
497
|
return line.substring(0, endOfCellOrLine) + '~' + line.substring(endOfCellOrLine);
|
|
459
498
|
}
|
|
460
499
|
}
|
|
@@ -580,6 +619,9 @@ export class IncompleteMarkdownParser {
|
|
|
580
619
|
continue;
|
|
581
620
|
if (nextChar && /\d/.test(nextChar))
|
|
582
621
|
continue;
|
|
622
|
+
// A '$' shown as code ('`$var`') must not flip the parity (e50b0c4)
|
|
623
|
+
if (isWithinCompleteInlineCode(line, i))
|
|
624
|
+
continue;
|
|
583
625
|
singleDollars++;
|
|
584
626
|
}
|
|
585
627
|
}
|
|
@@ -593,7 +635,8 @@ export class IncompleteMarkdownParser {
|
|
|
593
635
|
prevChar !== '$' &&
|
|
594
636
|
nextChar !== '$' &&
|
|
595
637
|
nextChar !== '' &&
|
|
596
|
-
!/\d/.test(nextChar)
|
|
638
|
+
!/\d/.test(nextChar) &&
|
|
639
|
+
!isWithinCompleteInlineCode(line, i)) {
|
|
597
640
|
lastDollarIndex = i;
|
|
598
641
|
break;
|
|
599
642
|
}
|
|
@@ -866,6 +909,11 @@ export const parseIncompleteMarkdown = (text) => {
|
|
|
866
909
|
return defaultParser.parse(text);
|
|
867
910
|
};
|
|
868
911
|
// Utility functions
|
|
912
|
+
// Full test for the comparisonOperator plugin, whose `pattern` only gates it.
|
|
913
|
+
const listItemComparison = /^(\s*(?:[-*+]|\d+[.)]) +)>(?==?\s*\$?\d)/;
|
|
914
|
+
// The char accessors in the plugins return '' past either end of the line, so an
|
|
915
|
+
// empty string here means "edge of line".
|
|
916
|
+
const isSpaceOrEdge = (char) => !char || /\s/.test(char);
|
|
869
917
|
const findEndOfCellOrLineContaining = (text, position) => {
|
|
870
918
|
let endPos = position;
|
|
871
919
|
while (endPos < text.length && text[endPos] !== '\n' && text[endPos] !== '|') {
|
|
@@ -873,6 +921,40 @@ const findEndOfCellOrLineContaining = (text, position) => {
|
|
|
873
921
|
}
|
|
874
922
|
return endPos;
|
|
875
923
|
};
|
|
924
|
+
// Scanned on demand, against the line as it stands now, and allocating nothing:
|
|
925
|
+
// the completer runs over every changed block of every streamed chunk, so garbage
|
|
926
|
+
// here surfaces as GC pauses in the stages around it.
|
|
927
|
+
const isWithinCompleteInlineCode = (line, position) => {
|
|
928
|
+
let open = line.indexOf('`');
|
|
929
|
+
while (open !== -1 && open <= position) {
|
|
930
|
+
let openEnd = open;
|
|
931
|
+
while (line.charCodeAt(openEnd) === 96)
|
|
932
|
+
openEnd++;
|
|
933
|
+
const runLength = openEnd - open;
|
|
934
|
+
let closeStart = -1;
|
|
935
|
+
for (let j = openEnd; j < line.length; j++) {
|
|
936
|
+
if (line.charCodeAt(j) !== 96)
|
|
937
|
+
continue;
|
|
938
|
+
let closeEnd = j;
|
|
939
|
+
while (line.charCodeAt(closeEnd) === 96)
|
|
940
|
+
closeEnd++;
|
|
941
|
+
if (closeEnd - j === runLength) {
|
|
942
|
+
closeStart = j;
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
j = closeEnd - 1;
|
|
946
|
+
}
|
|
947
|
+
// An unterminated run closes nothing, so neither it nor anything after it
|
|
948
|
+
// is code: completing emphasis inside it is what streaming needs.
|
|
949
|
+
if (closeStart === -1)
|
|
950
|
+
return false;
|
|
951
|
+
const spanEnd = closeStart + runLength;
|
|
952
|
+
if (position < spanEnd)
|
|
953
|
+
return true;
|
|
954
|
+
open = line.indexOf('`', spanEnd);
|
|
955
|
+
}
|
|
956
|
+
return false;
|
|
957
|
+
};
|
|
876
958
|
const isWithinMathBlock = (text, position) => {
|
|
877
959
|
let inInlineMath = false;
|
|
878
960
|
let inBlockMath = false;
|
package/dist/utils/save.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export const save = (filename, content, mimeType) => {
|
|
2
|
-
|
|
2
|
+
// Excel on Windows assumes the system codepage without a BOM, so accented
|
|
3
|
+
// and CJK text in a downloaded CSV opens as mojibake.
|
|
4
|
+
const body = mimeType.startsWith('text/csv') ? '\uFEFF' + content : content;
|
|
5
|
+
const blob = new Blob([body], { type: mimeType });
|
|
3
6
|
const url = URL.createObjectURL(blob);
|
|
4
7
|
const link = document.createElement('a');
|
|
5
8
|
link.href = url;
|
package/dist/utils/url.js
CHANGED
|
@@ -25,6 +25,7 @@ export const isPathRelativeUrl = (url) => {
|
|
|
25
25
|
return false;
|
|
26
26
|
return url.startsWith('/');
|
|
27
27
|
};
|
|
28
|
+
const WILDCARD_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
|
28
29
|
export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
|
|
29
30
|
if (!url)
|
|
30
31
|
return null;
|
|
@@ -61,8 +62,11 @@ export const transformUrl = (url, allowedPrefixes, defaultOrigin) => {
|
|
|
61
62
|
}
|
|
62
63
|
// Check for wildcard - allow all URLs
|
|
63
64
|
if (allowedPrefixes.includes('*')) {
|
|
64
|
-
//
|
|
65
|
-
|
|
65
|
+
// The wildcard allows the protocols a document can legitimately link to.
|
|
66
|
+
// javascript:, data: and vbscript: stay blocked - they execute in the
|
|
67
|
+
// page's origin. mailto:/tel: were missing, so the default ['*'] config
|
|
68
|
+
// rendered a [blocked] badge on every phone and email link.
|
|
69
|
+
if (!WILDCARD_PROTOCOLS.has(parsedUrl.protocol)) {
|
|
66
70
|
return null;
|
|
67
71
|
}
|
|
68
72
|
const inputWasRelative = isPathRelativeUrl(url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-streamdown",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"packageManager": "pnpm@10.32.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"lint": "prettier --check .",
|
|
23
23
|
"test:ui": "vitest --ui",
|
|
24
24
|
"test:unit": "vitest",
|
|
25
|
-
"test": "
|
|
25
|
+
"test:browser": "vitest --run --project client",
|
|
26
|
+
"test": "npm run test:unit -- --run --project server"
|
|
26
27
|
},
|
|
27
28
|
"files": [
|
|
28
29
|
"dist",
|