@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 stringified)
80
- * plus the value's path, so it can do something else with specific fields or
81
- * types; return undefined to keep the default rendering
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 format: ((value: string, path: (string | number)[]) => string | undefined) | undefined = undefined
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
- function formatValue (value: any, atPath: (string | number)[]) {
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
- return format?.(str, atPath) ?? str
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 arrayData}
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, path)}
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}>&hellip;</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
- * an object directly beneath a key drops below it, indented a step; the child
156
- * combinator keeps the indent from leaking into deeper levels, so an object
157
- * inside an array entry hangs on the li's bullet with no extra margin
158
- */
159
- dl.nested-data dd > :global(dl.nested-data) {
160
- margin-left: 0.75em;
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 stringified)
17
- * plus the value's path, so it can do something else with specific fields or
18
- * types; return undefined to keep the default rendering
19
- */ format?: ((value: string, path: (string | number)[]) => string | undefined) | undefined;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@txstate-mws/svelte-components",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Svelte components that are generically useful.",
5
5
  "scripts": {
6
6
  "prepublishOnly": "svelte-package",