@artooi/ag-ui-web-component 0.27.0 → 0.29.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 (85) hide show
  1. package/CHANGELOG.md +663 -1
  2. package/README.md +557 -11
  3. package/dist/ag-ui-web-component.bundle.js +294 -36
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +69 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +262 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +46 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +43 -1
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/create_http_agent.d.ts +13 -0
  14. package/dist/core/create_http_agent.d.ts.map +1 -1
  15. package/dist/core/remote_conversation_store.d.ts +23 -1
  16. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +28 -0
  18. package/dist/core/utils.d.ts.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2104 -194
  22. package/dist/index.js.map +4 -4
  23. package/dist/tools/is_destructive.d.ts +8 -2
  24. package/dist/tools/is_destructive.d.ts.map +1 -1
  25. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  26. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  27. package/dist/ui/approval_card.d.ts +18 -0
  28. package/dist/ui/approval_card.d.ts.map +1 -1
  29. package/dist/ui/checkpoint_menu.d.ts +10 -0
  30. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  31. package/dist/ui/confirmation_card.d.ts +16 -0
  32. package/dist/ui/confirmation_card.d.ts.map +1 -1
  33. package/dist/ui/message_actions.d.ts +46 -0
  34. package/dist/ui/message_actions.d.ts.map +1 -0
  35. package/dist/ui/page_quote_offer.d.ts +33 -0
  36. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  37. package/dist/ui/quote_selection.d.ts +66 -0
  38. package/dist/ui/quote_selection.d.ts.map +1 -0
  39. package/dist/ui/relative_time.d.ts +10 -0
  40. package/dist/ui/relative_time.d.ts.map +1 -1
  41. package/dist/ui/render_markdown.d.ts +23 -5
  42. package/dist/ui/render_markdown.d.ts.map +1 -1
  43. package/dist/ui/resize_handle.d.ts +5 -1
  44. package/dist/ui/resize_handle.d.ts.map +1 -1
  45. package/dist/ui/stick_to_bottom.d.ts +55 -0
  46. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  47. package/dist/ui/styles.d.ts +1 -1
  48. package/dist/ui/styles.d.ts.map +1 -1
  49. package/dist/ui/suggestion_chips.d.ts +29 -0
  50. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  51. package/dist/ui/thread_drawer.d.ts +10 -0
  52. package/dist/ui/thread_drawer.d.ts.map +1 -1
  53. package/dist/ui/tool_call_card.d.ts +8 -0
  54. package/dist/ui/tool_call_card.d.ts.map +1 -1
  55. package/dist/ui/ui_strings.d.ts +53 -7
  56. package/dist/ui/ui_strings.d.ts.map +1 -1
  57. package/dist/ui/voice_input.d.ts.map +1 -1
  58. package/package.json +1 -1
  59. package/src/constants.ts +75 -0
  60. package/src/core/ag_ui_chat.ts +1357 -113
  61. package/src/core/agui_client.ts +81 -1
  62. package/src/core/conversation_store.ts +128 -42
  63. package/src/core/create_http_agent.ts +24 -2
  64. package/src/core/remote_conversation_store.ts +35 -2
  65. package/src/core/utils.ts +58 -0
  66. package/src/index.ts +39 -0
  67. package/src/tools/is_destructive.ts +8 -2
  68. package/src/tools/parse_tool_catalog.ts +18 -6
  69. package/src/ui/approval_card.ts +90 -2
  70. package/src/ui/checkpoint_menu.ts +22 -5
  71. package/src/ui/confirmation_card.ts +29 -1
  72. package/src/ui/message_actions.ts +158 -0
  73. package/src/ui/page_quote_offer.ts +215 -0
  74. package/src/ui/quote_selection.ts +345 -0
  75. package/src/ui/relative_time.ts +11 -0
  76. package/src/ui/render_markdown.ts +111 -21
  77. package/src/ui/resize_handle.ts +32 -2
  78. package/src/ui/stick_to_bottom.ts +126 -0
  79. package/src/ui/styles.ts +227 -0
  80. package/src/ui/suggestion_chips.ts +73 -0
  81. package/src/ui/thread_drawer.ts +22 -2
  82. package/src/ui/tool_call_card.ts +9 -0
  83. package/src/ui/ui_strings.ts +79 -8
  84. package/src/ui/voice_input.ts +43 -0
  85. package/src/version.ts +1 -1
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Reading a text selection out of the shadow tree, and shaping it for the
3
+ * composer.
4
+ *
5
+ * Selection is the one DOM API shadow encapsulation genuinely broke, and it
6
+ * broke it in two different directions. WebKit rescopes a selection made inside
7
+ * a shadow tree to the **host** element, so `document.getSelection()` reports
8
+ * the whole widget and none of the words; Chromium exposes the shadow nodes
9
+ * directly, so the same call reports exactly the words. `getComposedRanges` is
10
+ * the settled answer to both -- it hands back real endpoints for each shadow
11
+ * root it is *given permission to see* -- but it is recent enough that the
12
+ * direct read has to stay behind it.
13
+ */
14
+
15
+ /**
16
+ * The longest quotation put into the composer, in characters.
17
+ *
18
+ * A cap rather than no cap because the point of a quotation is to say *which*
19
+ * part of an answer the next question is about. Select-all-then-quote is a
20
+ * gesture the transcript already answers -- it is the whole conversation, which
21
+ * the model has -- and pasting it back costs the user tokens to say nothing.
22
+ */
23
+ export const MAX_QUOTE_CHARS = 500;
24
+
25
+ /** A selection worth offering to quote. */
26
+ export interface QuotableSelection {
27
+ /** The selected text, trimmed. Never empty. */
28
+ readonly text: string;
29
+ /**
30
+ * One **line** of the selection, for placing the affordance beside it.
31
+ *
32
+ * Deliberately not the selection's bounding box. See {@link lineToHangFrom}:
33
+ * the union of a selection spanning several elements has a centre with no
34
+ * selected text anywhere near it.
35
+ */
36
+ readonly rect: DOMRect;
37
+ }
38
+
39
+ /** Where a gesture ended, in viewport coordinates. */
40
+ export interface Point {
41
+ readonly x: number;
42
+ readonly y: number;
43
+ }
44
+
45
+ /** A `Selection` on an engine new enough to reach into a shadow tree. */
46
+ type ComposedSelection = Selection & {
47
+ getComposedRanges?: (...args: readonly unknown[]) => readonly AbstractRange[];
48
+ };
49
+
50
+ /**
51
+ * The current selection, when it lies wholly inside `container`.
52
+ *
53
+ * `roots` are the shadow roots the read is allowed to look inside, in the shape
54
+ * `getComposedRanges` itself takes. Pass the one holding `container` for a
55
+ * selection made in a shadow tree, or nothing at all for one made in the page.
56
+ *
57
+ * `near` is where the gesture ended, when a pointer made it -- it decides which
58
+ * line of a multi-line selection the offer is hung from.
59
+ *
60
+ * `null` for no selection, a collapsed one, whitespace only, or one that
61
+ * starts or ends outside `container` -- a drag that ran off the transcript and
62
+ * into the page is not a quotation from the transcript.
63
+ */
64
+ export function quotableSelection(
65
+ container: HTMLElement,
66
+ roots: readonly ShadowRoot[] = [],
67
+ near?: Point,
68
+ ): QuotableSelection | null {
69
+ for (const endpoints of endpointCandidates(roots)) {
70
+ if (!container.contains(endpoints.startContainer)) {
71
+ continue;
72
+ }
73
+ if (!container.contains(endpoints.endContainer)) {
74
+ continue;
75
+ }
76
+ // A live `Range` regardless of what the engine handed back: `getComposedRanges`
77
+ // returns `StaticRange`s, which carry endpoints and nothing else -- no text,
78
+ // no geometry. Both are what this is for.
79
+ const range = document.createRange();
80
+ range.setStart(endpoints.startContainer, endpoints.startOffset);
81
+ range.setEnd(endpoints.endContainer, endpoints.endOffset);
82
+ const text = renderedText(range).trim();
83
+ if (text === "") {
84
+ continue;
85
+ }
86
+ return { text, rect: lineToHangFrom(range, near) };
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Wrap `text` as a markdown blockquote, ready to be followed by a question.
93
+ *
94
+ * Markdown rather than a bespoke fence because the transcript renders markdown
95
+ * and the server reads markdown: a quotation that survives both ends without a
96
+ * convention to agree on first. The trailing blank line is what leaves the
97
+ * caret on a fresh paragraph, which is the whole point of quoting into a
98
+ * composer rather than sending straight away.
99
+ */
100
+ export function asQuote(text: string): string {
101
+ const lines = tidy(text);
102
+ if (lines.length === 0) {
103
+ return "";
104
+ }
105
+ const capped = cap(lines.join("\n"));
106
+ const quoted = capped
107
+ .split("\n")
108
+ // `trimEnd` so a blank line inside the selection becomes a bare ">" rather
109
+ // than "> " -- trailing whitespace markdown treats as a line break.
110
+ .map((line) => `> ${line}`.trimEnd())
111
+ .join("\n");
112
+ return `${quoted}\n\n`;
113
+ }
114
+
115
+ /**
116
+ * The selection's lines, with the *markup's* whitespace taken back out.
117
+ *
118
+ * A selection crossing block elements carries the source's own layout with it:
119
+ * every newline and every run of indentation between one element and the next
120
+ * is a text node like any other. Quoting a form as marked up produced twenty-
121
+ * four lines of which twelve were a bare ">", the rest indented by wherever
122
+ * they happened to sit in the HTML. That is not a quotation of anything.
123
+ *
124
+ * Two rules, and deliberately only two. Runs of blank lines collapse to one,
125
+ * because the gap between two blocks is one gap however it was written. And the
126
+ * indentation every line shares is removed, not each line's own -- a quotation
127
+ * from a code block keeps its shape, which trimming each line would flatten.
128
+ */
129
+ function tidy(text: string): readonly string[] {
130
+ const lines = text.split(/\r\n?|\n/).map((line) => line.trimEnd());
131
+ const indents = lines.filter((line) => line !== "").map(indentOf);
132
+ const shared = indents.length === 0 ? 0 : Math.min(...indents);
133
+ const kept: string[] = [];
134
+ for (const line of lines) {
135
+ const dedented = line.slice(shared);
136
+ // No leading blank lines, and never two in a row.
137
+ if (dedented === "" && (kept.length === 0 || kept[kept.length - 1] === "")) {
138
+ continue;
139
+ }
140
+ kept.push(dedented);
141
+ }
142
+ while (kept[kept.length - 1] === "") {
143
+ kept.pop();
144
+ }
145
+ return kept;
146
+ }
147
+
148
+ /** How many spaces of indentation `line` opens with. */
149
+ function indentOf(line: string): number {
150
+ return line.length - line.trimStart().length;
151
+ }
152
+
153
+ /** `text`, cut to {@link MAX_QUOTE_CHARS} if it runs past it. */
154
+ function cap(text: string): string {
155
+ return text.length > MAX_QUOTE_CHARS ? `${text.slice(0, MAX_QUOTE_CHARS).trimEnd()}...` : text;
156
+ }
157
+
158
+ /**
159
+ * The selected text **as rendered**, which is not what `Range.toString()` says.
160
+ *
161
+ * `toString()` concatenates the text nodes in the range and asks no questions
162
+ * about CSS, so a drag across an ordinary form quotes back the values of every
163
+ * `<option>` in a closed `<select>` -- words the user has never seen, presented
164
+ * to the model as something they pointed at. `checkVisibility()` is the
165
+ * platform's own answer to "is this actually rendered", and it reports exactly
166
+ * those options as hidden.
167
+ *
168
+ * Older engines without it keep the previous behaviour rather than a guess:
169
+ * quoting a few invisible words is a smaller failure than dropping visible ones
170
+ * because a hand-rolled visibility test was wrong.
171
+ */
172
+ function renderedText(range: Range): string {
173
+ let text = "";
174
+ for (const node of textNodesIn(range)) {
175
+ // The element carries the styles; the text node has none of its own.
176
+ const parent = node.parentElement as HTMLElement;
177
+ if (!isRendered(parent)) {
178
+ continue;
179
+ }
180
+ // The end nodes are only partly inside the range; everything between them
181
+ // is wholly inside it.
182
+ const from = node === range.startContainer ? range.startOffset : 0;
183
+ const to = node === range.endContainer ? range.endOffset : node.data.length;
184
+ text += collapse(node.data.slice(from, to), parent);
185
+ }
186
+ return text;
187
+ }
188
+
189
+ /**
190
+ * Squeeze the runs of spaces the renderer squeezes, and keep the ones it keeps.
191
+ *
192
+ * Indentation between block elements is markup, not content: it is in the DOM
193
+ * as text nodes and it is on screen as nothing at all, because a collapsing
194
+ * `white-space` reduces it. Carrying it into a quotation is not merely untidy --
195
+ * four leading spaces inside a blockquote is a markdown **code block**, so a
196
+ * form quoted as marked up renders as source listing.
197
+ *
198
+ * Newlines survive on purpose, where CSS would collapse those too. They are the
199
+ * only record left of where one block ended and the next began, and a quotation
200
+ * of six form rows run together on one line is worse than one that keeps them
201
+ * apart. Preformatted text is passed through untouched: there the indentation
202
+ * *is* the content.
203
+ */
204
+ function collapse(text: string, parent: HTMLElement): string {
205
+ if (PREFORMATTED.has(whiteSpaceOf(parent))) {
206
+ return text;
207
+ }
208
+ return (
209
+ text
210
+ // Whitespace *around* a newline goes with it. Leaving a space behind
211
+ // would indent every line of the quotation by one, for nothing.
212
+ .replace(/[^\S\n]*\n[^\S\n]*/g, "\n")
213
+ .replace(/[^\S\n]+/g, " ")
214
+ );
215
+ }
216
+
217
+ /** The `white-space` values under which every space is content. */
218
+ const PREFORMATTED = new Set(["pre", "pre-wrap", "break-spaces"]);
219
+
220
+ /** The computed `white-space` of `element`, or `""` where it cannot be read. */
221
+ function whiteSpaceOf(element: HTMLElement): string {
222
+ return window.getComputedStyle(element).whiteSpace;
223
+ }
224
+
225
+ /** Every text node the range touches, in document order. */
226
+ function textNodesIn(range: Range): readonly Text[] {
227
+ const root = range.commonAncestorContainer;
228
+ // A selection inside a single text node has that node as its own common
229
+ // ancestor, and a `TreeWalker` never visits its root.
230
+ if (root.nodeType === Node.TEXT_NODE) {
231
+ return [root as Text];
232
+ }
233
+ const found: Text[] = [];
234
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
235
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
236
+ // The walker covers the whole subtree, which reaches past both ends of the
237
+ // range -- a sibling paragraph above the selection is in it too.
238
+ if (range.intersectsNode(node)) {
239
+ found.push(node as Text);
240
+ }
241
+ }
242
+ return found;
243
+ }
244
+
245
+ /** Whether `element` is actually drawn, where the engine will say. */
246
+ function isRendered(element: HTMLElement): boolean {
247
+ if (typeof element.checkVisibility !== "function") {
248
+ return true;
249
+ }
250
+ return element.checkVisibility({
251
+ contentVisibilityAuto: true,
252
+ opacityProperty: true,
253
+ visibilityProperty: true,
254
+ });
255
+ }
256
+
257
+ /**
258
+ * The line of the selection to hang the offer from.
259
+ *
260
+ * **Not the bounding box.** A drag from a narrow column of a form down to a
261
+ * full-width line running under the chat panel beside it produced a union
262
+ * reaching from the column's left edge to the far end of that line -- and its
263
+ * centre landed most of the way across the page, behind the panel, on a line
264
+ * the user had not been looking at, while the pointer had let go by the column.
265
+ *
266
+ * Note what the fix is *not*: that centre was over selected text. It was over
267
+ * selected text nobody could see. A union is a shape the selection does not
268
+ * have, so no point derived from it belongs to any particular line.
269
+ *
270
+ * `near` is where the pointer let go. The line under it is where the user is
271
+ * looking, and it needs no engine-specific way to ask which end of the
272
+ * selection is its focus -- which the shadow-aware read does not carry anyway.
273
+ * Without a pointer -- a keyboard selection -- the first line is the one that
274
+ * exists whichever way the selection was made.
275
+ *
276
+ * The bounding box remains the fallback for a range that reports no line boxes
277
+ * at all, which is what a DOM with no layout does.
278
+ */
279
+ function lineToHangFrom(range: Range, near: Point | undefined): DOMRect {
280
+ const lines = [...range.getClientRects()];
281
+ if (lines.length === 0) {
282
+ return range.getBoundingClientRect();
283
+ }
284
+ if (near === undefined) {
285
+ return lines[0] as DOMRect;
286
+ }
287
+ let closest = lines[0] as DOMRect;
288
+ let shortest = distanceTo(closest, near);
289
+ for (const line of lines.slice(1)) {
290
+ const distance = distanceTo(line, near);
291
+ if (distance < shortest) {
292
+ shortest = distance;
293
+ closest = line;
294
+ }
295
+ }
296
+ return closest;
297
+ }
298
+
299
+ /** How far `point` is from the nearest edge of `rect`, or 0 inside it. */
300
+ function distanceTo(rect: DOMRect, point: Point): number {
301
+ const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
302
+ const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
303
+ return Math.hypot(dx, dy);
304
+ }
305
+
306
+ /**
307
+ * Endpoint pairs to consider, best first.
308
+ *
309
+ * Both reads are offered rather than one being chosen, because which is correct
310
+ * is a property of the engine *and* of where the user dragged: an engine that
311
+ * rescopes to the host returns endpoints outside the transcript, and the caller
312
+ * rejects those on the same test it uses for a selection that genuinely ran off
313
+ * the transcript. One rule, no engine sniffing.
314
+ */
315
+ function endpointCandidates(roots: readonly ShadowRoot[]): readonly AbstractRange[] {
316
+ const selection = window.getSelection();
317
+ if (selection === null) {
318
+ return [];
319
+ }
320
+ const candidates = [...composedRanges(selection, roots)];
321
+ if (selection.rangeCount > 0) {
322
+ candidates.push(selection.getRangeAt(0));
323
+ }
324
+ return candidates;
325
+ }
326
+
327
+ /** The shadow-aware read, or nothing on an engine that does not have it. */
328
+ function composedRanges(
329
+ selection: Selection,
330
+ roots: readonly ShadowRoot[],
331
+ ): readonly AbstractRange[] {
332
+ const composed = (selection as ComposedSelection).getComposedRanges;
333
+ if (composed === undefined) {
334
+ return [];
335
+ }
336
+ try {
337
+ return composed.call(selection, { shadowRoots: roots });
338
+ } catch {
339
+ // The method shipped first with the shadow roots as rest parameters and
340
+ // only later as a dictionary member, and the earlier form rejects the
341
+ // options object outright rather than ignoring it. Lexical resolves the
342
+ // same split the same way, by trying both shapes at runtime.
343
+ return composed.call(selection, ...roots);
344
+ }
345
+ }
@@ -36,3 +36,14 @@ export function relativeTime(
36
36
  }
37
37
  return strings.weeksAgo.replace("{n}", String(Math.round(days / 7)));
38
38
  }
