svelte-streamdown 3.1.2 → 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.
Files changed (37) hide show
  1. package/README.md +130 -43
  2. package/dist/Block.svelte +5 -2
  3. package/dist/Elements/Citation.svelte +2 -2
  4. package/dist/Elements/Code.svelte +21 -54
  5. package/dist/Elements/Code.svelte.d.ts +2 -2
  6. package/dist/Elements/Element.svelte +8 -5
  7. package/dist/Elements/Link.svelte +2 -1
  8. package/dist/Elements/Mermaid.svelte +8 -3
  9. package/dist/Elements/Mermaid.svelte.d.ts +2 -2
  10. package/dist/Elements/MermaidDownload.svelte +1 -1
  11. package/dist/Elements/TableDownload.svelte +13 -5
  12. package/dist/Elements/fallbacks/CodeFallback.svelte +7 -1
  13. package/dist/Elements/fallbacks/MermaidFallback.svelte +7 -1
  14. package/dist/Streamdown.svelte +12 -15
  15. package/dist/context.svelte.d.ts +9 -8
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1 -1
  18. package/dist/marked/index.d.ts +20 -1
  19. package/dist/marked/index.js +160 -39
  20. package/dist/marked/marked-br.js +6 -0
  21. package/dist/marked/marked-citations.js +6 -0
  22. package/dist/marked/marked-footnotes.js +18 -4
  23. package/dist/marked/marked-math.js +5 -0
  24. package/dist/marked/marked-subsup.js +24 -3
  25. package/dist/marked/marked-table.js +15 -12
  26. package/dist/theme.d.ts +0 -3
  27. package/dist/theme.js +0 -2
  28. package/dist/utils/highlightThemes.d.ts +4 -0
  29. package/dist/utils/highlightThemes.js +14 -0
  30. package/dist/utils/hightlighter.svelte.d.ts +6 -29
  31. package/dist/utils/hightlighter.svelte.js +27 -212
  32. package/dist/utils/parse-incomplete-markdown.js +100 -18
  33. package/dist/utils/save.js +4 -1
  34. package/dist/utils/url.js +6 -2
  35. package/package.json +5 -6
  36. package/dist/utils/bundledLanguages.d.ts +0 -8
  37. package/dist/utils/bundledLanguages.js +0 -143
package/README.md CHANGED
@@ -75,9 +75,9 @@ Full support for
75
75
 
76
76
  ### 💻 Interactive Code Blocks
77
77
 
