@juspay/svelte-ui-components 4.11.5 → 4.13.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/MarkdownText/markdown.js +34 -5
- package/dist/MarkdownText/properties.d.ts +28 -8
- package/dist/Snippet/Snippet.svelte +47 -26
- package/dist/Snippet/copyState.svelte.d.ts +47 -0
- package/dist/Snippet/copyState.svelte.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist-wc/index.js +4770 -69
- package/package.json +4 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Marked } from 'marked';
|
|
2
|
+
import { parseFragment } from 'parse5';
|
|
2
3
|
/**
|
|
3
4
|
* Chat content comes from models and users, not from the app's own templates,
|
|
4
5
|
* so the output must be safe without asking consumers to run a sanitizer:
|
|
5
|
-
* raw HTML never passes through (
|
|
6
|
+
* raw HTML never passes through (escaped by default, tags optionally stripped), and only these
|
|
6
7
|
* URL protocols survive on links. Everything else in the output is built by
|
|
7
8
|
* marked from markdown syntax alone.
|
|
8
9
|
*/
|
|
@@ -109,10 +110,36 @@ function resolveTagAllowList(allowedTags) {
|
|
|
109
110
|
function tagAllowed(allowed, tag) {
|
|
110
111
|
return allowed === null || allowed.has(tag);
|
|
111
112
|
}
|
|
112
|
-
|
|
113
|
+
/**
|
|
114
|
+
* As with `narrow` and `resolveTagAllowList`, a plain-JS or web-component
|
|
115
|
+
* consumer can hand this anything at runtime, so anything other than the one
|
|
116
|
+
* recognised opt-in degrades to today's escaping rather than throwing — an
|
|
117
|
+
* unrecognised value must never be the reason content silently disappears.
|
|
118
|
+
*/
|
|
119
|
+
function resolveRawHtmlMode(rawHtml) {
|
|
120
|
+
return rawHtml === 'strip' ? 'strip' : 'escape';
|
|
121
|
+
}
|
|
122
|
+
// An inert, server-compatible HTML parser handles quoted delimiters, entities
|
|
123
|
+
// and raw-text elements. Escaping its text nodes prevents decoded entities from
|
|
124
|
+
// becoming markup when the resulting string is rendered by Svelte.
|
|
125
|
+
function rawHtmlText(node) {
|
|
126
|
+
if ('value' in node) {
|
|
127
|
+
return escapeHtml(node.value);
|
|
128
|
+
}
|
|
129
|
+
// A <template>'s children live in a separate `content` fragment rather than
|
|
130
|
+
// in childNodes, so recursing only through childNodes loses its text.
|
|
131
|
+
if ('content' in node) {
|
|
132
|
+
return rawHtmlText(node.content);
|
|
133
|
+
}
|
|
134
|
+
if ('childNodes' in node) {
|
|
135
|
+
return node.childNodes.map(rawHtmlText).join('');
|
|
136
|
+
}
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
function createSanitizingRenderer(protocols, allowedTags, disableTaskLists, rawHtml) {
|
|
113
140
|
return {
|
|
114
141
|
html(token) {
|
|
115
|
-
return escapeHtml(token.text);
|
|
142
|
+
return rawHtml === 'strip' ? rawHtmlText(parseFragment(token.text)) : escapeHtml(token.text);
|
|
116
143
|
},
|
|
117
144
|
link(token) {
|
|
118
145
|
if (hasSafeProtocol(token.href, protocols.links) && tagAllowed(allowedTags, 'a')) {
|
|
@@ -233,19 +260,21 @@ function instanceFor(options) {
|
|
|
233
260
|
const protocols = resolveProtocolSets(options.sanitize);
|
|
234
261
|
const allowedTags = resolveTagAllowList(options.sanitize?.allowedTags);
|
|
235
262
|
const disableTaskLists = options.sanitize?.disableTaskLists === true;
|
|
263
|
+
const rawHtml = resolveRawHtmlMode(options.sanitize?.rawHtml);
|
|
236
264
|
const key = [
|
|
237
265
|
breaks ? 'breaks' : 'default',
|
|
238
266
|
[...protocols.links].sort().join(','),
|
|
239
267
|
[...protocols.images].sort().join(','),
|
|
240
268
|
allowedTags ? [...allowedTags].sort().join(',') : '*',
|
|
241
|
-
disableTaskLists ? 'no-tasks' : 'tasks'
|
|
269
|
+
disableTaskLists ? 'no-tasks' : 'tasks',
|
|
270
|
+
rawHtml
|
|
242
271
|
].join('|');
|
|
243
272
|
let instance = instances.get(key);
|
|
244
273
|
if (!instance) {
|
|
245
274
|
instance = new Marked({
|
|
246
275
|
gfm: true,
|
|
247
276
|
breaks,
|
|
248
|
-
renderer: createSanitizingRenderer(protocols, allowedTags, disableTaskLists)
|
|
277
|
+
renderer: createSanitizingRenderer(protocols, allowedTags, disableTaskLists, rawHtml)
|
|
249
278
|
});
|
|
250
279
|
instances.set(key, instance);
|
|
251
280
|
}
|
|
@@ -2,7 +2,7 @@ export type MarkdownTextProperties = OptionalMarkdownTextProperties & MandatoryM
|
|
|
2
2
|
export type MandatoryMarkdownTextProperties = {
|
|
3
3
|
/**
|
|
4
4
|
* Markdown source. Rendered through the library's sanitized-by-construction
|
|
5
|
-
* pipeline: raw HTML
|
|
5
|
+
* pipeline: raw HTML is escaped by default (or stripped via sanitize.rawHtml), and
|
|
6
6
|
* link/image URLs outside the safe-protocol allow-list are stripped while
|
|
7
7
|
* their text is kept.
|
|
8
8
|
*/
|
|
@@ -34,8 +34,9 @@ export type OptionalMarkdownTextProperties = {
|
|
|
34
34
|
* with the library's own allow-list for each surface, so it can only NARROW
|
|
35
35
|
* what already renders, never widen it (listing `javascript:` here can never
|
|
36
36
|
* resurrect it). Omitting `sanitize`, or `allowedProtocols`, keeps today's
|
|
37
|
-
* defaults untouched. This is independent of
|
|
38
|
-
*
|
|
37
|
+
* defaults untouched. This is independent of how raw HTML is disposed of,
|
|
38
|
+
* which `rawHtml` governs separately; raw HTML is never inserted as markup
|
|
39
|
+
* either way, so neither option can widen what renders.
|
|
39
40
|
*/
|
|
40
41
|
export type MarkdownSanitizeOptions = {
|
|
41
42
|
/**
|
|
@@ -58,9 +59,8 @@ export type MarkdownSanitizeOptions = {
|
|
|
58
59
|
* `'blockquote'`, `'ul'`, `'ol'`, `'table'`, `'hr'`, and `'h1'`–`'h6'`. An
|
|
59
60
|
* unrecognised name is simply ignored — it narrows nothing, it does not
|
|
60
61
|
* error. Omitting `allowedTags` keeps today's defaults untouched: every tag
|
|
61
|
-
* renders as it does now. This
|
|
62
|
-
*
|
|
63
|
-
* list.
|
|
62
|
+
* renders as it does now. This list governs markdown-GENERATED tags only and
|
|
63
|
+
* never affects raw HTML from the source, whose disposal is `rawHtml`'s job.
|
|
64
64
|
*/
|
|
65
65
|
allowedTags?: string[];
|
|
66
66
|
/**
|
|
@@ -71,15 +71,35 @@ export type MarkdownSanitizeOptions = {
|
|
|
71
71
|
* safety one. Defaults to `false`, keeping today's checkbox rendering.
|
|
72
72
|
*/
|
|
73
73
|
disableTaskLists?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* What to do with raw HTML typed literally into the markdown source, as
|
|
76
|
+
* opposed to the tags markdown itself generates (which `allowedTags`
|
|
77
|
+
* governs). Defaults to `'escape'` — today's behaviour, where
|
|
78
|
+
* `<script>alert(1)</script>` renders as visible escaped tag text.
|
|
79
|
+
*
|
|
80
|
+
* `'strip'` removes raw tags and comments while retaining their text:
|
|
81
|
+
* `<div>hello</div>` becomes `hello`. An inert HTML parser handles quoted
|
|
82
|
+
* delimiters and entities; every retained text node is escaped before output.
|
|
83
|
+
* Script/style contents remain visible text, never executable elements.
|
|
84
|
+
* Fenced code and ordinary angle-bracket prose are not raw HTML tokens and
|
|
85
|
+
* keep their existing rendering. Neither mode inserts source HTML into the DOM.
|
|
86
|
+
*/
|
|
87
|
+
rawHtml?: MarkdownRawHtmlMode;
|
|
74
88
|
};
|
|
89
|
+
/**
|
|
90
|
+
* `'escape'` renders raw HTML as visible escaped text (the default, and what
|
|
91
|
+
* the component has always done); `'strip'` removes it. See
|
|
92
|
+
* `MarkdownSanitizeOptions.rawHtml`.
|
|
93
|
+
*/
|
|
94
|
+
export type MarkdownRawHtmlMode = 'escape' | 'strip';
|
|
75
95
|
export type RenderMarkdownOptions = {
|
|
76
96
|
/** Render single newlines as `<br>` (GFM "breaks" mode). */
|
|
77
97
|
breaks?: boolean;
|
|
78
98
|
/**
|
|
79
99
|
* Parse as inline markdown: no block elements (`<p>`, lists, tables) are
|
|
80
100
|
* produced, so the output can sit inside an existing `<p>` or `<span>`.
|
|
81
|
-
* The same sanitization applies — inline raw HTML is escaped
|
|
82
|
-
* link/image protocols are stripped.
|
|
101
|
+
* The same sanitization applies — inline raw HTML is escaped (or dropped,
|
|
102
|
+
* per `sanitize.rawHtml`) and unsafe link/image protocols are stripped.
|
|
83
103
|
*/
|
|
84
104
|
inline?: boolean;
|
|
85
105
|
/** Accessible name for the scroll region wrapping each table. See `MarkdownTextProperties.tableLabel`. */
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { SnippetProperties } from './properties';
|
|
4
4
|
import Button from '../Button/Button.svelte';
|
|
5
5
|
import copySvg from '../assets/copy.svg?raw';
|
|
6
|
-
import {
|
|
6
|
+
import { createCopyState } from './copyState.svelte';
|
|
7
7
|
|
|
8
8
|
let {
|
|
9
9
|
text,
|
|
@@ -17,29 +17,25 @@
|
|
|
17
17
|
classes
|
|
18
18
|
}: SnippetProperties = $props();
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
await navigator.clipboard.writeText(text);
|
|
34
|
-
copied = true;
|
|
35
|
-
oncopy?.();
|
|
36
|
-
resetTimer.arm(() => {
|
|
37
|
-
copied = false;
|
|
38
|
-
}, copyResetMs);
|
|
39
|
-
} catch {
|
|
40
|
-
// Clipboard API unavailable (non-secure context, iframe restrictions)
|
|
20
|
+
// The whole copy affordance -- clipboard write, `copied` flag, the reset
|
|
21
|
+
// timer's single-pending guarantee from #530, and teardown -- lives in
|
|
22
|
+
// `createCopyState` so a consumer who wants the behaviour without this
|
|
23
|
+
// component's `<code>` box can have it (#574). Getters rather than plain
|
|
24
|
+
// values, because the factory reads its options at copy time and these are
|
|
25
|
+
// props: passing them by value would freeze whatever they were at mount.
|
|
26
|
+
const copyState = createCopyState({
|
|
27
|
+
get copyResetMs() {
|
|
28
|
+
return copyResetMs;
|
|
29
|
+
},
|
|
30
|
+
get oncopy() {
|
|
31
|
+
return oncopy;
|
|
41
32
|
}
|
|
42
|
-
}
|
|
33
|
+
});
|
|
34
|
+
onDestroy(copyState.destroy);
|
|
35
|
+
|
|
36
|
+
const handleCopy = (): void => {
|
|
37
|
+
void copyState.copy(text);
|
|
38
|
+
};
|
|
43
39
|
</script>
|
|
44
40
|
|
|
45
41
|
<div
|
|
@@ -53,9 +49,13 @@
|
|
|
53
49
|
</code>
|
|
54
50
|
{#if showCopyButton}
|
|
55
51
|
<div class="snippet-copy">
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
<!-- The label is only supplied while the button shows an icon and has no
|
|
53
|
+
visible text of its own. Keeping it once the label swaps to
|
|
54
|
+
"Copied!" would leave a screen reader announcing "Copy to clipboard"
|
|
55
|
+
over the visible feedback (WCAG 2.5.3, Label in Name). -->
|
|
56
|
+
<Button onclick={handleCopy} {...copyState.copied ? {} : { ariaLabel: 'Copy to clipboard' }}>
|
|
57
|
+
{#if copyState.copied}
|
|
58
|
+
<span class="snippet-copied">{copiedLabel}</span>
|
|
59
59
|
{:else if typeof copyIcon === 'function'}
|
|
60
60
|
{@render copyIcon()}
|
|
61
61
|
{:else}
|
|
@@ -64,6 +64,13 @@
|
|
|
64
64
|
{/if}
|
|
65
65
|
</Button>
|
|
66
66
|
</div>
|
|
67
|
+
<!-- A live region has to be in the DOM *before* its text changes, or many
|
|
68
|
+
screen-reader/browser pairs never announce it. The visible label is
|
|
69
|
+
swapped inside the button, so the announcement lives here instead:
|
|
70
|
+
always rendered, outside the button, with only its text changing. -->
|
|
71
|
+
<span class="snippet-status" role="status" aria-live="polite"
|
|
72
|
+
>{copyState.copied ? copiedLabel : ''}</span
|
|
73
|
+
>
|
|
67
74
|
{/if}
|
|
68
75
|
</div>
|
|
69
76
|
|
|
@@ -132,6 +139,20 @@
|
|
|
132
139
|
height: 100%;
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
/* Announced, never shown: the visible "Copied!" is the label inside the
|
|
143
|
+
button, and repeating it on screen would read as a duplicate. */
|
|
144
|
+
.snippet-status {
|
|
145
|
+
position: absolute;
|
|
146
|
+
width: 1px;
|
|
147
|
+
height: 1px;
|
|
148
|
+
margin: -1px;
|
|
149
|
+
padding: 0;
|
|
150
|
+
overflow: hidden;
|
|
151
|
+
clip-path: inset(50%);
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
border: 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
135
156
|
.snippet-copied {
|
|
136
157
|
color: var(--snippet-copied-color, #4caf50);
|
|
137
158
|
font-size: var(--snippet-copied-font-size, 12px);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The copy-and-flash affordance behind `Snippet`, without its presentation.
|
|
3
|
+
*
|
|
4
|
+
* `Snippet` couples two separable things: this state machine — clipboard
|
|
5
|
+
* write, `copied` flag, reset timer, teardown — and a `<code>` box with a
|
|
6
|
+
* `$`-style prompt. A consumer who wants "copy this value" beside arbitrary
|
|
7
|
+
* content (a hostname in a settings row, a token, a URL) could take only the
|
|
8
|
+
* pair, so the affordance got hand-rolled outside the library instead. That
|
|
9
|
+
* is how the timer safety added in #530 failed to reach the surfaces most
|
|
10
|
+
* likely to need it: a duplicate has its own bare `setTimeout` and its own
|
|
11
|
+
* bugs.
|
|
12
|
+
*
|
|
13
|
+
* Extracted as a headless factory rather than a Svelte action because the
|
|
14
|
+
* caller has to *render* from `copied` — swapping a label for an icon — and
|
|
15
|
+
* an action can attach behaviour to an element but cannot hand state back to
|
|
16
|
+
* the template. The split follows `Tooltip/tooltip-action.ts`, which likewise
|
|
17
|
+
* keeps timing logic outside the component that uses it.
|
|
18
|
+
*
|
|
19
|
+
* Options are read at copy time, not captured at creation, so a caller with
|
|
20
|
+
* reactive values can pass getters:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const state = createCopyState({
|
|
24
|
+
* get copyResetMs() { return copyResetMs; },
|
|
25
|
+
* get oncopy() { return oncopy; }
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export type CopyStateOptions = {
|
|
30
|
+
/** Milliseconds before `copied` reverts. Defaults to `2000`. */
|
|
31
|
+
copyResetMs?: number;
|
|
32
|
+
/** Called once per successful copy. Never called when the write fails. */
|
|
33
|
+
oncopy?: () => void;
|
|
34
|
+
};
|
|
35
|
+
export type CopyState = {
|
|
36
|
+
/** True from a successful copy until the reset delay elapses. */
|
|
37
|
+
readonly copied: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Writes `text` to the clipboard. Resolves `true` on success, `false` if
|
|
40
|
+
* the clipboard was unavailable or refused — it never rejects, so a caller
|
|
41
|
+
* needs no try/catch of its own.
|
|
42
|
+
*/
|
|
43
|
+
copy: (text: string) => Promise<boolean>;
|
|
44
|
+
/** Cancels any pending reset. Call from the owner's teardown. */
|
|
45
|
+
destroy: () => void;
|
|
46
|
+
};
|
|
47
|
+
export declare function createCopyState(options?: CopyStateOptions): CopyState;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createCopyResetTimer } from './copyResetTimer';
|
|
2
|
+
const DEFAULT_COPY_RESET_MS = 2000;
|
|
3
|
+
/**
|
|
4
|
+
* The type says `number`, but a plain-JS or web-component caller can pass
|
|
5
|
+
* anything, and a getter can throw. Either would reach `setTimeout` as `NaN`
|
|
6
|
+
* — which fires immediately, so the feedback never appears — or leave the
|
|
7
|
+
* flag stuck on with no timer at all. An unusable delay must degrade to the
|
|
8
|
+
* default rather than break the affordance it controls.
|
|
9
|
+
*/
|
|
10
|
+
function resolveResetDelay(options) {
|
|
11
|
+
let requested;
|
|
12
|
+
try {
|
|
13
|
+
requested = options.copyResetMs;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return DEFAULT_COPY_RESET_MS;
|
|
17
|
+
}
|
|
18
|
+
if (typeof requested !== 'number' || !Number.isFinite(requested) || requested < 0) {
|
|
19
|
+
return DEFAULT_COPY_RESET_MS;
|
|
20
|
+
}
|
|
21
|
+
return requested;
|
|
22
|
+
}
|
|
23
|
+
export function createCopyState(options = {}) {
|
|
24
|
+
let copied = $state(false);
|
|
25
|
+
let destroyed = false;
|
|
26
|
+
const resetTimer = createCopyResetTimer();
|
|
27
|
+
return {
|
|
28
|
+
get copied() {
|
|
29
|
+
return copied;
|
|
30
|
+
},
|
|
31
|
+
async copy(text) {
|
|
32
|
+
if (destroyed) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
await navigator.clipboard.writeText(text);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* Clipboard unavailable: non-secure context, sandboxed iframe, or a
|
|
40
|
+
permission refusal. Reporting success here — and flashing "Copied!"
|
|
41
|
+
over a clipboard that never received the text — is the one outcome
|
|
42
|
+
worse than showing no feedback at all. */
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
// The clipboard promise can settle after its owner has unmounted.
|
|
46
|
+
if (destroyed) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
// Resolved before the flag is set, so a throwing getter cannot leave
|
|
50
|
+
// `copied` true with nothing armed to clear it.
|
|
51
|
+
const delay = resolveResetDelay(options);
|
|
52
|
+
copied = true;
|
|
53
|
+
resetTimer.arm(() => {
|
|
54
|
+
copied = false;
|
|
55
|
+
}, delay);
|
|
56
|
+
try {
|
|
57
|
+
options.oncopy?.();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// A notification callback cannot undo the successful clipboard write.
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
64
|
+
destroy() {
|
|
65
|
+
destroyed = true;
|
|
66
|
+
resetTimer.cancel();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -102,6 +102,7 @@ export { default as Gallery } from './Gallery/Gallery.svelte';
|
|
|
102
102
|
export { ChatController } from './Chat/controller.svelte';
|
|
103
103
|
export { partyOf } from './Chat/roles';
|
|
104
104
|
export { SpeechToTextController } from './SpeechToText/controller.svelte';
|
|
105
|
+
export { createCopyState } from './Snippet/copyState.svelte';
|
|
105
106
|
export type * from './Button/properties';
|
|
106
107
|
export type * from './Modal/properties';
|
|
107
108
|
export type * from './Input/properties';
|
|
@@ -139,6 +140,7 @@ export type * from './Progress/properties';
|
|
|
139
140
|
export type * from './Pill/properties';
|
|
140
141
|
export type * from './Pagination/properties';
|
|
141
142
|
export type * from './Snippet/properties';
|
|
143
|
+
export type * from './Snippet/copyState.svelte';
|
|
142
144
|
export type * from './Gauge/properties';
|
|
143
145
|
export type * from './SplitButton/properties';
|
|
144
146
|
export type * from './KeyboardInput/properties';
|
package/dist/index.js
CHANGED
|
@@ -102,6 +102,7 @@ export { default as Gallery } from './Gallery/Gallery.svelte';
|
|
|
102
102
|
export { ChatController } from './Chat/controller.svelte';
|
|
103
103
|
export { partyOf } from './Chat/roles';
|
|
104
104
|
export { SpeechToTextController } from './SpeechToText/controller.svelte';
|
|
105
|
+
export { createCopyState } from './Snippet/copyState.svelte';
|
|
105
106
|
export { createSoundKit } from './SoundKit/SoundKit';
|
|
106
107
|
export { validateInput, lockBodyScroll, unlockBodyScroll } from './utils';
|
|
107
108
|
export { formatNumberIndian } from './_chart/format';
|