39
+
40
+ /**
41
+ * A host's replacement for {@link relativeTime}.
42
+ *
43
+ * Takes an epoch-milliseconds timestamp and returns the text a row shows.
44
+ * `Intl.RelativeTimeFormat` is the obvious implementation and is deliberately
45
+ * *not* the default: a component that guessed a locale would disagree with the
46
+ * host page's own formatting, and being wrong in a second language is worse
47
+ * than being neutral in one.
48
+ */
49
+ export type RelativeTimeFormatter = (timestamp: number) => string;
@@ -4,7 +4,7 @@
4
4
  // happy-dom-only suite goes green while this module strips nothing at all.
5
5
  // Moving those assertions back under happy-dom for speed removes the only check
6
6
  // that this module does anything. CLAUDE.md records the upstream root cause.
7
- import DOMPurify from "dompurify";
7
+ import DOMPurify, { type Config, type DOMPurify as Purifier } from "dompurify";
8
8
  import { Marked } from "marked";
9
9
 
10
10
  // A local parser instance, so configuration never leaks into the shared
@@ -54,11 +54,108 @@ const ALLOWED_TAGS = [
54
54
  "td",
55
55
  ];
56
56
 
57
- const ALLOWED_ATTR = ["href", "title", "class"];
57
+ // `target` and `rel` are on the list because {@link harden} writes them onto
58
+ // every link. An attribute the returned markup carries but the config does not
59
+ // name would be markup the sanitiser never approved — which is the whole defect
60
+ // the hook exists to avoid.
61
+ //
62
+ // `class` is here only for marked's `language-*` code-fence hint, and `harden`
63
+ // narrows it to exactly that. Left wide, it is a chrome-forgery channel: the
64
+ // shadow stylesheet's component classes are unscoped selectors, so a `<span
65
+ // class="approval-btn approval-btn--approve">` in model output resolves to the
66
+ // same pixels as the real human-in-the-loop approval button, inside the surface
67
+ // where the user decides whether to approve.
68
+ const ALLOWED_ATTR = ["href", "title", "class", "target", "rel"];
69
+
70
+ /**
71
+ * The sanitiser's declared allowlist — and, because {@link harden} runs inside
72
+ * the sanitiser rather than after it, its *effective* one too.
73
+ *
74
+ * `ALLOW_DATA_ATTR` and `ALLOW_ARIA_ATTR` default to `true`, which would admit
75
+ * every `data-*` and `aria-*` attribute on top of the five named above. Both
76
+ * matter here: the cards drive their resolved / expanded / status appearance off
77
+ * `[data-resolved]`, `[data-status]` and `[data-expanded]`, and an `aria-label`
78
+ * on model output makes a screen reader announce something other than what is on
79
+ * screen. Turning them off is what makes the declared list the real one.
80
+ *
81
+ * Exported so a test can assert the returned markup is a fixed point of it.
82
+ */
83
+ export const SANITIZE_CONFIG: Config = {
84
+ ALLOWED_TAGS,
85
+ ALLOWED_ATTR,
86
+ ALLOW_DATA_ATTR: false,
87
+ ALLOW_ARIA_ATTR: false,
88
+ };
58
89
 
