@txstate-mws/svelte-components 1.6.16 → 1.7.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.
@@ -0,0 +1,306 @@
1
+ <!--
2
+ @component
3
+ Displays arbitrary JSON-ish data as a compact nested structure: property lists
4
+ for objects (and Maps), bulleted lists for arrays (and Sets), scalars as text.
5
+ Intended for read-only summaries like preview panes, not for editing.
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
+
15
+ Notes on odd input:
16
+ - datetimes render as ISO8601 in the browser's timezone, whether they arrive as
17
+ Date objects or ISO strings (e.g. UTC from a JSON API); use `format` to display
18
+ them some other way
19
+ - a Map renders like an object as long as every key is a string or has a custom
20
+ toString; otherwise the Map is skipped entirely
21
+ - cyclic structures are safe: a container that appears among its own ancestors
22
+ renders the placeholder at the point of the cycle
23
+ - empty arrays/objects, functions, and null/undefined render nothing
24
+ -->
25
+ <script lang="ts" context="module">
26
+ import { dateToISOWithTZ } from 'txstate-utils'
27
+ import { stripUnsafeHtml } from '../util/striphtml.js'
28
+
29
+ /**
30
+ * datetimes display as ISO8601 in the browser's timezone, whether they arrive
31
+ * as Date objects or ISO strings (e.g. UTC from a JSON API); date-only strings
32
+ * carry no timezone semantics and pass through untouched
33
+ */
34
+ const isoDatetimePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/
35
+
36
+ function localISO (dt: Date) {
37
+ return dateToISOWithTZ(dt).replace('.000', '')
38
+ }
39
+
40
+ function renderScalar (value: any) {
41
+ if (value instanceof Date) return localISO(value)
42
+ if (typeof value === 'string' && isoDatetimePattern.test(value)) {
43
+ const parsed = new Date(value)
44
+ if (!isNaN(parsed.getTime())) return localISO(parsed)
45
+ }
46
+ return String(value)
47
+ }
48
+
49
+ /** simple HTML auto-detection: any <tag ...> or </tag> in the string */
50
+ const htmlPattern = /<\/?[a-z][^>]*>/i
51
+
52
+ /** a Map key we can display: a primitive or anything with a custom toString */
53
+ function stringableKey (key: any) {
54
+ if (key == null) return false
55
+ if (typeof key === 'object' || typeof key === 'function') return key.toString !== Object.prototype.toString
56
+ return true
57
+ }
58
+
59
+ function arrayEntries (value: any): any[] | undefined {
60
+ if (Array.isArray(value)) return value
61
+ if (value instanceof Set) return Array.from(value)
62
+ return undefined
63
+ }
64
+
65
+ function objectEntries (value: any): [string, any][] | undefined {
66
+ if (value instanceof Map) {
67
+ const entries = Array.from(value.entries())
68
+ if (entries.some(([k]) => !stringableKey(k))) return undefined
69
+ return entries.map(([k, v]) => [String(k), v] as [string, any])
70
+ }
71
+ if (value != null && typeof value === 'object' && !(value instanceof Date)) return Object.entries(value)
72
+ return undefined
73
+ }
74
+ </script>
75
+
76
+ <script lang="ts">
77
+ export let data: any
78
+ /** where this value sits in the root data, e.g. ['sections', 0, 'heading']; the root is [] */
79
+ export let path: (string | number)[] = []
80
+ /**
81
+ * how many nested arrays/objects deep we render before eliding with a
82
+ * placeholder
83
+ *
84
+ * e.g. with the default of 5, { items: [{ moreitems: [{ id: 5 }] }] } displays
85
+ * fully - the root object is level 0 and the { id: 5 } object is level 4 - but
86
+ * a subarray or subobject inside { id: 5 } would render as the placeholder
87
+ */
88
+ export let maxlevel = 5
89
+ /**
90
+ * take over the display of any leaf value; receives the default rendering
91
+ * (datetimes already rewritten as local ISO8601, everything else stringified)
92
+ * plus the value's path, so it can do something else with specific fields or
93
+ * types; return undefined to keep the default rendering
94
+ */
95
+ export let format: ((value: string, path: (string | number)[]) => string | undefined) | undefined = undefined
96
+ /**
97
+ * leaf values longer than this many characters are cut off with an ellipsis,
98
+ * after `format` has had its say; set Infinity if you really want it all
99
+ */
100
+ export let maxtext = 200
101
+ /**
102
+ * render leaf values that contain HTML tags as actual HTML instead of escaped
103
+ * text; they display as an indented block below their key and are exempt from
104
+ * maxtext, since slicing markup in half would break it
105
+ *
106
+ * markup is scrubbed with stripUnsafeHtml before rendering (scripts, styles,
107
+ * iframes, event handlers, and executable urls dropped), but that is a safety
108
+ * net, not full sanitization; off by default because it means trusting the
109
+ * data: only enable this when the payload is sanitized or comes from a trusted
110
+ * source, otherwise you are opening your users up to XSS attacks
111
+ */
112
+ export let allowHtml = false
113
+ /** the placeholders shown in place of data nested deeper than maxlevel, distinct so the reader knows what kind of data is hiding */
114
+ export let elidedObjectText = '{ ... }'
115
+ export let elidedArrayText = '[...]'
116
+ export let elidedTooltip = 'deeper data not shown'
117
+ /** tooltip on the indicator shown when a rendered html block is taller than its max height and gets clipped */
118
+ export let clippedTooltip = 'more content not shown'
119
+ export let className = ''
120
+ /**
121
+ * the containers above this one, used internally to detect cycles; a container
122
+ * that appears among its own ancestors renders as the placeholder immediately
123
+ * instead of repeating itself down to maxlevel
124
+ */
125
+ export let ancestors: any[] = []
126
+
127
+ $: level = path.length
128
+ $: arrayData = arrayEntries(data)
129
+ $: objectData = arrayData == null ? objectEntries(data) : undefined
130
+ $: elided = level >= maxlevel || ancestors.includes(data)
131
+ $: childAncestors = [...ancestors, data]
132
+
133
+ /**
134
+ * a rendered string this long will essentially always need to wrap, so we drop
135
+ * it below the key up front; that also lets the CSS de-emphasize wrapped blocks,
136
+ * which it could not do if the drop only happened through inline-block layout
137
+ */
138
+ const BLOCKTEXT = 80
139
+
140
+ /**
141
+ * containers always drop below their key as a full-width block; scalars are
142
+ * inline-blocks that stay beside the key until they need to wrap - with two
143
+ * exceptions: a container the child will elide renders as a short placeholder,
144
+ * so it stays beside the key, and a scalar whose rendered text contains line
145
+ * breaks (or is long enough that wrapping is inevitable) drops below the key,
146
+ * so the breaks don't leave text hanging in the middle of the row
147
+ */
148
+ function isBlockValue (value: any, key: string) {
149
+ if (level + 1 >= maxlevel || childAncestors.includes(value)) return false
150
+ if (arrayEntries(value) != null || objectEntries(value) != null) return true
151
+ if (typeof value !== 'string') return false
152
+ const rendered = formatValue(value, [...path, key])
153
+ return isHtml(rendered) || rendered.includes('\n') || rendered.length > BLOCKTEXT
154
+ }
155
+
156
+ function isHtml (rendered: string) {
157
+ // no DOMParser means no script/style stripping (i.e. during SSR), so behave
158
+ // as if allowHtml were off; the browser re-renders as html when it hydrates
159
+ return allowHtml && typeof DOMParser !== 'undefined' && htmlPattern.test(rendered)
160
+ }
161
+
162
+ // measured heights of an html block's clip window and its natural content;
163
+ // when the content is taller, we fade the bottom and show an indicator
164
+ let htmlClipHeight = 0
165
+ let htmlContentHeight = 0
166
+ $: htmlClipped = htmlContentHeight > htmlClipHeight + 1
167
+
168
+ function formatValue (value: any, atPath: (string | number)[]) {
169
+ const str = renderScalar(value)
170
+ const formatted = format?.(str, atPath) ?? str
171
+ // never truncate html - slicing markup in half would break it
172
+ if (isHtml(formatted)) return formatted
173
+ return formatted.length > maxtext ? formatted.slice(0, maxtext).trimEnd() + '…' : formatted
174
+ }
175
+ </script>
176
+
177
+ {#if arrayData}
178
+ {#if elided}
179
+ <span class="nested-data-elided {className}" title={elidedTooltip}>{elidedArrayText}</span>
180
+ {:else if arrayData.length}
181
+ <ul class="nested-data {className}">
182
+ {#each arrayData as entry, i (i)}
183
+ <li><svelte:self data={entry} path={[...path, i]} ancestors={childAncestors} {maxlevel} {format} {maxtext} {allowHtml} {elidedObjectText} {elidedArrayText} {elidedTooltip} {clippedTooltip} /></li>
184
+ {/each}
185
+ </ul>
186
+ {/if}
187
+ {:else if objectData}
188
+ {#if elided}
189
+ <span class="nested-data-elided {className}" title={elidedTooltip}>{elidedObjectText}</span>
190
+ {:else}
191
+ <dl class="nested-data {className}">
192
+ {#each objectData as [key, value], i (i)}
193
+ <div>
194
+ <dt>{key}:</dt>
195
+ <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>
196
+ </div>
197
+ {/each}
198
+ </dl>
199
+ {/if}
200
+ {:else if data != null && (typeof data !== 'object' || data instanceof Date) && typeof data !== 'function'}
201
+ {#if isHtml(formatValue(data, path))}
202
+ <!-- the frame and legend keep rendered markup (especially lists) from being mistaken for the data's own structure -->
203
+ <fieldset class="nested-data-scalar nested-data-html {className}" class:nested-data-clipped={htmlClipped}>
204
+ <legend>HTML</legend>
205
+ <div class="nested-data-html-clip" bind:clientHeight={htmlClipHeight}>
206
+ <div bind:offsetHeight={htmlContentHeight}>{@html stripUnsafeHtml(formatValue(data, path))}</div>
207
+ </div>
208
+ {#if htmlClipped}
209
+ <div class="nested-data-html-more" title={clippedTooltip}>&hellip;</div>
210
+ {/if}
211
+ </fieldset>
212
+ {:else}
213
+ <span class="nested-data-scalar {className}">{formatValue(data, path)}</span>
214
+ {/if}
215
+ {/if}
216
+
217
+ <style>
218
+ .nested-data {
219
+ margin: 0;
220
+ padding: 0;
221
+ line-height: 1.3;
222
+ }
223
+ ul.nested-data {
224
+ margin-left: 0.75em;
225
+ padding-left: 0.9em;
226
+ list-style: disc;
227
+ }
228
+ /*
229
+ * hanging indent: a value that fits stays on the same line as its key, but the
230
+ * dd is an inline-block, so as soon as its content needs to wrap, the whole
231
+ * block drops below the key and picks up the row's padding as its indent;
232
+ * text-indent only pulls the first line (the key) back to the left edge
233
+ */
234
+ dl.nested-data > div {
235
+ padding-left: 0.75em;
236
+ text-indent: -0.75em;
237
+ }
238
+ dl.nested-data dt {
239
+ display: inline;
240
+ font-weight: 600;
241
+ }
242
+ dl.nested-data dd {
243
+ display: inline-block;
244
+ margin: 0;
245
+ max-width: 100%;
246
+ text-indent: 0;
247
+ vertical-align: top;
248
+ }
249
+ dl.nested-data dd.nested-data-block {
250
+ display: block;
251
+ }
252
+ .nested-data-scalar {
253
+ white-space: pre-line;
254
+ overflow-wrap: break-word;
255
+ }
256
+ /* html brings its own paragraphs and breaks; pre-line would double them up */
257
+ .nested-data-html {
258
+ white-space: normal;
259
+ display: block;
260
+ border: 1px dotted;
261
+ border-radius: 0.25em;
262
+ margin: 0.2em 0 0.2em 0;
263
+ padding: 0 0.6em 0.4em;
264
+ /* fieldsets refuse to shrink below their content width without this */
265
+ min-width: 0;
266
+ }
267
+ .nested-data-html legend {
268
+ font-size: 0.7em;
269
+ font-weight: 600;
270
+ letter-spacing: 0.06em;
271
+ padding: 0 0.4em;
272
+ margin-left: -0.4em;
273
+ opacity: 0.75;
274
+ }
275
+ .nested-data-html-clip {
276
+ max-height: var(--nested-data-html-max-height, 12em);
277
+ overflow: hidden;
278
+ }
279
+ /* fade the content itself so the effect works on any background */
280
+ .nested-data-clipped .nested-data-html-clip {
281
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
282
+ mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
283
+ }
284
+ .nested-data-html-more {
285
+ text-align: center;
286
+ line-height: 1;
287
+ opacity: var(--nested-data-elided-opacity, 0.65);
288
+ }
289
+ .nested-data-html :global(img) {
290
+ max-width: 100%;
291
+ }
292
+ /* 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) */
293
+ .nested-data-html :global(p:first-of-type) {
294
+ margin-top: 0;
295
+ }
296
+ .nested-data-html :global(p:last-of-type) {
297
+ margin-bottom: 0;
298
+ }
299
+ /* de-emphasize blocks of text that have dropped below their key */
300
+ dl.nested-data dd.nested-data-block > :global(.nested-data-scalar) {
301
+ opacity: var(--nested-data-block-opacity, 0.85);
302
+ }
303
+ .nested-data-elided {
304
+ opacity: var(--nested-data-elided-opacity, 0.65);
305
+ }
306
+ </style>
@@ -0,0 +1,79 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ declare const __propDef: {
3
+ props: {
4
+ data: any;
5
+ /** where this value sits in the root data, e.g. ['sections', 0, 'heading']; the root is [] */ path?: (string | number)[];
6
+ /**
7
+ * how many nested arrays/objects deep we render before eliding with a
8
+ * placeholder
9
+ *
10
+ * e.g. with the default of 5, { items: [{ moreitems: [{ id: 5 }] }] } displays
11
+ * fully - the root object is level 0 and the { id: 5 } object is level 4 - but
12
+ * a subarray or subobject inside { id: 5 } would render as the placeholder
13
+ */ maxlevel?: number;
14
+ /**
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;
20
+ /**
21
+ * leaf values longer than this many characters are cut off with an ellipsis,
22
+ * after `format` has had its say; set Infinity if you really want it all
23
+ */ maxtext?: number;
24
+ /**
25
+ * render leaf values that contain HTML tags as actual HTML instead of escaped
26
+ * text; they display as an indented block below their key and are exempt from
27
+ * maxtext, since slicing markup in half would break it
28
+ *
29
+ * markup is scrubbed with stripUnsafeHtml before rendering (scripts, styles,
30
+ * iframes, event handlers, and executable urls dropped), but that is a safety
31
+ * net, not full sanitization; off by default because it means trusting the
32
+ * data: only enable this when the payload is sanitized or comes from a trusted
33
+ * source, otherwise you are opening your users up to XSS attacks
34
+ */ allowHtml?: boolean;
35
+ /** the placeholders shown in place of data nested deeper than maxlevel, distinct so the reader knows what kind of data is hiding */ elidedObjectText?: string;
36
+ elidedArrayText?: string;
37
+ elidedTooltip?: string;
38
+ /** tooltip on the indicator shown when a rendered html block is taller than its max height and gets clipped */ clippedTooltip?: string;
39
+ className?: string;
40
+ /**
41
+ * the containers above this one, used internally to detect cycles; a container
42
+ * that appears among its own ancestors renders as the placeholder immediately
43
+ * instead of repeating itself down to maxlevel
44
+ */ ancestors?: any[];
45
+ };
46
+ events: {
47
+ [evt: string]: CustomEvent<any>;
48
+ };
49
+ slots: {};
50
+ };
51
+ export type NestedDataProps = typeof __propDef.props;
52
+ export type NestedDataEvents = typeof __propDef.events;
53
+ export type NestedDataSlots = typeof __propDef.slots;
54
+ /**
55
+ * Displays arbitrary JSON-ish data as a compact nested structure: property lists
56
+ * for objects (and Maps), bulleted lists for arrays (and Sets), scalars as text.
57
+ * Intended for read-only summaries like preview panes, not for editing.
58
+ *
59
+ * Long text is kept in check: leaf values cut off with an ellipsis after `maxtext`
60
+ * characters, line breaks are preserved, and a value that would need to wrap drops
61
+ * below its key as an indented block instead of snaking around it.
62
+ *
63
+ * Values are escaped text by default; the `allowHtml` prop renders values that
64
+ * contain markup as actual HTML in a framed, height-clipped block - only enable
65
+ * it for trusted payloads.
66
+ *
67
+ * Notes on odd input:
68
+ * - datetimes render as ISO8601 in the browser's timezone, whether they arrive as
69
+ * Date objects or ISO strings (e.g. UTC from a JSON API); use `format` to display
70
+ * them some other way
71
+ * - a Map renders like an object as long as every key is a string or has a custom
72
+ * toString; otherwise the Map is skipped entirely
73
+ * - cyclic structures are safe: a container that appears among its own ancestors
74
+ * renders the placeholder at the point of the cycle
75
+ * - empty arrays/objects, functions, and null/undefined render nothing
76
+ */
77
+ export default class NestedData extends SvelteComponentTyped<NestedDataProps, NestedDataEvents, NestedDataSlots> {
78
+ }
79
+ export {};
@@ -8,5 +8,6 @@ export { default as Loading } from './Loading.svelte';
8
8
  export { default as Lottie } from './Lottie.svelte';
9
9
  export { default as Modal } from './Modal.svelte';
10
10
  export { default as MultiSelect } from './MultiSelect.svelte';
11
+ export { default as NestedData } from './NestedData.svelte';
11
12
  export { default as PopupMenu } from './PopupMenu.svelte';
12
13
  export { default as ScreenReaderOnly } from './ScreenReaderOnly.svelte';
@@ -8,5 +8,6 @@ export { default as Loading } from './Loading.svelte';
8
8
  export { default as Lottie } from './Lottie.svelte';
9
9
  export { default as Modal } from './Modal.svelte';
10
10
  export { default as MultiSelect } from './MultiSelect.svelte';
11
+ export { default as NestedData } from './NestedData.svelte';
11
12
  export { default as PopupMenu } from './PopupMenu.svelte';
12
13
  export { default as ScreenReaderOnly } from './ScreenReaderOnly.svelte';
@@ -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.6.16",
3
+ "version": "1.7.1",
4
4
  "description": "Svelte components that are generically useful.",
5
5
  "scripts": {
6
6
  "prepublishOnly": "svelte-package",