svelte-streamdown 2.4.5 → 2.5.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 CHANGED
@@ -62,6 +62,7 @@ Full support for
62
62
  - Escaping currency symbols ($140)
63
63
  - Complex tables
64
64
  - Footnotes [^1]
65
+ - Inline citations [ref] [ref2]
65
66
 
66
67
  [^1]:
67
68
  Reference render in a popover by default.
@@ -133,11 +134,11 @@ pie title Project Time Allocation
133
134
 
134
135
  | H1 | H2 | H3 |
135
136
  | ------------------------- | --- | --- |
136
- | This cell spans 3 columns | | |
137
+ | This cell spans 3 columns |||
137
138
 
138
139
  | Header 1 | Header 2 | Header 3 |
139
140
  | ------------------------- | -------- | -------- |
140
- | This cell spans 2 columns | | Normal |
141
+ | This cell spans 2 columns || Normal |
141
142
  | Normal | Normal | Normal |
142
143
 
143
144
  #### Rowspan
@@ -153,7 +154,7 @@ pie title Project Time Allocation
153
154
  | --------------- | -------- |
154
155
  | Cell B | Cell A |
155
156
  | --------------- | -------- |
156
- | Footer | |
157
+ | Footer ||
157
158
 
158
159
  #### Column alignment
159
160
 
@@ -238,6 +239,98 @@ III. Third item
238
239
  : Topic 3 : Description 3
239
240
  : Topic 3 : Description 3
240
241
 
242
+ ### Citation Support
243
+
244
+ Streamdown supports inline citations that allow you to reference external sources and display them in interactive popovers. Citations work out-of-the-box with a simple object structure and support nested references like this `[cloudflare.website, vercel]` will render into [cloudflare.website, vercel]
245
+
246
+ To enable inline citations, pass a `sources` object as a prop to the `Streamdown` component.
247
+
248
+ #### Basic Usage
249
+
250
+ ```svelte
251
+ <script>
252
+ import { Streamdown } from 'svelte-streamdown';
253
+
254
+ let content = `According to [smith2023], AI is advancing rapidly. See also [nested.subsection] for related work.`;
255
+
256
+ let sources = {
257
+ "smith2023": {
258
+ title: "AI Research Paper",
259
+ url: "https://example.com/paper",
260
+ content: "Detailed content of the citation..."
261
+ },
262
+ "nested": {
263
+ "subsection": {
264
+ title: "Nested Citation",
265
+ url: "https://example.com/nested"
266
+ }
267
+ }
268
+ };
269
+ </script>
270
+
271
+ <Streamdown {content} {sources} />
272
+ ```
273
+
274
+ #### Default Citation Structure
275
+
276
+ Citations work with objects containing these properties:
277
+
278
+ - `title (or name or author)`: Display title for the citation
279
+ - `url (or href, url, link or source)`: Link to the source
280
+ - `content (or text, summary or excerpt)`: Rich content to display in carousel mode
281
+
282
+
283
+ #### Display Modes
284
+
285
+ Streamdown offers two ways to display citations:
286
+
287
+ - **List View**: Shows all citations in a compact list format
288
+ - **Carousel View** (default): Step-through navigation for multiple citations with full content display
289
+
290
+ You can control the display mode using the `inlineCitationsMode` prop:
291
+
292
+ ```svelte
293
+ <!-- List view -->
294
+ <Streamdown {content} {sources} inlineCitationsMode="list" />
295
+
296
+ <!-- Carousel view (default) -->
297
+ <Streamdown {content} {sources} inlineCitationsMode="carousel" />
298
+ ```
299
+
300
+ #### Citation Popovers
301
+
302
+ Citations appear as clickable buttons that open popovers when clicked. The popover shows:
303
+ - Source title and URL (when available)
304
+ - Favicon from the source domain
305
+ - Rich content (in carousel mode)
306
+ - Navigation controls (in carousel mode for multiple citations)
307
+
308
+ #### Custom Citation Rendering
309
+
310
+ If your citation data structure doesn't match the default format, you can customize how citations are rendered using `inlineCitationPreview`, `inlineCitationContent` or `inlineCitationPopover` snippets:
311
+
312
+ ```svelte
313
+ <Streamdown {content} {sources}>
314
+ {#snippet inlineCitationPreview({ token })}
315
+ <!-- Customize the clickable citation button -->
316
+ {token.keys[0]}
317
+ {/snippet}
318
+
319
+ {#snippet inlineCitationContent({ source, key, token })}
320
+ <!-- Customize content displayed in popover -->
321
+ <div class="custom-content">
322
+ <h4>{source.customTitle || key}</h4>
323
+ <p>{source.customDescription}</p>
324
+ </div>
325
+ {/snippet}
326
+ </Streamdown>
327
+ ```
328
+
329
+ These snippets allow you to:
330
+ - **`inlineCitationPreview`**: Customize the content of the clickable button that appears in the text
331
+ - **`inlineCitationContent`**: Customize how individual citation content is displayed within popovers
332
+ - **`inlineCitationPopover`**: Completely customize the list of citations
333
+
241
334
  ## 🔄 Differences from Original React Version
