@ai-gui/vanilla 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -26,18 +26,18 @@ const __ai_gui_core = __toESM(require("@ai-gui/core"));
26
26
 
27
27
  //#region src/render-output.ts
28
28
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
29
- function renderOutputToElement(out) {
29
+ function renderOutputToElement(out, sanitize) {
30
30
  switch (out.kind) {
31
31
  case "html": {
32
32
  const el = document.createElement("div");
33
- el.innerHTML = (0, __ai_gui_core.sanitizeHtml)(out.html);
33
+ el.innerHTML = sanitizeOutput(out.html, sanitize);
34
34
  return el;
35
35
  }
36
36
  case "element": {
37
37
  const el = document.createElement(out.tag);
38
38
  for (const [key, value] of Object.entries(out.props ?? {})) if (key === "class" || key === "className") el.className = String(value);
39
39
  else el.setAttribute(key, String(value));
40
- for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child));
40
+ for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child, sanitize));
41
41
  return el;
42
42
  }
43
43
  case "card": {
@@ -52,37 +52,53 @@ function renderOutputToElement(out) {
52
52
  const el = document.createElement("div");
53
53
  el.setAttribute("data-aigui-mount", "");
54
54
  queueMicrotask(() => {
55
+ if (el.__aiguiDisposed) return;
55
56
  const c = out.mount(el);
56
- if (typeof c === "function") el.__aiguiCleanup = c;
57
+ if (typeof c === "function") if (el.__aiguiDisposed) c();
58
+ else el.__aiguiCleanup = c;
57
59
  });
58
60
  return el;
59
61
  }
60
62
  }
61
63
  }
64
+ function sanitizeOutput(html, sanitize) {
65
+ if (sanitize === false) return html;
66
+ return (0, __ai_gui_core.sanitizeHtml)(html, typeof sanitize === "object" ? sanitize : void 0);
67
+ }
62
68
 
63
69
  //#endregion
64
70
  //#region src/render-node-dom.ts
