@humanspeak/svelte-markdown 1.2.0 → 1.4.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
@@ -683,11 +683,19 @@ Images automatically lazy load using native `loading="lazy"` and IntersectionObs
683
683
 
684
684
  For real-time rendering of AI responses from ChatGPT, Claude, Gemini, and other LLMs, enable the `streaming` prop. This uses a smart diff algorithm that re-parses the full source for correctness but only updates changed DOM nodes, keeping render times constant regardless of document size.
685
685
 
686
+ The preferred API is now imperative: bind the component instance and call `writeChunk()` as chunks arrive. This avoids prop reactivity edge cases like identical consecutive string chunks being coalesced.
687
+
686
688
  ```svelte
687
689
  <script lang="ts">
688
690
  import SvelteMarkdown from '@humanspeak/svelte-markdown'
691
+ import type { StreamingChunk } from '@humanspeak/svelte-markdown'
689
692
 
690
- let source = $state('')
693
+ let markdown:
694
+ | {
695
+ writeChunk: (chunk: StreamingChunk) => void
696
+ resetStream: (nextSource?: string) => void
697
+ }
698
+ | undefined
691
699
 
692
700
  async function streamResponse() {
693
701
  const response = await fetch('/api/chat', { method: 'POST', body: '...' })
@@ -697,11 +705,62 @@ For real-time rendering of AI responses from ChatGPT, Claude, Gemini, and other
697
705
  while (true) {
698
706
  const { done, value } = await reader.read()
699
707
  if (done) break
700
- source += decoder.decode(value, { stream: true })
708
+ markdown?.writeChunk(decoder.decode(value, { stream: true }))
701
709
  }
702
710
  }
703
711
  </script>
704
712
 
713
+ <SvelteMarkdown bind:this={markdown} source="" streaming={true} />
714
+ ```
715
+
716
+ For websocket-style offset patches, pass an object chunk instead:
717
+
718
+ ```ts
719
+ markdown?.writeChunk({ value: 'world', offset: 6 })
720
+ ```
721
+
722
+ Object chunks overwrite the internal buffer at `offset`. This is overwrite semantics, not insert semantics: the chunk replaces characters starting at that index and preserves any trailing content after the overwritten span.
723
+
724
+ If `offset` skips ahead, missing positions are padded with spaces. There is no delete or truncate behavior in offset mode.
725
+
726
+ Typical websocket-style usage can arrive out of order:
727
+
728
+ ```ts
729
+ markdown?.writeChunk({ value: ' world', offset: 5 })
730
+ markdown?.writeChunk({ value: 'Hello', offset: 0 })
731
+ ```
732
+
733
+ The internal buffer converges as later patches fill earlier gaps.
734
+
735
+ You can reset the internal streaming buffer at any time:
736
+
737
+ ```ts
738
+ markdown?.resetStream('')
739
+ markdown?.resetStream('# Seeded response')
740
+ ```
741
+
742
+ The first successful write after a reset locks the stream into one input mode:
743
+
744
+ - `string` chunks: append mode
745
+ - `{ value, offset }` chunks: offset mode
746
+
747
+ Switching modes before `resetStream()` or a `source` prop reset logs a warning and drops the chunk. Offset chunks must use a non-negative safe integer `offset`.
748
+
749
+ Changing the `source` prop also resets the imperative buffer, seeds a new baseline value, and unlocks the input mode.
750
+
751
+ Appending directly to `source` is still supported:
752
+
753
+ ```svelte
754
+ <script lang="ts">
755
+ import SvelteMarkdown from '@humanspeak/svelte-markdown'
756
+
757
+ let source = $state('')
758
+
759
+ function onChunk(chunk: string) {
760
+ source += chunk
761
+ }
762
+ </script>
763
+
705
764
  <SvelteMarkdown {source} streaming={true} />
706
765
  ```
707
766
 
@@ -55,6 +55,12 @@
55
55
  Tokens,
56
56
  RendererComponent
57
57
  } from './utils/markdown-parser.js'
