@particle-academy/react-fancy 5.65.0 → 5.67.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/CHANGELOG.md +74 -0
- package/dist/{PastePills.types-CDyotkUb.d.cts → PastePills.types-CpXzsuwJ.d.cts} +2 -0
- package/dist/{PastePills.types-CDyotkUb.d.ts → PastePills.types-CpXzsuwJ.d.ts} +2 -0
- package/dist/{PastedFilesInput-CrgNoEBd.d.ts → PastedFilesInput-Bt3KSTVN.d.ts} +1 -1
- package/dist/{PastedFilesInput-DQ1eZaHk.d.cts → PastedFilesInput-CUQeOeOA.d.cts} +1 -1
- package/dist/button.cjs +3 -4
- package/dist/button.js +1 -2
- package/dist/{chunk-COXHFZR6.js → chunk-23AVEYUP.js} +14 -8
- package/dist/chunk-23AVEYUP.js.map +1 -0
- package/dist/{chunk-C5YX5XVL.cjs → chunk-3U7PUZLI.cjs} +214 -4
- package/dist/chunk-3U7PUZLI.cjs.map +1 -0
- package/dist/{chunk-ATIDUWY6.cjs → chunk-4S5DFN5N.cjs} +12 -12
- package/dist/{chunk-ATIDUWY6.cjs.map → chunk-4S5DFN5N.cjs.map} +1 -1
- package/dist/{chunk-CMC2TOEQ.cjs → chunk-6CAXLP7D.cjs} +6 -6
- package/dist/{chunk-CMC2TOEQ.cjs.map → chunk-6CAXLP7D.cjs.map} +1 -1
- package/dist/{chunk-TQGRXJ74.cjs → chunk-7FYRFNPX.cjs} +9 -9
- package/dist/{chunk-TQGRXJ74.cjs.map → chunk-7FYRFNPX.cjs.map} +1 -1
- package/dist/{chunk-MVVHUTXA.js → chunk-B4LVQ4QI.js} +3 -3
- package/dist/{chunk-MVVHUTXA.js.map → chunk-B4LVQ4QI.js.map} +1 -1
- package/dist/{chunk-QP35Z2WB.js → chunk-BL5IKSB3.js} +3 -3
- package/dist/{chunk-QP35Z2WB.js.map → chunk-BL5IKSB3.js.map} +1 -1
- package/dist/{chunk-SZH7TFME.js → chunk-DCAD2JPP.js} +4 -4
- package/dist/{chunk-SZH7TFME.js.map → chunk-DCAD2JPP.js.map} +1 -1
- package/dist/{chunk-MCQJFMDP.cjs → chunk-E5UU666U.cjs} +478 -36
- package/dist/chunk-E5UU666U.cjs.map +1 -0
- package/dist/{chunk-L24LUSEE.js → chunk-FXOT4P4P.js} +478 -36
- package/dist/chunk-FXOT4P4P.js.map +1 -0
- package/dist/{chunk-CAUEKGMR.js → chunk-HEY4SMS2.js} +4 -4
- package/dist/{chunk-CAUEKGMR.js.map → chunk-HEY4SMS2.js.map} +1 -1
- package/dist/{chunk-QNQNGARW.cjs → chunk-HFK5SBNM.cjs} +27 -9
- package/dist/chunk-HFK5SBNM.cjs.map +1 -0
- package/dist/{chunk-TLRRMYNV.js → chunk-OXERDFFF.js} +212 -4
- package/dist/chunk-OXERDFFF.js.map +1 -0
- package/dist/{chunk-ELFTEJBT.js → chunk-QSGAFKK2.js} +3 -3
- package/dist/{chunk-ELFTEJBT.js.map → chunk-QSGAFKK2.js.map} +1 -1
- package/dist/{chunk-SYEHWUM5.cjs → chunk-RALJMI7C.cjs} +4 -4
- package/dist/{chunk-SYEHWUM5.cjs.map → chunk-RALJMI7C.cjs.map} +1 -1
- package/dist/{chunk-UM4JXBRZ.cjs → chunk-S3NB6LDV.cjs} +4 -4
- package/dist/{chunk-UM4JXBRZ.cjs.map → chunk-S3NB6LDV.cjs.map} +1 -1
- package/dist/{chunk-C463C4MM.cjs → chunk-SAG6DHQK.cjs} +22 -16
- package/dist/chunk-SAG6DHQK.cjs.map +1 -0
- package/dist/{chunk-4XHG6Q42.cjs → chunk-U5LKNGHU.cjs} +21 -21
- package/dist/{chunk-4XHG6Q42.cjs.map → chunk-U5LKNGHU.cjs.map} +1 -1
- package/dist/{chunk-M52S3TFI.js → chunk-WFR7HKJL.js} +3 -3
- package/dist/{chunk-M52S3TFI.js.map → chunk-WFR7HKJL.js.map} +1 -1
- package/dist/{chunk-NILA2LZY.js → chunk-YIKXJWO4.js} +27 -9
- package/dist/chunk-YIKXJWO4.js.map +1 -0
- package/dist/composer.cjs +4 -4
- package/dist/composer.d.cts +2 -2
- package/dist/composer.d.ts +2 -2
- package/dist/composer.js +2 -2
- package/dist/content-renderer.cjs +7 -5
- package/dist/content-renderer.d.cts +56 -4
- package/dist/content-renderer.d.ts +56 -4
- package/dist/content-renderer.js +4 -2
- package/dist/editor.cjs +6 -5
- package/dist/editor.d.cts +1 -1
- package/dist/editor.d.ts +1 -1
- package/dist/editor.js +4 -3
- package/dist/{extensions-6PVd3-vB.d.cts → extensions-DiByZIpD.d.cts} +26 -3
- package/dist/{extensions-6PVd3-vB.d.ts → extensions-DiByZIpD.d.ts} +26 -3
- package/dist/{index-CCJVT9Hu.d.cts → index-DWtSViJ3.d.cts} +7 -1
- package/dist/{index-DRlltbdV.d.ts → index-XR5t8bTq.d.ts} +7 -1
- package/dist/index.cjs +47 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +10 -11
- package/dist/index.js.map +1 -1
- package/dist/input.cjs +3 -3
- package/dist/input.d.cts +1 -1
- package/dist/input.d.ts +1 -1
- package/dist/input.js +2 -2
- package/dist/inputs.cjs +3 -3
- package/dist/inputs.d.cts +1 -1
- package/dist/inputs.d.ts +1 -1
- package/dist/inputs.js +2 -2
- package/dist/json-editor.cjs +15 -16
- package/dist/json-editor.js +4 -5
- package/dist/magic-wand.cjs +3 -4
- package/dist/magic-wand.js +2 -3
- package/dist/paste-pills.cjs +14 -14
- package/dist/paste-pills.d.cts +3 -3
- package/dist/paste-pills.d.ts +3 -3
- package/dist/paste-pills.js +1 -1
- package/dist/prompt-input.cjs +6 -7
- package/dist/prompt-input.d.cts +2 -2
- package/dist/prompt-input.d.ts +2 -2
- package/dist/prompt-input.js +3 -4
- package/dist/reason-tag.cjs +3 -4
- package/dist/reason-tag.js +2 -3
- package/docs/ContentRenderer.md +138 -3
- package/docs/PromptInput.md +24 -1
- package/package.json +1 -1
- package/dist/chunk-6TBRBN7H.js +0 -212
- package/dist/chunk-6TBRBN7H.js.map +0 -1
- package/dist/chunk-C463C4MM.cjs.map +0 -1
- package/dist/chunk-C5YX5XVL.cjs.map +0 -1
- package/dist/chunk-COXHFZR6.js.map +0 -1
- package/dist/chunk-L24LUSEE.js.map +0 -1
- package/dist/chunk-MCQJFMDP.cjs.map +0 -1
- package/dist/chunk-NILA2LZY.js.map +0 -1
- package/dist/chunk-QNQNGARW.cjs.map +0 -1
- package/dist/chunk-TLRRMYNV.js.map +0 -1
- package/dist/chunk-X2FC7ODX.cjs +0 -215
- package/dist/chunk-X2FC7ODX.cjs.map +0 -1
package/docs/ContentRenderer.md
CHANGED
|
@@ -24,6 +24,98 @@ import { ContentRenderer } from "@particle-academy/react-fancy";
|
|
|
24
24
|
| extensions | `RenderExtension[]` | - | Per-instance render extensions (merged with global) |
|
|
25
25
|
| className | `string` | - | Additional CSS classes |
|
|
26
26
|
| unsafe | `boolean` | `false` | Skip HTML sanitization. See **Sanitization** below. |
|
|
27
|
+
| copyButtons | `boolean \| { code?: boolean; table?: boolean }` | `true` | Copy buttons on code blocks and tables. See **Copy buttons**. |
|
|
28
|
+
| onCopy | `(event: ContentCopyEvent) => void` | - | Called after every copy attempt, successful or not |
|
|
29
|
+
| clipboardWriter | `ContentClipboardWriter` | - | Host-supplied clipboard writer. See **Clipboard writer**. |
|
|
30
|
+
| streaming | `boolean` | `false` | The value is still arriving: hold back an unfinished trailing tag or fence. See **Streaming**. |
|
|
31
|
+
|
|
32
|
+
## Copy buttons
|
|
33
|
+
|
|
34
|
+
Every code block and table gets a react-fancy `Button` (icon `copy`, then
|
|
35
|
+
`check` for two seconds after a copy, `x` after a failure). What is copied:
|
|
36
|
+
|
|
37
|
+
- **Code**: exactly the code between the fences. No language label, no
|
|
38
|
+
trailing newline added. (Marked itself drops trailing blank lines inside a
|
|
39
|
+
fence, so they are not in the copy either; indentation, inner blank lines and
|
|
40
|
+
characters like `<` and `&` are.)
|
|
41
|
+
- **Table**: TSV. A tab between cells, a newline between rows, no trailing
|
|
42
|
+
newline. Tabs and line breaks inside a cell become spaces, so the shape
|
|
43
|
+
survives a paste into a spreadsheet.
|
|
44
|
+
|
|
45
|
+
Each copy is announced as `Copied` or `Copy failed` through a polite
|
|
46
|
+
`aria-live` region (`[data-react-fancy-content-renderer-live]`).
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<ContentRenderer value={reply} copyButtons={false} /> // none
|
|
50
|
+
<ContentRenderer value={reply} copyButtons={{ table: false }} /> // code only
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The button sits beside the block, never inside it, so its own text never ends
|
|
54
|
+
up in the copy. Buttons are added after mount, so server-rendered HTML has none
|
|
55
|
+
until hydration.
|
|
56
|
+
|
|
57
|
+
### Clipboard writer
|
|
58
|
+
|
|
59
|
+
The default writer puts **text/html and text/plain** on the clipboard with a
|
|
60
|
+
`ClipboardItem`, so a table pastes as a table into a rich editor and as cells
|
|
61
|
+
into a spreadsheet. If `ClipboardItem` is missing or that write is refused, it
|
|
62
|
+
falls back to `navigator.clipboard.writeText`.
|
|
63
|
+
|
|
64
|
+
`navigator.clipboard` can fail silently in some hosts (Genie found it does in a
|
|
65
|
+
sandboxed Electron window). Pass your own writer there:
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { clipboard } from "electron";
|
|
69
|
+
|
|
70
|
+
<ContentRenderer
|
|
71
|
+
value={reply}
|
|
72
|
+
clipboardWriter={({ text, html }) => clipboard.write({ text, html })}
|
|
73
|
+
/>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A writer receives `{ kind, text, html }`. **Throwing, rejecting, or returning
|
|
77
|
+
`false` means the copy failed**, and so does a write that has not settled after
|
|
78
|
+
5 seconds. A failure is never silent: the button shows `x`, the live region says
|
|
79
|
+
`Copy failed`, and `onCopy` reports `ok: false`.
|
|
80
|
+
|
|
81
|
+
### Observing copies (Human+)
|
|
82
|
+
|
|
83
|
+
ContentRenderer is a read-only surface: it exposes stable DOM handles rather
|
|
84
|
+
than state. Copy follows that:
|
|
85
|
+
|
|
86
|
+
| Handle | On |
|
|
87
|
+
|--------|----|
|
|
88
|
+
| `data-react-fancy-content-copy="code" \| "table"` | the copy button |
|
|
89
|
+
| `data-copy-state="idle" \| "copied" \| "failed"` | the copy button |
|
|
90
|
+
| `data-react-fancy-content-copy-block="code" \| "table"` | the wrapper around the block |
|
|
91
|
+
| `data-react-fancy-content-renderer-live` | the live region |
|
|
92
|
+
| `data-streaming="true"` | the root, while `streaming` |
|
|
93
|
+
|
|
94
|
+
`onCopy` receives `{ kind, index, text, html, ok }`, where `index` is the
|
|
95
|
+
button's position among this renderer's copy buttons in document order. Wire it
|
|
96
|
+
to your activity feed if agents should see copies.
|
|
97
|
+
|
|
98
|
+
## Streaming
|
|
99
|
+
|
|
100
|
+
While a reply is still arriving, pass `streaming`:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<ContentRenderer value={partial} streaming={!done} extensions={[resultTag]} />
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Held back until it is complete:
|
|
107
|
+
|
|
108
|
+
- an opened tag of a **registered extension** with no closing tag yet, from its
|
|
109
|
+
opening tag onwards (`<result>` + half a body, or `<result>body</res`);
|
|
110
|
+
- a partial tag at the very end that could still become one: `<`, `</`,
|
|
111
|
+
`<res`, `<result title="Do`;
|
|
112
|
+
- an open code fence, from its fence line, and a fence marker still arriving
|
|
113
|
+
(` `` `).
|
|
114
|
+
|
|
115
|
+
A `<` that cannot become a registered tag is prose and always renders
|
|
116
|
+
(`a < b`, `a<b`, `3 <= 4`), as do unknown tags and anything in inline code.
|
|
117
|
+
A lone `<` at the very end is held for one chunk, because it may be the start of
|
|
118
|
+
a tag. When `streaming` goes back to `false`, everything renders.
|
|
27
119
|
|
|
28
120
|
## Sanitization
|
|
29
121
|
|
|
@@ -45,7 +137,7 @@ The helpers are also exported for use elsewhere:
|
|
|
45
137
|
import { sanitizeHtml, sanitizeHref } from "@particle-academy/react-fancy";
|
|
46
138
|
```
|
|
47
139
|
|
|
48
|
-
> **Render extensions still need to sanitize their own input.** The `content` prop passed to your extension component is the raw text inside the matched tag — treat it as untrusted and call `sanitizeHtml(content)` if you render it as HTML.
|
|
140
|
+
> **Render extensions still need to sanitize their own input.** The `content` prop passed to your extension component is the raw text inside the matched tag — treat it as untrusted and call `sanitizeHtml(content)` if you render it as HTML. A `markdown` extension gets an already-sanitised `children` instead; use that.
|
|
49
141
|
|
|
50
142
|
## Render Extensions
|
|
51
143
|
|
|
@@ -78,13 +170,56 @@ registerExtension({
|
|
|
78
170
|
| tag | `string` | - | Tag name to match (case-insensitive) |
|
|
79
171
|
| component | `ComponentType<RenderExtensionProps>` | - | React component to render the tag |
|
|
80
172
|
| block | `boolean` | `true` | Block-level (`<div>`) vs inline (`<span>`) wrapping |
|
|
173
|
+
| markdown | `boolean` | `false` | The body is markdown. See **Tags with a markdown body**. |
|
|
81
174
|
|
|
82
175
|
### RenderExtensionProps
|
|
83
176
|
|
|
84
177
|
| Prop | Type | Description |
|
|
85
178
|
|------|------|-------------|
|
|
86
|
-
| content | `string` | Inner content of the custom tag |
|
|
87
|
-
| attributes | `Record<string, string>` | Parsed
|
|
179
|
+
| content | `string` | Inner content of the custom tag. Raw markdown for a `markdown` extension in markdown content. Untrusted. |
|
|
180
|
+
| attributes | `Record<string, string>` | Parsed attributes from the opening tag, with `on*`, `srcdoc` and unsafe URLs removed |
|
|
181
|
+
| children | `ReactNode` | `markdown` extensions only: the body, rendered and sanitised |
|
|
182
|
+
|
|
183
|
+
### Tags with a markdown body
|
|
184
|
+
|
|
185
|
+
Custom tags are normally found after the markdown has become HTML, and a
|
|
186
|
+
CommonMark HTML block keeps the markdown inside it as literal text, so
|
|
187
|
+
`<result>**done**</result>` would show its asterisks. Mark the extension
|
|
188
|
+
`markdown: true` and the tag is lifted out of the markdown **source** first:
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
const resultTag: RenderExtension = {
|
|
192
|
+
tag: "result",
|
|
193
|
+
markdown: true,
|
|
194
|
+
component: ({ attributes, children }) => (
|
|
195
|
+
<Card>
|
|
196
|
+
<Card.Header>{attributes.title}</Card.Header>
|
|
197
|
+
<Card.Body>{children}</Card.Body>
|
|
198
|
+
</Card>
|
|
199
|
+
),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
<ContentRenderer
|
|
203
|
+
value={'Here it is:\n\n<result title="Build">\n**Passed**, `12/12`\n\n- lint\n- test\n</result>'}
|
|
204
|
+
extensions={[resultTag]}
|
|
205
|
+
/>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- `content` is the raw markdown body; `children` is that body rendered through
|
|
209
|
+
the same pipeline as everything else: sanitised, with nested extensions and
|
|
210
|
+
copy buttons. **Render `children`, not `content`.**
|
|
211
|
+
- A tag is lifted when its opening tag starts a line and it has a matching
|
|
212
|
+
closing tag. Same-name nesting is balanced, other markdown tags nest inside
|
|
213
|
+
it, and a tag inside a code fence or inline code is left alone.
|
|
214
|
+
- Anywhere else (mid-line, unclosed, or in HTML content) it is matched in the
|
|
215
|
+
HTML as before, and `children` renders that HTML body.
|
|
216
|
+
- Nesting stops at depth 8: deeper bodies render as plain sanitised content.
|
|
217
|
+
|
|
218
|
+
Sanitisation is unchanged by lifting. Every piece of markdown, including each
|
|
219
|
+
body, is converted and then run through `sanitizeHtml` before it is injected,
|
|
220
|
+
and the tag's attributes lose `on*`, `srcdoc` and unsafe URLs before the
|
|
221
|
+
component sees them. Unknown tags stay in the markdown and are sanitised like
|
|
222
|
+
any HTML. `unsafe` turns all of this off, as it always has.
|
|
88
223
|
|
|
89
224
|
## Markdown Rendering
|
|
90
225
|
|
package/docs/PromptInput.md
CHANGED
|
@@ -86,6 +86,29 @@ if (saved) composer.current!.setDraft(JSON.parse(saved));
|
|
|
86
86
|
- `setDraft` does not call `onChange` / `onDraftChange`. You set the draft yourself, so you already have it.
|
|
87
87
|
- A draft with an unknown `v` is refused with a console warning.
|
|
88
88
|
|
|
89
|
+
### Labelled text pills
|
|
90
|
+
|
|
91
|
+
A text pill normally reads `Pasted text · N characters`. Give it a `label` and
|
|
92
|
+
the pill shows that instead. Use this when the host puts the pill there itself,
|
|
93
|
+
for example a plan step sent into the chat:
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
composer.current!.setDraft({
|
|
97
|
+
v: 1,
|
|
98
|
+
text: `Do this next: ${pasteMarker("step-3")}`,
|
|
99
|
+
pills: [{ kind: "text", id: "step-3", text: fullStep, label: "Step 3: migrate the billing tables" }],
|
|
100
|
+
attachments: [],
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- The label is for display only. Sending still expands the pill to the full `text`.
|
|
105
|
+
- Its accessible name is `Insert this pasted text: <label>, N characters`.
|
|
106
|
+
- It is plain text and is never parsed as HTML. Line breaks, control characters
|
|
107
|
+
and U+FFFC become single spaces, and anything past 80 characters is cut with
|
|
108
|
+
`…`. If the label is blank or not a string, the generic text is shown.
|
|
109
|
+
- `label` round-trips through `getDraft`, `setDraft`, `defaultDraft`, `draft`
|
|
110
|
+
and `onDraftChange`.
|
|
111
|
+
|
|
89
112
|
### Attachments: where the bytes go
|
|
90
113
|
|
|
91
114
|
A `File` cannot be stored as JSON, and by default a draft does not carry the
|
|
@@ -122,7 +145,7 @@ type PromptAttachment = { id: string; name: string; bytes: number; file?: File;
|
|
|
122
145
|
|
|
123
146
|
interface PromptDraftFile { id: string; name: string; type: string; bytes: number; ref?: string }
|
|
124
147
|
type PromptDraftPill =
|
|
125
|
-
| { kind: "text"; id: string; text: string }
|
|
148
|
+
| { kind: "text"; id: string; text: string; label?: string }
|
|
126
149
|
| { kind: "image"; id: string; label: string; file: PromptDraftFile };
|
|
127
150
|
interface PromptDraft { v: 1; text: string; pills: PromptDraftPill[]; attachments: PromptDraftFile[] }
|
|
128
151
|
|
package/package.json
CHANGED
package/dist/chunk-6TBRBN7H.js
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
// src/utils/sanitize.ts
|
|
2
|
-
var DANGEROUS_TAGS = /* @__PURE__ */ new Set([
|
|
3
|
-
"script",
|
|
4
|
-
"style",
|
|
5
|
-
"iframe",
|
|
6
|
-
"object",
|
|
7
|
-
"embed",
|
|
8
|
-
"link",
|
|
9
|
-
"meta",
|
|
10
|
-
"base",
|
|
11
|
-
"form",
|
|
12
|
-
// A <title> anywhere in the document retitles the host's tab.
|
|
13
|
-
"title"
|
|
14
|
-
]);
|
|
15
|
-
var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src", "action", "formaction", "xlink:href"]);
|
|
16
|
-
var SAFE_PROTOCOL = /^(?:https?:|mailto:|tel:|sms:|ftp:|#|\/|\.\/|\.\.\/|[^:]*$)/i;
|
|
17
|
-
function sanitizeHref(href) {
|
|
18
|
-
if (href == null) return void 0;
|
|
19
|
-
const trimmed = href.trim();
|
|
20
|
-
if (!trimmed) return void 0;
|
|
21
|
-
return SAFE_PROTOCOL.test(trimmed) ? trimmed : void 0;
|
|
22
|
-
}
|
|
23
|
-
function stripDangerousAttrs(el) {
|
|
24
|
-
const names = [];
|
|
25
|
-
for (let i = 0; i < el.attributes.length; i++) {
|
|
26
|
-
names.push(el.attributes[i].name);
|
|
27
|
-
}
|
|
28
|
-
for (const name of names) {
|
|
29
|
-
const lower = name.toLowerCase();
|
|
30
|
-
if (lower.startsWith("on")) {
|
|
31
|
-
el.removeAttribute(name);
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
if (URL_ATTRS.has(lower)) {
|
|
35
|
-
const sanitized = sanitizeHref(el.getAttribute(name));
|
|
36
|
-
if (sanitized === void 0) {
|
|
37
|
-
el.removeAttribute(name);
|
|
38
|
-
} else {
|
|
39
|
-
el.setAttribute(name, sanitized);
|
|
40
|
-
}
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
if (lower === "srcdoc") {
|
|
44
|
-
el.removeAttribute(name);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function walk(el, removeQueue) {
|
|
49
|
-
const tag = el.tagName.toLowerCase();
|
|
50
|
-
if (DANGEROUS_TAGS.has(tag)) {
|
|
51
|
-
removeQueue.push(el);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
stripDangerousAttrs(el);
|
|
55
|
-
const children = Array.from(el.children);
|
|
56
|
-
for (const child of children) {
|
|
57
|
-
walk(child, removeQueue);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
var ALWAYS_REMOVED_TAGS = /* @__PURE__ */ new Set([
|
|
61
|
-
...DANGEROUS_TAGS,
|
|
62
|
-
"frame",
|
|
63
|
-
"frameset",
|
|
64
|
-
"applet",
|
|
65
|
-
"template",
|
|
66
|
-
"noscript",
|
|
67
|
-
"noembed",
|
|
68
|
-
"noframes",
|
|
69
|
-
"xmp",
|
|
70
|
-
"plaintext",
|
|
71
|
-
// Foreign content: namespace confusion is how mutation XSS gets past
|
|
72
|
-
// sanitisers that re-serialise, as this one does.
|
|
73
|
-
"svg",
|
|
74
|
-
"math"
|
|
75
|
-
]);
|
|
76
|
-
var ALLOWLIST_URL_ATTRS = /* @__PURE__ */ new Set([
|
|
77
|
-
"href",
|
|
78
|
-
"src",
|
|
79
|
-
"action",
|
|
80
|
-
"formaction",
|
|
81
|
-
"cite",
|
|
82
|
-
"poster",
|
|
83
|
-
"background",
|
|
84
|
-
"ping",
|
|
85
|
-
"xlink:href"
|
|
86
|
-
]);
|
|
87
|
-
var NEVER_ALLOWED_ATTRS = /* @__PURE__ */ new Set(["srcdoc", "srcset"]);
|
|
88
|
-
var NEVER_ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["javascript:", "vbscript:"]);
|
|
89
|
-
var DEFAULT_SCHEMES = ["http:", "https:", "mailto:"];
|
|
90
|
-
var SCHEME = /^([a-z][a-z0-9+.-]*:)/i;
|
|
91
|
-
var SLASH = 47;
|
|
92
|
-
var BACKSLASH = 92;
|
|
93
|
-
function lowerAll(list) {
|
|
94
|
-
return (list ?? []).map((s) => s.toLowerCase());
|
|
95
|
-
}
|
|
96
|
-
function toPolicy(options) {
|
|
97
|
-
const tagAttrs = /* @__PURE__ */ new Map();
|
|
98
|
-
let globalAttrs = [];
|
|
99
|
-
const attrs = options.allowedAttributes;
|
|
100
|
-
if (Array.isArray(attrs)) {
|
|
101
|
-
globalAttrs = lowerAll(attrs);
|
|
102
|
-
} else if (attrs) {
|
|
103
|
-
for (const [tag, list] of Object.entries(attrs)) {
|
|
104
|
-
if (tag === "*") globalAttrs = lowerAll(list);
|
|
105
|
-
else tagAttrs.set(tag.toLowerCase(), lowerAll(list));
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return {
|
|
109
|
-
tags: new Set(lowerAll(options.allowedTags)),
|
|
110
|
-
globalAttrs,
|
|
111
|
-
tagAttrs,
|
|
112
|
-
schemes: new Set(lowerAll(options.allowedSchemes ?? DEFAULT_SCHEMES)),
|
|
113
|
-
allowRelative: options.allowRelative === true
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
function attrListed(name, list) {
|
|
117
|
-
if (!list) return false;
|
|
118
|
-
for (const entry of list) {
|
|
119
|
-
if (entry === name) return true;
|
|
120
|
-
if (entry.endsWith("*") && name.startsWith(entry.slice(0, -1))) return true;
|
|
121
|
-
}
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
function stripControlAndSpace(value) {
|
|
125
|
-
let out = "";
|
|
126
|
-
for (let i = 0; i < value.length; i++) {
|
|
127
|
-
if (value.charCodeAt(i) > 32) out += value[i];
|
|
128
|
-
}
|
|
129
|
-
return out;
|
|
130
|
-
}
|
|
131
|
-
function urlAllowed(value, policy) {
|
|
132
|
-
const url = stripControlAndSpace(value);
|
|
133
|
-
const match = SCHEME.exec(url);
|
|
134
|
-
if (match) {
|
|
135
|
-
const scheme = match[1].toLowerCase();
|
|
136
|
-
return !NEVER_ALLOWED_SCHEMES.has(scheme) && policy.schemes.has(scheme);
|
|
137
|
-
}
|
|
138
|
-
if (!policy.allowRelative) return false;
|
|
139
|
-
const a = url.charCodeAt(0);
|
|
140
|
-
const b = url.charCodeAt(1);
|
|
141
|
-
const isSlash = (c) => c === SLASH || c === BACKSLASH;
|
|
142
|
-
return !(isSlash(a) && isSlash(b));
|
|
143
|
-
}
|
|
144
|
-
function cleanAttributes(el, tag, policy) {
|
|
145
|
-
const names = [];
|
|
146
|
-
for (let i = 0; i < el.attributes.length; i++) {
|
|
147
|
-
names.push(el.attributes[i].name);
|
|
148
|
-
}
|
|
149
|
-
for (const name of names) {
|
|
150
|
-
const lower = name.toLowerCase();
|
|
151
|
-
const keep = !lower.startsWith("on") && !NEVER_ALLOWED_ATTRS.has(lower) && (attrListed(lower, policy.globalAttrs) || attrListed(lower, policy.tagAttrs.get(tag))) && (!ALLOWLIST_URL_ATTRS.has(lower) || urlAllowed(el.getAttribute(name) ?? "", policy));
|
|
152
|
-
if (!keep) el.removeAttribute(name);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
function cleanChildren(parent, policy) {
|
|
156
|
-
for (const node of Array.from(parent.childNodes)) {
|
|
157
|
-
if (node.nodeType === 3) continue;
|
|
158
|
-
if (node.nodeType !== 1) {
|
|
159
|
-
parent.removeChild(node);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
const el = node;
|
|
163
|
-
const tag = el.tagName.toLowerCase();
|
|
164
|
-
if (ALWAYS_REMOVED_TAGS.has(tag)) {
|
|
165
|
-
parent.removeChild(el);
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
cleanChildren(el, policy);
|
|
169
|
-
if (!policy.tags.has(tag)) {
|
|
170
|
-
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
171
|
-
parent.removeChild(el);
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
cleanAttributes(el, tag, policy);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
function escapeText(text) {
|
|
178
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
179
|
-
}
|
|
180
|
-
function sanitizeAllowlist(html, options) {
|
|
181
|
-
if (typeof window === "undefined" || typeof DOMParser === "undefined") {
|
|
182
|
-
return escapeText(html);
|
|
183
|
-
}
|
|
184
|
-
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
|
185
|
-
const body = doc.body;
|
|
186
|
-
if (!body) return "";
|
|
187
|
-
cleanChildren(body, toPolicy(options));
|
|
188
|
-
return body.innerHTML;
|
|
189
|
-
}
|
|
190
|
-
function sanitizeHtml(html, options) {
|
|
191
|
-
if (options !== void 0 && options !== null) {
|
|
192
|
-
return sanitizeAllowlist(html, options);
|
|
193
|
-
}
|
|
194
|
-
if (typeof window === "undefined" || typeof DOMParser === "undefined") {
|
|
195
|
-
return html;
|
|
196
|
-
}
|
|
197
|
-
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
|
198
|
-
const body = doc.body;
|
|
199
|
-
if (!body) return html;
|
|
200
|
-
const removeQueue = [];
|
|
201
|
-
for (const child of Array.from(body.children)) {
|
|
202
|
-
walk(child, removeQueue);
|
|
203
|
-
}
|
|
204
|
-
for (const el of removeQueue) {
|
|
205
|
-
el.parentNode?.removeChild(el);
|
|
206
|
-
}
|
|
207
|
-
return body.innerHTML;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export { sanitizeHref, sanitizeHtml };
|
|
211
|
-
//# sourceMappingURL=chunk-6TBRBN7H.js.map
|
|
212
|
-
//# sourceMappingURL=chunk-6TBRBN7H.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/utils/sanitize.ts"],"names":[],"mappings":"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","file":"chunk-6TBRBN7H.js","sourcesContent":["/**\n * HTML and URL sanitization utilities.\n *\n * Hand-rolled (no third-party deps) using browser-native `DOMParser`. Designed\n * for the trust model \"consumer passes user-generated markdown/HTML to a\n * react-fancy component\" — strips script tags, event handlers, and dangerous\n * URI schemes from `href`/`src` attributes.\n *\n * For server-side rendering (no `window`), `sanitizeHtml` returns the input\n * unchanged. Consumers SSR-rendering untrusted content should sanitize on the\n * server with their own pipeline.\n */\n\nconst DANGEROUS_TAGS = new Set([\n \"script\",\n \"style\",\n \"iframe\",\n \"object\",\n \"embed\",\n \"link\",\n \"meta\",\n \"base\",\n \"form\",\n // A <title> anywhere in the document retitles the host's tab.\n \"title\",\n]);\n\nconst URL_ATTRS = new Set([\"href\", \"src\", \"action\", \"formaction\", \"xlink:href\"]);\n\nconst SAFE_PROTOCOL = /^(?:https?:|mailto:|tel:|sms:|ftp:|#|\\/|\\.\\/|\\.\\.\\/|[^:]*$)/i;\n\n/**\n * Validate a URL/href against an allow-list of safe protocols. Returns the\n * input if safe, or `undefined` if it begins with a dangerous scheme like\n * `javascript:`, `data:`, or `vbscript:`. Relative URLs and fragment links\n * are allowed.\n */\nexport function sanitizeHref(href: string | undefined | null): string | undefined {\n if (href == null) return undefined;\n const trimmed = href.trim();\n if (!trimmed) return undefined;\n return SAFE_PROTOCOL.test(trimmed) ? trimmed : undefined;\n}\n\nfunction stripDangerousAttrs(el: Element): void {\n // Collect names first; mutating during iteration is awkward with NamedNodeMap.\n const names: string[] = [];\n for (let i = 0; i < el.attributes.length; i++) {\n names.push(el.attributes[i].name);\n }\n\n for (const name of names) {\n const lower = name.toLowerCase();\n\n // All on* event handlers\n if (lower.startsWith(\"on\")) {\n el.removeAttribute(name);\n continue;\n }\n\n // URL attributes — protocol-filter\n if (URL_ATTRS.has(lower)) {\n const sanitized = sanitizeHref(el.getAttribute(name));\n if (sanitized === undefined) {\n el.removeAttribute(name);\n } else {\n el.setAttribute(name, sanitized);\n }\n continue;\n }\n\n // srcdoc executes JS in the iframe — drop wholesale\n if (lower === \"srcdoc\") {\n el.removeAttribute(name);\n }\n }\n}\n\nfunction walk(el: Element, removeQueue: Element[]): void {\n const tag = el.tagName.toLowerCase();\n\n if (DANGEROUS_TAGS.has(tag)) {\n removeQueue.push(el);\n return;\n }\n\n stripDangerousAttrs(el);\n\n const children = Array.from(el.children);\n for (const child of children) {\n walk(child, removeQueue);\n }\n}\n\n/**\n * Allowlist options for {@link sanitizeHtml}. Passing ANY options object\n * (even `{}`) switches from the default blocklist to allowlist mode, where\n * everything not listed is removed.\n */\nexport interface SanitizeHtmlOptions {\n /**\n * Tags to keep, e.g. `[\"p\", \"a\", \"strong\"]`. A tag that is not listed is\n * removed but its text and (cleaned) children are kept. Default: none, so\n * only text survives.\n *\n * Some tags are removed WITH their content even when listed, because no\n * attribute filtering makes them safe: `script`, `style`, `iframe`, `frame`,\n * `frameset`, `object`, `embed`, `applet`, `link`, `meta`, `base`, `form`,\n * `title`, `template`, `noscript`, `noembed`, `noframes`, `xmp`,\n * `plaintext`, `svg` and `math`.\n */\n allowedTags?: readonly string[];\n /**\n * Attributes to keep. Either one list for every allowed tag, or a map from\n * tag name to its list, where the key `\"*\"` applies to every tag. A trailing\n * `*` matches a prefix (`\"data-*\"`, `\"aria-*\"`). Default: none.\n *\n * Never kept, even when listed: event handlers (`on*`), `srcdoc` and\n * `srcset`.\n */\n allowedAttributes?: readonly string[] | Readonly<Record<string, readonly string[]>>;\n /**\n * URL schemes allowed in URL attributes (`href`, `src`, `action`,\n * `formaction`, `cite`, `poster`, `background`, `ping`, `xlink:href`), with\n * the colon: `[\"https:\", \"mailto:\"]`. A URL attribute whose scheme is not\n * listed is removed. Default: `[\"http:\", \"https:\", \"mailto:\"]`.\n *\n * `javascript:` and `vbscript:` are never kept, even when listed.\n */\n allowedSchemes?: readonly string[];\n /**\n * Keep URLs with no scheme that stay on this site: `/x`, `x/y`, `../x`,\n * `?q`, `#frag`. Protocol-relative URLs (`//host/x`, and the backslash\n * spellings a browser treats the same way) leave the site and are always\n * removed. Default: `false`.\n */\n allowRelative?: boolean;\n}\n\n/** Removed with their content in allowlist mode, whatever the options say. */\nconst ALWAYS_REMOVED_TAGS = new Set([\n ...DANGEROUS_TAGS,\n \"frame\",\n \"frameset\",\n \"applet\",\n \"template\",\n \"noscript\",\n \"noembed\",\n \"noframes\",\n \"xmp\",\n \"plaintext\",\n // Foreign content: namespace confusion is how mutation XSS gets past\n // sanitisers that re-serialise, as this one does.\n \"svg\",\n \"math\",\n]);\n\nconst ALLOWLIST_URL_ATTRS = new Set([\n \"href\",\n \"src\",\n \"action\",\n \"formaction\",\n \"cite\",\n \"poster\",\n \"background\",\n \"ping\",\n \"xlink:href\",\n]);\n\nconst NEVER_ALLOWED_ATTRS = new Set([\"srcdoc\", \"srcset\"]);\n\nconst NEVER_ALLOWED_SCHEMES = new Set([\"javascript:\", \"vbscript:\"]);\n\nconst DEFAULT_SCHEMES = [\"http:\", \"https:\", \"mailto:\"];\n\nconst SCHEME = /^([a-z][a-z0-9+.-]*:)/i;\n\nconst SLASH = 47;\nconst BACKSLASH = 92;\n\ninterface Policy {\n tags: Set<string>;\n globalAttrs: string[];\n tagAttrs: Map<string, string[]>;\n schemes: Set<string>;\n allowRelative: boolean;\n}\n\nfunction lowerAll(list: readonly string[] | undefined): string[] {\n return (list ?? []).map((s) => s.toLowerCase());\n}\n\nfunction toPolicy(options: SanitizeHtmlOptions): Policy {\n const tagAttrs = new Map<string, string[]>();\n let globalAttrs: string[] = [];\n const attrs = options.allowedAttributes;\n if (Array.isArray(attrs)) {\n globalAttrs = lowerAll(attrs);\n } else if (attrs) {\n for (const [tag, list] of Object.entries(attrs as Record<string, readonly string[]>)) {\n if (tag === \"*\") globalAttrs = lowerAll(list);\n else tagAttrs.set(tag.toLowerCase(), lowerAll(list));\n }\n }\n return {\n tags: new Set(lowerAll(options.allowedTags)),\n globalAttrs,\n tagAttrs,\n schemes: new Set(lowerAll(options.allowedSchemes ?? DEFAULT_SCHEMES)),\n allowRelative: options.allowRelative === true,\n };\n}\n\nfunction attrListed(name: string, list: string[] | undefined): boolean {\n if (!list) return false;\n for (const entry of list) {\n if (entry === name) return true;\n if (entry.endsWith(\"*\") && name.startsWith(entry.slice(0, -1))) return true;\n }\n return false;\n}\n\n/**\n * Browsers ignore ASCII whitespace and control characters inside a URL's\n * scheme (`java<TAB>script:` runs), so judge the URL with them removed.\n */\nfunction stripControlAndSpace(value: string): string {\n let out = \"\";\n for (let i = 0; i < value.length; i++) {\n if (value.charCodeAt(i) > 32) out += value[i];\n }\n return out;\n}\n\nfunction urlAllowed(value: string, policy: Policy): boolean {\n const url = stripControlAndSpace(value);\n const match = SCHEME.exec(url);\n if (match) {\n const scheme = match[1].toLowerCase();\n return !NEVER_ALLOWED_SCHEMES.has(scheme) && policy.schemes.has(scheme);\n }\n if (!policy.allowRelative) return false;\n const a = url.charCodeAt(0);\n const b = url.charCodeAt(1);\n const isSlash = (c: number) => c === SLASH || c === BACKSLASH;\n // `//host`, and its backslash spellings, is another site.\n return !(isSlash(a) && isSlash(b));\n}\n\nfunction cleanAttributes(el: Element, tag: string, policy: Policy): void {\n const names: string[] = [];\n for (let i = 0; i < el.attributes.length; i++) {\n names.push(el.attributes[i].name);\n }\n for (const name of names) {\n const lower = name.toLowerCase();\n const keep =\n !lower.startsWith(\"on\") &&\n !NEVER_ALLOWED_ATTRS.has(lower) &&\n (attrListed(lower, policy.globalAttrs) || attrListed(lower, policy.tagAttrs.get(tag))) &&\n (!ALLOWLIST_URL_ATTRS.has(lower) || urlAllowed(el.getAttribute(name) ?? \"\", policy));\n if (!keep) el.removeAttribute(name);\n }\n}\n\nfunction cleanChildren(parent: Node, policy: Policy): void {\n for (const node of Array.from(parent.childNodes)) {\n if (node.nodeType === 3) continue; // text\n if (node.nodeType !== 1) {\n // comments, processing instructions, CDATA\n parent.removeChild(node);\n continue;\n }\n const el = node as Element;\n const tag = el.tagName.toLowerCase();\n if (ALWAYS_REMOVED_TAGS.has(tag)) {\n parent.removeChild(el);\n continue;\n }\n cleanChildren(el, policy);\n if (!policy.tags.has(tag)) {\n while (el.firstChild) parent.insertBefore(el.firstChild, el);\n parent.removeChild(el);\n continue;\n }\n cleanAttributes(el, tag, policy);\n }\n}\n\nfunction escapeText(text: string): string {\n return text\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\nfunction sanitizeAllowlist(html: string, options: SanitizeHtmlOptions): string {\n if (typeof window === \"undefined\" || typeof DOMParser === \"undefined\") {\n // Fail closed: with no parser we cannot tell markup from text, so the\n // whole input is returned as escaped text rather than passed through.\n return escapeText(html);\n }\n const doc = new DOMParser().parseFromString(`<body>${html}</body>`, \"text/html\");\n const body = doc.body;\n if (!body) return \"\";\n cleanChildren(body, toPolicy(options));\n return body.innerHTML;\n}\n\n/**\n * Sanitize an HTML string.\n *\n * **With no options** (the default blocklist): removes dangerous tags (script,\n * iframe, etc.), event-handler attributes (`onclick`, `onerror`, ...), and\n * dangerous URI schemes in `href`/`src` attributes. Everything else is kept.\n * In non-browser environments returns the input unchanged — sanitize on the\n * server in those cases.\n *\n * **With options** (allowlist mode): keeps ONLY the listed tags, attributes and\n * URL schemes; see {@link SanitizeHtmlOptions}. The allowlist can only narrow:\n * event handlers, `javascript:` URLs and script-capable tags never survive,\n * even when listed. In non-browser environments the input is returned as\n * escaped text (fail closed).\n *\n * Uses the browser's `DOMParser`.\n */\nexport function sanitizeHtml(html: string, options?: SanitizeHtmlOptions): string {\n if (options !== undefined && options !== null) {\n return sanitizeAllowlist(html, options);\n }\n if (typeof window === \"undefined\" || typeof DOMParser === \"undefined\") {\n return html;\n }\n\n const doc = new DOMParser().parseFromString(`<body>${html}</body>`, \"text/html\");\n const body = doc.body;\n if (!body) return html;\n\n const removeQueue: Element[] = [];\n for (const child of Array.from(body.children)) {\n walk(child, removeQueue);\n }\n\n for (const el of removeQueue) {\n el.parentNode?.removeChild(el);\n }\n\n return body.innerHTML;\n}\n"]}
|