@artooi/ag-ui-web-component 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -1
- package/README.md +14 -6
- package/dist/ag-ui-web-component.bundle.js +23 -23
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +7 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +9 -0
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +91 -13
- package/dist/index.js.map +3 -3
- package/dist/tools/parse_tool_catalog.d.ts +19 -0
- package/dist/tools/parse_tool_catalog.d.ts.map +1 -0
- package/dist/ui/prettify_tool_name.d.ts +11 -0
- package/dist/ui/prettify_tool_name.d.ts.map +1 -0
- package/dist/ui/render_markdown.d.ts +10 -1
- package/dist/ui/render_markdown.d.ts.map +1 -1
- package/package.json +2 -3
- package/src/core/ag_ui_chat.ts +47 -5
- package/src/core/create_http_agent.ts +23 -2
- package/src/index.ts +3 -1
- package/src/tools/parse_tool_catalog.ts +36 -0
- package/src/ui/prettify_tool_name.ts +16 -0
- package/src/ui/render_markdown.ts +37 -11
- package/src/version.ts +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry in the server-tool catalog served by django-ag-ui's `tools/`
|
|
3
|
+
* endpoint and fetched via the `data-tools-url` attribute.
|
|
4
|
+
*/
|
|
5
|
+
export interface ToolCatalogEntry {
|
|
6
|
+
/** The tool's wire name (matches the name in `TOOL_CALL_START`). */
|
|
7
|
+
readonly name: string;
|
|
8
|
+
/** A friendly card label for the tool. */
|
|
9
|
+
readonly summary: string;
|
|
10
|
+
/** Optional longer blurb (e.g. for a future tooltip). */
|
|
11
|
+
readonly description?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Parse a fetched tool catalog into a `name → summary` map, skipping any entry
|
|
15
|
+
* that isn't a `{ name: string, summary: string }` object. Tolerant by design:
|
|
16
|
+
* a malformed payload yields an empty map rather than throwing.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseToolCatalog(data: unknown): Record<string, string>;
|
|
19
|
+
//# sourceMappingURL=parse_tool_catalog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse_tool_catalog.d.ts","sourceRoot":"","sources":["../../src/tools/parse_tool_catalog.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,0CAA0C;IAC1C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAiBtE"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prettify a raw tool name for display: separators become spaces and the
|
|
3
|
+
* first letter is capitalised — `list_projects` → "List projects",
|
|
4
|
+
* `invoices.retrieve` → "Invoices retrieve".
|
|
5
|
+
*
|
|
6
|
+
* Final fallback of the tool-card label chain (`x-summary` →
|
|
7
|
+
* `toolSummaries` → fetched catalog → this). Purely cosmetic: the original
|
|
8
|
+
* name still rides on the card's dataset for debugging.
|
|
9
|
+
*/
|
|
10
|
+
export declare function prettifyToolName(name: string): string;
|
|
11
|
+
//# sourceMappingURL=prettify_tool_name.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prettify_tool_name.d.ts","sourceRoot":"","sources":["../../src/ui/prettify_tool_name.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrD"}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/** Options for {@link renderMarkdown}. */
|
|
2
|
+
export interface RenderMarkdownOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Permit `<img>` tags (and their `src`/`alt`/`width`/`height` attributes)
|
|
5
|
+
* in the sanitised output. **Off by default** — see the allowlist note on
|
|
6
|
+
* the exfiltration risk. Only enable for trusted content sources.
|
|
7
|
+
*/
|
|
8
|
+
readonly allowImages?: boolean;
|
|
9
|
+
}
|
|
1
10
|
/**
|
|
2
11
|
* Render markdown (and any embedded raw HTML) to a sanitised HTML string.
|
|
3
12
|
*
|
|
@@ -9,5 +18,5 @@
|
|
|
9
18
|
* The result is trimmed so a single-paragraph message round-trips to clean
|
|
10
19
|
* `textContent` (no trailing newline from the wrapping `<p>`).
|
|
11
20
|
*/
|
|
12
|
-
export declare function renderMarkdown(text: string): string;
|
|
21
|
+
export declare function renderMarkdown(text: string, options?: RenderMarkdownOptions): string;
|
|
13
22
|
//# sourceMappingURL=render_markdown.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render_markdown.d.ts","sourceRoot":"","sources":["../../src/ui/render_markdown.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"render_markdown.d.ts","sourceRoot":"","sources":["../../src/ui/render_markdown.ts"],"names":[],"mappings":"AA0DA,0CAA0C;AAC1C,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,qBAAqB,GAAG,MAAM,CAcpF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@artooi/ag-ui-web-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Framework-free <ag-ui-chat> Web Component over the AG-UI protocol. Drop-in chat sidebar with a pluggable client-side tool registry, DOM driver primitives, animations, and destructive-action confirmation modal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"./bundle": {
|
|
15
15
|
"import": "./dist/ag-ui-web-component.bundle.js"
|
|
16
|
-
}
|
|
17
|
-
"./style.css": "./dist/ag-ui-web-component.bundle.css"
|
|
16
|
+
}
|
|
18
17
|
},
|
|
19
18
|
"files": [
|
|
20
19
|
"dist/",
|
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -15,9 +15,11 @@ import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_regist
|
|
|
15
15
|
import { isDestructive } from "../tools/is_destructive.js";
|
|
16
16
|
import { isNavigates } from "../tools/is_navigates.js";
|
|
17
17
|
import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
18
|
+
import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
|
|
18
19
|
import { createRouteTools, type RouteMap } from "../tools/route_map.js";
|
|
19
20
|
import { createStateHookTools, type StateHook } from "../tools/state_hook.js";
|
|
20
21
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
22
|
+
import { prettifyToolName } from "../ui/prettify_tool_name.js";
|
|
21
23
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
22
24
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
23
25
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
@@ -71,6 +73,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
71
73
|
/** Extra HTTP headers for the AG-UI endpoint (e.g. CSRF). */
|
|
72
74
|
headers: Record<string, string> = {};
|
|
73
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Permit `<img>` in rendered assistant markdown. **Off by default**: a
|
|
78
|
+
* model-controlled image URL is fetched with no user interaction, which
|
|
79
|
+
* makes it a zero-click exfiltration channel for prompt-injected page
|
|
80
|
+
* data. Enable only when the content source is trusted.
|
|
81
|
+
*/
|
|
82
|
+
allowImages = false;
|
|
83
|
+
|
|
74
84
|
/** When true, destructive tools execute without a confirmation modal. */
|
|
75
85
|
autoConfirm = false;
|
|
76
86
|
|
|
@@ -159,6 +169,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
159
169
|
*/
|
|
160
170
|
toolSummaries: Record<string, string> = {};
|
|
161
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Card labels fetched from a server tool catalog (`data-tools-url`), keyed by
|
|
174
|
+
* tool name. The base layer behind {@link toolSummaries}: an explicit entry in
|
|
175
|
+
* `toolSummaries` wins, this fills the rest. Populated once on connect.
|
|
176
|
+
*/
|
|
177
|
+
#toolCatalog: Record<string, string> = {};
|
|
178
|
+
|
|
162
179
|
readonly #toolRegistry = new ClientToolRegistry();
|
|
163
180
|
/** Tool-call cards awaiting execution, keyed by call id. */
|
|
164
181
|
readonly #toolCards = new Map<string, ToolCallCard>();
|
|
@@ -312,10 +329,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
312
329
|
this.setAttribute("collapsed", "");
|
|
313
330
|
}
|
|
314
331
|
this.#initSkills();
|
|
332
|
+
void this.#fetchToolCatalog();
|
|
315
333
|
this.#threadId = this.conversationStore.threadId();
|
|
316
334
|
void this.#rehydrate();
|
|
317
335
|
}
|
|
318
336
|
|
|
337
|
+
/** Fetch the server tool-label catalog from `data-tools-url`, if set. */
|
|
338
|
+
async #fetchToolCatalog(): Promise<void> {
|
|
339
|
+
const url = this.getAttribute("data-tools-url");
|
|
340
|
+
if (url === null) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const response = await fetch(url, { headers: this.headers });
|
|
345
|
+
this.#toolCatalog = parseToolCatalog(await response.json());
|
|
346
|
+
} catch {
|
|
347
|
+
// Network/parse failure: cards fall back to toolSummaries / raw names.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
319
351
|
/**
|
|
320
352
|
* Replace the host-supplied (client) skill catalog. Merged after the embedded
|
|
321
353
|
* and fetched skills (so a client skill overrides a same-named server one).
|
|
@@ -546,7 +578,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
546
578
|
const bubble = document.createElement("div");
|
|
547
579
|
bubble.className = `message message--${role}`;
|
|
548
580
|
if (role === MESSAGE_ROLE.ASSISTANT) {
|
|
549
|
-
bubble.innerHTML = renderMarkdown(content);
|
|
581
|
+
bubble.innerHTML = renderMarkdown(content, { allowImages: this.allowImages });
|
|
550
582
|
} else {
|
|
551
583
|
bubble.textContent = content;
|
|
552
584
|
}
|
|
@@ -677,6 +709,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
677
709
|
const agent = this.agentFactory({
|
|
678
710
|
endpoint: this.endpoint,
|
|
679
711
|
headers: this.headers,
|
|
712
|
+
// Live getter: the client is built once and cached, but a rotated
|
|
713
|
+
// token must still reach every request — the factory's fetch wrapper
|
|
714
|
+
// re-reads this on each call.
|
|
715
|
+
getHeaders: () => this.headers,
|
|
680
716
|
threadId: this.#threadId,
|
|
681
717
|
initialMessages: this.#initialMessages,
|
|
682
718
|
});
|
|
@@ -855,7 +891,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
855
891
|
this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
|
|
856
892
|
this.#streamDeltas = 0;
|
|
857
893
|
}
|
|
858
|
-
this.#streamingBubble.innerHTML = renderMarkdown(buffer);
|
|
894
|
+
this.#streamingBubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
|
|
859
895
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
860
896
|
return this.#streamingBubble;
|
|
861
897
|
}
|
|
@@ -871,10 +907,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
871
907
|
if (existing !== undefined) {
|
|
872
908
|
return existing;
|
|
873
909
|
}
|
|
874
|
-
// Prefer the tool's own `x-summary`;
|
|
875
|
-
//
|
|
910
|
+
// Prefer the tool's own `x-summary`; then an explicit `toolSummaries`
|
|
911
|
+
// entry; then the fetched server catalog (`data-tools-url`). All cover
|
|
912
|
+
// server-side tools whose schema never reached the browser.
|
|
876
913
|
const labelled = this.#resolveTool(call.name)?.parameters[X_SUMMARY_KEY];
|
|
877
|
-
const summary =
|
|
914
|
+
const summary =
|
|
915
|
+
typeof labelled === "string"
|
|
916
|
+
? labelled
|
|
917
|
+
: (this.toolSummaries[call.name] ??
|
|
918
|
+
this.#toolCatalog[call.name] ??
|
|
919
|
+
prettifyToolName(call.name));
|
|
878
920
|
const card = new ToolCallCard(call.name, call.args, this.toolDisplay, summary);
|
|
879
921
|
this.#toolCards.set(call.id, card);
|
|
880
922
|
this.#messages.appendChild(card.element);
|
|
@@ -5,6 +5,15 @@ import type { Message } from "@ag-ui/core";
|
|
|
5
5
|
export interface HttpAgentOptions {
|
|
6
6
|
endpoint: string;
|
|
7
7
|
headers?: Record<string, string>;
|
|
8
|
+
/**
|
|
9
|
+
* Live header source, re-read on **every** request. `HttpAgent` bakes the
|
|
10
|
+
* static `headers` into its constructor and the element caches the agent
|
|
11
|
+
* for the whole conversation — so a rotated token (CSRF, short-lived JWT)
|
|
12
|
+
* would otherwise never reach the agent endpoint and a long session 401s
|
|
13
|
+
* mid-conversation. When set, the fetch wrapper overlays these values on
|
|
14
|
+
* each call; `headers` still seeds the initial/static configuration.
|
|
15
|
+
*/
|
|
16
|
+
getHeaders?: () => Record<string, string>;
|
|
8
17
|
/** Stable conversation id, so the agent's runs share a thread. */
|
|
9
18
|
threadId?: string;
|
|
10
19
|
/** Rehydrated history to seed the agent with (durable conversation). */
|
|
@@ -25,8 +34,20 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
|
|
|
25
34
|
// HttpAgent invokes its configured fetch as a method (`this.fetch(...)`),
|
|
26
35
|
// which would rebind the global `fetch` to the agent instance and trigger
|
|
27
36
|
// "Illegal invocation" in browsers. Wrap it so `fetch` is always called as
|
|
28
|
-
// a free function with the correct receiver.
|
|
29
|
-
|
|
37
|
+
// a free function with the correct receiver. The wrapper also overlays
|
|
38
|
+
// `getHeaders()` per request, so header rotation (CSRF, short-lived JWT)
|
|
39
|
+
// reaches the stream even though the agent instance is cached.
|
|
40
|
+
fetch: (url, init) => {
|
|
41
|
+
const fresh = options.getHeaders?.();
|
|
42
|
+
if (fresh === undefined) {
|
|
43
|
+
return fetch(url, init);
|
|
44
|
+
}
|
|
45
|
+
const headers = new Headers(init?.headers);
|
|
46
|
+
for (const [name, value] of Object.entries(fresh)) {
|
|
47
|
+
headers.set(name, value);
|
|
48
|
+
}
|
|
49
|
+
return fetch(url, { ...init, headers });
|
|
50
|
+
},
|
|
30
51
|
// Spread conditionally: under `exactOptionalPropertyTypes` an explicit
|
|
31
52
|
// `undefined` is not assignable to these optional config fields.
|
|
32
53
|
...(options.threadId !== undefined ? { threadId: options.threadId } : {}),
|
package/src/index.ts
CHANGED
|
@@ -71,6 +71,7 @@ export { type ClientTool, ClientToolRegistry } from "./tools/client_tool_registr
|
|
|
71
71
|
export { isDestructive } from "./tools/is_destructive.js";
|
|
72
72
|
export { isNavigates } from "./tools/is_navigates.js";
|
|
73
73
|
export { createPageMapContext, type PageMap } from "./tools/page_map.js";
|
|
74
|
+
export { parseToolCatalog, type ToolCatalogEntry } from "./tools/parse_tool_catalog.js";
|
|
74
75
|
export {
|
|
75
76
|
createRouteTools,
|
|
76
77
|
type Route,
|
|
@@ -79,7 +80,8 @@ export {
|
|
|
79
80
|
} from "./tools/route_map.js";
|
|
80
81
|
export { createStateHookTools, type StateHook } from "./tools/state_hook.js";
|
|
81
82
|
export { type ConfirmationRequest, requestConfirmation } from "./ui/confirmation_card.js";
|
|
82
|
-
export {
|
|
83
|
+
export { prettifyToolName } from "./ui/prettify_tool_name.js";
|
|
84
|
+
export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
|
|
83
85
|
export {
|
|
84
86
|
type SettledStatus,
|
|
85
87
|
ToolCallCard,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry in the server-tool catalog served by django-ag-ui's `tools/`
|
|
3
|
+
* endpoint and fetched via the `data-tools-url` attribute.
|
|
4
|
+
*/
|
|
5
|
+
export interface ToolCatalogEntry {
|
|
6
|
+
/** The tool's wire name (matches the name in `TOOL_CALL_START`). */
|
|
7
|
+
readonly name: string;
|
|
8
|
+
/** A friendly card label for the tool. */
|
|
9
|
+
readonly summary: string;
|
|
10
|
+
/** Optional longer blurb (e.g. for a future tooltip). */
|
|
11
|
+
readonly description?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a fetched tool catalog into a `name → summary` map, skipping any entry
|
|
16
|
+
* that isn't a `{ name: string, summary: string }` object. Tolerant by design:
|
|
17
|
+
* a malformed payload yields an empty map rather than throwing.
|
|
18
|
+
*/
|
|
19
|
+
export function parseToolCatalog(data: unknown): Record<string, string> {
|
|
20
|
+
const out: Record<string, string> = {};
|
|
21
|
+
if (!Array.isArray(data)) {
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
for (const entry of data) {
|
|
25
|
+
if (entry === null || typeof entry !== "object") {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const record = entry as Record<string, unknown>;
|
|
29
|
+
const name = record["name"];
|
|
30
|
+
const summary = record["summary"];
|
|
31
|
+
if (typeof name === "string" && typeof summary === "string") {
|
|
32
|
+
out[name] = summary;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prettify a raw tool name for display: separators become spaces and the
|
|
3
|
+
* first letter is capitalised — `list_projects` → "List projects",
|
|
4
|
+
* `invoices.retrieve` → "Invoices retrieve".
|
|
5
|
+
*
|
|
6
|
+
* Final fallback of the tool-card label chain (`x-summary` →
|
|
7
|
+
* `toolSummaries` → fetched catalog → this). Purely cosmetic: the original
|
|
8
|
+
* name still rides on the card's dataset for debugging.
|
|
9
|
+
*/
|
|
10
|
+
export function prettifyToolName(name: string): string {
|
|
11
|
+
const spaced = name.replace(/[._-]+/g, " ").trim();
|
|
12
|
+
if (spaced === "") {
|
|
13
|
+
return name;
|
|
14
|
+
}
|
|
15
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
16
|
+
}
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import DOMPurify from "dompurify";
|
|
2
|
-
import {
|
|
2
|
+
import { Marked } from "marked";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
|
|
4
|
+
// Local parser instance so configuration never leaks into the shared `marked`
|
|
5
|
+
// singleton (a host app's deduped copy keeps its own options). GitHub-flavoured
|
|
6
|
+
// markdown with single-newline line breaks (chat-like). Constructed once at
|
|
7
|
+
// module scope — configured here and never mutated afterwards; per-call
|
|
8
|
+
// construction would re-pay setup on every streaming re-render.
|
|
9
|
+
const parser = new Marked({ gfm: true, breaks: true });
|
|
6
10
|
|
|
7
11
|
// Conservative allowlist for assistant chat content: inline emphasis, code,
|
|
8
|
-
// lists, quotes, headings, links,
|
|
9
|
-
// `
|
|
10
|
-
//
|
|
12
|
+
// lists, quotes, headings, links, and tables. Deliberately excludes `iframe`,
|
|
13
|
+
// `style`, and any scripting — rendering untrusted model/tool output as HTML
|
|
14
|
+
// is an XSS surface, so the sanitiser is the load-bearing safety net.
|
|
15
|
+
//
|
|
16
|
+
// `img` is excluded by default: a model-controlled `<img src="https://...">`
|
|
17
|
+
// is fetched by the browser with **no user interaction**, which turns any
|
|
18
|
+
// prompt-injected page data into a zero-click exfiltration channel. Hosts
|
|
19
|
+
// that trust their content can opt back in via `allowImages`.
|
|
11
20
|
const ALLOWED_TAGS = [
|
|
12
21
|
"a",
|
|
13
22
|
"p",
|
|
@@ -39,10 +48,23 @@ const ALLOWED_TAGS = [
|
|
|
39
48
|
"tr",
|
|
40
49
|
"th",
|
|
41
50
|
"td",
|
|
42
|
-
"img",
|
|
43
51
|
];
|
|
44
52
|
|
|
45
|
-
const ALLOWED_ATTR = ["href", "title", "class"
|
|
53
|
+
const ALLOWED_ATTR = ["href", "title", "class"];
|
|
54
|
+
|
|
55
|
+
// The image-permitting variants used when the host opts in.
|
|
56
|
+
const ALLOWED_TAGS_WITH_IMAGES = [...ALLOWED_TAGS, "img"];
|
|
57
|
+
const ALLOWED_ATTR_WITH_IMAGES = [...ALLOWED_ATTR, "src", "alt", "width", "height"];
|
|
58
|
+
|
|
59
|
+
/** Options for {@link renderMarkdown}. */
|
|
60
|
+
export interface RenderMarkdownOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Permit `<img>` tags (and their `src`/`alt`/`width`/`height` attributes)
|
|
63
|
+
* in the sanitised output. **Off by default** — see the allowlist note on
|
|
64
|
+
* the exfiltration risk. Only enable for trusted content sources.
|
|
65
|
+
*/
|
|
66
|
+
readonly allowImages?: boolean;
|
|
67
|
+
}
|
|
46
68
|
|
|
47
69
|
/**
|
|
48
70
|
* Render markdown (and any embedded raw HTML) to a sanitised HTML string.
|
|
@@ -55,9 +77,13 @@ const ALLOWED_ATTR = ["href", "title", "class", "src", "alt", "width", "height"]
|
|
|
55
77
|
* The result is trimmed so a single-paragraph message round-trips to clean
|
|
56
78
|
* `textContent` (no trailing newline from the wrapping `<p>`).
|
|
57
79
|
*/
|
|
58
|
-
export function renderMarkdown(text: string): string {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
80
|
+
export function renderMarkdown(text: string, options?: RenderMarkdownOptions): string {
|
|
81
|
+
const allowImages = options?.allowImages === true;
|
|
82
|
+
const rendered = parser.parse(text, { async: false });
|
|
83
|
+
const clean = DOMPurify.sanitize(rendered, {
|
|
84
|
+
ALLOWED_TAGS: allowImages ? ALLOWED_TAGS_WITH_IMAGES : ALLOWED_TAGS,
|
|
85
|
+
ALLOWED_ATTR: allowImages ? ALLOWED_ATTR_WITH_IMAGES : ALLOWED_ATTR,
|
|
86
|
+
});
|
|
61
87
|
const template = document.createElement("template");
|
|
62
88
|
template.innerHTML = clean;
|
|
63
89
|
for (const anchor of template.content.querySelectorAll("a[href]")) {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION: string = "0.
|
|
1
|
+
export const VERSION: string = "0.3.1";
|