@txstate-mws/svelte-components 1.7.0 → 1.7.2
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.
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
for objects (and Maps), bulleted lists for arrays (and Sets), scalars as text.
|
|
5
5
|
Intended for read-only summaries like preview panes, not for editing.
|
|
6
6
|
|
|
7
|
+
Long text is kept in check: leaf values cut off with an ellipsis after `maxtext`
|
|
8
|
+
characters, line breaks are preserved, and a value that would need to wrap drops
|
|
9
|
+
below its key as an indented block instead of snaking around it.
|
|
10
|
+
|
|
11
|
+
Values are escaped text by default; the `allowHtml` prop renders values that
|
|
12
|
+
contain markup as actual HTML in a framed, height-clipped block - only enable
|
|
13
|
+
it for trusted payloads.
|
|
14
|
+
|
|
7
15
|
Notes on odd input:
|
|
8
16
|
- datetimes render as ISO8601 in the browser's timezone, whether they arrive as
|
|
9
17
|
Date objects or ISO strings (e.g. UTC from a JSON API); use `format` to display
|
|
@@ -16,6 +24,7 @@
|
|
|
16
24
|
-->
|
|
17
25
|
<script lang="ts" context="module">
|
|
18
26
|
import { dateToISOWithTZ } from 'txstate-utils'
|
|
27
|
+
import { stripUnsafeHtml } from '../util/striphtml.js'
|
|
19
28
|
|
|
20
29
|
/**
|
|
21
30
|
* datetimes display as ISO8601 in the browser's timezone, whether they arrive
|
|
@@ -37,6 +46,9 @@
|
|
|
37
46
|
return String(value)
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
/** simple HTML auto-detection: any <tag ...> or </tag> in the string */
|
|
50
|
+
const htmlPattern = /<\/?[a-z][^>]*>/i
|
|
51
|
+
|
|
40
52
|
/** a Map key we can display: a primitive or anything with a custom toString */
|
|
41
53
|
function stringableKey (key: any) {
|
|
42
54
|
if (key == null) return false
|
|
@@ -76,15 +88,46 @@
|
|
|
76
88
|
export let maxlevel = 5
|
|
77
89
|
/**
|
|
78
90
|
* take over the display of any leaf value; receives the default rendering
|
|
79
|
-
* (datetimes already rewritten as local ISO8601, everything else
|
|
80
|
-
* plus the value's path, so it can do something else with
|
|
81
|
-
* types
|
|
91
|
+
* (datetimes already rewritten as local ISO8601, everything else
|
|
92
|
+
* stringified) plus the value's path, so it can do something else with
|
|
93
|
+
* specific fields or types - including recognizing marker strings a data
|
|
94
|
+
* source deliberately embedded, like '{"image": "https://..."}'
|
|
95
|
+
*
|
|
96
|
+
* return a string to display as text, return { html } to display markup the
|
|
97
|
+
* formatter built itself - it renders regardless of allowHtml, because the
|
|
98
|
+
* formatter is application code rather than data, though it is still
|
|
99
|
+
* scrubbed with stripUnsafeHtml since the urls and labels interpolated into
|
|
100
|
+
* it usually do come from the data - or return undefined to keep the
|
|
101
|
+
* default rendering
|
|
102
|
+
*
|
|
103
|
+
* { html } drops below its key as an indented block by default; pass
|
|
104
|
+
* inline: true for something compact like a link that should sit beside its
|
|
105
|
+
* key the way a short scalar does
|
|
106
|
+
*/
|
|
107
|
+
export let format: ((value: string, path: (string | number)[]) => string | { html: string, inline?: boolean } | undefined) | undefined = undefined
|
|
108
|
+
/**
|
|
109
|
+
* leaf values longer than this many characters are cut off with an ellipsis,
|
|
110
|
+
* after `format` has had its say; set Infinity if you really want it all
|
|
82
111
|
*/
|
|
83
|
-
export let
|
|
112
|
+
export let maxtext = 200
|
|
113
|
+
/**
|
|
114
|
+
* render leaf values that contain HTML tags as actual HTML instead of escaped
|
|
115
|
+
* text; they display as an indented block below their key and are exempt from
|
|
116
|
+
* maxtext, since slicing markup in half would break it
|
|
117
|
+
*
|
|
118
|
+
* markup is scrubbed with stripUnsafeHtml before rendering (scripts, styles,
|
|
119
|
+
* iframes, event handlers, and executable urls dropped), but that is a safety
|
|
120
|
+
* net, not full sanitization; off by default because it means trusting the
|
|
121
|
+
* data: only enable this when the payload is sanitized or comes from a trusted
|
|
122
|
+
* source, otherwise you are opening your users up to XSS attacks
|
|
123
|
+
*/
|
|
124
|
+
export let allowHtml = false
|
|
84
125
|
/** the placeholders shown in place of data nested deeper than maxlevel, distinct so the reader knows what kind of data is hiding */
|
|
85
126
|
export let elidedObjectText = '{ ... }'
|
|
86
127
|
export let elidedArrayText = '[...]'
|
|
87
128
|
export let elidedTooltip = 'deeper data not shown'
|
|
129
|
+
/** tooltip on the indicator shown when a rendered html block is taller than its max height and gets clipped */
|
|
130
|
+
export let clippedTooltip = 'more content not shown'
|
|
88
131
|
export let className = ''
|
|
89
132
|
/**
|
|
90
133
|
* the containers above this one, used internally to detect cycles; a container
|
|
@@ -99,19 +142,91 @@
|
|
|
99
142
|
$: elided = level >= maxlevel || ancestors.includes(data)
|
|
100
143
|
$: childAncestors = [...ancestors, data]
|
|
101
144
|
|
|
102
|
-
|
|
145
|
+
/**
|
|
146
|
+
* a rendered string this long will essentially always need to wrap, so we drop
|
|
147
|
+
* it below the key up front; that also lets the CSS de-emphasize wrapped blocks,
|
|
148
|
+
* which it could not do if the drop only happened through inline-block layout
|
|
149
|
+
*/
|
|
150
|
+
const BLOCKTEXT = 80
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* containers always drop below their key as a full-width block; scalars are
|
|
154
|
+
* inline-blocks that stay beside the key until they need to wrap - with two
|
|
155
|
+
* exceptions: a container the child will elide renders as a short placeholder,
|
|
156
|
+
* so it stays beside the key, and a scalar whose rendered text contains line
|
|
157
|
+
* breaks (or is long enough that wrapping is inevitable) drops below the key,
|
|
158
|
+
* so the breaks don't leave text hanging in the middle of the row
|
|
159
|
+
*
|
|
160
|
+
* formatted values follow the same rules: { html } is a block unless the
|
|
161
|
+
* formatter flagged it inline, text follows the scalar rules
|
|
162
|
+
*/
|
|
163
|
+
function isBlockValue (value: any, key: string) {
|
|
164
|
+
const formatted = applyFormat(value, [...path, key])
|
|
165
|
+
if (formatted != null) {
|
|
166
|
+
if (typeof formatted === 'object') return !formatted.inline
|
|
167
|
+
const t = truncate(formatted)
|
|
168
|
+
return t.includes('\n') || t.length > BLOCKTEXT
|
|
169
|
+
}
|
|
170
|
+
if (level + 1 >= maxlevel || childAncestors.includes(value)) return false
|
|
171
|
+
if (arrayEntries(value) != null || objectEntries(value) != null) return true
|
|
172
|
+
if (typeof value !== 'string') return false
|
|
173
|
+
const rendered = formatValue(value)
|
|
174
|
+
return isHtml(rendered) || rendered.includes('\n') || rendered.length > BLOCKTEXT
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isHtml (rendered: string) {
|
|
178
|
+
// no DOMParser means no script/style stripping (i.e. during SSR), so behave
|
|
179
|
+
// as if allowHtml were off; the browser re-renders as html when it hydrates
|
|
180
|
+
return allowHtml && typeof DOMParser !== 'undefined' && htmlPattern.test(rendered)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// measured heights of an html block's clip window and its natural content;
|
|
184
|
+
// when the content is taller, we fade the bottom and show an indicator
|
|
185
|
+
let htmlClipHeight = 0
|
|
186
|
+
let htmlContentHeight = 0
|
|
187
|
+
$: htmlClipped = htmlContentHeight > htmlClipHeight + 1
|
|
188
|
+
|
|
189
|
+
function truncate (str: string) {
|
|
190
|
+
return str.length > maxtext ? str.slice(0, maxtext).trimEnd() + '…' : str
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* run the format prop on a leaf value's default rendering; an { html }
|
|
195
|
+
* result needs DOMParser for scrubbing, so without one (i.e. during SSR) it
|
|
196
|
+
* is ignored and the value renders the default way until the browser
|
|
197
|
+
* hydrates
|
|
198
|
+
*/
|
|
199
|
+
function applyFormat (value: any, atPath: (string | number)[]): string | { html: string, inline?: boolean } | undefined {
|
|
200
|
+
if (format == null) return undefined
|
|
201
|
+
if (value == null || (typeof value === 'object' && !(value instanceof Date)) || typeof value === 'function') return undefined
|
|
202
|
+
const result = format(renderScalar(value), atPath)
|
|
203
|
+
if (result != null && typeof result === 'object' && typeof DOMParser === 'undefined') return undefined
|
|
204
|
+
return result
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
$: formatted = applyFormat(data, path)
|
|
208
|
+
$: formattedHtml = formatted != null && typeof formatted === 'object' ? stripUnsafeHtml(formatted.html) : undefined
|
|
209
|
+
$: formattedText = typeof formatted === 'string' ? truncate(formatted) : undefined
|
|
210
|
+
|
|
211
|
+
function formatValue (value: any) {
|
|
103
212
|
const str = renderScalar(value)
|
|
104
|
-
|
|
213
|
+
// never truncate html - slicing markup in half would break it
|
|
214
|
+
if (isHtml(str)) return str
|
|
215
|
+
return truncate(str)
|
|
105
216
|
}
|
|
106
217
|
</script>
|
|
107
218
|
|
|
108
|
-
{#if
|
|
219
|
+
{#if formattedHtml != null}
|
|
220
|
+
<span class="nested-data-scalar nested-data-trusted {className}">{@html formattedHtml}</span>
|
|
221
|
+
{:else if formattedText != null}
|
|
222
|
+
<span class="nested-data-scalar {className}">{formattedText}</span>
|
|
223
|
+
{:else if arrayData}
|
|
109
224
|
{#if elided}
|
|
110
225
|
<span class="nested-data-elided {className}" title={elidedTooltip}>{elidedArrayText}</span>
|
|
111
226
|
{:else if arrayData.length}
|
|
112
227
|
<ul class="nested-data {className}">
|
|
113
228
|
{#each arrayData as entry, i (i)}
|
|
114
|
-
<li><svelte:self data={entry} path={[...path, i]} ancestors={childAncestors} {maxlevel} {format} {elidedObjectText} {elidedArrayText} {elidedTooltip} /></li>
|
|
229
|
+
<li><svelte:self data={entry} path={[...path, i]} ancestors={childAncestors} {maxlevel} {format} {maxtext} {allowHtml} {elidedObjectText} {elidedArrayText} {elidedTooltip} {clippedTooltip} /></li>
|
|
115
230
|
{/each}
|
|
116
231
|
</ul>
|
|
117
232
|
{/if}
|
|
@@ -123,13 +238,26 @@
|
|
|
123
238
|
{#each objectData as [key, value], i (i)}
|
|
124
239
|
<div>
|
|
125
240
|
<dt>{key}:</dt>
|
|
126
|
-
<dd><svelte:self data={value} path={[...path, key]} ancestors={childAncestors} {maxlevel} {format} {elidedObjectText} {elidedArrayText} {elidedTooltip} /></dd>
|
|
241
|
+
<dd class:nested-data-block={isBlockValue(value, key)}><svelte:self data={value} path={[...path, key]} ancestors={childAncestors} {maxlevel} {format} {maxtext} {allowHtml} {elidedObjectText} {elidedArrayText} {elidedTooltip} {clippedTooltip} /></dd>
|
|
127
242
|
</div>
|
|
128
243
|
{/each}
|
|
129
244
|
</dl>
|
|
130
245
|
{/if}
|
|
131
246
|
{:else if data != null && (typeof data !== 'object' || data instanceof Date) && typeof data !== 'function'}
|
|
132
|
-
{formatValue(data
|
|
247
|
+
{#if isHtml(formatValue(data))}
|
|
248
|
+
<!-- the frame and legend keep rendered markup (especially lists) from being mistaken for the data's own structure -->
|
|
249
|
+
<fieldset class="nested-data-scalar nested-data-html {className}" class:nested-data-clipped={htmlClipped}>
|
|
250
|
+
<legend>HTML</legend>
|
|
251
|
+
<div class="nested-data-html-clip" bind:clientHeight={htmlClipHeight}>
|
|
252
|
+
<div bind:offsetHeight={htmlContentHeight}>{@html stripUnsafeHtml(formatValue(data))}</div>
|
|
253
|
+
</div>
|
|
254
|
+
{#if htmlClipped}
|
|
255
|
+
<div class="nested-data-html-more" title={clippedTooltip}>…</div>
|
|
256
|
+
{/if}
|
|
257
|
+
</fieldset>
|
|
258
|
+
{:else}
|
|
259
|
+
<span class="nested-data-scalar {className}">{formatValue(data)}</span>
|
|
260
|
+
{/if}
|
|
133
261
|
{/if}
|
|
134
262
|
|
|
135
263
|
<style>
|
|
@@ -143,23 +271,90 @@
|
|
|
143
271
|
padding-left: 0.9em;
|
|
144
272
|
list-style: disc;
|
|
145
273
|
}
|
|
274
|
+
/*
|
|
275
|
+
* hanging indent: a value that fits stays on the same line as its key, but the
|
|
276
|
+
* dd is an inline-block, so as soon as its content needs to wrap, the whole
|
|
277
|
+
* block drops below the key and picks up the row's padding as its indent;
|
|
278
|
+
* text-indent only pulls the first line (the key) back to the left edge
|
|
279
|
+
*/
|
|
280
|
+
dl.nested-data > div {
|
|
281
|
+
padding-left: 0.75em;
|
|
282
|
+
text-indent: -0.75em;
|
|
283
|
+
}
|
|
146
284
|
dl.nested-data dt {
|
|
147
285
|
display: inline;
|
|
148
286
|
font-weight: 600;
|
|
149
287
|
}
|
|
150
288
|
dl.nested-data dd {
|
|
151
|
-
display: inline;
|
|
289
|
+
display: inline-block;
|
|
152
290
|
margin: 0;
|
|
291
|
+
max-width: 100%;
|
|
292
|
+
text-indent: 0;
|
|
293
|
+
vertical-align: top;
|
|
153
294
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
295
|
+
dl.nested-data dd.nested-data-block {
|
|
296
|
+
display: block;
|
|
297
|
+
}
|
|
298
|
+
.nested-data-scalar {
|
|
299
|
+
white-space: pre-line;
|
|
300
|
+
overflow-wrap: break-word;
|
|
301
|
+
}
|
|
302
|
+
/* markup built by the format prop renders inline, unframed */
|
|
303
|
+
.nested-data-trusted {
|
|
304
|
+
white-space: normal;
|
|
305
|
+
}
|
|
306
|
+
.nested-data-trusted :global(img) {
|
|
307
|
+
max-width: 100%;
|
|
308
|
+
vertical-align: middle;
|
|
309
|
+
}
|
|
310
|
+
/* html brings its own paragraphs and breaks; pre-line would double them up */
|
|
311
|
+
.nested-data-html {
|
|
312
|
+
white-space: normal;
|
|
313
|
+
display: block;
|
|
314
|
+
border: 1px dotted;
|
|
315
|
+
border-radius: 0.25em;
|
|
316
|
+
margin: 0.2em 0 0.2em 0;
|
|
317
|
+
padding: 0 0.6em 0.4em;
|
|
318
|
+
/* fieldsets refuse to shrink below their content width without this */
|
|
319
|
+
min-width: 0;
|
|
320
|
+
}
|
|
321
|
+
.nested-data-html legend {
|
|
322
|
+
font-size: 0.7em;
|
|
323
|
+
font-weight: 600;
|
|
324
|
+
letter-spacing: 0.06em;
|
|
325
|
+
padding: 0 0.4em;
|
|
326
|
+
margin-left: -0.4em;
|
|
327
|
+
opacity: 0.75;
|
|
328
|
+
}
|
|
329
|
+
.nested-data-html-clip {
|
|
330
|
+
max-height: var(--nested-data-html-max-height, 12em);
|
|
331
|
+
overflow: hidden;
|
|
332
|
+
}
|
|
333
|
+
/* fade the content itself so the effect works on any background */
|
|
334
|
+
.nested-data-clipped .nested-data-html-clip {
|
|
335
|
+
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
|
|
336
|
+
mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
|
|
337
|
+
}
|
|
338
|
+
.nested-data-html-more {
|
|
339
|
+
text-align: center;
|
|
340
|
+
line-height: 1;
|
|
341
|
+
opacity: var(--nested-data-elided-opacity, 0.65);
|
|
342
|
+
}
|
|
343
|
+
.nested-data-html :global(img) {
|
|
344
|
+
max-width: 100%;
|
|
345
|
+
}
|
|
346
|
+
/* the browser's default paragraph margins would pad out the block's top and bottom (first-of-type since the legend is always the first child) */
|
|
347
|
+
.nested-data-html :global(p:first-of-type) {
|
|
348
|
+
margin-top: 0;
|
|
349
|
+
}
|
|
350
|
+
.nested-data-html :global(p:last-of-type) {
|
|
351
|
+
margin-bottom: 0;
|
|
352
|
+
}
|
|
353
|
+
/* de-emphasize blocks of text that have dropped below their key; formatter-built markup keeps full strength */
|
|
354
|
+
dl.nested-data dd.nested-data-block > :global(.nested-data-scalar:not(.nested-data-trusted)) {
|
|
355
|
+
opacity: var(--nested-data-block-opacity, 0.85);
|
|
161
356
|
}
|
|
162
357
|
.nested-data-elided {
|
|
163
|
-
opacity: 0.65;
|
|
358
|
+
opacity: var(--nested-data-elided-opacity, 0.65);
|
|
164
359
|
}
|
|
165
360
|
</style>
|
|
@@ -13,13 +13,44 @@ declare const __propDef: {
|
|
|
13
13
|
*/ maxlevel?: number;
|
|
14
14
|
/**
|
|
15
15
|
* take over the display of any leaf value; receives the default rendering
|
|
16
|
-
* (datetimes already rewritten as local ISO8601, everything else
|
|
17
|
-
* plus the value's path, so it can do something else with
|
|
18
|
-
* types
|
|
19
|
-
|
|
16
|
+
* (datetimes already rewritten as local ISO8601, everything else
|
|
17
|
+
* stringified) plus the value's path, so it can do something else with
|
|
18
|
+
* specific fields or types - including recognizing marker strings a data
|
|
19
|
+
* source deliberately embedded, like '{"image": "https://..."}'
|
|
20
|
+
*
|
|
21
|
+
* return a string to display as text, return { html } to display markup the
|
|
22
|
+
* formatter built itself - it renders regardless of allowHtml, because the
|
|
23
|
+
* formatter is application code rather than data, though it is still
|
|
24
|
+
* scrubbed with stripUnsafeHtml since the urls and labels interpolated into
|
|
25
|
+
* it usually do come from the data - or return undefined to keep the
|
|
26
|
+
* default rendering
|
|
27
|
+
*
|
|
28
|
+
* { html } drops below its key as an indented block by default; pass
|
|
29
|
+
* inline: true for something compact like a link that should sit beside its
|
|
30
|
+
* key the way a short scalar does
|
|
31
|
+
*/ format?: ((value: string, path: (string | number)[]) => string | {
|
|
32
|
+
html: string;
|
|
33
|
+
inline?: boolean;
|
|
34
|
+
} | undefined) | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* leaf values longer than this many characters are cut off with an ellipsis,
|
|
37
|
+
* after `format` has had its say; set Infinity if you really want it all
|
|
38
|
+
*/ maxtext?: number;
|
|
39
|
+
/**
|
|
40
|
+
* render leaf values that contain HTML tags as actual HTML instead of escaped
|
|
41
|
+
* text; they display as an indented block below their key and are exempt from
|
|
42
|
+
* maxtext, since slicing markup in half would break it
|
|
43
|
+
*
|
|
44
|
+
* markup is scrubbed with stripUnsafeHtml before rendering (scripts, styles,
|
|
45
|
+
* iframes, event handlers, and executable urls dropped), but that is a safety
|
|
46
|
+
* net, not full sanitization; off by default because it means trusting the
|
|
47
|
+
* data: only enable this when the payload is sanitized or comes from a trusted
|
|
48
|
+
* source, otherwise you are opening your users up to XSS attacks
|
|
49
|
+
*/ allowHtml?: boolean;
|
|
20
50
|
/** the placeholders shown in place of data nested deeper than maxlevel, distinct so the reader knows what kind of data is hiding */ elidedObjectText?: string;
|
|
21
51
|
elidedArrayText?: string;
|
|
22
52
|
elidedTooltip?: string;
|
|
53
|
+
/** tooltip on the indicator shown when a rendered html block is taller than its max height and gets clipped */ clippedTooltip?: string;
|
|
23
54
|
className?: string;
|
|
24
55
|
/**
|
|
25
56
|
* the containers above this one, used internally to detect cycles; a container
|
|
@@ -40,6 +71,14 @@ export type NestedDataSlots = typeof __propDef.slots;
|
|
|
40
71
|
* for objects (and Maps), bulleted lists for arrays (and Sets), scalars as text.
|
|
41
72
|
* Intended for read-only summaries like preview panes, not for editing.
|
|
42
73
|
*
|
|
74
|
+
* Long text is kept in check: leaf values cut off with an ellipsis after `maxtext`
|
|
75
|
+
* characters, line breaks are preserved, and a value that would need to wrap drops
|
|
76
|
+
* below its key as an indented block instead of snaking around it.
|
|
77
|
+
*
|
|
78
|
+
* Values are escaped text by default; the `allowHtml` prop renders values that
|
|
79
|
+
* contain markup as actual HTML in a framed, height-clipped block - only enable
|
|
80
|
+
* it for trusted payloads.
|
|
81
|
+
*
|
|
43
82
|
* Notes on odd input:
|
|
44
83
|
* - datetimes render as ISO8601 in the browser's timezone, whether they arrive as
|
|
45
84
|
* Date objects or ISO strings (e.g. UTC from a JSON API); use `format` to display
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrub a string of HTML so it can be displayed with reasonable confidence that
|
|
3
|
+
* it will not execute anything. Parsing happens in the browser's own DOM - a
|
|
4
|
+
* DOMParser document is inert, so nothing runs or loads during parsing - and
|
|
5
|
+
* then we drop dangerous elements (script, style, iframe, and friends), inline
|
|
6
|
+
* event handlers, and urls with executable schemes like `javascript:` (inline
|
|
7
|
+
* `data:image` urls survive, since nothing executes in an img src). Forms are
|
|
8
|
+
* kept for display but stripped of their action, and svg is kept minus its
|
|
9
|
+
* smuggling compartments (foreignObject, SMIL animation elements).
|
|
10
|
+
*
|
|
11
|
+
* This is a strong net for displaying rich text, but it is intentionally
|
|
12
|
+
* simpler than a real sanitizer like DOMPurify - prefer displaying content from
|
|
13
|
+
* trusted sources and treat this as defense in depth, not permission to render
|
|
14
|
+
* hostile input.
|
|
15
|
+
*
|
|
16
|
+
* In an environment with no DOMParser (i.e. during server-side rendering) this
|
|
17
|
+
* returns an empty string rather than emit unvetted markup; render the original
|
|
18
|
+
* on the client after mount/hydration instead.
|
|
19
|
+
*/
|
|
20
|
+
export declare function stripUnsafeHtml(html: string): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* elements that have no business in displayed rich text, matched by localName
|
|
3
|
+
* so camelCase svg names cannot dodge a case-sensitive selector; svg itself is
|
|
4
|
+
* allowed, but foreignObject can smuggle arbitrary html and the SMIL animation
|
|
5
|
+
* elements can rewrite an href to javascript: at runtime, so they go; math goes
|
|
6
|
+
* entirely as another classic smuggling vehicle
|
|
7
|
+
*/
|
|
8
|
+
const dangerousTags = new Set(['script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'link', 'meta', 'base', 'template', 'noscript', 'math', 'foreignobject', 'animate', 'animatetransform', 'animatemotion', 'set']);
|
|
9
|
+
/**
|
|
10
|
+
* attributes removed no matter their value: forms may stay for display, but
|
|
11
|
+
* they don't get to submit anywhere
|
|
12
|
+
*/
|
|
13
|
+
const strippedAttributes = ['action', 'formaction'];
|
|
14
|
+
/** attributes that carry urls and could smuggle an executable scheme */
|
|
15
|
+
const urlAttributes = ['href', 'src', 'xlink:href', 'srcset'];
|
|
16
|
+
function safeUrl(attr, value) {
|
|
17
|
+
// browsers ignore control characters and whitespace inside a scheme, so
|
|
18
|
+
// "jav\nascript:" still executes; normalize before testing
|
|
19
|
+
const normalized = value.replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
|
20
|
+
if (/^(javascript|vbscript|data):/.test(normalized)) {
|
|
21
|
+
// inline images are common and nothing executes in an img src data url
|
|
22
|
+
return attr === 'src' && normalized.startsWith('data:image/');
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Scrub a string of HTML so it can be displayed with reasonable confidence that
|
|
28
|
+
* it will not execute anything. Parsing happens in the browser's own DOM - a
|
|
29
|
+
* DOMParser document is inert, so nothing runs or loads during parsing - and
|
|
30
|
+
* then we drop dangerous elements (script, style, iframe, and friends), inline
|
|
31
|
+
* event handlers, and urls with executable schemes like `javascript:` (inline
|
|
32
|
+
* `data:image` urls survive, since nothing executes in an img src). Forms are
|
|
33
|
+
* kept for display but stripped of their action, and svg is kept minus its
|
|
34
|
+
* smuggling compartments (foreignObject, SMIL animation elements).
|
|
35
|
+
*
|
|
36
|
+
* This is a strong net for displaying rich text, but it is intentionally
|
|
37
|
+
* simpler than a real sanitizer like DOMPurify - prefer displaying content from
|
|
38
|
+
* trusted sources and treat this as defense in depth, not permission to render
|
|
39
|
+
* hostile input.
|
|
40
|
+
*
|
|
41
|
+
* In an environment with no DOMParser (i.e. during server-side rendering) this
|
|
42
|
+
* returns an empty string rather than emit unvetted markup; render the original
|
|
43
|
+
* on the client after mount/hydration instead.
|
|
44
|
+
*/
|
|
45
|
+
export function stripUnsafeHtml(html) {
|
|
46
|
+
if (typeof DOMParser === 'undefined')
|
|
47
|
+
return '';
|
|
48
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
49
|
+
for (const el of Array.from(doc.body.querySelectorAll('*'))) {
|
|
50
|
+
if (dangerousTags.has(el.localName.toLowerCase())) {
|
|
51
|
+
el.remove();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const attr of Array.from(el.attributes)) {
|
|
55
|
+
const name = attr.name.toLowerCase();
|
|
56
|
+
if (name.startsWith('on') || strippedAttributes.includes(name) || (urlAttributes.includes(name) && !safeUrl(name, attr.value)))
|
|
57
|
+
el.removeAttribute(attr.name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return doc.body.innerHTML;
|
|
61
|
+
}
|