@humanspeak/svelte-markdown 1.3.0 → 1.4.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.
@@ -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;
@@ -65,6 +65,7 @@
65
65
  } from './utils/markdown-parser.js'
66
66
  import { parseAndCacheTokens, parseAndCacheTokensAsync } from './utils/parse-and-cache.js'
67
67
  import { rendererKeysInternal } from './utils/rendererKeys.js'
68
+ import { defaultSanitizeAttributes, defaultSanitizeUrl } from './utils/sanitize.js'
68
69
  import { Marked } from 'marked'
69
70
 
70
71
  // trunk-ignore(eslint/@typescript-eslint/no-explicit-any)
@@ -85,6 +86,8 @@
85
86
  isInline = false,
86
87
  parsed = () => {},
87
88
  extensions = [],
89
+ sanitizeUrl = defaultSanitizeUrl,
90
+ sanitizeAttributes = defaultSanitizeAttributes,
88
91
  ...rest
89
92
  }: SvelteMarkdownProps & {
90
93
  [key: string]: unknown
@@ -496,4 +499,6 @@
496
499
  renderers={combinedRenderers}
497
500
  {snippetOverrides}
498
501
  {htmlSnippetOverrides}
502
+ {sanitizeUrl}
503
+ {sanitizeAttributes}
499
504
  />
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ import { type HtmlRenderers } from './renderers/html/index.js';
19
19
  import SvelteMarkdown from './SvelteMarkdown.svelte';
20
20
  import type { BlockquoteSnippetProps, BrSnippetProps, CodeSnippetProps, CodespanSnippetProps, DelSnippetProps, EmSnippetProps, EscapeSnippetProps, HeadingSnippetProps, HrSnippetProps, HtmlSnippetOverrides, HtmlSnippetProps, ImageSnippetProps, LinkSnippetProps, ListItemSnippetProps, ListSnippetProps, ParagraphSnippetProps, RawTextSnippetProps, SnippetOverrides, StreamingChunk, StreamingOffsetChunk, StrongSnippetProps, SvelteMarkdownOptions, SvelteMarkdownProps, TableBodySnippetProps, TableCellSnippetProps, TableHeadSnippetProps, TableRowSnippetProps, TableSnippetProps, TextSnippetProps } from './types.js';
21
21
  import { defaultRenderers, type RendererComponent, type Renderers, type Token, type TokensList } from './utils/markdown-parser.js';
22
+ import type { SanitizeAttributesFn, SanitizeContext, SanitizeUrlFn } from './utils/sanitize.js';
22
23
  /** The primary markdown rendering component. */
23
24
  export default SvelteMarkdown;
24
25
  /** Default HTML tag-to-component map and the unsupported-tag placeholder. */
@@ -59,8 +60,17 @@ export { htmlRendererKeysInternal as htmlRendererKeys, rendererKeysInternal as r
59
60
  */
60
61
  export { MemoryCache } from './utils/cache.js';
61
62
  export { IncrementalParser, type IncrementalUpdateResult } from './utils/incremental-parser.js';
63
+ /**
64
+ * URL sanitization utilities for XSS prevention.
65
+ *
66
+ * - `defaultSanitizeUrl` — protocol allowlist (http, https, mailto, tel, relative)
67
+ * - `defaultSanitizeAttributes` — strips event handlers and sanitizes URL attributes
68
+ * - `unsanitizedUrl` — passthrough (allows all URLs)
69
+ * - `unsanitizedAttributes` — passthrough (allows all attributes)
70
+ */
71
+ export { defaultSanitizeAttributes, defaultSanitizeUrl, unsanitizedAttributes, unsanitizedUrl } from './utils/sanitize.js';
62
72
  export { TokenCache, tokenCache } from './utils/token-cache.js';
63
73
  /** Re-exported `MarkedExtension` type for the `extensions` prop. */
64
74
  export type { MarkedExtension } from 'marked';
65
75
  /** Re-exported types for consumer convenience. */
66
- export type { BlockquoteSnippetProps, BrSnippetProps, CodeSnippetProps, CodespanSnippetProps, DelSnippetProps, EmSnippetProps, EscapeSnippetProps, HeadingSnippetProps, HrSnippetProps, HtmlRenderers, HtmlSnippetOverrides, HtmlSnippetProps, ImageSnippetProps, LinkSnippetProps, ListItemSnippetProps, ListSnippetProps, ParagraphSnippetProps, RawTextSnippetProps, RendererComponent, Renderers, SnippetOverrides, StreamingChunk, StreamingOffsetChunk, StrongSnippetProps, SvelteMarkdownOptions, SvelteMarkdownProps, TableBodySnippetProps, TableCellSnippetProps, TableHeadSnippetProps, TableRowSnippetProps, TableSnippetProps, TextSnippetProps, Token, TokensList };
76
+ export type { BlockquoteSnippetProps, BrSnippetProps, CodeSnippetProps, CodespanSnippetProps, DelSnippetProps, EmSnippetProps, EscapeSnippetProps, HeadingSnippetProps, HrSnippetProps, HtmlRenderers, HtmlSnippetOverrides, HtmlSnippetProps, ImageSnippetProps, LinkSnippetProps, ListItemSnippetProps, ListSnippetProps, ParagraphSnippetProps, RawTextSnippetProps, RendererComponent, Renderers, SanitizeAttributesFn, SanitizeContext, SanitizeUrlFn, SnippetOverrides, StreamingChunk, StreamingOffsetChunk, StrongSnippetProps, SvelteMarkdownOptions, SvelteMarkdownProps, TableBodySnippetProps, TableCellSnippetProps, TableHeadSnippetProps, TableRowSnippetProps, TableSnippetProps, TextSnippetProps, Token, TokensList };
package/dist/index.js CHANGED
@@ -58,4 +58,13 @@ export { htmlRendererKeysInternal as htmlRendererKeys, rendererKeysInternal as r
58
58
  */
59
59
  export { MemoryCache } from './utils/cache.js';
60
60
  export { IncrementalParser } from './utils/incremental-parser.js';
61
+ /**
62
+ * URL sanitization utilities for XSS prevention.
63
+ *
64
+ * - `defaultSanitizeUrl` — protocol allowlist (http, https, mailto, tel, relative)
65
+ * - `defaultSanitizeAttributes` — strips event handlers and sanitizes URL attributes
66
+ * - `unsanitizedUrl` — passthrough (allows all URLs)
67
+ * - `unsanitizedAttributes` — passthrough (allows all attributes)
68
+ */
69
+ export { defaultSanitizeAttributes, defaultSanitizeUrl, unsanitizedAttributes, unsanitizedUrl } from './utils/sanitize.js';
61
70
  export { TokenCache, tokenCache } from './utils/token-cache.js';
@@ -24,7 +24,13 @@ is displayed with reduced opacity and a grayscale filter.
24
24
  fadeIn?: boolean // Enable fade-in effect (default: true)
25
25
  }
26
26
 
27
- const { href = '', title = undefined, text = '', lazy = true, fadeIn = true }: Props = $props()
27
+ const {
28
+ href = undefined,
29
+ title = undefined,
30
+ text = '',
31
+ lazy = true,
32
+ fadeIn = true
33
+ }: Props = $props()
28
34
 
29
35
  let img: HTMLImageElement
30
36
  let loaded = $state(false)
@@ -14,7 +14,7 @@ Renders a markdown link (`[text](url "title")`) as an `<a>` element.
14
14
  title?: string
15
15
  children?: Snippet
16
16
  }
17
- const { href = '', title = undefined, children }: Props = $props()
17
+ const { href = undefined, title = undefined, children }: Props = $props()
18
18
  </script>
19
19
 
20
20
  <a {href} {title}>{@render children?.()}</a>
package/dist/types.d.ts CHANGED
@@ -21,7 +21,10 @@ import type { MarkedExtension, Token, TokensList } from 'marked';
21
21
  import type { Snippet } from 'svelte';
22
22
  import type { MarkedOptions, Renderers } from './utils/markdown-parser.js';
23
23
  import type { HtmlKey } from './utils/rendererKeys.js';
24
+ import type { SanitizeAttributesFn, SanitizeUrlFn } from './utils/sanitize.js';
24
25
  export interface ParagraphSnippetProps {
26
+ raw?: string;
27
+ text?: string;
25
28
  children?: Snippet;
26
29
  }
27
30
  export interface HeadingSnippetProps {
@@ -35,29 +38,41 @@ export interface HeadingSnippetProps {
35
38
  export interface LinkSnippetProps {
36
39
  href?: string;
37
40
  title?: string;
41
+ raw?: string;
42
+ text?: string;
38
43
  children?: Snippet;
39
44
  }
40
45
  export interface ImageSnippetProps {
41
46
  href?: string;
42
47
  title?: string;
43
48
  text?: string;
49
+ raw?: string;
44
50
  }
45
51
  export interface CodeSnippetProps {
46
52
  lang: string;
47
53
  text: string;
54
+ codeBlockStyle?: 'indented';
48
55
  }
49
56
  export interface CodespanSnippetProps {
50
57
  raw: string;
58
+ text?: string;
51
59
  }
52
60
  export interface BlockquoteSnippetProps {
61
+ raw?: string;
62
+ text?: string;
53
63
  children?: Snippet;
54
64
  }
55
65
  export interface ListSnippetProps {
56
66
  ordered?: boolean;
57
67
  start?: number;
68
+ loose?: boolean;
58
69
  children?: Snippet;
59
70
  }
60
71
  export interface ListItemSnippetProps {
72
+ text?: string;
73
+ task?: boolean;
74
+ checked?: boolean;
75
+ loose?: boolean;
61
76
  children?: Snippet;
62
77
  listItemIndex?: number;
63
78
  }
@@ -79,17 +94,25 @@ export interface TableCellSnippetProps {
79
94
  children?: Snippet;
80
95
  }
81
96
  export interface EmSnippetProps {
97
+ raw?: string;
98
+ text?: string;
82
99
  children?: Snippet;
83
100
  }
84
101
  export interface StrongSnippetProps {
102
+ raw?: string;
103
+ text?: string;
85
104
  children?: Snippet;
86
105
  }
87
106
  export interface DelSnippetProps {
107
+ raw?: string;
108
+ text?: string;
88
109
  children?: Snippet;
89
110
  }
90
111
  export type HrSnippetProps = Record<string, never>;
91
112
  export type BrSnippetProps = Record<string, never>;
92
113
  export interface TextSnippetProps {
114
+ raw?: string;
115
+ text?: string;
93
116
  children?: Snippet;
94
117
  }
95
118
  export interface RawTextSnippetProps {
@@ -97,6 +120,7 @@ export interface RawTextSnippetProps {
97
120
  }
98
121
  export interface EscapeSnippetProps {
99
122
  text: string;
123
+ raw?: string;
100
124
  }
101
125
  export type SnippetOverrides = {
102
126
  paragraph?: Snippet<[ParagraphSnippetProps]>;
@@ -127,10 +151,10 @@ export type SnippetOverrides = {
127
151
  /**
128
152
  * Props passed to HTML snippet overrides.
129
153
  *
130
- * **Security note:** `attributes` are spread directly onto the rendered HTML element.
131
- * This includes any attribute from the source markdown, such as `onclick` or `onerror`.
132
- * If rendering untrusted markdown, use `allowHtmlOnly`/`excludeHtmlOnly` to restrict
133
- * allowed tags, or integrate your own sanitizer to strip dangerous attributes.
154
+ * **Security note:** By default, `attributes` are sanitized in the Parser before
155
+ * reaching any renderer or snippet event handlers (`on*`) are stripped and
156
+ * URL attributes are validated against a protocol allowlist. To customize this
157
+ * behavior, pass a `sanitizeUrl` prop to `SvelteMarkdown`.
134
158
  */
135
159
  export interface HtmlSnippetProps {
136
160
  attributes?: Record<string, string | number | boolean | undefined>;
@@ -218,6 +242,30 @@ export type SvelteMarkdownProps<T extends Renderers = Renderers> = {
218
242
  * @param tokens - The parsed token array or `TokensList`.
219
243
  */
220
244
  parsed?: (tokens: Token[] | TokensList) => void;
245
+ /**
246
+ * Custom URL sanitizer applied in the Parser before tokens reach any
247
+ * renderer component or snippet. Receives a URL string and must return
248
+ * a sanitized URL (or `''` to strip it).
249
+ *
250
+ * The default allowlists `http:`, `https:`, `mailto:`, `tel:`, and
251
+ * relative URLs. Override to implement a custom policy.
252
+ *
253
+ * @defaultValue {@link defaultSanitizeUrl}
254
+ */
255
+ sanitizeUrl?: SanitizeUrlFn;
256
+ /**
257
+ * Custom HTML attribute sanitizer applied in the Parser before tokens
258
+ * reach any renderer component or snippet. Receives the attribute map,
259
+ * a {@link SanitizeContext} with token type and HTML tag name, and the
260
+ * active `sanitizeUrl` function.
261
+ *
262
+ * The default strips all `on*` event handlers and runs URL-bearing
263
+ * attributes (`href`, `src`, `action`, etc.) through `sanitizeUrl`.
264
+ * Override to add custom attribute rules per tag.
265
+ *
266
+ * @defaultValue {@link defaultSanitizeAttributes}
267
+ */
268
+ sanitizeAttributes?: SanitizeAttributesFn;
221
269
  } & Partial<SnippetOverrides> & Partial<HtmlSnippetOverrides>;
222
270
  export interface SvelteMarkdownOptions extends MarkedOptions {
223
271
  /**
@@ -0,0 +1,69 @@
1
+ /**
2
+ * URL and HTML attribute sanitization utilities for XSS prevention.
3
+ *
4
+ * These functions are applied in the Parser before tokens reach any
5
+ * renderer component or snippet, ensuring custom renderers cannot
6
+ * bypass sanitization.
7
+ *
8
+ * @see https://github.com/humanspeak/svelte-markdown/issues/272
9
+ * @packageDocumentation
10
+ */
11
+ /**
12
+ * Context passed to sanitization functions so users can apply
13
+ * different rules per markdown token type or HTML tag.
14
+ *
15
+ * - For markdown links: `{ type: 'link', tag: 'a' }`
16
+ * - For markdown images: `{ type: 'image', tag: 'img' }`
17
+ * - For HTML tags: `{ type: 'html', tag: 'a' | 'img' | 'div' | ... }`
18
+ */
19
+ export interface SanitizeContext {
20
+ /** The markdown token type. */
21
+ type: 'link' | 'image' | 'html';
22
+ /** The HTML tag name being rendered (e.g. `'a'`, `'img'`, `'div'`). */
23
+ tag: string;
24
+ }
25
+ export type SanitizeUrlFn = (_url: string, _context: SanitizeContext) => string;
26
+ export type SanitizeAttributesFn = (_attributes: Record<string, string>, _context: SanitizeContext, _sanitizeUrl: SanitizeUrlFn) => Record<string, string>;
27
+ /**
28
+ * Sanitizes a URL against a protocol allowlist.
29
+ *
30
+ * Allows `http:`, `https:`, `mailto:`, `tel:`, and relative URLs
31
+ * (starting with `/`, `#`, `?`, or no protocol). Blocks everything
32
+ * else including `javascript:`, `data:`, `vbscript:`, etc.
33
+ *
34
+ * Handles mixed-case protocols and leading whitespace.
35
+ *
36
+ * The `context` parameter provides the token type and HTML tag name,
37
+ * enabling per-element policies in custom overrides.
38
+ */
39
+ export declare const defaultSanitizeUrl: (url: string, _context: SanitizeContext) => string;
40
+ /**
41
+ * Passthrough URL sanitizer that allows all URLs unchanged.
42
+ *
43
+ * Use this to disable URL sanitization entirely:
44
+ * ```svelte
45
+ * <SvelteMarkdown source={markdown} sanitizeUrl={unsanitizedUrl} />
46
+ * ```
47
+ */
48
+ export declare const unsanitizedUrl: SanitizeUrlFn;
49
+ /**
50
+ * Passthrough attribute sanitizer that allows all attributes unchanged.
51
+ *
52
+ * Use this to disable attribute sanitization entirely:
53
+ * ```svelte
54
+ * <SvelteMarkdown source={markdown} sanitizeAttributes={unsanitizedAttributes} />
55
+ * ```
56
+ */
57
+ export declare const unsanitizedAttributes: SanitizeAttributesFn;
58
+ /**
59
+ * Sanitizes an HTML attribute object by:
60
+ * 1. Removing all event handler attributes (`on*`)
61
+ * 2. Running URL-bearing attributes through the sanitizer
62
+ *
63
+ * The `context` parameter provides the HTML tag name, enabling
64
+ * per-element policies in custom overrides (e.g. stricter rules
65
+ * for `<iframe>` than `<a>`).
66
+ *
67
+ * Returns a new object; does not mutate the input.
68
+ */
69
+ export declare const defaultSanitizeAttributes: SanitizeAttributesFn;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * URL and HTML attribute sanitization utilities for XSS prevention.
3
+ *
4
+ * These functions are applied in the Parser before tokens reach any
5
+ * renderer component or snippet, ensuring custom renderers cannot
6
+ * bypass sanitization.
7
+ *
8
+ * @see https://github.com/humanspeak/svelte-markdown/issues/272
9
+ * @packageDocumentation
10
+ */
11
+ /** Protocols considered safe for href/src attributes. */
12
+ const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
13
+ /**
14
+ * URL attributes in HTML that should be run through the sanitizer.
15
+ * Covers standard attributes that can trigger navigation or resource loading.
16
+ */
17
+ const URL_ATTRIBUTES = new Set(['href', 'src', 'action', 'formaction', 'cite', 'data', 'poster']);
18
+ /** Fast-path: most URLs are http/https — avoid `new URL()` for these. */
19
+ const SAFE_PREFIX_RE = /^https?:/i;
20
+ const LEADING_WS_RE = /^\s+/;
21
+ const RELATIVE_RE = /^[#/?.]/;
22
+ /**
23
+ * Sanitizes a URL against a protocol allowlist.
24
+ *
25
+ * Allows `http:`, `https:`, `mailto:`, `tel:`, and relative URLs
26
+ * (starting with `/`, `#`, `?`, or no protocol). Blocks everything
27
+ * else including `javascript:`, `data:`, `vbscript:`, etc.
28
+ *
29
+ * Handles mixed-case protocols and leading whitespace.
30
+ *
31
+ * The `context` parameter provides the token type and HTML tag name,
32
+ * enabling per-element policies in custom overrides.
33
+ */
34
+ export const defaultSanitizeUrl = (url, _context) => {
35
+ if (!url)
36
+ return '';
37
+ const trimmed = url.replace(LEADING_WS_RE, '');
38
+ // Relative URLs are safe: #anchor, /path, ?query, ./relative, ../parent
39
+ if (RELATIVE_RE.test(trimmed))
40
+ return trimmed;
41
+ // No colon means no protocol — safe relative URL
42
+ if (!trimmed.includes(':'))
43
+ return trimmed;
44
+ // Fast-path for http/https — avoids new URL() allocation
45
+ if (SAFE_PREFIX_RE.test(trimmed))
46
+ return trimmed;
47
+ try {
48
+ const parsed = new URL(trimmed, 'http://localhost');
49
+ if (SAFE_PROTOCOLS.has(parsed.protocol))
50
+ return trimmed;
51
+ }
52
+ catch {
53
+ // Malformed URL — block it
54
+ }
55
+ return '';
56
+ };
57
+ /**
58
+ * Passthrough URL sanitizer that allows all URLs unchanged.
59
+ *
60
+ * Use this to disable URL sanitization entirely:
61
+ * ```svelte
62
+ * <SvelteMarkdown source={markdown} sanitizeUrl={unsanitizedUrl} />
63
+ * ```
64
+ */
65
+ export const unsanitizedUrl = (url) => url;
66
+ /**
67
+ * Passthrough attribute sanitizer that allows all attributes unchanged.
68
+ *
69
+ * Use this to disable attribute sanitization entirely:
70
+ * ```svelte
71
+ * <SvelteMarkdown source={markdown} sanitizeAttributes={unsanitizedAttributes} />
72
+ * ```
73
+ */
74
+ export const unsanitizedAttributes = (attributes) => attributes;
75
+ /**
76
+ * Sanitizes an HTML attribute object by:
77
+ * 1. Removing all event handler attributes (`on*`)
78
+ * 2. Running URL-bearing attributes through the sanitizer
79
+ *
80
+ * The `context` parameter provides the HTML tag name, enabling
81
+ * per-element policies in custom overrides (e.g. stricter rules
82
+ * for `<iframe>` than `<a>`).
83
+ *
84
+ * Returns a new object; does not mutate the input.
85
+ */
86
+ export const defaultSanitizeAttributes = (attributes, context, sanitizeUrl) => {
87
+ const result = {};
88
+ for (const [key, value] of Object.entries(attributes)) {
89
+ const lower = key.toLowerCase();
90
+ // Strip event handlers (onclick, onerror, onload, etc.)
91
+ // Strip srcdoc — allows arbitrary HTML/script execution in iframes
92
+ if (lower.startsWith('on') || lower === 'srcdoc')
93
+ continue;
94
+ // Sanitize URL-bearing attributes
95
+ if (URL_ATTRIBUTES.has(lower)) {
96
+ const sanitized = sanitizeUrl(value, context);
97
+ if (sanitized)
98
+ result[key] = sanitized;
99
+ continue;
100
+ }
101
+ result[key] = value;
102
+ }
103
+ return result;
104
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-markdown",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Fast, customizable markdown renderer for Svelte with built-in caching, TypeScript support, and Svelte 5 runes",
5
5
  "keywords": [
6
6
  "svelte",
@@ -70,48 +70,48 @@
70
70
  "@humanspeak/memory-cache": "^1.0.6",
71
71
  "github-slugger": "^2.0.0",
72
72
  "htmlparser2": "^12.0.0",
73
- "marked": "^17.0.5"
73
+ "marked": "^18.0.0"
74
74
  },
75
75
  "devDependencies": {
76
- "@eslint/compat": "^2.0.3",
76
+ "@eslint/compat": "^2.0.5",
77
77
  "@eslint/js": "^10.0.1",
78
- "@playwright/cli": "^0.1.2",
79
- "@playwright/test": "^1.58.2",
78
+ "@playwright/cli": "^0.1.6",
79
+ "@playwright/test": "^1.59.1",
80
80
  "@sveltejs/adapter-auto": "^7.0.1",
81
- "@sveltejs/kit": "^2.55.0",
81
+ "@sveltejs/kit": "^2.57.1",
82
82
  "@sveltejs/package": "^2.5.7",
83
83
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
84
84
  "@testing-library/jest-dom": "^6.9.1",
85
85
  "@testing-library/svelte": "^5.3.1",
86
86
  "@testing-library/user-event": "^14.6.1",
87
87
  "@types/katex": "^0.16.8",
88
- "@types/node": "^25.5.0",
89
- "@typescript-eslint/eslint-plugin": "^8.58.0",
90
- "@typescript-eslint/parser": "^8.58.0",
91
- "@vitest/coverage-v8": "^4.1.2",
92
- "eslint": "^10.1.0",
88
+ "@types/node": "^25.5.2",
89
+ "@typescript-eslint/eslint-plugin": "^8.58.1",
90
+ "@typescript-eslint/parser": "^8.58.1",
91
+ "@vitest/coverage-v8": "^4.1.4",
92
+ "eslint": "^10.2.0",
93
93
  "eslint-config-prettier": "^10.1.8",
94
94
  "eslint-plugin-import": "^2.32.0",
95
- "eslint-plugin-svelte": "^3.16.0",
95
+ "eslint-plugin-svelte": "^3.17.0",
96
96
  "eslint-plugin-unused-imports": "^4.4.1",
97
97
  "globals": "^17.4.0",
98
98
  "husky": "^9.1.7",
99
- "jsdom": "^29.0.1",
100
- "katex": "^0.16.44",
101
- "marked-katex-extension": "^5.1.7",
102
- "mermaid": "^11.13.0",
99
+ "jsdom": "^29.0.2",
100
+ "katex": "^0.16.45",
101
+ "marked-katex-extension": "^5.1.8",
102
+ "mermaid": "^11.14.0",
103
103
  "mprocs": "^0.9.2",
104
104
  "prettier": "^3.8.1",
105
105
  "prettier-plugin-organize-imports": "^4.3.0",
106
106
  "prettier-plugin-svelte": "^3.5.1",
107
107
  "prettier-plugin-tailwindcss": "^0.7.2",
108
108
  "publint": "^0.3.18",
109
- "svelte": "^5.55.1",
109
+ "svelte": "^5.55.2",
110
110
  "svelte-check": "^4.4.6",
111
111
  "typescript": "^6.0.2",
112
- "typescript-eslint": "^8.58.0",
113
- "vite": "^8.0.3",
114
- "vitest": "^4.1.2"
112
+ "typescript-eslint": "^8.58.1",
113
+ "vite": "^8.0.8",
114
+ "vitest": "^4.1.4"
115
115
  },
116
116
  "peerDependencies": {
117
117
  "mermaid": ">=10.0.0",