242
335
 
243
336
  This Svelte port maintains feature parity with the original [Streamdown](https://streamdown.ai/) while adapting to Svelte's patterns:
@@ -369,6 +462,7 @@ This heading will use a custom component!`;
369
462
  | Prop | Type | Default | Description |
370
463
  | -------------------------- | -------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
371
464
  | `content` | `string` | - | **Required.** The markdown content to render |
465
+ | `sources` | `Record<string, any>` | - | Citation data object for inline citations |
372
466
  | `class` | `string` | - | CSS class names for the wrapper element |
373
467
  | `parseIncompleteMarkdown` | `boolean` | `true` | Parse and fix incomplete markdown syntax |
374
468
  | `defaultOrigin` | `string` | - | Default origin for relative URLs |
@@ -405,7 +499,7 @@ This heading will use a custom component!`;
405
499
 
406
500
  **Tables**: `table`, `thead`, `tbody`, `tr`, `th`, `td`, `tfoot`
407
501
 
408
- **Special Content**: `blockquote`, `hr`, `alert`, `mermaid`, `math`, `footnoteRef`
502
+ **Special Content**: `blockquote`, `hr`, `alert`, `mermaid`, `math`, `footnoteRef`, `inlineCitation`
409
503
 
410
504
  **Note**: The above elements are **supported by Streamdown** and should be customized using individual props or the theme system.
411
505
 
package/dist/Block.svelte CHANGED
@@ -4,17 +4,17 @@
4
4
  import { lex, type StreamdownToken } from './marked/index.js';
5
5
  import AnimatedText from './AnimatedText.svelte';
6
6
  import { useStreamdown } from './context.svelte.js';
7
+ import { getContext } from 'svelte';
7
8
 
8
9
  let {
9
- block,
10
- insideFootnote = false
10
+ block
11
11
  }: {
12
12
  block: string;
13
- insideFootnote?: boolean;
14
13
  } = $props();
15
14
 
16
15
  const streamdown = useStreamdown();
17
16
  const tokens = $derived(lex(parseIncompleteMarkdown(block.trim()), streamdown.extensions));
17
+ const insidePopover = getContext('POPOVER');
18
18
  </script>
19
19
 
20
20
  {#snippet renderChildren(tokens: StreamdownToken[])}
@@ -24,7 +24,7 @@
24
24
  {@const isTextOnlyNode = children.length === 0}
25
25
  <Element {token}>
26
26
  {#if isTextOnlyNode}
27
- {#if streamdown.animation.enabled && !insideFootnote}
27
+ {#if streamdown.animation.enabled && !insidePopover}
28
28
  <AnimatedText text={'text' in token ? token.text : ''} />
29
29
  {:else}
30
30
  {'text' in token ? token.text : ''}
@@ -1,6 +1,5 @@
1
1
  type $$ComponentProps = {
2
2
  block: string;
3
- insideFootnote?: boolean;
4
3
  };
5
4
  declare const Block: import("svelte").Component<$$ComponentProps, {}, "">;
6
5
  type Block = ReturnType<typeof Block>;
@@ -0,0 +1,285 @@
1
+ <script lang="ts">
2
+ import { useStreamdown } from '../context.svelte.js';
3
+ import Slot from './Slot.svelte';
4
+ import { useClickOutside } from '../utils/useClickOutside.svelte.js';
5
+ import { scale } from 'svelte/transition';
6
+ import Block from '../Block.svelte';
7
+ import { useKeyDown } from '../utils/useKeyDown.svelte.js';
8
+ import type { CitationToken } from '../marked/marked-citations.js';
9
+ import { StepperState } from './stepperState.svelte.js';
10
+ import { Popover } from './popover.svelte.js';
11
+ import { get } from '../utils/get.js';
12
+
13
+ const {
14
+ token
15
+ }: {
16
+ token: CitationToken;
17
+ } = $props();
18
+ const streamdown = useStreamdown();
19
+ const id = $props.id();
20
+ const popover = new Popover();
21
+
22
+ const citationWithSources = $derived.by(() => {
23
+ return token.keys.reduce((acc, key) => {
24
+ const source = get<Record<string, any>>(streamdown.sources, key);
25
+ const safeUrl = (url: string | null) => {
26
+ if (url == null) return null;
27
+ try {
28
+ return new URL(url);
29
+ } catch (error) {
30
+ return null;
31
+ }
32
+ };
33
+ if (source) {
34
+ const url = safeUrl(source.url ?? source.href ?? source.link ?? source.source);
35
+ acc.push({
36
+ key,
37
+ title: source.title ?? source.name ?? source.author ?? null,
38
+ url,
39
+ host: url ? url.host.replace('www.', '') : null,
40
+ favicon: url ? `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=128` : null,
41
+ content: source.content ?? source.text ?? source.summary ?? source.excerpt ?? null,
42
+ source
43
+ });
44
+ }
45
+ return acc;
46
+ }, [] as CitationWithSource[]);
47
+ });
48
+ useKeyDown({
49
+ keys: ['Escape'],
50
+ get isActive() {
51
+ return popover.isOpen;
52
+ },
53
+ callback: () => {
54
+ popover.isOpen = false;
55
+ }
56
+ });
57
+ const clickOutside = useClickOutside({
58
+ get isActive() {
59
+ return popover.isOpen;
60
+ },
61
+ callback: () => {
62
+ popover.isOpen = false;
63
+ }
64
+ });
65
+
66
+ const stepper = new StepperState({
67
+ get items() {
68
+ return citationWithSources;
69
+ },
70
+ keyFramesOptions: { duration: 200, easing: 'ease-in-out', fill: 'forwards' }
71
+ });
72
+
73
+ type CitationWithSource = Record<string, any> & {
74
+ key: string;
75
+ title: string | null;
76
+ url: URL | null;
77
+ favicon: string | null;
78
+ content: string | null;
79
+ source: Record<string, any>;
80
+ };
81
+ </script>
82
+
83
+ {#if popover.isOpen}
84
+ <dialog
85
+ id={'citation-popover-' + id}
86
+ aria-modal="false"
87
+ transition:scale|global={{ start: 0.95, duration: 200 }}
88
+ {@attach clickOutside.attachment}
89
+ {@attach popover.popoverAttachment}
90
+ open
91
+ class={`${streamdown.theme.components.popover}`}
92
+ style:max-width="400px"
93
+ >
94
+ <Slot
95
+ props={{
96
+ token,
97
+ isOpen: popover.isOpen
98
+ }}
99
+ render={streamdown.snippets.inlineCitationPopover}
100
+ >
101
+ {#if streamdown.inlineCitationsMode === 'carousel'}
102
+ {@render carouselView()}
103
+ {:else}
104
+ {@render listView()}
105
+ {/if}
106
+ </Slot>
107
+ </dialog>
108
+ {/if}
109
+
110
+ {#snippet listView()}
111
+ <div class={streamdown.theme.inlineCitation.list.base}>
112
+ {#each citationWithSources as { favicon, key, source, title, url }}
113
+ <Slot render={streamdown.snippets.inlineCitationContent} props={{ source, key, token }}>
114
+ <a
115
+ class={streamdown.theme.inlineCitation.list.item}
116
+ href={url?.toString()}
117
+ target="_blank"
118
+ rel="noopener noreferrer"
119
+ >
120
+ {#if title}
121
+ <span class={streamdown.theme.inlineCitation.list.title}>{title}</span>
122
+ {/if}
123
+ {#if url}
124
+ <span class={streamdown.theme.inlineCitation.list.url}>
125
+ {#if favicon}
126
+ <img
127
+ src={favicon}
128
+ alt={title || url.host}
129
+ class={streamdown.theme.inlineCitation.list.favicon}
130
+ />
131
+ {/if}
132
+ {url.host}{url.pathname === '/' ? '' : url.pathname}
133
+ </span>
134
+ {/if}
135
+ </a>
136
+ </Slot>
137
+ {/each}
138
+ </div>
139
+ {/snippet}
140
+
141
+ {#snippet carouselView()}
142
+ {#if citationWithSources.length > 1}
143
+ <div class={streamdown.theme.inlineCitation.carousel.header}>
144
+ <div class={streamdown.theme.inlineCitation.carousel.stepCounter}>
145
+ {stepper.activeStep + 1}
146
+ / {citationWithSources.length}
147
+ </div>
148
+ <div class={streamdown.theme.inlineCitation.carousel.buttons}>
149
+ <button
150
+ disabled={!stepper.canGoPrevious}
151
+ class={streamdown.theme.components.button}
152
+ onclick={() => stepper.previous()}
153
+ >
154
+ {@render (streamdown.icons?.chevronLeft || chevronLeft)()}
155
+ </button>
156
+ <button
157
+ disabled={!stepper.canGoNext}
158
+ class={streamdown.theme.components.button}
159
+ onclick={() => stepper.next()}
160
+ >
161
+ {@render (streamdown.icons?.chevronRight || chevronRight)()}
162
+ </button>
163
+ </div>
164
+ </div>
165
+ {/if}
166
+ <div
167
+ use:stepper.scroller
168
+ style:height={Number.isFinite(stepper.stepHeights[stepper.activeStep])
169
+ ? `${stepper.stepHeights[stepper.activeStep]}px`
170
+ : 'auto'}
171
+ style:overflow="hidden"
172
+ style:position="relative"
173
+ style:transition-duration="200ms"
174
+ style:transition-timing-function="ease-in-out"
175
+ aria-label="Citations-${id}"
176
+ >
177
+ <div
178
+ bind:this={stepper.stepContainer}
179
+ style:display="grid"
180
+ style:transition-duration={`${200}ms`}
181
+ style:width="100%"
182
+ style:grid-template-columns="repeat({citationWithSources.length}, 100%)"
183
+ >
184
+ {#each citationWithSources as { favicon, key, source, title, url, content }, index}
185
+ <div
186
+ bind:offsetHeight={stepper.stepHeights[index]}
187
+ data-step={index}
188
+ tabindex={stepper.activeStep === index ? 0 : -1}
189
+ inert={stepper.activeStep !== index}
190
+ role="tabpanel"
191
+ style:height="fit-content"
192
+ style:width="100%"
193
+ style:flex-grow="1"
194
+ aria-label="Citation-${id}"
195
+ >
196
+ <Slot render={streamdown.snippets.inlineCitationContent} props={{ source, key, token }}>
197
+ {#if url || title}
198
+ <div class="mb-4">
199
+ {#if title}
200
+ <div class={streamdown.theme.inlineCitation.carousel.title}>
201
+ {title}
202
+ </div>
203
+ {/if}
204
+ {#if url}
205
+ <a
206
+ href={url.toString()}
207
+ target="_blank"
208
+ rel="noopener noreferrer"
209
+ class={streamdown.theme.inlineCitation.carousel.url}
210
+ >
211
+ {#if favicon}
212
+ <img
213
+ src={favicon}
214
+ alt={title || url.host}
215
+ class={streamdown.theme.inlineCitation.carousel.favicon}
216
+ />
217
+ {/if}
218
+ <!-- Skip search params -->
219
+ {url.host}{url.pathname === '/' ? '' : url.pathname}
220
+ </a>
221
+ {/if}
222
+ </div>
223
+ {/if}
224
+ {#if content}
225
+ <Block block={content} />
226
+ {/if}
227
+ </Slot>
228
+ </div>
229
+ {/each}
230
+ </div>
231
+ </div>
232
+ {/snippet}
233
+
234
+ {#snippet chevronRight()}
235
+ <svg
236
+ xmlns="http://www.w3.org/2000/svg"
237
+ width="100%"
238
+ height="100%"
239
+ viewBox="0 0 24 24"
240
+ fill="none"
241
+ stroke="currentColor"
242
+ stroke-width="2"
243
+ stroke-linecap="round"
244
+ stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg
245
+ >
246
+ {/snippet}
247
+
248
+ {#snippet chevronLeft()}
249
+ <svg
250
+ xmlns="http://www.w3.org/2000/svg"
251
+ width="100%"
252
+ height="100%"
253
+ viewBox="0 0 24 24"
254
+ fill="none"
255
+ stroke="currentColor"
256
+ stroke-width="2"
257
+ stroke-linecap="round"
258
+ stroke-linejoin="round"><path d="m15 18-6-6 6-6" /></svg
259
+ >
260
+ {/snippet}
261
+
262
+ {#if citationWithSources.length > 0}
263
+ <button
264
+ style={streamdown.animationBlockStyle}
265
+ bind:this={popover.reference}
266
+ class={streamdown.theme.inlineCitation.preview}
267
+ onclick={() => (popover.isOpen = !popover.isOpen)}
268
+ aria-expanded={popover.isOpen}
269
+ aria-haspopup="dialog"
270
+ aria-controls={'citation-popover-' + id}
271
+ {@attach clickOutside.attachment}
272
+ >
273
+ <Slot
274
+ render={streamdown.snippets.inlineCitationPreview}
275
+ props={{
276
+ token
277
+ }}
278
+ >
279
+ {citationWithSources[0]?.url?.host ??
280
+ citationWithSources[0]?.title ??
281
+ citationWithSources[0]?.key}
282
+ {citationWithSources.length > 1 ? ` +${citationWithSources.length - 1}` : ''}
283
+ </Slot>
284
+ </button>
285
+ {/if}
@@ -0,0 +1,7 @@
1
+ import type { CitationToken } from '../marked/marked-citations.js';
2
+ type $$ComponentProps = {
3
+ token: CitationToken;
4
+ };
5
+ declare const Citation: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type Citation = ReturnType<typeof Citation>;
7
+ export default Citation;
@@ -56,7 +56,7 @@
56
56
  {#if streamdown.controls.code}
57
57
  <div class={streamdown.theme.code.buttons}>
58
58
  <button
59
- class={streamdown.theme.code.button}
59
+ class={streamdown.theme.components.button}
60
60
  onclick={downloadCode}
61
61
  title="Download file"
62
62
  type="button"
@@ -64,7 +64,7 @@
64
64
  {@render (streamdown.icons?.download || downloadIcon)()}
65
65
  </button>
66
66
 
67
- <button class={streamdown.theme.code.button} onclick={copy.copy} type="button">
67
+ <button class={streamdown.theme.components.button} onclick={copy.copy} type="button">
68
68
  {@render (streamdown.icons?.copy || copyIcon)()}
69
69
  </button>
70
70
  </div>
@@ -119,7 +119,6 @@
119
119
  stroke-width="2"
120
120
  stroke-linecap="round"
121
121
  stroke-linejoin="round"
122
- class="lucide lucide-clipboard-icon lucide-clipboard"
123
122
  ><rect width="8" height="4" x="8" y="2" rx="1" ry="1" /><path
124
123
  d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
125
124
  /></svg
@@ -137,7 +136,6 @@
137
136
  stroke-width="2"
138
137
  stroke-linecap="round"
139
138
  stroke-linejoin="round"
140
- class="lucide lucide-download-icon lucide-download"
141
139
  ><path d="M12 15V3" /><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path
142
140
  d="m7 10 5 5 5-5"
143
141
  /></svg
@@ -10,6 +10,7 @@
10
10
  import Slot from './Slot.svelte';
11
11
  import { useStreamdown } from '../context.svelte.js';
12
12
  import FootnoteRef from './FootnoteRef.svelte';
13
+ import Citation from './Citation.svelte';
13
14
  let { token, children }: { token: StreamdownToken; children: Snippet } = $props();
14
15
  const streamdown = useStreamdown();
15
16
 
@@ -112,7 +113,7 @@
112
113
  </Slot>
113
114
  {:else if token.type === 'table'}
114
115
  <Slot props={{ token, children }} render={streamdown.snippets.table}>
115
- <div {style} class={streamdown.theme.table.base}>
116
+ <div {style} class={streamdown.theme.table.base} style:overscroll-behavior-x="none">
116
117
  <table class={streamdown.theme.table.table}>
117
118
  {@render children()}
118
119
  </table>
@@ -227,7 +228,13 @@
227
228
  {:else if token.type === 'alert'}
228
229
  <Alert {token} {children} />
229
230
  {:else if token.type === 'footnoteRef'}
230
- <FootnoteRef {token} />
231
+ <Slot props={{ token }} render={streamdown.snippets.footnoteRef}>
232
+ <FootnoteRef {token} />
233
+ </Slot>
234
+ {:else if token.type === 'inline-citations'}
235
+ <Slot props={{ token }} render={streamdown.snippets.inlineCitation}>
236
+ <Citation {token} />
237
+ </Slot>
231
238
  {:else if token.type === 'footnote'}
232
239
  <!-- TODO Footnotes are rendered inside the FootnoteRef popover -->
233
240
  {:else if token.type === 'descriptionList'}