58
+ import {
59
+ defaultSanitizeAttributes,
60
+ defaultSanitizeUrl,
61
+ type SanitizeAttributesFn,
62
+ type SanitizeUrlFn
63
+ } from './utils/sanitize.js'
58
64
 
59
65
  // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
60
66
  type AnySnippet = (..._args: any[]) => any
@@ -68,6 +74,8 @@
68
74
  renderers: T
69
75
  snippetOverrides?: Record<string, AnySnippet>
70
76
  htmlSnippetOverrides?: Record<string, AnySnippet>
77
+ sanitizeUrl?: SanitizeUrlFn
78
+ sanitizeAttributes?: SanitizeAttributesFn
71
79
  }
72
80
 
73
81
  const {
@@ -79,10 +87,34 @@
79
87
  renderers,
80
88
  snippetOverrides = {},
81
89
  htmlSnippetOverrides = {},
90
+ sanitizeUrl = defaultSanitizeUrl,
91
+ sanitizeAttributes = defaultSanitizeAttributes,
82
92
  ...rest
83
93
  }: Props & {
84
94
  [key: string]: unknown
85
95
  } = $props()
96
+
97
+ // Sanitize rest props before they reach any renderer or snippet.
98
+ // This is the single enforcement point — custom renderers cannot bypass it.
99
+ const sanitizedRest = $derived.by(() => {
100
+ if ((type === 'link' || type === 'image') && typeof rest.href === 'string') {
101
+ const tag = type === 'link' ? 'a' : 'img'
102
+ const sanitized = sanitizeUrl(rest.href, { type, tag })
103
+ return { ...rest, href: sanitized || undefined }
104
+ }
105
+ if (type === 'html' && rest.attributes) {
106
+ const tag = (rest.tag as string) ?? ''
107
+ return {
108
+ ...rest,
109
+ attributes: sanitizeAttributes(
110
+ rest.attributes as Record<string, string>,
111
+ { type, tag },
112
+ sanitizeUrl
113
+ )
114
+ }
115
+ }
116
+ return rest
117
+ })
86
118
  </script>
87
119
 
