@humanspeak/svelte-markdown 1.3.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/dist/Parser.svelte +92 -33
- package/dist/Parser.svelte.d.ts +3 -0
- package/dist/SvelteMarkdown.svelte +5 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +9 -0
- package/dist/renderers/Image.svelte +7 -1
- package/dist/renderers/Link.svelte +1 -1
- package/dist/types.d.ts +29 -4
- package/dist/utils/sanitize.d.ts +69 -0
- package/dist/utils/sanitize.js +104 -0
- package/package.json +16 -16
package/dist/Parser.svelte
CHANGED
|
@@ -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 } =
|
|
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:
|
|
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={(
|
|
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({ ...
|
|
182
|
+
{@render rowSnippet({ ...sanitizedRest, children: headerRowContent })}
|
|
144
183
|
{:else}
|
|
145
|
-
<renderers.tablerow {...
|
|
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({ ...
|
|
190
|
+
{@render theadSnippet({ ...sanitizedRest, children: theadContent })}
|
|
152
191
|
{:else}
|
|
153
|
-
<renderers.tablehead {...
|
|
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 } =
|
|
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
|
-
(
|
|
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={(
|
|
188
|
-
|
|
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({ ...
|
|
240
|
+
{@render rowSnippet({ ...sanitizedRest, children: bodyRowContent })}
|
|
197
241
|
{:else}
|
|
198
|
-
<renderers.tablerow {...
|
|
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({ ...
|
|
249
|
+
{@render tbodySnippet({ ...sanitizedRest, children: tbodyContent })}
|
|
206
250
|
{:else}
|
|
207
|
-
<renderers.tablebody {...
|
|
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({ ...
|
|
259
|
+
{@render tableSnippet({ ...sanitizedRest, children: tableContent })}
|
|
216
260
|
{:else}
|
|
217
|
-
<renderers.table {...
|
|
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 } =
|
|
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, ...
|
|
298
|
+
{@render listSnippet({ ordered, ...sanitizedRest, children: orderedListContent })}
|
|
253
299
|
{:else}
|
|
254
|
-
<renderers.list {ordered} {...
|
|
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 } =
|
|
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, ...
|
|
334
|
+
{@render listSnippet({ ordered, ...sanitizedRest, children: unorderedListContent })}
|
|
287
335
|
{:else}
|
|
288
|
-
<renderers.list {ordered} {...
|
|
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 } =
|
|
295
|
-
{@const htmlTag =
|
|
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={
|
|
360
|
+
<renderers.rawtext text={sanitizedRest.raw} {...sanitizedRest} />
|
|
311
361
|
{/if}
|
|
312
362
|
{/snippet}
|
|
313
|
-
{@render htmlSnippet({
|
|
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 {...
|
|
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={
|
|
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 } =
|
|
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={
|
|
418
|
+
<renderers.rawtext text={sanitizedRest.raw} {...sanitizedRest} />
|
|
360
419
|
{/if}
|
|
361
420
|
{/snippet}
|
|
362
421
|
|
|
363
422
|
{#if typeSnippet}
|
|
364
|
-
{@render typeSnippet({ ...
|
|
423
|
+
{@render typeSnippet({ ...sanitizedRest, children: renderChildren })}
|
|
365
424
|
{:else if GeneralComponent}
|
|
366
|
-
<GeneralComponent {...
|
|
425
|
+
<GeneralComponent {...sanitizedRest}>
|
|
367
426
|
{@render renderChildren()}
|
|
368
427
|
</GeneralComponent>
|
|
369
428
|
{/if}
|
package/dist/Parser.svelte.d.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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,6 +21,7 @@ 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 {
|
|
25
26
|
children?: Snippet;
|
|
26
27
|
}
|
|
@@ -127,10 +128,10 @@ export type SnippetOverrides = {
|
|
|
127
128
|
/**
|
|
128
129
|
* Props passed to HTML snippet overrides.
|
|
129
130
|
*
|
|
130
|
-
* **Security note:** `attributes` are
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* **Security note:** By default, `attributes` are sanitized in the Parser before
|
|
132
|
+
* reaching any renderer or snippet — event handlers (`on*`) are stripped and
|
|
133
|
+
* URL attributes are validated against a protocol allowlist. To customize this
|
|
134
|
+
* behavior, pass a `sanitizeUrl` prop to `SvelteMarkdown`.
|
|
134
135
|
*/
|
|
135
136
|
export interface HtmlSnippetProps {
|
|
136
137
|
attributes?: Record<string, string | number | boolean | undefined>;
|
|
@@ -218,6 +219,30 @@ export type SvelteMarkdownProps<T extends Renderers = Renderers> = {
|
|
|
218
219
|
* @param tokens - The parsed token array or `TokensList`.
|
|
219
220
|
*/
|
|
220
221
|
parsed?: (tokens: Token[] | TokensList) => void;
|
|
222
|
+
/**
|
|
223
|
+
* Custom URL sanitizer applied in the Parser before tokens reach any
|
|
224
|
+
* renderer component or snippet. Receives a URL string and must return
|
|
225
|
+
* a sanitized URL (or `''` to strip it).
|
|
226
|
+
*
|
|
227
|
+
* The default allowlists `http:`, `https:`, `mailto:`, `tel:`, and
|
|
228
|
+
* relative URLs. Override to implement a custom policy.
|
|
229
|
+
*
|
|
230
|
+
* @defaultValue {@link defaultSanitizeUrl}
|
|
231
|
+
*/
|
|
232
|
+
sanitizeUrl?: SanitizeUrlFn;
|
|
233
|
+
/**
|
|
234
|
+
* Custom HTML attribute sanitizer applied in the Parser before tokens
|
|
235
|
+
* reach any renderer component or snippet. Receives the attribute map,
|
|
236
|
+
* a {@link SanitizeContext} with token type and HTML tag name, and the
|
|
237
|
+
* active `sanitizeUrl` function.
|
|
238
|
+
*
|
|
239
|
+
* The default strips all `on*` event handlers and runs URL-bearing
|
|
240
|
+
* attributes (`href`, `src`, `action`, etc.) through `sanitizeUrl`.
|
|
241
|
+
* Override to add custom attribute rules per tag.
|
|
242
|
+
*
|
|
243
|
+
* @defaultValue {@link defaultSanitizeAttributes}
|
|
244
|
+
*/
|
|
245
|
+
sanitizeAttributes?: SanitizeAttributesFn;
|
|
221
246
|
} & Partial<SnippetOverrides> & Partial<HtmlSnippetOverrides>;
|
|
222
247
|
export interface SvelteMarkdownOptions extends MarkedOptions {
|
|
223
248
|
/**
|
|
@@ -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
|
+
"version": "1.4.0",
|
|
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,36 +70,36 @@
|
|
|
70
70
|
"@humanspeak/memory-cache": "^1.0.6",
|
|
71
71
|
"github-slugger": "^2.0.0",
|
|
72
72
|
"htmlparser2": "^12.0.0",
|
|
73
|
-
"marked": "^
|
|
73
|
+
"marked": "^18.0.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@eslint/compat": "^2.0.
|
|
76
|
+
"@eslint/compat": "^2.0.4",
|
|
77
77
|
"@eslint/js": "^10.0.1",
|
|
78
|
-
"@playwright/cli": "^0.1.
|
|
79
|
-
"@playwright/test": "^1.
|
|
78
|
+
"@playwright/cli": "^0.1.5",
|
|
79
|
+
"@playwright/test": "^1.59.1",
|
|
80
80
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
81
|
-
"@sveltejs/kit": "^2.
|
|
81
|
+
"@sveltejs/kit": "^2.56.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.
|
|
88
|
+
"@types/node": "^25.5.2",
|
|
89
89
|
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
|
90
90
|
"@typescript-eslint/parser": "^8.58.0",
|
|
91
|
-
"@vitest/coverage-v8": "^4.1.
|
|
92
|
-
"eslint": "^10.
|
|
91
|
+
"@vitest/coverage-v8": "^4.1.3",
|
|
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.
|
|
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.
|
|
100
|
-
"katex": "^0.16.
|
|
101
|
-
"marked-katex-extension": "^5.1.
|
|
102
|
-
"mermaid": "^11.
|
|
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",
|
|
@@ -110,8 +110,8 @@
|
|
|
110
110
|
"svelte-check": "^4.4.6",
|
|
111
111
|
"typescript": "^6.0.2",
|
|
112
112
|
"typescript-eslint": "^8.58.0",
|
|
113
|
-
"vite": "^8.0.
|
|
114
|
-
"vitest": "^4.1.
|
|
113
|
+
"vite": "^8.0.7",
|
|
114
|
+
"vitest": "^4.1.3"
|
|
115
115
|
},
|
|
116
116
|
"peerDependencies": {
|
|
117
117
|
"mermaid": ">=10.0.0",
|