78
- - Syntax highlighting powered by Shiki
78
+ - Syntax highlighting powered by [@tanstack/highlight](https://github.com/TanStack/highlight) (synchronous, SSR-friendly, ~31KB min / ~11KB gzip for every language)
79
79
  - Copy-to-clipboard functionality
80
- - Support any Shiki themes
80
+ - Support any `@tanstack/highlight` theme, or your own
81
81
 
82
82
  ### 🔢 Mathematical Expressions
83
83
 
@@ -398,7 +398,7 @@ Here is how it works on each content update:
398
398
 
399
399
  During streaming, newly received text almost always only changes the **last** block (and occasionally starts a new one). Every earlier block keeps an identical raw string, so Svelte skips its `lex()` call entirely — this is the equivalent of the memoized `Block` component in the React version. The block-splitting pass itself runs on every update, but it is the cheap pass; the costly inline parsing is what gets reused.
400
400
 
401
- Code highlighting is incremental as well: a code block is only re-highlighted when its text changes, and the Shiki highlighter caches the languages and themes it has already loaded, so a streaming code block does not reload its grammar on every chunk.
401
+ Code highlighting is incremental as well: a code block is only re-highlighted when its text changes. Highlighting itself is synchronous and runs during SSR, so there is no grammar to load, no loading state and no hydration flash.
402
402
 
403
403
  > [!NOTE]
404
404
  > There is intentionally no separate block-level parse cache (e.g. an LRU keyed by block content). For the common append-only streaming case the reactivity-based approach above already avoids redundant work, and a standalone cache would add memory usage and invalidation complexity without a measurable benefit. If you have a workload where this matters, please [open an issue](https://github.com/beynar/svelte-streamdown/issues) with a repro — we're happy to revisit.
@@ -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 all `http://` and `https://` URLs. 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:'`.
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
 
@@ -524,7 +524,7 @@ Streamdown is optimized for minimal bundle size by making heavy components **opt
524
524
  <script>
525
525
  import { Streamdown } from 'svelte-streamdown';
526
526
  // Import only the components you need
527
- import Code from 'svelte-streamdown/code'; // Shiki syntax highlighting
527
+ import Code from 'svelte-streamdown/code'; // syntax highlighting
528
528
  import Mermaid from 'svelte-streamdown/mermaid'; // Mermaid diagrams
529
529
  import Math from 'svelte-streamdown/math'; // KaTeX math rendering
530
530
  </script>
@@ -534,11 +534,11 @@ Streamdown is optimized for minimal bundle size by making heavy components **opt
534
534
 
535
535
  ### Component Dependencies
536
536
 
537
- | Component | Import Path | Dependency | Size Impact |
538
- | --------- | --------------------------- | ---------- | ------------------------- |
539
- | `Code` | `svelte-streamdown/code` | Shiki | ~2MB (languages + themes) |
540
- | `Mermaid` | `svelte-streamdown/mermaid` | Mermaid.js | ~1.5MB |
541
- | `Math` | `svelte-streamdown/math` | KaTeX | ~300KB |
537
+ | Component | Import Path | Dependency | Size Impact |
538
+ | --------- | --------------------------- | --------------------- | ---------------------------------------------------- |
539
+ | `Code` | `svelte-streamdown/code` | `@tanstack/highlight` | ~31KB min / ~11KB gzip (all 30 languages + 2 themes) |
540
+ | `Mermaid` | `svelte-streamdown/mermaid` | Mermaid.js | ~1.5MB |
541
+ | `Math` | `svelte-streamdown/math` | KaTeX | ~300KB |
542
542
 
543
543
  > [!TIP]
544
544
  > Only import the components your application actually uses. If your content doesn't include code blocks, mermaid diagrams, or math expressions, you can skip those imports entirely for a much smaller bundle.
@@ -551,71 +551,150 @@ When a heavy component is not provided:
551
551
  - **Mermaid**: Renders the mermaid source as a code block
552
552
  - **Math**: Renders the raw LaTeX/math text
553
553
 
554
- ### Shiki Themes
554
+ ### Highlight themes
555
555
 
556
- The `Code` component bundles two themes out of the box: **`github-dark`** and **`github-light`**. By default `shikiTheme` follows the active color scheme (`github-dark` in dark mode, `github-light` otherwise), so basic light/dark theming works with no extra configuration.
556
+ The `Code` component bundles two themes out of the box: **`github-dark`** and **`github-light`**. By default `highlightTheme` follows the active color scheme (`github-dark` in dark mode, `github-light` otherwise), so basic light/dark theming works with no extra configuration.
557
557
 
558
- To use any other Shiki theme (e.g. `vesper`, `github-dark-default`, `github-light-default`), import it from `@shikijs/themes/<name>` and register it via the `shikiThemes` prop. The **key** you register it under is the value you pass to `shikiTheme`:
558
+ To use any other theme (`aurora-x`, `dracula`, `gruvbox-dark`, `gruvbox-light`, `monokai`, `nord`, `one-dark-pro`, `solarized-dark`, `solarized-light`), import it from `@tanstack/highlight/themes/<name>` and register it via the `highlightThemes` prop. The **key** you register it under is the value you pass to `highlightTheme`:
559
559
 
560
560
  ```svelte
561
561
  <script lang="ts">
562
562
  import { Streamdown } from 'svelte-streamdown';
563
- import Code from 'svelte-streamdown/code'; // enables Shiki highlighting
564
- import vesper from '@shikijs/themes/vesper';
563
+ import Code from 'svelte-streamdown/code'; // enables highlighting
564
+ import dracula from '@tanstack/highlight/themes/dracula';
565
565
 
566
566
  let { content } = $props();
567
567
  </script>
568
568
 
569
- <Streamdown {content} components={{ code: Code }} shikiThemes={{ vesper }} shikiTheme="vesper" />
569
+ <Streamdown
570
+ {content}
571
+ components={{ code: Code }}
572
+ highlightThemes={{ dracula }}
573
+ highlightTheme="dracula"
574
+ />
570
575
  ```
571
576
 
572
- > [!IMPORTANT]
573
- > A theme passed to `shikiTheme` must be one of the two built-in themes **or** registered via `shikiThemes`. If it is neither, the code block stays in its loading (skeleton) state and is never highlighted — this is the most common cause of "my theme stopped working".
577
+ > [!NOTE]
578
+ > An unknown `highlightTheme` key falls back to the built-in `github-dark` / `github-light` (following the color scheme) instead of leaving the code block unhighlighted.
579
+
580
+ A custom theme is just an object of the `HighlightTheme` shape (exported from `svelte-streamdown`) — a `name`, `type`, `background`, `foreground` and a color per token class:
581
+
582
+ ```svelte
583
+ <script lang="ts">
584
+ import { Streamdown, type HighlightTheme } from 'svelte-streamdown';
585
+ import Code from 'svelte-streamdown/code';
586
+ import githubDark from '@tanstack/highlight/themes/github-dark';
587
+
588
+ const myTheme: HighlightTheme = {
589
+ ...githubDark,
590
+ name: 'my-theme',
591
+ tokens: { ...githubDark.tokens, keyword: '#ff0088', comment: '#5a5a5a' }
592
+ };
593
+ </script>
594
+
595
+ <Streamdown
596
+ {content}
597
+ components={{ code: Code }}
598
+ highlightThemes={{ 'my-theme': myTheme }}
599
+ highlightTheme="my-theme"
600
+ />
601
+ ```
574
602
 
575
603
  #### Dynamic (light/dark) theme switching
576
604
 
577
- Register every theme you intend to switch between in `shikiThemes`, then drive `shikiTheme` from your color-scheme store. Switching is fully reactive — code blocks re-highlight when `shikiTheme` changes:
605
+ Register every theme you intend to switch between in `highlightThemes`, then drive `highlightTheme` from your color-scheme store. Switching is fully reactive:
578
606
 
579
607
  ```svelte
580
608
  <script lang="ts">
581
609
  import { Streamdown } from 'svelte-streamdown';
582
610
  import Code from 'svelte-streamdown/code';
583
611
  import { mode } from 'mode-watcher';
584
- import githubDarkDefault from '@shikijs/themes/github-dark-default';
585
- import githubLightDefault from '@shikijs/themes/github-light-default';
612
+ import oneDarkPro from '@tanstack/highlight/themes/one-dark-pro';
613
+ import solarizedLight from '@tanstack/highlight/themes/solarized-light';
586
614
 
587
615
  let { content } = $props();
588
616
 
589
- const shikiTheme = $derived(
590
- mode.current === 'dark' ? 'github-dark-default' : 'github-light-default'
591
- );
617
+ const highlightTheme = $derived(mode.current === 'dark' ? 'one-dark-pro' : 'solarized-light');
592
618
  </script>
593
619
 
594
620
  <Streamdown
595
621
  {content}
596
622
  components={{ code: Code }}
597
623
  baseTheme="shadcn"
598
- shikiThemes={{
599
- 'github-dark-default': githubDarkDefault,
600
- 'github-light-default': githubLightDefault
624
+ highlightThemes={{
625
+ 'one-dark-pro': oneDarkPro,
626
+ 'solarized-light': solarizedLight
601
627
  }}
602
- {shikiTheme}
628
+ {highlightTheme}
603
629
  />
604
630
  ```
605
631
 
606
632
  > [!NOTE]
607
- > The built-in `github-dark` / `github-light` themes can be switched dynamically with just `shikiTheme` (no `shikiThemes` registration needed), since both are always loaded.
633
+ > The built-in `github-dark` / `github-light` themes can be switched dynamically with just `highlightTheme` (no `highlightThemes` registration needed).
634
+
635
+ #### Styling tokens with CSS
636
+
637
+ Every token span carries `th-token` plus a `th-<class>` class (`th-keyword`, `th-string`, `th-comment`, `th-function`, `th-number`, `th-operator`, `th-tag`, `th-attr`, …) alongside its inline color from the active theme, so you can tweak or override token styles from CSS:
638
+
639
+ ```css
640
+ [data-streamdown-code] .th-comment {
641
+ font-style: italic;
642
+ }
643
+ ```
644
+
645
+ ### Supported languages
646
+
647
+ All 30 scanners ship in the same ~31KB bundle, so there is nothing to lazy-load:
648
+
649
+ `apache`, `cmake`, `cpp`, `css`, `diff`, `dockerfile`, `ejs`, `env`, `go`, `html`, `http`, `js`, `json`, `jsx`, `markdown`, `mermaid`, `nginx`, `php`, `plaintext`, `python`, `scheme`, `shell`, `sql`, `svelte`, `toml`, `ts`, `tsrx`, `tsx`, `vue`, `yaml`.
650
+
651
+ Common aliases are normalized (`javascript` → `js`, `typescript` → `ts`, `bash`/`sh`/`zsh` → `shell`, `py` → `python`, `jsonc`/`json5` → `json`, `xml`/`htm` → `html`, `md` → `markdown`, `yml` → `yaml`, `docker` → `dockerfile`).
652
+
653
+ > [!IMPORTANT]
654
+ > Any other language — including `java`, `rust`, `c`, `c#`, `swift`, `kotlin`, `ruby` and `graphql` — is **not** highlighted as of `@tanstack/highlight@0.1.0`: the code block still renders (and keeps its copy/download UI), but as plaintext.
655
+
656
+ You can add your own language with `defineLanguage` and the `highlightLanguages` prop. A language is a tokenizer returning `{ className, start, end }` ranges:
657
+
658
+ ```svelte
659
+ <script lang="ts">
660
+ import { Streamdown, defineLanguage } from 'svelte-streamdown';
661
+ import Code from 'svelte-streamdown/code';
662
+
663
+ const brainfuck = defineLanguage({
664
+ name: 'brainfuck',
665
+ tokenize: (code) =>
666
+ [...code.matchAll(/[+\-<>]+/g)].map((match) => ({
667
+ className: 'operator' as const,
668
+ start: match.index,
669
+ end: match.index + match[0].length
670
+ }))
671
+ });
672
+ </script>
673
+
674
+ <Streamdown {content} components={{ code: Code }} highlightLanguages={[brainfuck]} />
675
+ ```
676
+
677
+ > [!NOTE]
678
+ > Keep the array passed to `highlightLanguages` referentially stable (define it outside the template): a new array builds a new highlighter.
608
679
 
609
- #### Migrating from v2 (`shikiPreloadThemes`)
680
+ ### Migrating from v3 (shiki)
610
681
 
611
- The v2 `shikiPreloadThemes` prop has been **removed**. Themes are no longer referenced by bundled name; instead you import the theme objects yourself and register them with `shikiThemes`. Registered themes are loaded together with the highlighter (i.e. effectively preloaded), so there is no separate preload step:
682
+ v4 replaces shiki with `@tanstack/highlight`. Highlighting is now synchronous, renders during SSR and no longer has a skeleton/loading state. Remove `shiki` / `@shikijs/*` from your dependencies and rename the props:
612
683
 
613
684
  ```diff
614
- - shikiPreloadThemes={['github-dark-default', 'github-light-default']}
615
- + shikiThemes={{ 'github-dark-default': githubDarkDefault, 'github-light-default': githubLightDefault }}
685
+ - shikiTheme="nord"
686
+ - shikiThemes={{ nord }}
687
+ - shikiLanguages={[{ id: 'haskell', name: 'Haskell', import: () => import('@shikijs/langs/haskell') }]}
688
+ + highlightTheme="nord"
689
+ + highlightThemes={{ nord }}
690
+ + highlightLanguages={[myLanguage]}
616
691
  ```
617
692
 
618
- This also keeps the default bundle small: only the themes you actually import are included.
693
+ - Theme objects come from `@tanstack/highlight/themes/<name>` instead of `@shikijs/themes/<name>`, and follow the `HighlightTheme` shape (not shiki's `ThemeRegistration`).
694
+ - `highlightLanguages` takes `LanguageDefinition`s built with `defineLanguage`, not lazy shiki grammar loaders.
695
+ - `bundledLanguagesInfo`, `createLanguageSet` and `LanguageInfo` are gone; `defineLanguage`, `LanguageDefinition` and `HighlightTheme` are exported instead.
696
+ - The `code.skeleton` theme key is gone (there is no loading state anymore).
697
+ - Languages shiki covered but `@tanstack/highlight@0.1.0` does not (`java`, `rust`, `c`, `c#`, `swift`, `kotlin`, `ruby`, `graphql`, …) now render as plaintext.
619
698
 
620
699
  ## 📋 Props API
621
700
 
@@ -628,20 +707,24 @@ This also keeps the default bundle small: only the themes you actually import ar
628
707
  | `defaultOrigin` | `string` | - | Default origin for relative URLs |
629
708
  | `allowedLinkPrefixes` | `string[]` | `['*']` | Allowed URL prefixes for links |
630
709
  | `allowedImagePrefixes` | `string[]` | `['*']` | Allowed URL prefixes for images |
631
- | `skipHtml` | `boolean` | - | Skip HTML parsing entirely |
632
- | `unwrapDisallowed` | `boolean` | - | Unwrap instead of removing disallowed elements |
633
- | `urlTransform` | `UrlTransform \| null` | - | Custom URL transformation function |
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) |
634
717
  | `theme` | `DeepPartial<Theme>` | - | Custom theme overrides |
635
718
  | `baseTheme` | `'tailwind' \| 'shadcn'` | `'tailwind'` | Base theme to use before applying overrides |
636
719
  | `mergeTheme` | `boolean` | `true` | Whether to merge theme with base theme |
637
- | `shikiTheme` | `string` | auto (dark-mode aware) | Code highlighting theme. Defaults to `github-dark` in dark mode / `github-light` otherwise. Any other value must be a key registered via `shikiThemes`. See [Shiki Themes](#shiki-themes). |
638
- | `shikiThemes` | `Record<string, ThemeRegistration>` | - | Register additional pre-imported themes (e.g. `{ vesper }`) so they can be selected via `shikiTheme`, including dynamic light/dark switching. Replaces the v2 `shikiPreloadThemes` prop. |
639
- | `shikiLanguages` | `LanguageInfo[]` | - | Additional syntax highlighting languages (merged with defaults) |
720
+ | `highlightTheme` | `string` | auto (dark-mode aware) | Code highlighting theme. Defaults to `github-dark` in dark mode / `github-light` otherwise. Any other value must be a key registered via `highlightThemes`. See [Highlight themes](#highlight-themes). |
721
+ | `highlightThemes` | `Record<string, HighlightTheme>` | - | Register additional pre-imported themes (e.g. `{ dracula }`) so they can be selected via `highlightTheme`, including dynamic light/dark switching. |
722
+ | `highlightLanguages` | `LanguageDefinition[]` | - | Additional languages built with `defineLanguage` (merged with the 30 built-in ones) |
640
723
  | `mermaidConfig` | `MermaidConfig` | - | Mermaid diagram configuration |
641
724
  | `katexConfig` | `KatexOptions \| ((inline: boolean) => KatexOptions)` | - | KaTeX math rendering options |
642
725
  | `animation` | `AnimationConfig` | - | Animation configuration for streaming content |
643
726
  | `animation.enabled` | `boolean` | `false` | Enable/disable animations |
644
- | `animation.type` | `'fade' \| 'blur' \| 'typewriter' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
727
+ | `animation.type` | `'fade' \| 'blur' \| 'slideUp' \| 'slideDown'` | `'blur'` | Animation style for text appearance |
645
728
  | `animation.duration` | `number` | `500` | Animation duration in milliseconds |
646
729
  | `animation.timingFunction` | `'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| 'linear'` | `'ease-in'` | CSS timing function for animations |
647
730
  | `animation.tokenize` | `'word' \| 'char'` | `'word'` | Tokenization method for text animations |
@@ -660,7 +743,7 @@ This also keeps the default bundle small: only the themes you actually import ar
660
743
 
661
744
  **Lists**: `ul`, `ol`, `li`
662
745
 
663
- **Code**: `code`, `codeSpan`
746
+ **Code**: `code`, `codespan`
664
747
 
665
748
  **Tables**: `table`, `thead`, `tbody`, `tr`, `th`, `td`, `tfoot`
666
749
 
@@ -753,7 +836,7 @@ Each component supports multiple themeable parts:
753
836
 
754
837
  **Links (`a`)**: `base`, `blocked` (for blocked/unsafe links)
755
838
 
756
- **Code (`code`)**: `base`, `container`, `header`, `button`, `language`, `skeleton`, `pre`
839
+ **Code (`code`)**: `base`, `container`, `header`, `buttons`, `language`, `line`, `pre`
757
840
 
758
841
  **Inline Code (`inlineCode`)**: `base`
759
842
 
@@ -1024,6 +1107,10 @@ pnpm dev
1024
1107
  # Run tests
1025
1108
  pnpm test
1026
1109
 
1110
+ # Run the browser (component) tests — needs a Chromium binary:
1111
+ # pnpm exec playwright install chromium
1112
+ pnpm test:browser
1113
+
1027
1114
  # Build for production
1028
1115
  pnpm build
1029
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(isStatic ? block : parseIncompleteMarkdown(block.trim()), streamdown.extensions)
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="Citations-${id}"
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="Citation-${id}"
196
+ aria-label={'Citation-' + id}
197
197
  >
198
198
  <Slot render={streamdown.snippets.inlineCitationContent} props={{ source, key, token }}>
199
199
  {#if url || title}
@@ -2,31 +2,28 @@
2
2
  import { useStreamdown } from '../context.svelte.js';
3
3
  import { save } from '../utils/save.js';
4
4
  import { useCopy } from '../utils/copy.svelte.js';
5
- import { HighlighterManager, languageExtensionMap } from '../utils/hightlighter.svelte.js';
6
- import { bundledLanguagesInfo } from '../utils/bundledLanguages.js';
7
- import type { Tokens } from 'marked';
8
- import { type ThemedToken } from 'shiki';
9
- import { untrack } from 'svelte';
5
+ import { highlightLines, languageExtensionMap } from '../utils/hightlighter.svelte.js';
6
+ import type { CodeToken } from '../marked/index.js';
10
7
  import { checkIcon, copyIcon, downloadIcon } from './icons.js';
11
8
 
12
9
  const {
13
10
  token,
14
11
  id
15
12
  }: {
16
- token: Tokens.Code;
13
+ token: CodeToken;
17
14
  id: string;
18
15
  } = $props();
19
16
 
20
17
  const streamdown = useStreamdown();
21
- const highlighter = HighlighterManager.create(
22
- bundledLanguagesInfo,
23
- streamdown.shikiThemes,
24
- streamdown.shikiLanguages
25
- );
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+$/, ''));
26
23
 
27
24
  const copy = useCopy({
28
25
  get content() {
29
- return token.text;
26
+ return code;
30
27
  }
31
28
  });
32
29
 
@@ -40,19 +37,13 @@
40
37
  : 'txt';
41
38
  const filename = `file.${extension}`;
42
39
  const mimeType = 'text/plain';
43
- save(filename, token.text, mimeType);
40
+ save(filename, code, mimeType);
44
41
  } catch (error) {
45
42
  console.error('Failed to download file:', error);
46
43
  }
47
44
  };
48
45
 
49
- $effect(() => {
50
- const theme = streamdown.shikiTheme;
51
- const lang = token.lang;
52
- untrack(() => {
53
- void highlighter.load(theme, lang);
54
- });
55
- });
46
+ const lines = $derived(highlightLines(code, token.lang, streamdown.highlightLanguages));
56
47
  </script>
57
48
 
58
49
  <div
@@ -84,39 +75,15 @@
84
75
  {/if}
85
76
  </div>
86
77
  <div style="height: fit-content; width: 100%;" class={streamdown.theme.code.container}>
87
- {#if highlighter.isReady(streamdown.shikiTheme, token.lang)}
88
- <pre class={streamdown.theme.code.pre}><code
89
- >{@render Tokens(
90
- highlighter.highlightCode(token.text, token.lang, streamdown.shikiTheme)
91
- )}</code
92
- ></pre>
93
- {:else}
94
- <pre class={streamdown.theme.code.pre}><code>{@render Skeleton(token.text.split('\n'))}</code
95
- ></pre>
96
- {/if}
78
+ <pre class={streamdown.theme.code.pre}><code
79
+ >{#each lines as line}<span class={streamdown.theme.code.line}
80
+ >{#if line.length === 0}&#8203;{/if}{#each line as t}<span
81
+ class="th-token{t.className ? ` th-${t.className}` : ''}"
82
+ style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
83
+ style:color={streamdown.highlightTheme.tokens[t.className ?? 'token']}
84
+ >{t.value}</span
85
+ >{/each}</span
86
+ >{/each}</code
87
+ ></pre>
97
88
  </div>
98
89
  </div>
99
-
100
- {#snippet Tokens(lines: ThemedToken[][])}
101
- {#each lines as tokens}
102
- <span class={streamdown.theme.code.line}>
103
- {#each tokens as token}
104
- <span
105
- style={streamdown.isMounted ? streamdown.animationTextStyle : ''}
106
- style:color={token.color}
107
- style:background-color={token.bgColor}
108
- >
109
- {token.content}
110
- </span>
111
- {/each}
112
- </span>
113
- {/each}
114
- {/snippet}
115
-
116
- {#snippet Skeleton(lines: string[])}
117
- {#each lines as line}
118
- <span class={streamdown.theme.code.skeleton}>
119
- {line.trim().length > 0 ? line : '\u200B'}
120
- </span>
121
- {/each}
122
- {/snippet}
@@ -1,6 +1,6 @@
1
- import type { Tokens } from 'marked';
1
+ import type { CodeToken } from '../marked/index.js';
2
2
  type $$ComponentProps = {
3
- token: Tokens.Code;
3
+ token: CodeToken;
4
4
  id: string;
5
5
  };
6
6
  declare const Code: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -288,12 +288,11 @@
288
288
  {@render children()}
289
289
  </dd>
290
290
  </Slot>
291
- {:else if token.type === 'def'}
292
- <!-- TODO This does not seems to be tokenized for now -->
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
- <!-- TODO This does not seems to be tokenized for now -->
295
- {:else if token.type === 'space'}
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={token.title ? `Blocked URL: ${token.href}` : undefined}
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 { Tokens } from 'marked';
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: Tokens.Code;
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(token.text, node)}
225
+ {@attach (node) => renderMermaid(chart, node)}
221
226
  {@attach insider.attach}
222
227
  data-expanded={'false'}
223
228
  >
@@ -1,6 +1,6 @@
1
- import type { Tokens } from 'marked';
1
+ import type { CodeToken } from '../marked/index.js';
2
2
  type $$ComponentProps = {
3
- token: Tokens.Code;
3
+ token: CodeToken;
4
4
  id: string;
5
5
  };
6
6
  declare const Mermaid: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -156,7 +156,7 @@
156
156
 
157
157
  {#if popover.isOpen}
158
158
  <dialog
159
- id={'mermaid-download-popover'}
159
+ id={'mermaid-download-popover-' + id}
160
160
  aria-modal="false"
161
161
  transition:scale|global={{ start: 0.95, duration: 100 }}
162
162
  {@attach clickOutside.attachment}
@@ -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=${id}]`);
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=${id}]`);
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.textContent || '';
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}