88
120
  {#if !type}
@@ -95,6 +127,8 @@
95
127
  {renderers}
96
128
  {snippetOverrides}
97
129
  {htmlSnippetOverrides}
130
+ {sanitizeUrl}
131
+ {sanitizeAttributes}
98
132
  />
99
133
  {/each}
100
134
  {/if}
@@ -112,26 +146,31 @@
112
146
  {#snippet theadContent()}
113
147
  {#snippet headerRowContent()}
114
148
  {#each header ?? [] as headerItem, i (i)}
115
- {@const { align: _align, ...cellRest } = rest}
149
+ {@const { align: _align, ...cellRest } = sanitizedRest}
116
150
  {#snippet headerCellContent()}
117
151
  <Parser
118
152
  tokens={headerItem.tokens}
119
153
  {renderers}
120
154
  {snippetOverrides}
121
155
  {htmlSnippetOverrides}
156
+ {sanitizeUrl}
157
+ {sanitizeAttributes}
122
158
  />
123
159
  {/snippet}
124
160
  {#if cellSnippet}
125
161
  {@render cellSnippet({
126
162
  header: true,
127
- align: (rest.align as string[] | undefined)?.[i] ?? null,
163
+ align:
164
+ (sanitizedRest.align as string[] | undefined)?.[i] ??
165
+ null,
128
166
  ...cellRest,
129
167
  children: headerCellContent
130
168
  })}
131
169
  {:else}
132
170
  <renderers.tablecell
133
171
  header={true}
134
- align={(rest.align as string[] | undefined)?.[i] ?? null}
172
+ align={(sanitizedRest.align as string[] | undefined)?.[i] ??
173
+ null}
135
174
  {...cellRest}
136
175
  >
137
176
  {@render headerCellContent()}
@@ -140,17 +179,17 @@
140
179
  {/each}
141
180
  {/snippet}
142
181
  {#if rowSnippet}
143
- {@render rowSnippet({ ...rest, children: headerRowContent })}
182
+ {@render rowSnippet({ ...sanitizedRest, children: headerRowContent })}
144
183
  {:else}
145
- <renderers.tablerow {...rest}>
184
+ <renderers.tablerow {...sanitizedRest}>
146
185
  {@render headerRowContent()}
147
186
  </renderers.tablerow>
148
187
  {/if}
149
188
  {/snippet}
150
189
  {#if theadSnippet}
151
- {@render theadSnippet({ ...rest, children: theadContent })}
190
+ {@render theadSnippet({ ...sanitizedRest, children: theadContent })}
152
191
  {:else}
153
- <renderers.tablehead {...rest}>
192
+ <renderers.tablehead {...sanitizedRest}>
154
193
  {@render theadContent()}
155
194
  </renderers.tablehead>
156
195
  {/if}
@@ -160,7 +199,7 @@
160
199
  {#each rows ?? [] as row, i (i)}
161
200
  {#snippet bodyRowContent()}
162
201
  {#each row ?? [] as cells, j (j)}
163
- {@const { align: _align, ...cellRest } = rest}
202
+ {@const { align: _align, ...cellRest } = sanitizedRest}
164
203
  {#snippet bodyCellContent()}
165
204
  {#each cells.tokens ?? [] as cellToken, index (index)}
166
205
  <Parser
@@ -169,6 +208,8 @@
169
208
  {renderers}
170
209
  {snippetOverrides}
171
210
  {htmlSnippetOverrides}
211
+ {sanitizeUrl}
212
+ {sanitizeAttributes}
172
213
  />
173
214
  {/each}
174
215
  {/snippet}
@@ -176,7 +217,9 @@
176
217
  {@render cellSnippet({
177
218
  header: false,
178
219
  align:
179
- (rest.align as string[] | undefined)?.[j] ?? null,
220
+ (sanitizedRest.align as string[] | undefined)?.[
221
+ j
222
+ ] ?? null,
180
223
  ...cellRest,
181
224
  children: bodyCellContent
182
225
  })}
@@ -184,8 +227,9 @@
184
227
  <renderers.tablecell
185
228
  {...cellRest}
186
229
  header={false}
187
- align={(rest.align as string[] | undefined)?.[j] ??
188
- null}
230
+ align={(sanitizedRest.align as string[] | undefined)?.[
231
+ j
232
+ ] ?? null}
189
233
  >
190
234
  {@render bodyCellContent()}
191
235
  </renderers.tablecell>
@@ -193,18 +237,18 @@
193
237
  {/each}
194
238
  {/snippet}
195
239
  {#if rowSnippet}
196
- {@render rowSnippet({ ...rest, children: bodyRowContent })}
240
+ {@render rowSnippet({ ...sanitizedRest, children: bodyRowContent })}
197
241
  {:else}
198
- <renderers.tablerow {...rest}>
242
+ <renderers.tablerow {...sanitizedRest}>
199
243
  {@render bodyRowContent()}
200
244
  </renderers.tablerow>
201
245
  {/if}
202
246
  {/each}
203
247
  {/snippet}
204
248
  {#if tbodySnippet}
205
- {@render tbodySnippet({ ...rest, children: tbodyContent })}
249
+ {@render tbodySnippet({ ...sanitizedRest, children: tbodyContent })}
206
250
  {:else}
207
- <renderers.tablebody {...rest}>
251
+ <renderers.tablebody {...sanitizedRest}>
208
252
  {@render tbodyContent()}
209
253
  </renderers.tablebody>
210
254
  {/if}
@@ -212,9 +256,9 @@
212
256
  {/snippet}
213
257
 
214
258
  {#if tableSnippet}
215
- {@render tableSnippet({ ...rest, children: tableContent })}
259
+ {@render tableSnippet({ ...sanitizedRest, children: tableContent })}
216
260
  {:else}
217
- <renderers.table {...rest}>
261
+ <renderers.table {...sanitizedRest}>
218
262
  {@render tableContent()}
219
263
  </renderers.table>
220
264
  {/if}
@@ -224,7 +268,7 @@
224
268
 
225
269
  {#if ordered}
226
270
  {#snippet orderedListContent()}
227
- {@const { items: _items, ...parserRest } = rest}
271
+ {@const { items: _items, ...parserRest } = sanitizedRest}
228
272
  {@const items = (_items as Props[] | undefined) ?? []}
229
273
  {#each items as item, index (index)}
230
274
  {@const OrderedListComponent = renderers.orderedlistitem || renderers.listitem}
@@ -237,6 +281,8 @@
237
281
  {renderers}
238
282
  {snippetOverrides}
239
283
  {htmlSnippetOverrides}
284
+ {sanitizeUrl}
285
+ {sanitizeAttributes}
240
286
  />
241
287
  {/snippet}
242
288
  {#if orderedItemSnippet}
@@ -249,15 +295,15 @@
249
295
  {/each}
250
296
  {/snippet}
251
297
  {#if listSnippet}
252
- {@render listSnippet({ ordered, ...rest, children: orderedListContent })}
298
+ {@render listSnippet({ ordered, ...sanitizedRest, children: orderedListContent })}
253
299
  {:else}
254
- <renderers.list {ordered} {...rest}>
300
+ <renderers.list {ordered} {...sanitizedRest}>
255
301
  {@render orderedListContent()}
256
302
  </renderers.list>
257
303
  {/if}
258
304
  {:else}
259
305
  {#snippet unorderedListContent()}
260
- {@const { items: _items, ...parserRest } = rest}
306
+ {@const { items: _items, ...parserRest } = sanitizedRest}
261
307
  {@const items = (_items as Props[] | undefined) ?? []}
262
308
  {#each items as item, index (index)}
263
309
  {@const UnorderedListComponent =
@@ -271,6 +317,8 @@
271
317
  {renderers}
272
318
  {snippetOverrides}
273
319
  {htmlSnippetOverrides}
320
+ {sanitizeUrl}
321
+ {sanitizeAttributes}
274
322
  />
275
323
  {/snippet}
276
324
  {#if unorderedItemSnippet}
@@ -283,16 +331,16 @@
283
331
  {/each}
284
332
  {/snippet}
285
333
  {#if listSnippet}
286
- {@render listSnippet({ ordered, ...rest, children: unorderedListContent })}
334
+ {@render listSnippet({ ordered, ...sanitizedRest, children: unorderedListContent })}
287
335
  {:else}
288
- <renderers.list {ordered} {...rest}>
336
+ <renderers.list {ordered} {...sanitizedRest}>
289
337
  {@render unorderedListContent()}
290
338
  </renderers.list>
291
339
  {/if}
292
340
  {/if}
293
341
  {:else if type === 'html'}
294
- {@const { tag, ...localRest } = rest}
295
- {@const htmlTag = rest.tag as keyof typeof Html}
342
+ {@const { tag, ...localRest } = sanitizedRest}
343
+ {@const htmlTag = sanitizedRest.tag as keyof typeof Html}
296
344
  {@const htmlSnippet = htmlSnippetOverrides[htmlTag as string]}
297
345
  {#if htmlSnippet}
298
346
  {#snippet htmlSnippetChildren()}
@@ -302,31 +350,38 @@
302
350
  {renderers}
303
351
  {snippetOverrides}
304
352
  {htmlSnippetOverrides}
353
+ {sanitizeUrl}
354
+ {sanitizeAttributes}
305
355
  {...Object.fromEntries(
306
356
  Object.entries(localRest).filter(([key]) => key !== 'attributes')
307
357
  )}
308
358
  />
309
359
  {:else}
310
- <renderers.rawtext text={rest.raw} {...rest} />
360
+ <renderers.rawtext text={sanitizedRest.raw} {...sanitizedRest} />
311
361
  {/if}
312
362
  {/snippet}
313
- {@render htmlSnippet({ attributes: rest.attributes, children: htmlSnippetChildren })}
363
+ {@render htmlSnippet({
364
+ attributes: sanitizedRest.attributes,
365
+ children: htmlSnippetChildren
366
+ })}
314
367
  {:else if renderers.html && htmlTag in renderers.html}
315
368
  {@const HtmlComponent = renderers.html[htmlTag as keyof typeof renderers.html]}
316
369
  {#if HtmlComponent}
317
- <HtmlComponent {...rest}>
370
+ <HtmlComponent {...sanitizedRest}>
318
371
  {#if tokens && (tokens as Token[]).length}
319
372
  <Parser
320
373
  tokens={tokens as Token[]}
321
374
  {renderers}
322
375
  {snippetOverrides}
323
376
  {htmlSnippetOverrides}
377
+ {sanitizeUrl}
378
+ {sanitizeAttributes}
324
379
  {...Object.fromEntries(
325
380
  Object.entries(localRest).filter(([key]) => key !== 'attributes')
326
381
  )}
327
382
  />
328
383
  {:else}
329
- <renderers.rawtext text={rest.raw} {...rest} />
384
+ <renderers.rawtext text={sanitizedRest.raw} {...sanitizedRest} />
330
385
  {/if}
331
386
  </HtmlComponent>
332
387
  {/if}
@@ -336,6 +391,8 @@
336
391
  {renderers}
337
392
  {snippetOverrides}
338
393
  {htmlSnippetOverrides}
394
+ {sanitizeUrl}
395
+ {sanitizeAttributes}
339
396
  {...Object.fromEntries(
340
397
  Object.entries(localRest).filter(([key]) => key !== 'tokens')
341
398
  )}
@@ -347,23 +404,25 @@
347
404
 
348
405
  {#snippet renderChildren()}
349
406
  {#if tokens}
350
- {@const { text: _text, raw: _raw, ...parserRest } = rest}
407
+ {@const { text: _text, raw: _raw, ...parserRest } = sanitizedRest}
351
408
  <Parser
352
409
  {...parserRest}
353
410
  {tokens}
354
411
  {renderers}
355
412
  {snippetOverrides}
356
413
  {htmlSnippetOverrides}
414
+ {sanitizeUrl}
415
+ {sanitizeAttributes}
357
416
  />
358
417
  {:else}
359
- <renderers.rawtext text={rest.raw} {...rest} />
418
+ <renderers.rawtext text={sanitizedRest.raw} {...sanitizedRest} />
360
419
  {/if}
361
420
  {/snippet}
362
421
 
363
422
  {#if typeSnippet}
364
- {@render typeSnippet({ ...rest, children: renderChildren })}
423
+ {@render typeSnippet({ ...sanitizedRest, children: renderChildren })}
365
424
  {:else if GeneralComponent}
366
- <GeneralComponent {...rest}>
425
+ <GeneralComponent {...sanitizedRest}>
367
426
  {@render renderChildren()}
368
427
  </GeneralComponent>
369
428
  {/if}
@@ -46,6 +46,7 @@
46
46
  */
47
47
  import Parser from './Parser.svelte';
48
48
  import type { Renderers, Token, TokensList, Tokens } from './utils/markdown-parser.js';
49
+ import { type SanitizeAttributesFn, type SanitizeUrlFn } from './utils/sanitize.js';
49
50
  type AnySnippet = (..._args: any[]) => any;
50
51
  interface Props<T extends Renderers = Renderers> {
51
52
  type?: string;
@@ -56,6 +57,8 @@ interface Props<T extends Renderers = Renderers> {
56
57
  renderers: T;
57
58
  snippetOverrides?: Record<string, AnySnippet>;
58
59
  htmlSnippetOverrides?: Record<string, AnySnippet>;
60
+ sanitizeUrl?: SanitizeUrlFn;
61
+ sanitizeAttributes?: SanitizeAttributesFn;
59
62
  }
60
63
  type $$ComponentProps = Props & {
61
64
  [key: string]: unknown;