@ai-gui/plugin-ui 0.4.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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/index.cjs +1142 -0
- package/dist/index.d.cts +303 -0
- package/dist/index.d.ts +303 -0
- package/dist/index.js +1108 -0
- package/package.json +59 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
//#region rolldown:runtime
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
+
get: ((k) => from[k]).bind(null, key),
|
|
14
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
20
|
+
value: mod,
|
|
21
|
+
enumerable: true
|
|
22
|
+
}) : target, mod));
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
const __ai_gui_core = __toESM(require("@ai-gui/core"));
|
|
26
|
+
|
|
27
|
+
//#region src/css.ts
|
|
28
|
+
const uiCss = `
|
|
29
|
+
[data-aigui-ui] { display: block; color: inherit; font: inherit; }
|
|
30
|
+
[data-aigui-ui-stack="row"] { display: flex; flex-direction: row; flex-wrap: wrap; }
|
|
31
|
+
[data-aigui-ui-stack="column"] { display: flex; flex-direction: column; }
|
|
32
|
+
[data-aigui-ui-grid] { display: grid; grid-template-columns: repeat(var(--aigui-ui-columns, 1), minmax(0, 1fr)); }
|
|
33
|
+
[data-aigui-ui-grid="2"] { --aigui-ui-columns: 2; }
|
|
34
|
+
[data-aigui-ui-grid="3"] { --aigui-ui-columns: 3; }
|
|
35
|
+
[data-aigui-ui-grid="4"] { --aigui-ui-columns: 4; }
|
|
36
|
+
[data-gap="sm"] { gap: .5rem; } [data-gap="md"] { gap: 1rem; } [data-gap="lg"] { gap: 1.5rem; }
|
|
37
|
+
[data-align="start"] { align-items: flex-start; } [data-align="center"] { align-items: center; } [data-align="end"] { align-items: flex-end; } [data-align="stretch"] { align-items: stretch; }
|
|
38
|
+
[data-tone="muted"] { color: color-mix(in srgb, currentColor 60%, transparent); }
|
|
39
|
+
[data-tone="positive"] { color: #18794e; } [data-tone="warning"] { color: #9a6700; } [data-tone="critical"] { color: #b42318; }
|
|
40
|
+
[data-aigui-ui] table { border-collapse: collapse; width: 100%; }
|
|
41
|
+
[data-aigui-ui] th, [data-aigui-ui] td { border: 1px solid currentColor; padding: .4rem .6rem; text-align: left; }
|
|
42
|
+
[data-aigui-ui-field] { display: grid; gap: .25rem; }
|
|
43
|
+
[data-aigui-ui-field-error], [data-aigui-ui-action-error] { color: #b42318; }
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/errors.ts
|
|
48
|
+
var UIDocumentError = class extends Error {
|
|
49
|
+
issues;
|
|
50
|
+
constructor(issues) {
|
|
51
|
+
super(issues[0] ?? "Invalid UI document.");
|
|
52
|
+
this.name = "UIDocumentError";
|
|
53
|
+
this.issues = [...issues];
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var UILimitError = class extends UIDocumentError {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super([message]);
|
|
59
|
+
this.name = "UILimitError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/limits.ts
|
|
65
|
+
const DEFAULT_UI_LIMITS = Object.freeze({
|
|
66
|
+
sourceBytes: 64 * 1024,
|
|
67
|
+
nodes: 200,
|
|
68
|
+
depth: 12,
|
|
69
|
+
children: 50,
|
|
70
|
+
state: 64,
|
|
71
|
+
string: 4096,
|
|
72
|
+
totalStrings: 64 * 1024,
|
|
73
|
+
tableRows: 100,
|
|
74
|
+
tableColumns: 20,
|
|
75
|
+
options: 100,
|
|
76
|
+
boundJSONDepth: 8,
|
|
77
|
+
boundJSONNodes: 512
|
|
78
|
+
});
|
|
79
|
+
function resolveUILimits(overrides) {
|
|
80
|
+
const limits = {
|
|
81
|
+
...DEFAULT_UI_LIMITS,
|
|
82
|
+
...overrides
|
|
83
|
+
};
|
|
84
|
+
for (const [key, value] of Object.entries(limits)) if (!Number.isInteger(value) || value < 1) throw new TypeError(`UI limit "${key}" must be a positive integer.`);
|
|
85
|
+
return limits;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/validate.ts
|
|
90
|
+
const SAFE_ID = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
|
|
91
|
+
const SAFE_STATE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
|
|
92
|
+
const DANGEROUS_KEYS = new Set([
|
|
93
|
+
"__proto__",
|
|
94
|
+
"prototype",
|
|
95
|
+
"constructor"
|
|
96
|
+
]);
|
|
97
|
+
const DOCUMENT_KEYS = set("version", "id", "state", "root");
|
|
98
|
+
const BASE_KEYS = ["kind", "id"];
|
|
99
|
+
const NODE_KEYS = {
|
|
100
|
+
stack: set(...BASE_KEYS, "direction", "gap", "align", "children"),
|
|
101
|
+
grid: set(...BASE_KEYS, "columns", "gap", "children"),
|
|
102
|
+
text: set(...BASE_KEYS, "text", "tone"),
|
|
103
|
+
heading: set(...BASE_KEYS, "level", "text"),
|
|
104
|
+
divider: set(...BASE_KEYS),
|
|
105
|
+
list: set(...BASE_KEYS, "ordered", "items"),
|
|
106
|
+
table: set(...BASE_KEYS, "caption", "headers", "rows"),
|
|
107
|
+
keyValue: set(...BASE_KEYS, "items"),
|
|
108
|
+
form: set(...BASE_KEYS, "children", "submit", "submitLabel"),
|
|
109
|
+
field: set(...BASE_KEYS, "bind", "fieldType", "label", "required", "placeholder", "minLength", "maxLength", "pattern", "min", "max", "options"),
|
|
110
|
+
button: set(...BASE_KEYS, "label", "variant", "action"),
|
|
111
|
+
card: set(...BASE_KEYS, "type", "data")
|
|
112
|
+
};
|
|
113
|
+
const GAPS = new Set([
|
|
114
|
+
"none",
|
|
115
|
+
"sm",
|
|
116
|
+
"md",
|
|
117
|
+
"lg"
|
|
118
|
+
]);
|
|
119
|
+
const ALIGNS = new Set([
|
|
120
|
+
"start",
|
|
121
|
+
"center",
|
|
122
|
+
"end",
|
|
123
|
+
"stretch"
|
|
124
|
+
]);
|
|
125
|
+
const TONES = new Set([
|
|
126
|
+
"default",
|
|
127
|
+
"muted",
|
|
128
|
+
"positive",
|
|
129
|
+
"warning",
|
|
130
|
+
"critical"
|
|
131
|
+
]);
|
|
132
|
+
const FIELD_TYPES = new Set([
|
|
133
|
+
"text",
|
|
134
|
+
"textarea",
|
|
135
|
+
"number",
|
|
136
|
+
"date",
|
|
137
|
+
"select",
|
|
138
|
+
"checkbox",
|
|
139
|
+
"radio"
|
|
140
|
+
]);
|
|
141
|
+
function parseUIDocument(source, options) {
|
|
142
|
+
if (typeof source !== "string") throw new UIDocumentError(["UI source must be a string."]);
|
|
143
|
+
const limits = resolveUILimits(options.limits);
|
|
144
|
+
if (new TextEncoder().encode(source).byteLength > limits.sourceBytes) throw new UILimitError("UI source exceeds the byte limit.");
|
|
145
|
+
let value;
|
|
146
|
+
try {
|
|
147
|
+
value = JSON.parse(source);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new UIDocumentError(["UI source must be valid JSON."]);
|
|
150
|
+
}
|
|
151
|
+
return validateUIDocument(value, {
|
|
152
|
+
...options,
|
|
153
|
+
limits
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function validateUIDocument(value, options) {
|
|
157
|
+
if (!options?.registry || !options?.actionRuntime) throw new TypeError("UI validation requires registry and actionRuntime.");
|
|
158
|
+
const limits = resolveUILimits(options.limits);
|
|
159
|
+
assertSafeGraph(value);
|
|
160
|
+
if (!isPlainObject(value)) throw new UIDocumentError(["UI document must be a plain object."]);
|
|
161
|
+
const issues = [];
|
|
162
|
+
rejectKeys(value, DOCUMENT_KEYS, "$", issues);
|
|
163
|
+
if (value.version !== 1) issues.push("$.version must be 1.");
|
|
164
|
+
const id = readString(value.id, "$.id", issues, limits);
|
|
165
|
+
if (id && !SAFE_ID.test(id)) issues.push("$.id is not a safe id.");
|
|
166
|
+
const state = Object.create(null);
|
|
167
|
+
if (value.state !== void 0) if (!isPlainObject(value.state)) issues.push("$.state must be a plain object.");
|
|
168
|
+
else {
|
|
169
|
+
const entries = Object.entries(value.state);
|
|
170
|
+
if (entries.length > limits.state) issues.push(`$.state exceeds ${limits.state} entries.`);
|
|
171
|
+
for (const [key, scalar] of entries) {
|
|
172
|
+
if (DANGEROUS_KEYS.has(key) || !SAFE_STATE_KEY.test(key)) issues.push(`$.state.${key} is not a safe state key.`);
|
|
173
|
+
if (!isScalar(scalar)) issues.push(`$.state.${key} must be a scalar.`);
|
|
174
|
+
else state[key] = scalar;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const ctx = {
|
|
178
|
+
registry: options.registry,
|
|
179
|
+
actionRuntime: options.actionRuntime,
|
|
180
|
+
limits,
|
|
181
|
+
state,
|
|
182
|
+
ids: new Set(),
|
|
183
|
+
nodes: 0,
|
|
184
|
+
totalStrings: 0,
|
|
185
|
+
issues
|
|
186
|
+
};
|
|
187
|
+
const root = validateNode(value.root, "$.root", 1, false, ctx);
|
|
188
|
+
if (issues.length || !id || !root) throw new UIDocumentError(issues.length ? issues : ["Invalid UI document."]);
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
function validateNode(value, path, depth, insideForm, ctx) {
|
|
192
|
+
if (depth > ctx.limits.depth) {
|
|
193
|
+
ctx.issues.push(`${path} exceeds maximum depth.`);
|
|
194
|
+
return void 0;
|
|
195
|
+
}
|
|
196
|
+
if (++ctx.nodes > ctx.limits.nodes) {
|
|
197
|
+
ctx.issues.push(`UI exceeds ${ctx.limits.nodes} nodes.`);
|
|
198
|
+
return void 0;
|
|
199
|
+
}
|
|
200
|
+
if (!isPlainObject(value)) {
|
|
201
|
+
ctx.issues.push(`${path} must be a plain object.`);
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
const kind = typeof value.kind === "string" ? value.kind : "";
|
|
205
|
+
const allowed = NODE_KEYS[kind];
|
|
206
|
+
if (!allowed) {
|
|
207
|
+
ctx.issues.push(`${path}.kind is not supported.`);
|
|
208
|
+
return void 0;
|
|
209
|
+
}
|
|
210
|
+
rejectKeys(value, allowed, path, ctx.issues);
|
|
211
|
+
const id = readString(value.id, `${path}.id`, ctx.issues, ctx.limits);
|
|
212
|
+
if (id) {
|
|
213
|
+
if (!SAFE_ID.test(id)) ctx.issues.push(`${path}.id is not safe.`);
|
|
214
|
+
if (ctx.ids.has(id)) ctx.issues.push(`${path}.id must be unique.`);
|
|
215
|
+
ctx.ids.add(id);
|
|
216
|
+
}
|
|
217
|
+
switch (kind) {
|
|
218
|
+
case "stack":
|
|
219
|
+
optionalEnum(value.direction, ["row", "column"], `${path}.direction`, ctx.issues);
|
|
220
|
+
optionalEnum(value.gap, GAPS, `${path}.gap`, ctx.issues);
|
|
221
|
+
optionalEnum(value.align, ALIGNS, `${path}.align`, ctx.issues);
|
|
222
|
+
validateChildren(value.children, path, depth, insideForm, ctx);
|
|
223
|
+
break;
|
|
224
|
+
case "grid":
|
|
225
|
+
if (![
|
|
226
|
+
1,
|
|
227
|
+
2,
|
|
228
|
+
3,
|
|
229
|
+
4
|
|
230
|
+
].includes(value.columns)) ctx.issues.push(`${path}.columns must be 1, 2, 3, or 4.`);
|
|
231
|
+
optionalEnum(value.gap, GAPS, `${path}.gap`, ctx.issues);
|
|
232
|
+
validateChildren(value.children, path, depth, insideForm, ctx);
|
|
233
|
+
break;
|
|
234
|
+
case "text":
|
|
235
|
+
validateScalarExpression(value.text, `${path}.text`, ctx);
|
|
236
|
+
optionalEnum(value.tone, TONES, `${path}.tone`, ctx.issues);
|
|
237
|
+
break;
|
|
238
|
+
case "heading":
|
|
239
|
+
if (![
|
|
240
|
+
2,
|
|
241
|
+
3,
|
|
242
|
+
4
|
|
243
|
+
].includes(value.level)) ctx.issues.push(`${path}.level must be 2, 3, or 4.`);
|
|
244
|
+
validateScalarExpression(value.text, `${path}.text`, ctx);
|
|
245
|
+
break;
|
|
246
|
+
case "divider": break;
|
|
247
|
+
case "list":
|
|
248
|
+
if (value.ordered !== void 0 && typeof value.ordered !== "boolean") ctx.issues.push(`${path}.ordered must be boolean.`);
|
|
249
|
+
if (!Array.isArray(value.items)) ctx.issues.push(`${path}.items must be an array.`);
|
|
250
|
+
else value.items.forEach((item, index) => {
|
|
251
|
+
if (!isScalar(item)) ctx.issues.push(`${path}.items[${index}] must be a scalar.`);
|
|
252
|
+
else countScalar(item, ctx, `${path}.items[${index}]`);
|
|
253
|
+
});
|
|
254
|
+
break;
|
|
255
|
+
case "table":
|
|
256
|
+
validateTable(value, path, ctx);
|
|
257
|
+
break;
|
|
258
|
+
case "keyValue":
|
|
259
|
+
validateKeyValue(value, path, ctx);
|
|
260
|
+
break;
|
|
261
|
+
case "form":
|
|
262
|
+
if (insideForm) ctx.issues.push(`${path} cannot nest a form.`);
|
|
263
|
+
validateActionType(value.submit, `${path}.submit`, ctx);
|
|
264
|
+
if (value.submitLabel !== void 0) readString(value.submitLabel, `${path}.submitLabel`, ctx.issues, ctx.limits);
|
|
265
|
+
validateChildren(value.children, path, depth, true, ctx);
|
|
266
|
+
break;
|
|
267
|
+
case "field":
|
|
268
|
+
validateField$1(value, path, insideForm, ctx);
|
|
269
|
+
break;
|
|
270
|
+
case "button":
|
|
271
|
+
readString(value.label, `${path}.label`, ctx.issues, ctx.limits);
|
|
272
|
+
optionalEnum(value.variant, [
|
|
273
|
+
"primary",
|
|
274
|
+
"secondary",
|
|
275
|
+
"danger"
|
|
276
|
+
], `${path}.variant`, ctx.issues);
|
|
277
|
+
validateAction(value.action, `${path}.action`, ctx);
|
|
278
|
+
break;
|
|
279
|
+
case "card": {
|
|
280
|
+
const type = readString(value.type, `${path}.type`, ctx.issues, ctx.limits);
|
|
281
|
+
validateBoundJSON(value.data, `${path}.data`, ctx);
|
|
282
|
+
if (type && !ctx.registry.get(type)) ctx.issues.push(`${path}.type is not registered.`);
|
|
283
|
+
else if (type) try {
|
|
284
|
+
if (!ctx.registry.validate(type, resolveBoundJSON(value.data, ctx.state))) ctx.issues.push(`${path}.data is invalid for card type "${type}".`);
|
|
285
|
+
} catch {
|
|
286
|
+
ctx.issues.push(`${path}.data is invalid.`);
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
function validateChildren(value, path, depth, insideForm, ctx) {
|
|
294
|
+
if (!Array.isArray(value)) {
|
|
295
|
+
ctx.issues.push(`${path}.children must be an array.`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (value.length > ctx.limits.children) ctx.issues.push(`${path}.children exceeds ${ctx.limits.children} items.`);
|
|
299
|
+
value.forEach((child, index) => validateNode(child, `${path}.children[${index}]`, depth + 1, insideForm, ctx));
|
|
300
|
+
}
|
|
301
|
+
function validateTable(value, path, ctx) {
|
|
302
|
+
readString(value.caption, `${path}.caption`, ctx.issues, ctx.limits, true);
|
|
303
|
+
if (!Array.isArray(value.headers) || value.headers.length > ctx.limits.tableColumns) ctx.issues.push(`${path}.headers must contain at most ${ctx.limits.tableColumns} strings.`);
|
|
304
|
+
else value.headers.forEach((header, index) => readString(header, `${path}.headers[${index}]`, ctx.issues, ctx.limits, true));
|
|
305
|
+
if (!Array.isArray(value.rows) || value.rows.length > ctx.limits.tableRows) ctx.issues.push(`${path}.rows must contain at most ${ctx.limits.tableRows} rows.`);
|
|
306
|
+
else value.rows.forEach((row, rowIndex) => {
|
|
307
|
+
if (!Array.isArray(row) || !Array.isArray(value.headers) || row.length !== value.headers.length) ctx.issues.push(`${path}.rows[${rowIndex}] must match header width.`);
|
|
308
|
+
else row.forEach((cell, cellIndex) => {
|
|
309
|
+
if (!isScalar(cell)) ctx.issues.push(`${path}.rows[${rowIndex}][${cellIndex}] must be a scalar.`);
|
|
310
|
+
else countScalar(cell, ctx, `${path}.rows[${rowIndex}][${cellIndex}]`);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function validateKeyValue(value, path, ctx) {
|
|
315
|
+
if (!Array.isArray(value.items)) {
|
|
316
|
+
ctx.issues.push(`${path}.items must be an array.`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
value.items.forEach((item, index) => {
|
|
320
|
+
const itemPath = `${path}.items[${index}]`;
|
|
321
|
+
if (!isPlainObject(item)) {
|
|
322
|
+
ctx.issues.push(`${itemPath} must be a plain object.`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
rejectKeys(item, set("label", "value"), itemPath, ctx.issues);
|
|
326
|
+
readString(item.label, `${itemPath}.label`, ctx.issues, ctx.limits);
|
|
327
|
+
validateScalarExpression(item.value, `${itemPath}.value`, ctx);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
function validateField$1(value, path, insideForm, ctx) {
|
|
331
|
+
if (!insideForm) ctx.issues.push(`${path} must be inside a form.`);
|
|
332
|
+
const field = value;
|
|
333
|
+
const bind = readString(value.bind, `${path}.bind`, ctx.issues, ctx.limits);
|
|
334
|
+
const type = readString(value.fieldType, `${path}.fieldType`, ctx.issues, ctx.limits);
|
|
335
|
+
readString(value.label, `${path}.label`, ctx.issues, ctx.limits);
|
|
336
|
+
if (bind && !Object.hasOwn(ctx.state, bind)) ctx.issues.push(`${path}.bind must reference declared state.`);
|
|
337
|
+
if (type && !FIELD_TYPES.has(type)) ctx.issues.push(`${path}.fieldType is unsupported.`);
|
|
338
|
+
if (value.required !== void 0 && typeof value.required !== "boolean") ctx.issues.push(`${path}.required must be boolean.`);
|
|
339
|
+
if (value.placeholder !== void 0) readString(value.placeholder, `${path}.placeholder`, ctx.issues, ctx.limits, true);
|
|
340
|
+
const minLength = optionalNonNegativeInteger(value.minLength, `${path}.minLength`, ctx.issues);
|
|
341
|
+
const maxLength = optionalNonNegativeInteger(value.maxLength, `${path}.maxLength`, ctx.issues);
|
|
342
|
+
const min = optionalFiniteNumber(value.min, `${path}.min`, ctx.issues);
|
|
343
|
+
const max = optionalFiniteNumber(value.max, `${path}.max`, ctx.issues);
|
|
344
|
+
if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) ctx.issues.push(`${path}.minLength cannot exceed maxLength.`);
|
|
345
|
+
if (min !== void 0 && max !== void 0 && min > max) ctx.issues.push(`${path}.min cannot exceed max.`);
|
|
346
|
+
let pattern;
|
|
347
|
+
if (value.pattern !== void 0) {
|
|
348
|
+
pattern = readString(value.pattern, `${path}.pattern`, ctx.issues, {
|
|
349
|
+
...ctx.limits,
|
|
350
|
+
string: Math.min(ctx.limits.string, 128)
|
|
351
|
+
});
|
|
352
|
+
if (pattern && !isSafePattern(pattern)) ctx.issues.push(`${path}.pattern uses unsupported regular expression features.`);
|
|
353
|
+
else if (pattern) try {
|
|
354
|
+
new RegExp(pattern);
|
|
355
|
+
} catch {
|
|
356
|
+
ctx.issues.push(`${path}.pattern is invalid.`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
validateOptions(value.options, path, ctx);
|
|
360
|
+
if (bind && type && Object.hasOwn(ctx.state, bind) && !compatibleState(type, ctx.state[bind])) ctx.issues.push(`${path}.bind has an incompatible initial value.`);
|
|
361
|
+
if (type === "number" && (minLength !== void 0 || maxLength !== void 0 || pattern !== void 0 || value.options !== void 0 || value.placeholder !== void 0)) ctx.issues.push(`${path} has constraints unsupported by number fields.`);
|
|
362
|
+
if ((type === "select" || type === "radio") && (!Array.isArray(value.options) || minLength !== void 0 || maxLength !== void 0 || pattern !== void 0 || min !== void 0 || max !== void 0 || value.placeholder !== void 0)) ctx.issues.push(`${path} has invalid choice-field properties.`);
|
|
363
|
+
if (type === "checkbox" && (value.options !== void 0 || minLength !== void 0 || maxLength !== void 0 || pattern !== void 0 || min !== void 0 || max !== void 0 || value.placeholder !== void 0)) ctx.issues.push(`${path} has invalid checkbox properties.`);
|
|
364
|
+
if (type === "date" && (value.options !== void 0 || minLength !== void 0 || maxLength !== void 0 || pattern !== void 0 || min !== void 0 || max !== void 0)) ctx.issues.push(`${path} has invalid date properties.`);
|
|
365
|
+
if ((type === "text" || type === "textarea") && (value.options !== void 0 || min !== void 0 || max !== void 0)) ctx.issues.push(`${path} has invalid string-field properties.`);
|
|
366
|
+
}
|
|
367
|
+
function validateOptions(value, path, ctx) {
|
|
368
|
+
if (value === void 0) return;
|
|
369
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > ctx.limits.options) {
|
|
370
|
+
ctx.issues.push(`${path}.options must be a non-empty bounded array.`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const seen = new Set();
|
|
374
|
+
value.forEach((option, index) => {
|
|
375
|
+
const optionPath = `${path}.options[${index}]`;
|
|
376
|
+
if (!isPlainObject(option)) {
|
|
377
|
+
ctx.issues.push(`${optionPath} must be a plain object.`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
rejectKeys(option, set("label", "value"), optionPath, ctx.issues);
|
|
381
|
+
readString(option.label, `${optionPath}.label`, ctx.issues, ctx.limits);
|
|
382
|
+
const optionValue = readString(option.value, `${optionPath}.value`, ctx.issues, ctx.limits, true);
|
|
383
|
+
if (optionValue !== void 0 && seen.has(optionValue)) ctx.issues.push(`${optionPath}.value must be unique.`);
|
|
384
|
+
if (optionValue !== void 0) seen.add(optionValue);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function validateAction(value, path, ctx) {
|
|
388
|
+
if (!isPlainObject(value)) {
|
|
389
|
+
ctx.issues.push(`${path} must be a plain object.`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
rejectKeys(value, set("type", "params"), path, ctx.issues);
|
|
393
|
+
validateActionName(value.type, `${path}.type`, ctx);
|
|
394
|
+
if (value.params !== void 0) validateBoundJSON(value.params, `${path}.params`, ctx);
|
|
395
|
+
}
|
|
396
|
+
function validateActionType(value, path, ctx) {
|
|
397
|
+
if (!isPlainObject(value)) {
|
|
398
|
+
ctx.issues.push(`${path} must be a plain object.`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
rejectKeys(value, set("type"), path, ctx.issues);
|
|
402
|
+
validateActionName(value.type, `${path}.type`, ctx);
|
|
403
|
+
}
|
|
404
|
+
function validateActionName(value, path, ctx) {
|
|
405
|
+
const type = readString(value, path, ctx.issues, ctx.limits);
|
|
406
|
+
if (type && !ctx.actionRuntime.hasAction(type)) ctx.issues.push(`${path} is not registered.`);
|
|
407
|
+
}
|
|
408
|
+
function validateScalarExpression(value, path, ctx) {
|
|
409
|
+
if (isScalar(value)) {
|
|
410
|
+
countScalar(value, ctx, path);
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
if (!isPlainObject(value) || Object.keys(value).length !== 1 || typeof value.$state !== "string") {
|
|
414
|
+
ctx.issues.push(`${path} must be a scalar or exact {$state:string} binding.`);
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
if (!Object.hasOwn(ctx.state, value.$state)) ctx.issues.push(`${path} references undeclared state.`);
|
|
418
|
+
countString(value.$state, ctx, path);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
function validateBoundJSON(value, path, ctx) {
|
|
422
|
+
let count = 0;
|
|
423
|
+
const walk = (current, currentPath, depth) => {
|
|
424
|
+
if (++count > ctx.limits.boundJSONNodes) {
|
|
425
|
+
ctx.issues.push(`${path} exceeds bound JSON node limit.`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (depth > ctx.limits.boundJSONDepth) {
|
|
429
|
+
ctx.issues.push(`${path} exceeds bound JSON depth.`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (isScalar(current)) {
|
|
433
|
+
countScalar(current, ctx, currentPath);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (Array.isArray(current)) {
|
|
437
|
+
current.forEach((item, index) => walk(item, `${currentPath}[${index}]`, depth + 1));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (!isPlainObject(current)) {
|
|
441
|
+
ctx.issues.push(`${currentPath} must contain only JSON values and state bindings.`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (Object.keys(current).length === 1 && typeof current.$state === "string") {
|
|
445
|
+
validateScalarExpression(current, currentPath, ctx);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
for (const [key, item] of Object.entries(current)) {
|
|
449
|
+
if (DANGEROUS_KEYS.has(key)) ctx.issues.push(`${currentPath}.${key} is dangerous.`);
|
|
450
|
+
countString(key, ctx, currentPath);
|
|
451
|
+
walk(item, `${currentPath}.${key}`, depth + 1);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
walk(value, path, 1);
|
|
455
|
+
}
|
|
456
|
+
function resolveBoundJSON(value, state) {
|
|
457
|
+
if (isScalar(value)) return value;
|
|
458
|
+
if (Array.isArray(value)) return value.map((item) => resolveBoundJSON(item, state));
|
|
459
|
+
if (Object.keys(value).length === 1 && typeof value.$state === "string") return state[value.$state];
|
|
460
|
+
const result = Object.create(null);
|
|
461
|
+
for (const [key, item] of Object.entries(value)) result[key] = resolveBoundJSON(item, state);
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
function assertSafeGraph(value) {
|
|
465
|
+
const active = new Set();
|
|
466
|
+
const visited = new Set();
|
|
467
|
+
const walk = (current) => {
|
|
468
|
+
if (typeof current === "number" && !Number.isFinite(current)) throw new UIDocumentError(["UI values must be finite."]);
|
|
469
|
+
if (typeof current !== "object" || current === null) return;
|
|
470
|
+
if (active.has(current)) throw new UIDocumentError(["UI values must not contain cycles."]);
|
|
471
|
+
if (visited.has(current)) return;
|
|
472
|
+
if (!Array.isArray(current) && !isPlainObject(current)) throw new UIDocumentError(["UI values must use plain objects."]);
|
|
473
|
+
if (Array.isArray(current)) {
|
|
474
|
+
for (let i = 0; i < current.length; i++) if (!Object.hasOwn(current, i)) throw new UIDocumentError(["UI arrays must not be sparse."]);
|
|
475
|
+
}
|
|
476
|
+
active.add(current);
|
|
477
|
+
visited.add(current);
|
|
478
|
+
for (const key of Object.keys(current)) {
|
|
479
|
+
if (DANGEROUS_KEYS.has(key)) throw new UIDocumentError([`Dangerous key "${key}" is not allowed.`]);
|
|
480
|
+
walk(current[key]);
|
|
481
|
+
}
|
|
482
|
+
active.delete(current);
|
|
483
|
+
};
|
|
484
|
+
walk(value);
|
|
485
|
+
}
|
|
486
|
+
function rejectKeys(value, allowed, path, issues) {
|
|
487
|
+
for (const key of Object.keys(value)) if (DANGEROUS_KEYS.has(key) || !allowed.has(key)) issues.push(`${path}.${key} is not allowed.`);
|
|
488
|
+
}
|
|
489
|
+
function readString(value, path, issues, limits, allowEmpty = false) {
|
|
490
|
+
if (typeof value !== "string" || !allowEmpty && value.trim() === "" || value.length > limits.string) {
|
|
491
|
+
issues.push(`${path} must be ${allowEmpty ? "a" : "a non-empty"} bounded string.`);
|
|
492
|
+
return void 0;
|
|
493
|
+
}
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
function countString(value, ctx, path) {
|
|
497
|
+
if (value.length > ctx.limits.string) ctx.issues.push(`${path} exceeds string limit.`);
|
|
498
|
+
ctx.totalStrings += value.length;
|
|
499
|
+
if (ctx.totalStrings > ctx.limits.totalStrings) ctx.issues.push("UI exceeds total string limit.");
|
|
500
|
+
}
|
|
501
|
+
function countScalar(value, ctx, path) {
|
|
502
|
+
if (typeof value === "string") countString(value, ctx, path);
|
|
503
|
+
}
|
|
504
|
+
function optionalEnum(value, allowed, path, issues) {
|
|
505
|
+
if (value !== void 0 && !new Set(allowed).has(value)) issues.push(`${path} is invalid.`);
|
|
506
|
+
}
|
|
507
|
+
function optionalNonNegativeInteger(value, path, issues) {
|
|
508
|
+
if (value === void 0) return void 0;
|
|
509
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
510
|
+
issues.push(`${path} must be a non-negative integer.`);
|
|
511
|
+
return void 0;
|
|
512
|
+
}
|
|
513
|
+
return value;
|
|
514
|
+
}
|
|
515
|
+
function optionalFiniteNumber(value, path, issues) {
|
|
516
|
+
if (value === void 0) return void 0;
|
|
517
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
518
|
+
issues.push(`${path} must be finite.`);
|
|
519
|
+
return void 0;
|
|
520
|
+
}
|
|
521
|
+
return value;
|
|
522
|
+
}
|
|
523
|
+
function compatibleState(type, value) {
|
|
524
|
+
if (value === null) return type !== "checkbox";
|
|
525
|
+
if (type === "checkbox") return typeof value === "boolean";
|
|
526
|
+
if (type === "number") return typeof value === "number";
|
|
527
|
+
return typeof value === "string";
|
|
528
|
+
}
|
|
529
|
+
function isScalar(value) {
|
|
530
|
+
return value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value);
|
|
531
|
+
}
|
|
532
|
+
function isPlainObject(value) {
|
|
533
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
534
|
+
const prototype = Object.getPrototypeOf(value);
|
|
535
|
+
return prototype === Object.prototype || prototype === null;
|
|
536
|
+
}
|
|
537
|
+
function set(...values) {
|
|
538
|
+
return new Set(values);
|
|
539
|
+
}
|
|
540
|
+
function isSafePattern(pattern) {
|
|
541
|
+
let index = pattern.startsWith("^") ? 1 : 0;
|
|
542
|
+
let hasAtom = false;
|
|
543
|
+
let canQuantify = false;
|
|
544
|
+
let quantified = false;
|
|
545
|
+
while (index < pattern.length) {
|
|
546
|
+
const char = pattern[index];
|
|
547
|
+
if (char === "$" && index === pattern.length - 1) return hasAtom;
|
|
548
|
+
if (char === "[") {
|
|
549
|
+
const end = readCharacterClass(pattern, index);
|
|
550
|
+
if (end < 0) return false;
|
|
551
|
+
index = end;
|
|
552
|
+
hasAtom = canQuantify = true;
|
|
553
|
+
quantified = false;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (char === "\\") {
|
|
557
|
+
if (!isSafeEscape(pattern[index + 1])) return false;
|
|
558
|
+
index += 2;
|
|
559
|
+
hasAtom = canQuantify = true;
|
|
560
|
+
quantified = false;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (char === "*" || char === "+" || char === "?") {
|
|
564
|
+
if (!canQuantify || quantified) return false;
|
|
565
|
+
index++;
|
|
566
|
+
canQuantify = false;
|
|
567
|
+
quantified = true;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (char === "{") {
|
|
571
|
+
if (!canQuantify || quantified) return false;
|
|
572
|
+
const end = readBoundedQuantifier(pattern, index);
|
|
573
|
+
if (end < 0) return false;
|
|
574
|
+
index = end;
|
|
575
|
+
canQuantify = false;
|
|
576
|
+
quantified = true;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (".^$|()]}".includes(char)) return false;
|
|
580
|
+
index++;
|
|
581
|
+
hasAtom = canQuantify = true;
|
|
582
|
+
quantified = false;
|
|
583
|
+
}
|
|
584
|
+
return hasAtom;
|
|
585
|
+
}
|
|
586
|
+
function readCharacterClass(pattern, start) {
|
|
587
|
+
let index = start + 1;
|
|
588
|
+
if (pattern[index] === "^") index++;
|
|
589
|
+
let any = false;
|
|
590
|
+
while (index < pattern.length) {
|
|
591
|
+
const char = pattern[index];
|
|
592
|
+
if (char === "]") return any ? index + 1 : -1;
|
|
593
|
+
if (char === "\\") {
|
|
594
|
+
if (!isSafeEscape(pattern[index + 1])) return -1;
|
|
595
|
+
index += 2;
|
|
596
|
+
} else {
|
|
597
|
+
if (char === "[") return -1;
|
|
598
|
+
index++;
|
|
599
|
+
}
|
|
600
|
+
any = true;
|
|
601
|
+
}
|
|
602
|
+
return -1;
|
|
603
|
+
}
|
|
604
|
+
function readBoundedQuantifier(pattern, start) {
|
|
605
|
+
const match = /^\{(\d{1,4})(?:,(\d{0,4}))?\}/.exec(pattern.slice(start));
|
|
606
|
+
if (!match) return -1;
|
|
607
|
+
const min = Number(match[1]);
|
|
608
|
+
const max = match[2] === void 0 || match[2] === "" ? min : Number(match[2]);
|
|
609
|
+
return min <= 1e3 && max <= 1e3 && max >= min ? start + match[0].length : -1;
|
|
610
|
+
}
|
|
611
|
+
function isSafeEscape(char) {
|
|
612
|
+
return char !== void 0 && ("dDsSwW\\.^$*+?{}[]|()-".includes(char) || !/[A-Za-z0-9]/.test(char));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
//#endregion
|
|
616
|
+
//#region src/mount.ts
|
|
617
|
+
function mountUIDocument(host, document, options) {
|
|
618
|
+
if (!host || typeof host.replaceChildren !== "function") throw new TypeError("mountUIDocument requires an HTMLElement host.");
|
|
619
|
+
if (!options?.actionRuntime) throw new TypeError("mountUIDocument requires an actionRuntime.");
|
|
620
|
+
const context = {
|
|
621
|
+
document,
|
|
622
|
+
state: Object.assign(Object.create(null), document.state ?? {}),
|
|
623
|
+
options,
|
|
624
|
+
bindings: new Map(),
|
|
625
|
+
cleanups: [],
|
|
626
|
+
disposed: false
|
|
627
|
+
};
|
|
628
|
+
const root = createNode(document.root, context, void 0);
|
|
629
|
+
const container = globalThis.document.createElement("div");
|
|
630
|
+
container.setAttribute("data-aigui-ui", document.id);
|
|
631
|
+
container.appendChild(root);
|
|
632
|
+
host.replaceChildren(container);
|
|
633
|
+
let cleaned = false;
|
|
634
|
+
return () => {
|
|
635
|
+
if (cleaned) return;
|
|
636
|
+
cleaned = true;
|
|
637
|
+
context.disposed = true;
|
|
638
|
+
for (const cleanup of context.cleanups.splice(0).reverse()) try {
|
|
639
|
+
cleanup();
|
|
640
|
+
} catch {}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function createNode(node, context, form) {
|
|
644
|
+
switch (node.kind) {
|
|
645
|
+
case "stack": {
|
|
646
|
+
const element = base("div", node);
|
|
647
|
+
element.setAttribute("data-aigui-ui-stack", node.direction ?? "column");
|
|
648
|
+
if (node.gap) element.setAttribute("data-gap", node.gap);
|
|
649
|
+
if (node.align) element.setAttribute("data-align", node.align);
|
|
650
|
+
for (const child of node.children) element.appendChild(createNode(child, context, form));
|
|
651
|
+
return element;
|
|
652
|
+
}
|
|
653
|
+
case "grid": {
|
|
654
|
+
const element = base("div", node);
|
|
655
|
+
element.setAttribute("data-aigui-ui-grid", String(node.columns));
|
|
656
|
+
if (node.gap) element.setAttribute("data-gap", node.gap);
|
|
657
|
+
for (const child of node.children) element.appendChild(createNode(child, context, form));
|
|
658
|
+
return element;
|
|
659
|
+
}
|
|
660
|
+
case "text": {
|
|
661
|
+
const element = base("p", node);
|
|
662
|
+
element.setAttribute("data-tone", node.tone ?? "default");
|
|
663
|
+
bindText(element, node.text, context);
|
|
664
|
+
return element;
|
|
665
|
+
}
|
|
666
|
+
case "heading": {
|
|
667
|
+
const element = base(`h${node.level}`, node);
|
|
668
|
+
bindText(element, node.text, context);
|
|
669
|
+
return element;
|
|
670
|
+
}
|
|
671
|
+
case "divider": return base("hr", node);
|
|
672
|
+
case "list": {
|
|
673
|
+
const element = base(node.ordered ? "ol" : "ul", node);
|
|
674
|
+
for (const item of node.items) {
|
|
675
|
+
const li = globalThis.document.createElement("li");
|
|
676
|
+
li.textContent = scalarText(item);
|
|
677
|
+
element.appendChild(li);
|
|
678
|
+
}
|
|
679
|
+
return element;
|
|
680
|
+
}
|
|
681
|
+
case "table": {
|
|
682
|
+
const table = base("table", node);
|
|
683
|
+
const caption = globalThis.document.createElement("caption");
|
|
684
|
+
caption.textContent = node.caption;
|
|
685
|
+
table.appendChild(caption);
|
|
686
|
+
const thead = globalThis.document.createElement("thead");
|
|
687
|
+
const headRow = globalThis.document.createElement("tr");
|
|
688
|
+
for (const header of node.headers) {
|
|
689
|
+
const th = globalThis.document.createElement("th");
|
|
690
|
+
th.scope = "col";
|
|
691
|
+
th.textContent = header;
|
|
692
|
+
headRow.appendChild(th);
|
|
693
|
+
}
|
|
694
|
+
thead.appendChild(headRow);
|
|
695
|
+
table.appendChild(thead);
|
|
696
|
+
const tbody = globalThis.document.createElement("tbody");
|
|
697
|
+
for (const row of node.rows) {
|
|
698
|
+
const tr = globalThis.document.createElement("tr");
|
|
699
|
+
for (const cell of row) {
|
|
700
|
+
const td = globalThis.document.createElement("td");
|
|
701
|
+
td.textContent = scalarText(cell);
|
|
702
|
+
tr.appendChild(td);
|
|
703
|
+
}
|
|
704
|
+
tbody.appendChild(tr);
|
|
705
|
+
}
|
|
706
|
+
table.appendChild(tbody);
|
|
707
|
+
return table;
|
|
708
|
+
}
|
|
709
|
+
case "keyValue": return createKeyValue(node, context);
|
|
710
|
+
case "form": return createForm(node, context);
|
|
711
|
+
case "field": return createField(node, context, form);
|
|
712
|
+
case "button": return createButton(node, context);
|
|
713
|
+
case "card": return createCard(node, context);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function createKeyValue(node, context) {
|
|
717
|
+
const dl = base("dl", node);
|
|
718
|
+
for (const item of node.items) {
|
|
719
|
+
const dt = globalThis.document.createElement("dt");
|
|
720
|
+
dt.textContent = item.label;
|
|
721
|
+
const dd = globalThis.document.createElement("dd");
|
|
722
|
+
bindText(dd, item.value, context);
|
|
723
|
+
dl.append(dt, dd);
|
|
724
|
+
}
|
|
725
|
+
return dl;
|
|
726
|
+
}
|
|
727
|
+
function createForm(node, context) {
|
|
728
|
+
const form = base("form", node);
|
|
729
|
+
form.noValidate = true;
|
|
730
|
+
form.setAttribute("data-aigui-ui-form", node.id);
|
|
731
|
+
for (const child of node.children) form.appendChild(createNode(child, context, form));
|
|
732
|
+
const actionError = actionErrorElement(node.id);
|
|
733
|
+
const submit = globalThis.document.createElement("button");
|
|
734
|
+
submit.type = "submit";
|
|
735
|
+
submit.textContent = node.submitLabel ?? "Submit";
|
|
736
|
+
form.append(actionError, submit);
|
|
737
|
+
const owner = {};
|
|
738
|
+
const controller = new AbortController();
|
|
739
|
+
let pending = false;
|
|
740
|
+
const onSubmit = (event) => {
|
|
741
|
+
event.preventDefault();
|
|
742
|
+
if (pending || context.disposed) return;
|
|
743
|
+
const fields = collectFields(node);
|
|
744
|
+
const firstInvalid = fields.find((field) => !validateField(field, context, form));
|
|
745
|
+
if (firstInvalid) {
|
|
746
|
+
form.querySelector(`[data-aigui-ui-id="${firstInvalid.id}"] input, [data-aigui-ui-id="${firstInvalid.id}"] textarea, [data-aigui-ui-id="${firstInvalid.id}"] select`)?.focus();
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
pending = true;
|
|
750
|
+
submit.disabled = true;
|
|
751
|
+
form.setAttribute("aria-busy", "true");
|
|
752
|
+
actionError.hidden = true;
|
|
753
|
+
const params = Object.create(null);
|
|
754
|
+
for (const field of fields) params[field.bind] = context.state[field.bind];
|
|
755
|
+
context.options.actionRuntime.dispatch({
|
|
756
|
+
type: node.submit.type,
|
|
757
|
+
params,
|
|
758
|
+
cardType: `ui:${context.document.id}:${node.id}`
|
|
759
|
+
}, {
|
|
760
|
+
owner,
|
|
761
|
+
signal: controller.signal
|
|
762
|
+
}).then(() => settle(), (error) => {
|
|
763
|
+
if (!context.disposed && !controller.signal.aborted) {
|
|
764
|
+
actionError.textContent = safeActionError(error);
|
|
765
|
+
actionError.hidden = false;
|
|
766
|
+
}
|
|
767
|
+
settle();
|
|
768
|
+
});
|
|
769
|
+
function settle() {
|
|
770
|
+
if (context.disposed) return;
|
|
771
|
+
pending = false;
|
|
772
|
+
submit.disabled = false;
|
|
773
|
+
form.removeAttribute("aria-busy");
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
form.addEventListener("submit", onSubmit);
|
|
777
|
+
context.cleanups.push(() => {
|
|
778
|
+
controller.abort();
|
|
779
|
+
form.removeEventListener("submit", onSubmit);
|
|
780
|
+
});
|
|
781
|
+
return form;
|
|
782
|
+
}
|
|
783
|
+
function createField(node, context, form) {
|
|
784
|
+
const container = base(node.fieldType === "radio" ? "fieldset" : "div", node);
|
|
785
|
+
container.setAttribute("data-aigui-ui-field", node.fieldType);
|
|
786
|
+
const controlId = `aigui-ui-${nextInstance()}-${context.document.id}-${node.id}`;
|
|
787
|
+
const errorId = `${controlId}-error`;
|
|
788
|
+
const error = globalThis.document.createElement("div");
|
|
789
|
+
error.id = errorId;
|
|
790
|
+
error.setAttribute("data-aigui-ui-field-error", node.id);
|
|
791
|
+
error.setAttribute("role", "alert");
|
|
792
|
+
error.hidden = true;
|
|
793
|
+
if (node.fieldType === "radio") {
|
|
794
|
+
const legend = globalThis.document.createElement("legend");
|
|
795
|
+
legend.textContent = node.label;
|
|
796
|
+
container.appendChild(legend);
|
|
797
|
+
for (const [index, option] of (node.options ?? []).entries()) {
|
|
798
|
+
const input = globalThis.document.createElement("input");
|
|
799
|
+
input.type = "radio";
|
|
800
|
+
input.id = `${controlId}-${index}`;
|
|
801
|
+
input.name = node.bind;
|
|
802
|
+
input.value = option.value;
|
|
803
|
+
input.checked = context.state[node.bind] === option.value;
|
|
804
|
+
input.required = node.required === true;
|
|
805
|
+
input.setAttribute("aria-describedby", errorId);
|
|
806
|
+
const label$1 = globalThis.document.createElement("label");
|
|
807
|
+
label$1.htmlFor = input.id;
|
|
808
|
+
label$1.textContent = option.label;
|
|
809
|
+
const onChange = () => {
|
|
810
|
+
if (input.checked) setState(node.bind, input.value, context);
|
|
811
|
+
clearFieldError(node, container);
|
|
812
|
+
};
|
|
813
|
+
input.addEventListener("change", onChange);
|
|
814
|
+
context.cleanups.push(() => input.removeEventListener("change", onChange));
|
|
815
|
+
container.append(input, label$1);
|
|
816
|
+
}
|
|
817
|
+
container.appendChild(error);
|
|
818
|
+
return container;
|
|
819
|
+
}
|
|
820
|
+
let control;
|
|
821
|
+
if (node.fieldType === "textarea") control = globalThis.document.createElement("textarea");
|
|
822
|
+
else if (node.fieldType === "select") {
|
|
823
|
+
const select = globalThis.document.createElement("select");
|
|
824
|
+
for (const option of node.options ?? []) {
|
|
825
|
+
const item = globalThis.document.createElement("option");
|
|
826
|
+
item.value = option.value;
|
|
827
|
+
item.textContent = option.label;
|
|
828
|
+
select.appendChild(item);
|
|
829
|
+
}
|
|
830
|
+
control = select;
|
|
831
|
+
} else {
|
|
832
|
+
const input = globalThis.document.createElement("input");
|
|
833
|
+
input.type = node.fieldType;
|
|
834
|
+
control = input;
|
|
835
|
+
}
|
|
836
|
+
control.id = controlId;
|
|
837
|
+
control.setAttribute("name", node.bind);
|
|
838
|
+
control.required = node.required === true;
|
|
839
|
+
control.setAttribute("aria-describedby", errorId);
|
|
840
|
+
if (node.placeholder !== void 0 && !(control instanceof HTMLSelectElement)) control.placeholder = node.placeholder;
|
|
841
|
+
if ((node.fieldType === "text" || node.fieldType === "textarea") && (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) {
|
|
842
|
+
if (node.minLength !== void 0) control.minLength = node.minLength;
|
|
843
|
+
if (node.maxLength !== void 0) control.maxLength = node.maxLength;
|
|
844
|
+
if (node.pattern !== void 0 && control instanceof HTMLInputElement) control.pattern = node.pattern;
|
|
845
|
+
}
|
|
846
|
+
if (node.fieldType === "number" && control instanceof HTMLInputElement) {
|
|
847
|
+
if (node.min !== void 0) control.min = String(node.min);
|
|
848
|
+
if (node.max !== void 0) control.max = String(node.max);
|
|
849
|
+
}
|
|
850
|
+
writeControl(control, node, context.state[node.bind]);
|
|
851
|
+
const label = globalThis.document.createElement("label");
|
|
852
|
+
label.htmlFor = controlId;
|
|
853
|
+
label.textContent = node.label;
|
|
854
|
+
const onInput = () => {
|
|
855
|
+
setState(node.bind, readControl(control, node), context);
|
|
856
|
+
clearFieldError(node, container);
|
|
857
|
+
};
|
|
858
|
+
control.addEventListener("input", onInput);
|
|
859
|
+
control.addEventListener("change", onInput);
|
|
860
|
+
context.cleanups.push(() => {
|
|
861
|
+
control.removeEventListener("input", onInput);
|
|
862
|
+
control.removeEventListener("change", onInput);
|
|
863
|
+
});
|
|
864
|
+
if (node.fieldType === "checkbox") container.append(control, label, error);
|
|
865
|
+
else container.append(label, control, error);
|
|
866
|
+
return container;
|
|
867
|
+
}
|
|
868
|
+
function createButton(node, context) {
|
|
869
|
+
const wrapper = globalThis.document.createElement("div");
|
|
870
|
+
wrapper.setAttribute("data-aigui-ui-button", node.variant ?? "secondary");
|
|
871
|
+
const button = base("button", node);
|
|
872
|
+
button.type = "button";
|
|
873
|
+
button.textContent = node.label;
|
|
874
|
+
const error = actionErrorElement(node.id);
|
|
875
|
+
wrapper.append(button, error);
|
|
876
|
+
const owner = {};
|
|
877
|
+
const controller = new AbortController();
|
|
878
|
+
let pending = false;
|
|
879
|
+
const onClick = () => {
|
|
880
|
+
if (pending || context.disposed) return;
|
|
881
|
+
pending = true;
|
|
882
|
+
button.disabled = true;
|
|
883
|
+
button.setAttribute("aria-busy", "true");
|
|
884
|
+
error.hidden = true;
|
|
885
|
+
const params = node.action.params === void 0 ? Object.create(null) : resolveBoundJSON(node.action.params, context.state);
|
|
886
|
+
context.options.actionRuntime.dispatch({
|
|
887
|
+
type: node.action.type,
|
|
888
|
+
params,
|
|
889
|
+
cardType: `ui:${context.document.id}:${node.id}`
|
|
890
|
+
}, {
|
|
891
|
+
owner,
|
|
892
|
+
signal: controller.signal
|
|
893
|
+
}).then(() => settle(), (cause) => {
|
|
894
|
+
if (!context.disposed && !controller.signal.aborted) {
|
|
895
|
+
error.textContent = safeActionError(cause);
|
|
896
|
+
error.hidden = false;
|
|
897
|
+
}
|
|
898
|
+
settle();
|
|
899
|
+
});
|
|
900
|
+
function settle() {
|
|
901
|
+
if (context.disposed) return;
|
|
902
|
+
pending = false;
|
|
903
|
+
button.disabled = false;
|
|
904
|
+
button.removeAttribute("aria-busy");
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
button.addEventListener("click", onClick);
|
|
908
|
+
context.cleanups.push(() => {
|
|
909
|
+
controller.abort();
|
|
910
|
+
button.removeEventListener("click", onClick);
|
|
911
|
+
});
|
|
912
|
+
return wrapper;
|
|
913
|
+
}
|
|
914
|
+
function createCard(node, context) {
|
|
915
|
+
const host = base("div", node);
|
|
916
|
+
host.setAttribute("data-aigui-ui-card", node.type);
|
|
917
|
+
let slot;
|
|
918
|
+
const update = () => {
|
|
919
|
+
const data = resolveBoundJSON(node.data, context.state);
|
|
920
|
+
if (slot) slot.update(data);
|
|
921
|
+
else {
|
|
922
|
+
slot = context.options.mountContext?.mountCard?.(host, {
|
|
923
|
+
type: node.type,
|
|
924
|
+
data
|
|
925
|
+
});
|
|
926
|
+
if (!slot) {
|
|
927
|
+
host.replaceChildren();
|
|
928
|
+
const fallback = globalThis.document.createElement("span");
|
|
929
|
+
fallback.setAttribute("data-aigui-ui-card-fallback", node.type);
|
|
930
|
+
fallback.textContent = "Card unavailable.";
|
|
931
|
+
host.appendChild(fallback);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
for (const key of bindingKeys(node.data)) subscribe(key, update, context);
|
|
936
|
+
update();
|
|
937
|
+
context.cleanups.push(() => slot?.destroy());
|
|
938
|
+
return host;
|
|
939
|
+
}
|
|
940
|
+
function validateField(node, context, form) {
|
|
941
|
+
const container = form.querySelector(`[data-aigui-ui-id="${node.id}"]`);
|
|
942
|
+
if (!container) return true;
|
|
943
|
+
const value = context.state[node.bind];
|
|
944
|
+
let message = "";
|
|
945
|
+
if (node.required && (value === "" || value === null || value === false)) message = "This field is required.";
|
|
946
|
+
else if (node.fieldType === "number" && typeof value === "number") {
|
|
947
|
+
if (node.min !== void 0 && value < node.min) message = `Must be at least ${node.min}.`;
|
|
948
|
+
else if (node.max !== void 0 && value > node.max) message = `Must be at most ${node.max}.`;
|
|
949
|
+
} else if (typeof value === "string") {
|
|
950
|
+
if (node.minLength !== void 0 && value.length < node.minLength) message = `Must contain at least ${node.minLength} characters.`;
|
|
951
|
+
else if (node.maxLength !== void 0 && value.length > node.maxLength) message = `Must contain at most ${node.maxLength} characters.`;
|
|
952
|
+
else if (node.pattern !== void 0 && !new RegExp(node.pattern).test(value)) message = "Must match the required format.";
|
|
953
|
+
else if ((node.fieldType === "select" || node.fieldType === "radio") && !node.options?.some((option) => option.value === value)) message = "Select an allowed option.";
|
|
954
|
+
}
|
|
955
|
+
const controls = container.querySelectorAll("input, textarea, select");
|
|
956
|
+
const error = container.querySelector(`[data-aigui-ui-field-error="${node.id}"]`);
|
|
957
|
+
for (const control of controls) if (message) control.setAttribute("aria-invalid", "true");
|
|
958
|
+
else control.removeAttribute("aria-invalid");
|
|
959
|
+
if (error) {
|
|
960
|
+
error.textContent = message;
|
|
961
|
+
error.hidden = !message;
|
|
962
|
+
}
|
|
963
|
+
return !message;
|
|
964
|
+
}
|
|
965
|
+
function collectFields(node) {
|
|
966
|
+
const fields = [];
|
|
967
|
+
const walk = (children) => {
|
|
968
|
+
for (const child of children) if (child.kind === "field") fields.push(child);
|
|
969
|
+
else if (child.kind === "stack" || child.kind === "grid") walk(child.children);
|
|
970
|
+
};
|
|
971
|
+
walk(node.children);
|
|
972
|
+
return fields;
|
|
973
|
+
}
|
|
974
|
+
function bindText(element, expression, context) {
|
|
975
|
+
const update = () => {
|
|
976
|
+
element.textContent = scalarText(resolveScalar(expression, context.state));
|
|
977
|
+
};
|
|
978
|
+
if (isBinding(expression)) subscribe(expression.$state, update, context);
|
|
979
|
+
update();
|
|
980
|
+
}
|
|
981
|
+
function subscribe(key, update, context) {
|
|
982
|
+
let listeners = context.bindings.get(key);
|
|
983
|
+
if (!listeners) {
|
|
984
|
+
listeners = new Set();
|
|
985
|
+
context.bindings.set(key, listeners);
|
|
986
|
+
}
|
|
987
|
+
listeners.add(update);
|
|
988
|
+
context.cleanups.push(() => listeners?.delete(update));
|
|
989
|
+
}
|
|
990
|
+
function setState(key, value, context) {
|
|
991
|
+
if (Object.is(context.state[key], value)) return;
|
|
992
|
+
context.state[key] = value;
|
|
993
|
+
for (const update of context.bindings.get(key) ?? []) update();
|
|
994
|
+
}
|
|
995
|
+
function resolveScalar(value, state) {
|
|
996
|
+
return isBinding(value) ? state[value.$state] : value;
|
|
997
|
+
}
|
|
998
|
+
function bindingKeys(value) {
|
|
999
|
+
const keys = new Set();
|
|
1000
|
+
const walk = (item) => {
|
|
1001
|
+
if (isBinding(item)) keys.add(item.$state);
|
|
1002
|
+
else if (Array.isArray(item)) item.forEach(walk);
|
|
1003
|
+
else if (item !== null && typeof item === "object") Object.values(item).forEach(walk);
|
|
1004
|
+
};
|
|
1005
|
+
walk(value);
|
|
1006
|
+
return keys;
|
|
1007
|
+
}
|
|
1008
|
+
function isBinding(value) {
|
|
1009
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 1 && typeof value.$state === "string";
|
|
1010
|
+
}
|
|
1011
|
+
function scalarText(value) {
|
|
1012
|
+
return value === null ? "" : String(value);
|
|
1013
|
+
}
|
|
1014
|
+
function readControl(control, field) {
|
|
1015
|
+
if (field.fieldType === "checkbox" && control instanceof HTMLInputElement) return control.checked;
|
|
1016
|
+
if (field.fieldType === "number") return control.value === "" ? null : Number(control.value);
|
|
1017
|
+
return control.value;
|
|
1018
|
+
}
|
|
1019
|
+
function writeControl(control, field, value) {
|
|
1020
|
+
if (field.fieldType === "checkbox" && control instanceof HTMLInputElement) control.checked = value === true;
|
|
1021
|
+
else control.value = value === null ? "" : String(value);
|
|
1022
|
+
}
|
|
1023
|
+
function clearFieldError(node, container) {
|
|
1024
|
+
for (const control of container.querySelectorAll("input, textarea, select")) control.removeAttribute("aria-invalid");
|
|
1025
|
+
const error = container.querySelector(`[data-aigui-ui-field-error="${node.id}"]`);
|
|
1026
|
+
if (error) {
|
|
1027
|
+
error.textContent = "";
|
|
1028
|
+
error.hidden = true;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function actionErrorElement(id) {
|
|
1032
|
+
const error = globalThis.document.createElement("div");
|
|
1033
|
+
error.setAttribute("data-aigui-ui-action-error", id);
|
|
1034
|
+
error.setAttribute("role", "alert");
|
|
1035
|
+
error.hidden = true;
|
|
1036
|
+
return error;
|
|
1037
|
+
}
|
|
1038
|
+
function safeActionError(error) {
|
|
1039
|
+
return error instanceof __ai_gui_core.ActionRuntimeError ? "Action failed." : "Action failed.";
|
|
1040
|
+
}
|
|
1041
|
+
function base(tag, node) {
|
|
1042
|
+
const element = globalThis.document.createElement(tag);
|
|
1043
|
+
element.setAttribute("data-aigui-ui-id", node.id);
|
|
1044
|
+
return element;
|
|
1045
|
+
}
|
|
1046
|
+
let instance = 0;
|
|
1047
|
+
function nextInstance() {
|
|
1048
|
+
return ++instance;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
//#endregion
|
|
1052
|
+
//#region src/prompt.ts
|
|
1053
|
+
function uiPromptSpec(registry, actionRuntime, limits) {
|
|
1054
|
+
const bounded = resolveUILimits(limits);
|
|
1055
|
+
const actions = actionRuntime.listActionTypes();
|
|
1056
|
+
const cards = registry.list();
|
|
1057
|
+
const lines = [
|
|
1058
|
+
"Declarative UI (fenced): emit exactly one ```ui fenced block containing one JSON document: {version:1,id,state?,root}.",
|
|
1059
|
+
"Use only these node kinds: stack, grid, text, heading, divider, list, table, keyValue, form, field, button, card.",
|
|
1060
|
+
"State is a flat scalar object. Bindings use only the exact form {\"$state\":\"key\"}. Actions are declarative; the application performs them.",
|
|
1061
|
+
"Never emit HTML, Markdown, CSS, JavaScript, URLs, imports, remote components, workflows, artifact commands, generated code, or extra keys.",
|
|
1062
|
+
`Bounds: ${bounded.nodes} nodes, depth ${bounded.depth}, ${bounded.children} children per container, ${bounded.sourceBytes} source bytes.`,
|
|
1063
|
+
`Registered actions: ${actions.length ? actions.join(", ") : "none"}.`,
|
|
1064
|
+
"Registered cards:"
|
|
1065
|
+
];
|
|
1066
|
+
if (!cards.length) lines.push("- none");
|
|
1067
|
+
for (const card of cards) {
|
|
1068
|
+
lines.push(`- ${card.type}: ${card.description}`);
|
|
1069
|
+
const fields = schemaFields(card.schema);
|
|
1070
|
+
if (fields) lines.push(` fields: ${fields}`);
|
|
1071
|
+
}
|
|
1072
|
+
return lines.join("\n");
|
|
1073
|
+
}
|
|
1074
|
+
function schemaFields(schema) {
|
|
1075
|
+
if (!schema?.properties) return "";
|
|
1076
|
+
return Object.entries(schema.properties).map(([name, field]) => `${name}(${Array.isArray(field.type) ? field.type.join("|") : field.type ?? "any"})`).join(", ");
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region src/index.ts
|
|
1081
|
+
function ui(options) {
|
|
1082
|
+
if (!options?.registry || !options?.actionRuntime) throw new TypeError("ui() requires registry and actionRuntime.");
|
|
1083
|
+
const outputs = new WeakMap();
|
|
1084
|
+
const rejected = new WeakSet();
|
|
1085
|
+
const render = (node) => {
|
|
1086
|
+
const cached = outputs.get(node);
|
|
1087
|
+
if (cached) return cached;
|
|
1088
|
+
let output;
|
|
1089
|
+
if (!node.complete) output = {
|
|
1090
|
+
kind: "html",
|
|
1091
|
+
html: "<div data-aigui-block-loading=\"\" data-block-type=\"ui\"></div>"
|
|
1092
|
+
};
|
|
1093
|
+
else if (rejected.has(node)) output = invalidOutput();
|
|
1094
|
+
else try {
|
|
1095
|
+
const document = parseUIDocument(node.content ?? "", options);
|
|
1096
|
+
output = {
|
|
1097
|
+
kind: "mount",
|
|
1098
|
+
mount: (host, mountContext) => mountUIDocument(host, document, {
|
|
1099
|
+
actionRuntime: options.actionRuntime,
|
|
1100
|
+
mountContext
|
|
1101
|
+
})
|
|
1102
|
+
};
|
|
1103
|
+
} catch {
|
|
1104
|
+
output = invalidOutput();
|
|
1105
|
+
}
|
|
1106
|
+
outputs.set(node, output);
|
|
1107
|
+
return output;
|
|
1108
|
+
};
|
|
1109
|
+
return {
|
|
1110
|
+
name: "ui",
|
|
1111
|
+
nodeRenderers: { ui: render },
|
|
1112
|
+
onASTCommit: (nodes) => {
|
|
1113
|
+
let accepted = false;
|
|
1114
|
+
for (const node of nodes) {
|
|
1115
|
+
if (node.type !== "ui" || !node.complete) continue;
|
|
1116
|
+
if (accepted) rejected.add(node);
|
|
1117
|
+
else accepted = true;
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
promptSpec: () => uiPromptSpec(options.registry, options.actionRuntime, options.limits),
|
|
1121
|
+
css: uiCss
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
function invalidOutput() {
|
|
1125
|
+
return {
|
|
1126
|
+
kind: "html",
|
|
1127
|
+
html: "<div data-aigui-ui-invalid=\"\" role=\"alert\">Invalid UI.</div>"
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
//#endregion
|
|
1132
|
+
exports.DEFAULT_UI_LIMITS = DEFAULT_UI_LIMITS
|
|
1133
|
+
exports.UIDocumentError = UIDocumentError
|
|
1134
|
+
exports.UILimitError = UILimitError
|
|
1135
|
+
exports.mountUIDocument = mountUIDocument
|
|
1136
|
+
exports.parseUIDocument = parseUIDocument
|
|
1137
|
+
exports.resolveBoundJSON = resolveBoundJSON
|
|
1138
|
+
exports.resolveUILimits = resolveUILimits
|
|
1139
|
+
exports.ui = ui
|
|
1140
|
+
exports.uiCss = uiCss
|
|
1141
|
+
exports.uiPromptSpec = uiPromptSpec
|
|
1142
|
+
exports.validateUIDocument = validateUIDocument
|