59
- // The image-permitting variants used when the host opts in.
60
- const ALLOWED_TAGS_WITH_IMAGES = [...ALLOWED_TAGS, "img"];
61
- const ALLOWED_ATTR_WITH_IMAGES = [...ALLOWED_ATTR, "src", "alt", "width", "height"];
90
+ /** {@link SANITIZE_CONFIG} plus the images a host opts into. */
91
+ export const SANITIZE_CONFIG_WITH_IMAGES: Config = {
92
+ ...SANITIZE_CONFIG,
93
+ ALLOWED_TAGS: [...ALLOWED_TAGS, "img"],
94
+ ALLOWED_ATTR: [...ALLOWED_ATTR, "src", "alt", "width", "height"],
95
+ };
96
+
97
+ /** The one class value markdown is allowed to carry, and where it may sit. */
98
+ const LANGUAGE_CLASS = /^language-[A-Za-z0-9_+#.-]+$/;
99
+ const LANGUAGE_HOSTS = new Set(["CODE", "PRE"]);
100
+
101
+ /**
102
+ * Per-element hardening, run *inside* the sanitiser.
103
+ *
104
+ * It belongs here rather than in a pass over the finished string because
105
+ * DOMPurify must be the last thing to touch the markup: anything edited in
106
+ * afterwards is inserted into the document without the sanitiser ever having
107
+ * seen it, and re-parsing sanitiser output is the shape every mXSS bypass takes.
108
+ * Running as a hook means DOMPurify serialises the result of these edits, so
109
+ * what the caller inserts is exactly what it approved.
110
+ *
111
+ * Two jobs:
112
+ *
113
+ * - links open in a new tab and never hand over their opener. `target`/`rel` are
114
+ * this module's to write, so they are stripped from everything else rather
115
+ * than inherited from the model's own markup;
116
+ * - `class` is narrowed to the code-fence language hint on `code`/`pre`, the
117
+ * only thing it is allowed for, and dropped everywhere else so model output
118
+ * cannot adopt the component's own chrome.
119
+ */
120
+ function harden(node: Element): void {
121
+ if (node.nodeName === "A" && node.hasAttribute("href")) {
122
+ node.setAttribute("target", "_blank");
123
+ node.setAttribute("rel", "noopener noreferrer");
124
+ } else {
125
+ node.removeAttribute("target");
126
+ node.removeAttribute("rel");
127
+ }
128
+ const classes = node.getAttribute("class");
129
+ if (classes === null) {
130
+ return;
131
+ }
132
+ const kept = LANGUAGE_HOSTS.has(node.nodeName)
133
+ ? classes.split(/\s+/).filter((token) => LANGUAGE_CLASS.test(token))
134
+ : [];
135
+ if (kept.length === 0) {
136
+ node.removeAttribute("class");
137
+ return;
138
+ }
139
+ node.setAttribute("class", kept.join(" "));
140
+ }
141
+
142
+ // DOMPurify's default export is a singleton, and `addHook` mutates it for every
143
+ // caller sharing that copy — including the host app, if its bundler deduped to
144
+ // ours. So this module builds its own instance, for the same reason `parser`
145
+ // above is a local `Marked`.
146
+ //
147
+ // Built on first render rather than at module scope: a DOMPurify instance
148
+ // created without a DOM has no `addHook` at all, and doing this eagerly would
149
+ // turn a server-side `import` of the package into a throw.
150
+ let purifier: Purifier | null = null;
151
+
152
+ function sanitizer(): Purifier {
153
+ if (purifier === null) {
154
+ purifier = DOMPurify();
155
+ purifier.addHook("afterSanitizeAttributes", harden);
156
+ }
157
+ return purifier;
158
+ }
62
159
 
63
160
  /** Options for {@link renderMarkdown}. */
64
161
  export interface RenderMarkdownOptions {
@@ -74,25 +171,18 @@ export interface RenderMarkdownOptions {
74
171
  * Render markdown (and any embedded raw HTML) to a sanitised HTML string.
75
172
  *
76
173
  * Markdown syntax and literal HTML share one path: `marked` emits HTML, then
77
- * DOMPurify strips everything outside {@link ALLOWED_TAGS} /
78
- * {@link ALLOWED_ATTR} — scripts, event handlers, `javascript:` URLs. Links are
79
- * hardened with `target="_blank"` and `rel="noopener noreferrer"`.
174
+ * DOMPurify strips everything outside {@link SANITIZE_CONFIG} — scripts, event
175
+ * handlers, `javascript:` URLs, `data-*`/`aria-*`, and every class but a code
176
+ * fence's `language-*` hint. Links come out with `target="_blank"` and
177
+ * `rel="noopener noreferrer"`.
80
178
  *
81
- * The result is trimmed so a single-paragraph message round-trips to clean
82
- * `textContent`, without the wrapping paragraph's trailing newline.
179
+ * The returned string is the sanitiser's own output, trimmed. Nothing edits it
180
+ * afterwards, so what a caller inserts is what DOMPurify approved.
83
181
  */
84
182
  export function renderMarkdown(text: string, options?: RenderMarkdownOptions): string {
85
183
  const allowImages = options?.allowImages === true;
86
184
  const rendered = parser.parse(text, { async: false });
87
- const clean = DOMPurify.sanitize(rendered, {
88
- ALLOWED_TAGS: allowImages ? ALLOWED_TAGS_WITH_IMAGES : ALLOWED_TAGS,
89
- ALLOWED_ATTR: allowImages ? ALLOWED_ATTR_WITH_IMAGES : ALLOWED_ATTR,
90
- });
91
- const template = document.createElement("template");
92
- template.innerHTML = clean;
93
- for (const anchor of template.content.querySelectorAll("a[href]")) {
94
- anchor.setAttribute("target", "_blank");
95
- anchor.setAttribute("rel", "noopener noreferrer");
96
- }
97
- return template.innerHTML.trim();
185
+ return sanitizer()
186
+ .sanitize(rendered, allowImages ? SANITIZE_CONFIG_WITH_IMAGES : SANITIZE_CONFIG)
187
+ .trim();
98
188
  }
@@ -45,7 +45,11 @@ export interface ResizeOptions {
45
45
  readonly rect: () => PanelRect;
46
46
  /** Apply a size (the host writes the custom properties). */
47
47
  readonly apply: (size: ResizeSize) => void;
48
- /** Called once per completed drag, for persistence. */
48
+ /**
49
+ * Called once per completed resize, for persistence: on `pointerup` for a
50
+ * drag, and when the key comes up (or focus leaves the handle) for a key
51
+ * press. Never per pointer move, and never per key repeat.
52
+ */
49
53
  readonly commit: (size: ResizeSize) => void;
50
54
  /** Accessible label. */
51
55
  readonly label: string;
@@ -127,6 +131,21 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
127
131
  event.preventDefault();
128
132
  });
129
133
 
134
+ // The size the current key gesture has applied but not yet persisted. The
135
+ // pointer path can commit inline because a drag has one unambiguous end;
136
+ // a key press does not, so the gesture's result is held here until it does.
137
+ let pending: ResizeSize | null = null;
138
+
139
+ /** End a key gesture: persist what it applied, once. */
140
+ const settle = (): void => {
141
+ if (pending === null) {
142
+ return;
143
+ }
144
+ const size = pending;
145
+ pending = null;
146
+ options.commit(size);
147
+ };
148
+
130
149
  // Keyboard parity: a pointer-only resize is unreachable without a mouse, and
131
150
  // this control has no equivalent elsewhere in the UI.
132
151
  handle.addEventListener("keydown", (event: KeyboardEvent) => {
@@ -155,9 +174,20 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
155
174
  return;
156
175
  }
157
176
  event.preventDefault();
177
+ // Live feedback per key event, persistence only when the gesture ends:
178
+ // `commit` promises one call per completed resize, and a held arrow key
179
+ // repeats at the OS rate (20-30 events a second), so committing here would
180
+ // put that many storage writes or PATCHes behind a single press — landing
181
+ // hardest on the keyboard users this path exists for.
158
182
  options.apply(next);
159
- options.commit(next);
183
+ pending = next;
160
184
  });
161
185
 
186
+ // The key coming up ends the gesture, mirroring `pointerup`. `blur` closes one
187
+ // whose keyup never arrives here — focus moved on mid-press — because a size
188
+ // that was applied but never committed is a resize the host silently forgets.
189
+ handle.addEventListener("keyup", settle);
190
+ handle.addEventListener("blur", settle);
191
+
162
192
  return handle;
163
193
  }