@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/{PastePills.types-CDyotkUb.d.cts → PastePills.types-CpXzsuwJ.d.cts} +2 -0
  3. package/dist/{PastePills.types-CDyotkUb.d.ts → PastePills.types-CpXzsuwJ.d.ts} +2 -0
  4. package/dist/{PastedFilesInput-CrgNoEBd.d.ts → PastedFilesInput-Bt3KSTVN.d.ts} +1 -1
  5. package/dist/{PastedFilesInput-DQ1eZaHk.d.cts → PastedFilesInput-CUQeOeOA.d.cts} +1 -1
  6. package/dist/button.cjs +3 -4
  7. package/dist/button.js +1 -2
  8. package/dist/{chunk-COXHFZR6.js → chunk-23AVEYUP.js} +14 -8
  9. package/dist/chunk-23AVEYUP.js.map +1 -0
  10. package/dist/{chunk-C5YX5XVL.cjs → chunk-3U7PUZLI.cjs} +214 -4
  11. package/dist/chunk-3U7PUZLI.cjs.map +1 -0
  12. package/dist/{chunk-ATIDUWY6.cjs → chunk-4S5DFN5N.cjs} +12 -12
  13. package/dist/{chunk-ATIDUWY6.cjs.map → chunk-4S5DFN5N.cjs.map} +1 -1
  14. package/dist/{chunk-CMC2TOEQ.cjs → chunk-6CAXLP7D.cjs} +6 -6
  15. package/dist/{chunk-CMC2TOEQ.cjs.map → chunk-6CAXLP7D.cjs.map} +1 -1
  16. package/dist/{chunk-TQGRXJ74.cjs → chunk-7FYRFNPX.cjs} +9 -9
  17. package/dist/{chunk-TQGRXJ74.cjs.map → chunk-7FYRFNPX.cjs.map} +1 -1
  18. package/dist/{chunk-MVVHUTXA.js → chunk-B4LVQ4QI.js} +3 -3
  19. package/dist/{chunk-MVVHUTXA.js.map → chunk-B4LVQ4QI.js.map} +1 -1
  20. package/dist/{chunk-QP35Z2WB.js → chunk-BL5IKSB3.js} +3 -3
  21. package/dist/{chunk-QP35Z2WB.js.map → chunk-BL5IKSB3.js.map} +1 -1
  22. package/dist/{chunk-SZH7TFME.js → chunk-DCAD2JPP.js} +4 -4
  23. package/dist/{chunk-SZH7TFME.js.map → chunk-DCAD2JPP.js.map} +1 -1
  24. package/dist/{chunk-MCQJFMDP.cjs → chunk-E5UU666U.cjs} +478 -36
  25. package/dist/chunk-E5UU666U.cjs.map +1 -0
  26. package/dist/{chunk-L24LUSEE.js → chunk-FXOT4P4P.js} +478 -36
  27. package/dist/chunk-FXOT4P4P.js.map +1 -0
  28. package/dist/{chunk-CAUEKGMR.js → chunk-HEY4SMS2.js} +4 -4
  29. package/dist/{chunk-CAUEKGMR.js.map → chunk-HEY4SMS2.js.map} +1 -1
  30. package/dist/{chunk-QNQNGARW.cjs → chunk-HFK5SBNM.cjs} +27 -9
  31. package/dist/chunk-HFK5SBNM.cjs.map +1 -0
  32. package/dist/{chunk-TLRRMYNV.js → chunk-OXERDFFF.js} +212 -4
  33. package/dist/chunk-OXERDFFF.js.map +1 -0
  34. package/dist/{chunk-ELFTEJBT.js → chunk-QSGAFKK2.js} +3 -3
  35. package/dist/{chunk-ELFTEJBT.js.map → chunk-QSGAFKK2.js.map} +1 -1
  36. package/dist/{chunk-SYEHWUM5.cjs → chunk-RALJMI7C.cjs} +4 -4
  37. package/dist/{chunk-SYEHWUM5.cjs.map → chunk-RALJMI7C.cjs.map} +1 -1
  38. package/dist/{chunk-UM4JXBRZ.cjs → chunk-S3NB6LDV.cjs} +4 -4
  39. package/dist/{chunk-UM4JXBRZ.cjs.map → chunk-S3NB6LDV.cjs.map} +1 -1
  40. package/dist/{chunk-C463C4MM.cjs → chunk-SAG6DHQK.cjs} +22 -16
  41. package/dist/chunk-SAG6DHQK.cjs.map +1 -0
  42. package/dist/{chunk-4XHG6Q42.cjs → chunk-U5LKNGHU.cjs} +21 -21
  43. package/dist/{chunk-4XHG6Q42.cjs.map → chunk-U5LKNGHU.cjs.map} +1 -1
  44. package/dist/{chunk-M52S3TFI.js → chunk-WFR7HKJL.js} +3 -3
  45. package/dist/{chunk-M52S3TFI.js.map → chunk-WFR7HKJL.js.map} +1 -1
  46. package/dist/{chunk-NILA2LZY.js → chunk-YIKXJWO4.js} +27 -9
  47. package/dist/chunk-YIKXJWO4.js.map +1 -0
  48. package/dist/composer.cjs +4 -4
  49. package/dist/composer.d.cts +2 -2
  50. package/dist/composer.d.ts +2 -2
  51. package/dist/composer.js +2 -2
  52. package/dist/content-renderer.cjs +7 -5
  53. package/dist/content-renderer.d.cts +56 -4
  54. package/dist/content-renderer.d.ts +56 -4
  55. package/dist/content-renderer.js +4 -2
  56. package/dist/editor.cjs +6 -5
  57. package/dist/editor.d.cts +1 -1
  58. package/dist/editor.d.ts +1 -1
  59. package/dist/editor.js +4 -3
  60. package/dist/{extensions-6PVd3-vB.d.cts → extensions-DiByZIpD.d.cts} +26 -3
  61. package/dist/{extensions-6PVd3-vB.d.ts → extensions-DiByZIpD.d.ts} +26 -3
  62. package/dist/{index-CCJVT9Hu.d.cts → index-DWtSViJ3.d.cts} +7 -1
  63. package/dist/{index-DRlltbdV.d.ts → index-XR5t8bTq.d.ts} +7 -1
  64. package/dist/index.cjs +47 -48
  65. package/dist/index.cjs.map +1 -1
  66. package/dist/index.d.cts +5 -5
  67. package/dist/index.d.ts +5 -5
  68. package/dist/index.js +10 -11
  69. package/dist/index.js.map +1 -1
  70. package/dist/input.cjs +3 -3
  71. package/dist/input.d.cts +1 -1
  72. package/dist/input.d.ts +1 -1
  73. package/dist/input.js +2 -2
  74. package/dist/inputs.cjs +3 -3
  75. package/dist/inputs.d.cts +1 -1
  76. package/dist/inputs.d.ts +1 -1
  77. package/dist/inputs.js +2 -2
  78. package/dist/json-editor.cjs +15 -16
  79. package/dist/json-editor.js +4 -5
  80. package/dist/magic-wand.cjs +3 -4
  81. package/dist/magic-wand.js +2 -3
  82. package/dist/paste-pills.cjs +14 -14
  83. package/dist/paste-pills.d.cts +3 -3
  84. package/dist/paste-pills.d.ts +3 -3
  85. package/dist/paste-pills.js +1 -1
  86. package/dist/prompt-input.cjs +6 -7
  87. package/dist/prompt-input.d.cts +2 -2
  88. package/dist/prompt-input.d.ts +2 -2
  89. package/dist/prompt-input.js +3 -4
  90. package/dist/reason-tag.cjs +3 -4
  91. package/dist/reason-tag.js +2 -3
  92. package/docs/ContentRenderer.md +138 -3
  93. package/docs/PromptInput.md +24 -1
  94. package/package.json +1 -1
  95. package/dist/chunk-6TBRBN7H.js +0 -212
  96. package/dist/chunk-6TBRBN7H.js.map +0 -1
  97. package/dist/chunk-C463C4MM.cjs.map +0 -1
  98. package/dist/chunk-C5YX5XVL.cjs.map +0 -1
  99. package/dist/chunk-COXHFZR6.js.map +0 -1
  100. package/dist/chunk-L24LUSEE.js.map +0 -1
  101. package/dist/chunk-MCQJFMDP.cjs.map +0 -1
  102. package/dist/chunk-NILA2LZY.js.map +0 -1
  103. package/dist/chunk-QNQNGARW.cjs.map +0 -1
  104. package/dist/chunk-TLRRMYNV.js.map +0 -1
  105. package/dist/chunk-X2FC7ODX.cjs +0 -215
  106. package/dist/chunk-X2FC7ODX.cjs.map +0 -1
@@ -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 HTML attributes from the opening tag |
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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/react-fancy",
3
- "version": "5.65.0",
3
+ "version": "5.67.0",
4
4
  "description": "React UI component library for Human+ UX — controlled, agent-bridgeable primitives",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\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"]}