65
71
  function renderNodeToElement(node, ctx) {
66
- const r = (0, __ai_gui_core.collectNodeRenderers)(ctx.plugins)[node.type];
67
- if (r) {
72
+ const r = (ctx.nodeRenderers ?? (0, __ai_gui_core.collectNodeRenderers)(ctx.plugins))[node.type];
73
+ if (r) try {
68
74
  const out = r(node);
69
75
  if (typeof out?.then === "function") {
70
- const ph = document.createElement("div");
71
- ph.setAttribute("data-aigui-async-pending", "");
72
- out.then((res) => ph.replaceWith(renderOutputToElement(res)));
73
- return ph;
76
+ const host = document.createElement("div");
77
+ host.setAttribute("data-aigui-async-pending", "");
78
+ out.then((res) => {
79
+ if (host.__aiguiDisposed) return;
80
+ host.removeAttribute("data-aigui-async-pending");
81
+ host.replaceChildren(renderOutputToElement(res, ctx.sanitize));
82
+ }, () => {
83
+ if (host.__aiguiDisposed) return;
84
+ host.removeAttribute("data-aigui-async-pending");
85
+ host.setAttribute("data-aigui-async-error", "");
86
+ });
87
+ return host;
74
88
  }
75
- return renderOutputToElement(out);
89
+ return renderOutputToElement(out, ctx.sanitize);
90
+ } catch {
91
+ return renderFallback(node, ctx);
76
92
  }
77
93
  switch (node.type) {
78
94
  case "heading": {
79
95
  const el = document.createElement(node.tag ?? "h1");
80
- el.innerHTML = node.html ?? "";
96
+ el.innerHTML = renderHtml(node.html ?? "", ctx);
81
97
  return el;
82
98
  }
83
99
  case "paragraph": {
84
100
  const el = document.createElement("p");
85
- el.innerHTML = node.html ?? "";
101
+ el.innerHTML = renderHtml(node.html ?? "", ctx);
86
102
  return el;
87
103
  }
88
104
  case "code": {
@@ -96,17 +112,22 @@ function renderNodeToElement(node, ctx) {
96
112
  case "hr": return document.createElement("hr");
97
113
  case "html": {
98
114
  const el = document.createElement("div");
99
- el.innerHTML = node.content ?? "";
115
+ el.innerHTML = renderHtml(node.content ?? "", ctx);
100
116
  return el;
101
117
  }
102
118
  case "card": return renderCardElement(node, ctx);
103
- default: {
104
- const el = document.createElement("div");
105
- el.innerHTML = node.html ?? (0, __ai_gui_core.sanitizeHtml)(node.content ?? "");
106
- return el;
107
- }
119
+ default: return renderFallback(node, ctx);
108
120
  }
109
121
  }
122
+ function renderFallback(node, ctx) {
123
+ const el = document.createElement("div");
124
+ el.innerHTML = renderHtml(node.html ?? node.content ?? "", ctx);
125
+ return el;
126
+ }
127
+ function renderHtml(html, ctx) {
128
+ if (ctx.sanitized || ctx.sanitize === false) return html;
129
+ return (0, __ai_gui_core.sanitizeHtml)(html, typeof ctx.sanitize === "object" ? ctx.sanitize : void 0);
130
+ }
110
131
  function renderCardElement(node, ctx) {
111
132
  const card = node.card;
112
133
  if (!card) return document.createElement("div");
@@ -149,7 +170,15 @@ function createReconcileState() {
149
170
  }
150
171
  /** Run a mounted widget's cleanup (if any) before the element leaves the DOM. */
151
172
  function disposeEl(el) {
152
- el.__aiguiCleanup?.();
173
+ for (const child of el.querySelectorAll("*")) disposeManagedEl(child);
174
+ disposeManagedEl(el);
175
+ }
176
+ function disposeManagedEl(el) {
177
+ const managed = el;
178
+ if (managed.__aiguiDisposed) return;
179
+ managed.__aiguiDisposed = true;
180
+ managed.__aiguiCleanup?.();
181
+ managed.__aiguiCleanup = void 0;
153
182
  }
154
183
  function reconcile(container, nodes, ctx, state) {
155
184
  const nextKeys = new Set(nodes.map((n) => n.key));
@@ -184,6 +213,7 @@ function reconcile(container, nodes, ctx, state) {
184
213
  }
185
214
  /** Mutate an existing element to reflect the node without replacing it. Returns false when a fresh element is required. */
186
215
  function updateElementInPlace(el, node, ctx) {
216
+ if (ctx.nodeRenderers?.[node.type]) return false;
187
217
  switch (node.type) {
188
218
  case "heading":
189
219
  if (el.tagName !== (node.tag ?? "h1").toUpperCase()) return false;
@@ -212,7 +242,7 @@ function updateElementInPlace(el, node, ctx) {
212
242
  case "card": return false;
213
243
  default:
214
244
  if (el.tagName !== "DIV") return false;
215
- el.innerHTML = node.html ?? (0, __ai_gui_core.sanitizeHtml)(node.content ?? "");
245
+ el.innerHTML = node.html ?? node.content ?? "";
216
246
  return true;
217
247
  }
218
248
  }
@@ -224,29 +254,43 @@ function createRenderer(el, options = {}) {
224
254
  const ctx = {
225
255
  registry: options.registry,
226
256
  onCardAction,
227
- plugins: options.plugins
257
+ plugins: options.plugins,
258
+ nodeRenderers: (0, __ai_gui_core.collectNodeRenderers)(options.plugins),
259
+ sanitize: options.sanitize,
260
+ sanitized: true
228
261
  };
229
262
  const state = createReconcileState();
263
+ let destroyed = false;
230
264
  const renderer = new __ai_gui_core.Renderer({
231
265
  ...rendererOpts,
232
- onPatch: (_patches, nodes) => reconcile(el, nodes, ctx, state)
266
+ onPatch: (_patches, nodes) => {
267
+ if (!destroyed) reconcile(el, nodes, ctx, state);
268
+ }
233
269
  });
234
270
  const disposeAll = () => {
235
271
  for (const entry of state.els.values()) disposeEl(entry.el);
236
272
  };
237
273
  return {
238
- push: (c) => renderer.push(c),
239
- feed: (s) => renderer.feed(s),
274
+ push: (c) => {
275
+ if (!destroyed) renderer.push(c);
276
+ },
277
+ feed: (source, feedOptions) => destroyed ? Promise.resolve() : renderer.feed(source, feedOptions),
240
278
  reset: () => {
241
- disposeAll();
242
- renderer.reset();
243
- state.els.clear();
244
- el.replaceChildren();
279
+ if (!destroyed) {
280
+ disposeAll();
281
+ renderer.reset();
282
+ state.els.clear();
283
+ el.replaceChildren();
284
+ }
245
285
  },
246
286
  destroy: () => {
247
- disposeAll();
248
- state.els.clear();
249
- el.replaceChildren();
287
+ if (!destroyed) {
288
+ destroyed = true;
289
+ renderer.reset();
290
+ disposeAll();
291
+ state.els.clear();
292
+ el.replaceChildren();
293
+ }
250
294
  }
251
295
  };
252
296
  }
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AIGuiPlugin, ASTNode, CardRegistry, RenderOutput, RendererOptions } from "@ai-gui/core";
1
+ import { AIGuiPlugin, ASTNode, CardRegistry, FeedOptions, FeedSource, NodeRenderer, RenderOutput, RendererOptions } from "@ai-gui/core";
2
2
 
3
3
  //#region src/render-node-dom.d.ts
4
4
  interface DomRenderContext {
@@ -9,6 +9,9 @@ interface DomRenderContext {
9
9
  cardType: string;
10
10
  }) => void;
11
11
  plugins?: AIGuiPlugin[];
12
+ nodeRenderers?: Record<string, NodeRenderer>;
13
+ sanitize?: RendererOptions["sanitize"];
14
+ sanitized?: boolean;
12
15
  }
13
16
  declare function renderNodeToElement(node: ASTNode, ctx: DomRenderContext): HTMLElement;
14
17
 
@@ -19,7 +22,7 @@ interface CreateRendererOptions extends Omit<RendererOptions, "onPatch"> {
19
22
  }
20
23
  interface VanillaRenderer {
21
24
  push: (chunk: string) => void;
22
- feed: (source: AsyncIterable<string> | ReadableStream) => Promise<void>;
25
+ feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
23
26
  reset: () => void;
24
27
  destroy: () => void;
25
28
  }
@@ -28,7 +31,7 @@ declare function createRenderer(el: HTMLElement, options?: CreateRendererOptions
28
31
  //#endregion
29
32
  //#region src/render-output.d.ts
30
33
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
31
- declare function renderOutputToElement(out: RenderOutput): HTMLElement;
34
+ declare function renderOutputToElement(out: RenderOutput, sanitize?: RendererOptions["sanitize"]): HTMLElement;
32
35
 
33
36
  //#endregion
34
37
  export { CreateRendererOptions, DomRenderContext, VanillaRenderer, createRenderer, renderNodeToElement, renderOutputToElement };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AIGuiPlugin, ASTNode, CardRegistry, RenderOutput, RendererOptions } from "@ai-gui/core";
1
+ import { AIGuiPlugin, ASTNode, CardRegistry, FeedOptions, FeedSource, NodeRenderer, RenderOutput, RendererOptions } from "@ai-gui/core";
2
2
 
3
3
  //#region src/render-node-dom.d.ts
4
4
  interface DomRenderContext {
@@ -9,6 +9,9 @@ interface DomRenderContext {
9
9
  cardType: string;
10
10
  }) => void;
11
11
  plugins?: AIGuiPlugin[];
12
+ nodeRenderers?: Record<string, NodeRenderer>;
13
+ sanitize?: RendererOptions["sanitize"];
14
+ sanitized?: boolean;
12
15
  }
13
16
  declare function renderNodeToElement(node: ASTNode, ctx: DomRenderContext): HTMLElement;
14
17
 
@@ -19,7 +22,7 @@ interface CreateRendererOptions extends Omit<RendererOptions, "onPatch"> {
19
22
  }
20
23
  interface VanillaRenderer {
21
24
  push: (chunk: string) => void;
22
- feed: (source: AsyncIterable<string> | ReadableStream) => Promise<void>;
25
+ feed: (source: FeedSource, options?: FeedOptions) => Promise<void>;
23
26
  reset: () => void;
24
27
  destroy: () => void;
25
28
  }
@@ -28,7 +31,7 @@ declare function createRenderer(el: HTMLElement, options?: CreateRendererOptions
28
31
  //#endregion
29
32
  //#region src/render-output.d.ts
30
33
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
31
- declare function renderOutputToElement(out: RenderOutput): HTMLElement;
34
+ declare function renderOutputToElement(out: RenderOutput, sanitize?: RendererOptions["sanitize"]): HTMLElement;
32
35
 
33
36
  //#endregion
34
37
  export { CreateRendererOptions, DomRenderContext, VanillaRenderer, createRenderer, renderNodeToElement, renderOutputToElement };
package/dist/index.js CHANGED
@@ -2,18 +2,18 @@ import { Renderer, collectNodeRenderers, sanitizeHtml } from "@ai-gui/core";
2
2
 
3
3
  //#region src/render-output.ts
4
4
  /** Translate a framework-neutral RenderOutput (from a plugin node renderer) into a DOM element. */
5
- function renderOutputToElement(out) {
5
+ function renderOutputToElement(out, sanitize) {
6
6
  switch (out.kind) {
7
7
  case "html": {
8
8
  const el = document.createElement("div");
9
- el.innerHTML = sanitizeHtml(out.html);
9
+ el.innerHTML = sanitizeOutput(out.html, sanitize);
10
10
  return el;
11
11
  }
12
12
  case "element": {
13
13
  const el = document.createElement(out.tag);
14
14
  for (const [key, value] of Object.entries(out.props ?? {})) if (key === "class" || key === "className") el.className = String(value);
15
15
  else el.setAttribute(key, String(value));
16
- for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child));
16
+ for (const child of out.children ?? []) el.appendChild(renderOutputToElement(child, sanitize));
17
17
  return el;
18
18
  }
19
19
  case "card": {
@@ -28,37 +28,53 @@ function renderOutputToElement(out) {
28
28
  const el = document.createElement("div");
29
29
  el.setAttribute("data-aigui-mount", "");
30
30
  queueMicrotask(() => {
31
+ if (el.__aiguiDisposed) return;
31
32
  const c = out.mount(el);
32
- if (typeof c === "function") el.__aiguiCleanup = c;
33
+ if (typeof c === "function") if (el.__aiguiDisposed) c();
34
+ else el.__aiguiCleanup = c;
33
35
  });
34
36
  return el;
35
37
  }
36
38
  }
37
39
  }
40
+ function sanitizeOutput(html, sanitize) {
41
+ if (sanitize === false) return html;
42
+ return sanitizeHtml(html, typeof sanitize === "object" ? sanitize : void 0);
43
+ }
38
44
 
39
45
  //#endregion
40
46
  //#region src/render-node-dom.ts
41
47
  function renderNodeToElement(node, ctx) {
42
- const r = collectNodeRenderers(ctx.plugins)[node.type];
43
- if (r) {
48
+ const r = (ctx.nodeRenderers ?? collectNodeRenderers(ctx.plugins))[node.type];
49
+ if (r) try {
44
50
  const out = r(node);
45
51
  if (typeof out?.then === "function") {
46
- const ph = document.createElement("div");
47
- ph.setAttribute("data-aigui-async-pending", "");
48
- out.then((res) => ph.replaceWith(renderOutputToElement(res)));
49
- return ph;
52
+ const host = document.createElement("div");
53
+ host.setAttribute("data-aigui-async-pending", "");
54
+ out.then((res) => {
55
+ if (host.__aiguiDisposed) return;
56
+ host.removeAttribute("data-aigui-async-pending");
57
+ host.replaceChildren(renderOutputToElement(res, ctx.sanitize));
58
+ }, () => {
59
+ if (host.__aiguiDisposed) return;
60
+ host.removeAttribute("data-aigui-async-pending");
61
+ host.setAttribute("data-aigui-async-error", "");
62
+ });
63
+ return host;
50
64
  }
51
- return renderOutputToElement(out);
65
+ return renderOutputToElement(out, ctx.sanitize);
66
+ } catch {
67
+ return renderFallback(node, ctx);
52
68
  }
53
69
  switch (node.type) {
54
70
  case "heading": {
55
71
  const el = document.createElement(node.tag ?? "h1");
56
- el.innerHTML = node.html ?? "";
72
+ el.innerHTML = renderHtml(node.html ?? "", ctx);
57
73
  return el;
58
74
  }
59
75
  case "paragraph": {
60
76
  const el = document.createElement("p");
61
- el.innerHTML = node.html ?? "";
77
+ el.innerHTML = renderHtml(node.html ?? "", ctx);
62
78
  return el;
63
79
  }
64
80
  case "code": {
@@ -72,17 +88,22 @@ function renderNodeToElement(node, ctx) {
72
88
  case "hr": return document.createElement("hr");
73
89
  case "html": {
74
90
  const el = document.createElement("div");
75
- el.innerHTML = node.content ?? "";
91
+ el.innerHTML = renderHtml(node.content ?? "", ctx);
76
92
  return el;
77
93
  }
78
94
  case "card": return renderCardElement(node, ctx);
79
- default: {
80
- const el = document.createElement("div");
81
- el.innerHTML = node.html ?? sanitizeHtml(node.content ?? "");
82
- return el;
83
- }
95
+ default: return renderFallback(node, ctx);
84
96
  }
85
97
  }
98
+ function renderFallback(node, ctx) {
99
+ const el = document.createElement("div");
100
+ el.innerHTML = renderHtml(node.html ?? node.content ?? "", ctx);
101
+ return el;
102
+ }
103
+ function renderHtml(html, ctx) {
104
+ if (ctx.sanitized || ctx.sanitize === false) return html;
105
+ return sanitizeHtml(html, typeof ctx.sanitize === "object" ? ctx.sanitize : void 0);
106
+ }
86
107
  function renderCardElement(node, ctx) {
87
108
  const card = node.card;
88
109
  if (!card) return document.createElement("div");
@@ -125,7 +146,15 @@ function createReconcileState() {
125
146
  }
126
147
  /** Run a mounted widget's cleanup (if any) before the element leaves the DOM. */
127
148
  function disposeEl(el) {
128
- el.__aiguiCleanup?.();
149
+ for (const child of el.querySelectorAll("*")) disposeManagedEl(child);
150
+ disposeManagedEl(el);
151
+ }
152
+ function disposeManagedEl(el) {
153
+ const managed = el;
154
+ if (managed.__aiguiDisposed) return;
155
+ managed.__aiguiDisposed = true;
156
+ managed.__aiguiCleanup?.();
157
+ managed.__aiguiCleanup = void 0;
129
158
  }
130
159
  function reconcile(container, nodes, ctx, state) {
131
160
  const nextKeys = new Set(nodes.map((n) => n.key));
@@ -160,6 +189,7 @@ function reconcile(container, nodes, ctx, state) {
160
189
  }
161
190
  /** Mutate an existing element to reflect the node without replacing it. Returns false when a fresh element is required. */
162
191
  function updateElementInPlace(el, node, ctx) {
192
+ if (ctx.nodeRenderers?.[node.type]) return false;
163
193
  switch (node.type) {
164
194
  case "heading":
165
195
  if (el.tagName !== (node.tag ?? "h1").toUpperCase()) return false;
@@ -188,7 +218,7 @@ function updateElementInPlace(el, node, ctx) {
188
218
  case "card": return false;
189
219
  default:
190
220
  if (el.tagName !== "DIV") return false;
191
- el.innerHTML = node.html ?? sanitizeHtml(node.content ?? "");
221
+ el.innerHTML = node.html ?? node.content ?? "";
192
222
  return true;
193
223
  }
194
224
  }
@@ -200,29 +230,43 @@ function createRenderer(el, options = {}) {
200
230
  const ctx = {
201
231
  registry: options.registry,
202
232
  onCardAction,
203
- plugins: options.plugins
233
+ plugins: options.plugins,
234
+ nodeRenderers: collectNodeRenderers(options.plugins),
235
+ sanitize: options.sanitize,
236
+ sanitized: true
204
237
  };
205
238
  const state = createReconcileState();
239
+ let destroyed = false;
206
240
  const renderer = new Renderer({
207
241
  ...rendererOpts,
208
- onPatch: (_patches, nodes) => reconcile(el, nodes, ctx, state)
242
+ onPatch: (_patches, nodes) => {
243
+ if (!destroyed) reconcile(el, nodes, ctx, state);
244
+ }
209
245
  });
210
246
  const disposeAll = () => {
211
247
  for (const entry of state.els.values()) disposeEl(entry.el);
212
248
  };
213
249
  return {
214
- push: (c) => renderer.push(c),
215
- feed: (s) => renderer.feed(s),
250
+ push: (c) => {
251
+ if (!destroyed) renderer.push(c);
252
+ },
253
+ feed: (source, feedOptions) => destroyed ? Promise.resolve() : renderer.feed(source, feedOptions),
216
254
  reset: () => {
217
- disposeAll();
218
- renderer.reset();
219
- state.els.clear();
220
- el.replaceChildren();
255
+ if (!destroyed) {
256
+ disposeAll();
257
+ renderer.reset();
258
+ state.els.clear();
259
+ el.replaceChildren();
260
+ }
221
261
  },
222
262
  destroy: () => {
223
- disposeAll();
224
- state.els.clear();
225
- el.replaceChildren();
263
+ if (!destroyed) {
264
+ destroyed = true;
265
+ renderer.reset();
266
+ disposeAll();
267
+ state.els.clear();
268
+ el.replaceChildren();
269
+ }
226
270
  }
227
271
  };
228
272
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-gui/vanilla",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Framework-free DOM adapter for AIGUI — stream LLM output straight into the DOM.",
5
5
  "keywords": [
6
6
  "llm",
@@ -22,14 +22,23 @@
22
22
  "homepage": "https://github.com/liliang-cn/aigui#readme",
23
23
  "bugs": "https://github.com/liliang-cn/aigui/issues",
24
24
  "type": "module",
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
25
29
  "main": "./dist/index.cjs",
26
30
  "module": "./dist/index.js",
27
31
  "types": "./dist/index.d.ts",
28
32
  "exports": {
29
33
  ".": {
30
- "types": "./dist/index.d.ts",
31
- "import": "./dist/index.js",
32
- "require": "./dist/index.cjs"
34
+ "import": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "require": {
39
+ "types": "./dist/index.d.cts",
40
+ "default": "./dist/index.cjs"
41
+ }
33
42
  }
34
43
  },
35
44
  "files": [
@@ -41,10 +50,11 @@
41
50
  "access": "public"
42
51
  },
43
52
  "dependencies": {
44
- "@ai-gui/core": "0.1.0"
53
+ "@ai-gui/core": "0.2.0"
45
54
  },
46
55
  "scripts": {
47
56
  "build": "tsdown",
57
+ "test": "pnpm --dir ../.. exec vitest run --project vanilla",
48
58
  "typecheck": "tsc --noEmit"
49
59
  }
50
60
  }