mlola-ui 1.9.2 → 1.10.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/README.md +38 -1
- package/package.json +11 -2
- package/registry/agents.md +1 -0
- package/registry/contract.json +6 -0
- package/registry/examples.json +21 -0
- package/registry/index.json +133 -50
- package/registry/render-app.html +8480 -0
- package/registry/render-catalog.json +1650 -0
- package/registry/render-rules.json +1356 -0
- package/registry/render.d.ts +106 -0
- package/registry/render.js +989 -0
- package/registry/source/packages/components/render-surface/render-surface.tsx +488 -0
- package/registry/source-manifest.json +1 -0
- package/src/cli.js +21 -0
- package/src/knowledge.js +15 -0
- package/src/mcp.js +92 -9
- package/src/render-rules.d.ts +5 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mlola Render: checks the UI an agent composes before anything renders.
|
|
3
|
+
*
|
|
4
|
+
* An agent describes an interface as A2UI messages (https://a2ui.org): a
|
|
5
|
+
* surface, a flat list of components that name their children by id, and a
|
|
6
|
+
* data model the inputs bind to. This module checks those messages against
|
|
7
|
+
* the Mlola catalog (render-rules.json, generated from the components) and
|
|
8
|
+
* answers with A2UI's own error shape, so the agent can correct itself:
|
|
9
|
+
*
|
|
10
|
+
* { code: "VALIDATION_FAILED", surfaceId, path, message }
|
|
11
|
+
*
|
|
12
|
+
* `path` is a JSON Pointer into the input: "/1/updateComponents/components/3/variant"
|
|
13
|
+
* when the input is an array of messages, "/updateComponents/components/3/variant"
|
|
14
|
+
* when it is one message.
|
|
15
|
+
*
|
|
16
|
+
* Beyond the schema it holds what makes UI usable: every field and control
|
|
17
|
+
* has a name, headings do not skip a level, a tree has one root and no
|
|
18
|
+
* cycles, a value is one the component offers. It never reads a theme or a
|
|
19
|
+
* color, since the components and the engine already guarantee contrast,
|
|
20
|
+
* focus, target size and reduced motion.
|
|
21
|
+
*
|
|
22
|
+
* Messages come from a model, so everything here runs in time that grows with
|
|
23
|
+
* the input's length and stops at the catalog's limits. No Node or DOM API is
|
|
24
|
+
* used: the CLI, both MCP servers and a renderer in the browser share it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const KINDS = ["createSurface", "updateComponents", "updateDataModel", "deleteSurface"];
|
|
28
|
+
const COMMON_PROPS = new Set(["id", "component", "accessibility"]);
|
|
29
|
+
const STYLE_PROPS = new Set(["style", "className", "class", "css", "color", "background", "backgroundColor", "theme", "sx", "tw"]);
|
|
30
|
+
const UNSAFE_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
31
|
+
const ACTION_NAME = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
|
|
32
|
+
const COLUMN_KEY = /^[A-Za-z0-9_-]{1,64}$/;
|
|
33
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
34
|
+
/** A day that exists, written as an ISO date: "2026-02-30" is not one. */
|
|
35
|
+
const isDate = (value) => {
|
|
36
|
+
const match = typeof value === "string" ? ISO_DATE.exec(value) : null;
|
|
37
|
+
if (!match) return false;
|
|
38
|
+
const [year, month, day] = match.slice(1).map(Number);
|
|
39
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
40
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
41
|
+
};
|
|
42
|
+
/** More errors than this are summed up in one line: an agent fixes the first ones and checks again. */
|
|
43
|
+
const MAX_REPORTED = 100;
|
|
44
|
+
|
|
45
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
46
|
+
/** A key the input chose, looked up on the catalog's own entries: "toString" or "__proto__" is not a component. */
|
|
47
|
+
const own = (object, key) => (Object.hasOwn(object, key) ? object[key] : undefined);
|
|
48
|
+
const typeName = (value) => (value === null ? "null" : Array.isArray(value) ? "an array" : typeof value === "object" ? "an object" : `a ${typeof value}`);
|
|
49
|
+
/** A value echoed back in a message: short, and never a whole object someone sent. */
|
|
50
|
+
const quote = (value) => {
|
|
51
|
+
if (value !== null && typeof value === "object") return typeName(value);
|
|
52
|
+
if (typeof value === "string" && value.length > 80) return JSON.stringify(`${value.slice(0, 77)}…`);
|
|
53
|
+
return String(JSON.stringify(value));
|
|
54
|
+
};
|
|
55
|
+
const escapeSegment = (segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
|
|
56
|
+
const pointer = (base, ...segments) => segments.reduce((path, segment) => `${path}/${escapeSegment(segment)}`, base);
|
|
57
|
+
const list = (values) => values.map((value) => quote(value)).join(", ");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reads what an agent or a file holds: one message, an array of messages,
|
|
61
|
+
* or JSON Lines (one message per line, as A2UI streams them).
|
|
62
|
+
*/
|
|
63
|
+
export function parseRenderInput(source, limits) {
|
|
64
|
+
if (typeof source !== "string") return { messages: source, error: null };
|
|
65
|
+
if (limits && source.length > limits.inputBytes) return { messages: null, error: `The input is ${source.length} characters; at most ${limits.inputBytes} are checked at a time. Send fewer messages per call.` };
|
|
66
|
+
const trimmed = source.trim();
|
|
67
|
+
if (!trimmed) return { messages: null, error: "The input is empty. Pass A2UI messages: one object, an array, or one object per line." };
|
|
68
|
+
try {
|
|
69
|
+
return { messages: JSON.parse(trimmed), error: null };
|
|
70
|
+
} catch (whole) {
|
|
71
|
+
const lines = trimmed.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
72
|
+
if (lines.length > 1) {
|
|
73
|
+
const messages = [];
|
|
74
|
+
for (const [index, line] of lines.entries()) {
|
|
75
|
+
try {
|
|
76
|
+
messages.push(JSON.parse(line));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { messages: null, error: `Line ${index + 1} is not JSON: ${error.message}` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { messages, error: null };
|
|
82
|
+
}
|
|
83
|
+
return { messages: null, error: `The input is not JSON: ${whole.message}` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The state a renderer keeps between messages: the surfaces, their
|
|
89
|
+
* components and their data. Checking a stream message by message and
|
|
90
|
+
* checking it all at once give the same answer. A state can live as long
|
|
91
|
+
* as its stream: the limits hold what one call brings and what is open at
|
|
92
|
+
* once, never a running total.
|
|
93
|
+
*/
|
|
94
|
+
export function createRenderState(rules) {
|
|
95
|
+
return { rules, surfaces: new Map() };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Checks messages and applies the valid ones to the state. Structure that
|
|
100
|
+
* needs the whole tree (the root, references, cycles, heading order) is
|
|
101
|
+
* checked by `checkSurfaces`, once the batch is in.
|
|
102
|
+
*/
|
|
103
|
+
export function applyRenderMessages(state, input) {
|
|
104
|
+
const issues = { errors: [], advice: [] };
|
|
105
|
+
const single = isObject(input);
|
|
106
|
+
const messages = single ? [input] : input;
|
|
107
|
+
if (!Array.isArray(messages)) {
|
|
108
|
+
issues.errors.push(failure("", "", `Expected A2UI messages: an object with "version" and one of ${KINDS.join(", ")}, or an array of them. Got ${typeName(input)}.`));
|
|
109
|
+
return issues;
|
|
110
|
+
}
|
|
111
|
+
const { limits } = state.rules;
|
|
112
|
+
if (!messages.length) issues.errors.push(failure("", "", "There are no messages. Start with createSurface, then updateComponents."));
|
|
113
|
+
if (messages.length > limits.messages) {
|
|
114
|
+
issues.errors.push(failure("", "", `At most ${limits.messages} messages are checked at a time; this is ${messages.length}. Check the rest in another call.`));
|
|
115
|
+
return issues;
|
|
116
|
+
}
|
|
117
|
+
for (const [index, message] of messages.entries()) {
|
|
118
|
+
applyMessage(state, message, single ? "" : `/${index}`, issues);
|
|
119
|
+
}
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whole-tree checks for every surface the messages touched. */
|
|
124
|
+
export function checkSurfaces(state, issues = { errors: [], advice: [] }) {
|
|
125
|
+
for (const surface of state.surfaces.values()) {
|
|
126
|
+
if (!surface.deleted && surface.components.size) checkTree(state.rules, surface, issues);
|
|
127
|
+
}
|
|
128
|
+
return issues;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One surface, checked whole: how it was created, every component it holds
|
|
133
|
+
* now and the tree they make. A renderer asks this before it draws, so what
|
|
134
|
+
* shows has passed the check however many updates it took to get there.
|
|
135
|
+
*/
|
|
136
|
+
export function checkSurface(state, surfaceId) {
|
|
137
|
+
const issues = { errors: [], advice: [] };
|
|
138
|
+
const surface = state.surfaces.get(surfaceId);
|
|
139
|
+
if (!surface || surface.deleted) return issues;
|
|
140
|
+
issues.errors.push(...surface.problems);
|
|
141
|
+
for (const node of surface.components.values()) checkComponent(state.rules, surface.id, node.props, node.at, issues);
|
|
142
|
+
if (surface.components.size) checkTree(state.rules, surface, issues);
|
|
143
|
+
return issues;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Checks A2UI messages against the Mlola catalog. Takes parsed JSON or the
|
|
148
|
+
* text of it, and answers with the errors that stop it rendering, advice
|
|
149
|
+
* that does not, and what each surface holds.
|
|
150
|
+
*/
|
|
151
|
+
export function validateRender(input, rules) {
|
|
152
|
+
const parsed = parseRenderInput(input, rules.limits);
|
|
153
|
+
if (parsed.error) return report([failure("", "", parsed.error)], [], []);
|
|
154
|
+
const state = createRenderState(rules);
|
|
155
|
+
const issues = applyRenderMessages(state, parsed.messages);
|
|
156
|
+
checkSurfaces(state, issues);
|
|
157
|
+
if (issues.errors.length > MAX_REPORTED) {
|
|
158
|
+
const more = issues.errors.length - MAX_REPORTED;
|
|
159
|
+
issues.errors = [...issues.errors.slice(0, MAX_REPORTED), failure("", "", `${more} more ${more === 1 ? "error" : "errors"} not shown. Fix these first and check again.`)];
|
|
160
|
+
}
|
|
161
|
+
issues.advice = issues.advice.slice(0, MAX_REPORTED);
|
|
162
|
+
const surfaces = [...state.surfaces.values()].map((surface) => ({
|
|
163
|
+
surfaceId: surface.id,
|
|
164
|
+
deleted: surface.deleted,
|
|
165
|
+
theme: surface.theme,
|
|
166
|
+
components: surface.components.size,
|
|
167
|
+
}));
|
|
168
|
+
return report(issues.errors, issues.advice, surfaces);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function report(errors, notes, surfaces) {
|
|
172
|
+
return { valid: errors.length === 0, errors, advice: notes, surfaces };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function failure(surfaceId, path, message) {
|
|
176
|
+
return { code: "VALIDATION_FAILED", surfaceId: surfaceId ?? "", path, message };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function advice(surfaceId, path, message) {
|
|
180
|
+
return { code: "ADVICE", surfaceId: surfaceId ?? "", path, message };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Messages ---------------------------------------------------------------- */
|
|
184
|
+
|
|
185
|
+
function applyMessage(state, message, base, issues) {
|
|
186
|
+
const { rules } = state;
|
|
187
|
+
if (!isObject(message)) {
|
|
188
|
+
issues.errors.push(failure("", base, `A message is an object with "version" and one of ${KINDS.join(", ")}; this one is ${typeName(message)}.`));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const kinds = KINDS.filter((kind) => kind in message);
|
|
192
|
+
const surfaceGuess = kinds.length === 1 && isObject(message[kinds[0]]) && typeof message[kinds[0]].surfaceId === "string" ? message[kinds[0]].surfaceId : "";
|
|
193
|
+
if (!rules.protocolVersions.includes(message.version)) {
|
|
194
|
+
issues.errors.push(failure(surfaceGuess, pointer(base, "version"), message.version === undefined ? `Add "version": ${quote(rules.protocolVersions.at(-1))} to every message.` : `Version ${quote(message.version)} is not supported; use ${list(rules.protocolVersions)}.`));
|
|
195
|
+
}
|
|
196
|
+
if (kinds.length !== 1) {
|
|
197
|
+
issues.errors.push(failure(surfaceGuess, base, kinds.length ? `A message carries one of ${KINDS.join(", ")}; this one has ${kinds.join(" and ")}. Send them as separate messages.` : `A message carries one of ${KINDS.join(", ")}; this one has none.`));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const key of Object.keys(message)) {
|
|
201
|
+
if (key !== "version" && key !== kinds[0]) issues.errors.push(failure(surfaceGuess, pointer(base, key), `A message holds only "version" and ${quote(kinds[0])}; remove ${quote(key)}.`));
|
|
202
|
+
}
|
|
203
|
+
const kind = kinds[0];
|
|
204
|
+
const body = message[kind];
|
|
205
|
+
const at = pointer(base, kind);
|
|
206
|
+
if (!isObject(body)) {
|
|
207
|
+
issues.errors.push(failure("", at, `${kind} is an object; got ${typeName(body)}.`));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const surfaceId = body.surfaceId;
|
|
211
|
+
if (typeof surfaceId !== "string" || !surfaceId.trim() || surfaceId.length > rules.limits.idLength) {
|
|
212
|
+
issues.errors.push(failure("", pointer(at, "surfaceId"), `${kind} needs a "surfaceId": a string of 1 to ${rules.limits.idLength} characters that names the surface.`));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (kind === "createSurface") createSurface(state, body, at, issues);
|
|
216
|
+
else {
|
|
217
|
+
const surface = state.surfaces.get(surfaceId);
|
|
218
|
+
if (!surface || surface.deleted) {
|
|
219
|
+
issues.errors.push(failure(surfaceId, pointer(at, "surfaceId"), surface?.deleted ? `Surface ${quote(surfaceId)} was deleted; send createSurface again before ${kind}.` : `No surface is called ${quote(surfaceId)}. Send createSurface for it first, in the same messages.`));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (kind === "updateComponents") updateComponents(rules, surface, body, at, issues);
|
|
223
|
+
else if (kind === "updateDataModel") updateDataModel(rules, surface, body, at, issues);
|
|
224
|
+
else deleteSurface(state, surface, body, at, issues);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function onlyKeys(object, allowed, at, surfaceId, issues, name) {
|
|
229
|
+
for (const key of Object.keys(object)) {
|
|
230
|
+
if (!allowed.includes(key)) issues.errors.push(failure(surfaceId, pointer(at, key), `${name} does not take ${quote(key)}. It takes ${list(allowed)}.`));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createSurface(state, body, at, issues) {
|
|
235
|
+
const { rules } = state;
|
|
236
|
+
const { surfaceId } = body;
|
|
237
|
+
const before = issues.errors.length;
|
|
238
|
+
onlyKeys(body, ["surfaceId", "catalogId", "theme", "sendDataModel"], at, surfaceId, issues, "createSurface");
|
|
239
|
+
if (body.catalogId !== rules.catalogId) {
|
|
240
|
+
issues.errors.push(failure(surfaceId, pointer(at, "catalogId"), `Set "catalogId" to ${quote(rules.catalogId)}: this renderer draws the Mlola catalog${typeof body.catalogId === "string" ? `, not ${quote(body.catalogId)}` : ""}.`));
|
|
241
|
+
}
|
|
242
|
+
if (body.sendDataModel !== undefined && typeof body.sendDataModel !== "boolean") {
|
|
243
|
+
issues.errors.push(failure(surfaceId, pointer(at, "sendDataModel"), `"sendDataModel" is true or false.`));
|
|
244
|
+
}
|
|
245
|
+
const theme = { name: rules.defaultTheme, mode: "system" };
|
|
246
|
+
if (body.theme !== undefined) {
|
|
247
|
+
const themeAt = pointer(at, "theme");
|
|
248
|
+
if (!isObject(body.theme)) {
|
|
249
|
+
issues.errors.push(failure(surfaceId, themeAt, `"theme" is an object like {"name": ${quote(rules.defaultTheme)}, "mode": "system"}.`));
|
|
250
|
+
} else {
|
|
251
|
+
for (const key of Object.keys(body.theme)) {
|
|
252
|
+
if (key === "name" || key === "mode") continue;
|
|
253
|
+
issues.errors.push(failure(surfaceId, pointer(themeAt, key), /color|font|radius|spacing|icon/i.test(key)
|
|
254
|
+
? `Colors, type and shape come from the theme; pick one by name instead of setting ${quote(key)}: ${list(rules.themes)}.`
|
|
255
|
+
: `"theme" takes "name" and "mode"; remove ${quote(key)}.`));
|
|
256
|
+
}
|
|
257
|
+
if (body.theme.name !== undefined) {
|
|
258
|
+
if (rules.themes.includes(body.theme.name)) theme.name = body.theme.name;
|
|
259
|
+
else issues.errors.push(failure(surfaceId, pointer(themeAt, "name"), `Theme ${quote(body.theme.name)} does not exist; use ${list(rules.themes)}.`));
|
|
260
|
+
}
|
|
261
|
+
if (body.theme.mode !== undefined) {
|
|
262
|
+
if (rules.modes.includes(body.theme.mode)) theme.mode = body.theme.mode;
|
|
263
|
+
else issues.errors.push(failure(surfaceId, pointer(themeAt, "mode"), `Mode ${quote(body.theme.mode)} does not exist; use ${list(rules.modes)} (system follows the reader's setting).`));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const existing = state.surfaces.get(surfaceId);
|
|
268
|
+
if (existing && !existing.deleted) {
|
|
269
|
+
issues.errors.push(failure(surfaceId, pointer(at, "surfaceId"), `Surface ${quote(surfaceId)} already exists. Use another surfaceId, or send deleteSurface first.`));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let open = 0;
|
|
273
|
+
for (const surface of state.surfaces.values()) if (!surface.deleted) open += 1;
|
|
274
|
+
if (open >= rules.limits.surfaces) {
|
|
275
|
+
issues.errors.push(failure(surfaceId, at, `At most ${rules.limits.surfaces} surfaces are open at once; send deleteSurface for one that is done.`));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Created again, a surface takes its place at the end, as the newest.
|
|
279
|
+
state.surfaces.delete(surfaceId);
|
|
280
|
+
// What was wrong with createSurface stays with the surface: a renderer does not draw one made for another catalog.
|
|
281
|
+
state.surfaces.set(surfaceId, { id: surfaceId, theme, sendDataModel: body.sendDataModel === true, deleted: false, components: new Map(), data: {}, dataBytes: 0, at, problems: issues.errors.slice(before) });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function deleteSurface(state, surface, body, at, issues) {
|
|
285
|
+
onlyKeys(body, ["surfaceId"], at, surface.id, issues, "deleteSurface");
|
|
286
|
+
surface.deleted = true;
|
|
287
|
+
surface.components = new Map();
|
|
288
|
+
surface.data = {};
|
|
289
|
+
surface.dataBytes = 0;
|
|
290
|
+
// A deleted surface is remembered, so a late update is told why it fails; the oldest are let go.
|
|
291
|
+
const gone = [...state.surfaces.values()].filter((entry) => entry.deleted);
|
|
292
|
+
for (const entry of gone.slice(0, Math.max(0, gone.length - state.rules.limits.surfaces))) state.surfaces.delete(entry.id);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function updateComponents(rules, surface, body, at, issues) {
|
|
296
|
+
onlyKeys(body, ["surfaceId", "components"], at, surface.id, issues, "updateComponents");
|
|
297
|
+
const listAt = pointer(at, "components");
|
|
298
|
+
if (!Array.isArray(body.components) || !body.components.length) {
|
|
299
|
+
issues.errors.push(failure(surface.id, listAt, `"components" is a non-empty array of components, each with an "id" and a "component".`));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (body.components.length > rules.limits.components) {
|
|
303
|
+
issues.errors.push(failure(surface.id, listAt, `This message sends ${body.components.length} components; a surface holds at most ${rules.limits.components}. Split it into more surfaces.`));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
for (const [index, component] of body.components.entries()) {
|
|
308
|
+
const componentAt = pointer(listAt, index);
|
|
309
|
+
if (!isObject(component)) {
|
|
310
|
+
issues.errors.push(failure(surface.id, componentAt, `A component is an object with "id" and "component"; got ${typeName(component)}.`));
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const { id } = component;
|
|
314
|
+
if (typeof id !== "string" || !id.trim() || id.length > rules.limits.idLength) {
|
|
315
|
+
issues.errors.push(failure(surface.id, pointer(componentAt, "id"), `Every component needs an "id": a string of 1 to ${rules.limits.idLength} characters, unique on the surface.`));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (seen.has(id)) {
|
|
319
|
+
issues.errors.push(failure(surface.id, pointer(componentAt, "id"), `Two components in this message have the id ${quote(id)}; give each its own.`));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
seen.add(id);
|
|
323
|
+
if (!surface.components.has(id) && surface.components.size >= rules.limits.components) {
|
|
324
|
+
issues.errors.push(failure(surface.id, componentAt, `A surface holds at most ${rules.limits.components} components; split it into more surfaces.`));
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
const definition = checkComponent(rules, surface.id, component, componentAt, issues);
|
|
328
|
+
surface.components.set(id, { id, type: component.component, props: component, definition, at: componentAt });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function updateDataModel(rules, surface, body, at, issues) {
|
|
333
|
+
onlyKeys(body, ["surfaceId", "path", "value"], at, surface.id, issues, "updateDataModel");
|
|
334
|
+
const path = body.path ?? "/";
|
|
335
|
+
const pathAt = pointer(at, "path");
|
|
336
|
+
if (typeof path !== "string" || (path !== "" && !validPointer(path, { absolute: true, rules }))) {
|
|
337
|
+
issues.errors.push(failure(surface.id, pathAt, `"path" is a JSON Pointer like "/form/email", or "/" for the whole data model.`));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// The size is what the model holds: the value written, less the one it
|
|
341
|
+
// replaces, so a stream that keeps updating one field never fills it.
|
|
342
|
+
// The model holds a copy: what a person types later is written to the surface, never into the message it came from.
|
|
343
|
+
let total = 0;
|
|
344
|
+
let value;
|
|
345
|
+
try {
|
|
346
|
+
const written = body.value === undefined ? undefined : JSON.stringify(body.value);
|
|
347
|
+
value = written === undefined ? undefined : JSON.parse(written);
|
|
348
|
+
total = surface.dataBytes - jsonLength(getAt(surface.data, path)) + (written?.length ?? 0);
|
|
349
|
+
} catch {
|
|
350
|
+
issues.errors.push(failure(surface.id, pointer(at, "value"), `This value is nested too deeply to read; flatten it.`));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (total > rules.limits.dataBytes) {
|
|
354
|
+
issues.errors.push(failure(surface.id, pointer(at, "value"), `A surface's data model holds at most ${rules.limits.dataBytes} bytes of JSON; send less, or page through it.`));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const written = setAt(surface.data, path, value);
|
|
358
|
+
if (written.error) {
|
|
359
|
+
issues.errors.push(failure(surface.id, pathAt, written.error));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
surface.data = written.root;
|
|
363
|
+
surface.dataBytes = total;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const jsonLength = (value) => (value === undefined ? 0 : (JSON.stringify(value)?.length ?? 0));
|
|
367
|
+
|
|
368
|
+
/* Components -------------------------------------------------------------- */
|
|
369
|
+
|
|
370
|
+
function checkComponent(rules, surfaceId, component, at, issues) {
|
|
371
|
+
const type = component.component;
|
|
372
|
+
const typeAt = pointer(at, "component");
|
|
373
|
+
if (typeof type !== "string" || !type) {
|
|
374
|
+
issues.errors.push(failure(surfaceId, typeAt, `Every component names its type in "component", for example "Stack", "Text" or "Button".`));
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const definition = own(rules.components, type);
|
|
378
|
+
if (!definition) {
|
|
379
|
+
issues.errors.push(failure(surfaceId, typeAt, unknownComponent(rules, type)));
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
for (const [name, value] of Object.entries(component)) {
|
|
383
|
+
if (COMMON_PROPS.has(name)) continue;
|
|
384
|
+
const prop = own(definition.props, name);
|
|
385
|
+
const propAt = pointer(at, name);
|
|
386
|
+
if (!prop) {
|
|
387
|
+
issues.errors.push(failure(surfaceId, propAt, unknownProp(rules, type, definition, name)));
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
checkProp(rules, surfaceId, type, name, prop, value, propAt, issues);
|
|
391
|
+
}
|
|
392
|
+
for (const [name, prop] of Object.entries(definition.props)) {
|
|
393
|
+
if (prop.required && component[name] === undefined) {
|
|
394
|
+
issues.errors.push(failure(surfaceId, at, `${type} needs ${quote(name)}${prop.description ? `: ${lowerFirst(prop.description)}` : "."}`));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (component.accessibility !== undefined) checkAccessibility(rules, surfaceId, component.accessibility, pointer(at, "accessibility"), issues);
|
|
398
|
+
checkValues(rules, surfaceId, type, definition, component, at, issues);
|
|
399
|
+
return definition;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
403
|
+
|
|
404
|
+
function unknownComponent(rules, type) {
|
|
405
|
+
const alias = own(rules.aliases, type) ?? Object.keys(rules.components).find((name) => name.toLowerCase() === type.toLowerCase());
|
|
406
|
+
if (alias) return `${quote(type)} is not in the Mlola catalog; use ${quote(alias)}.`;
|
|
407
|
+
const hint = own(rules.borrowed.components, type);
|
|
408
|
+
if (hint) return `${quote(type)} is not in the Mlola catalog. ${hint}`;
|
|
409
|
+
return `${quote(type)} is not in the Mlola catalog. It has: ${catalogGroups(rules).map(([title, names]) => `${title} (${names.join(", ")})`).join("; ")}.`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The catalog's components by group, in the order the groups are titled: [title, names][]. */
|
|
413
|
+
function catalogGroups(rules) {
|
|
414
|
+
return Object.entries(rules.groups).map(([group, title]) => [title, Object.keys(rules.components).filter((name) => rules.components[name].group === group)]);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function unknownProp(rules, type, definition, name) {
|
|
418
|
+
const props = Object.keys(definition.props);
|
|
419
|
+
const hint = own(rules.borrowed.props, `${type}.${name}`) ?? own(rules.borrowed.props, name);
|
|
420
|
+
if (hint) return `${type} does not take ${quote(name)}. ${hint}`;
|
|
421
|
+
if (STYLE_PROPS.has(name)) return `${type} takes no ${quote(name)}: Mlola Render draws with the surface's theme. Choose a named value instead (${props.filter((prop) => definition.props[prop].kind === "enum").join(", ") || "none on this component"}).`;
|
|
422
|
+
if (name === "child" && props.includes("children")) return `${type} takes "children", an array of ids, not "child".`;
|
|
423
|
+
if (name === "checks") return `Client-side checks are not in the Mlola catalog yet; use "required" on the field, and check the values when the action arrives.`;
|
|
424
|
+
if (/^on[A-Z]/.test(name)) return `${type} does not take ${quote(name)}. A Button's "action" sends an event to the agent; inputs bind their value with {"path": …}.`;
|
|
425
|
+
return `${type} does not take ${quote(name)}. It takes: ${props.join(", ")}.`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function checkAccessibility(rules, surfaceId, value, at, issues) {
|
|
429
|
+
if (!isObject(value)) {
|
|
430
|
+
issues.errors.push(failure(surfaceId, at, `"accessibility" is an object with "label" and "description".`));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
434
|
+
if (key !== "label" && key !== "description") {
|
|
435
|
+
issues.errors.push(failure(surfaceId, pointer(at, key), `"accessibility" takes "label" and "description"; remove ${quote(key)}.`));
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
checkText(rules, surfaceId, entry, pointer(at, key), { maxLength: rules.limits.labelLength }, false, issues);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function checkProp(rules, surfaceId, type, name, prop, value, at, issues) {
|
|
443
|
+
switch (prop.kind) {
|
|
444
|
+
case "text":
|
|
445
|
+
return checkText(rules, surfaceId, value, at, prop, prop.required, issues, `${type} ${quote(name)}`);
|
|
446
|
+
case "boolean":
|
|
447
|
+
if (typeof value === "boolean" || (!prop.literal && binding(rules, surfaceId, value, at, issues))) return;
|
|
448
|
+
return issues.errors.push(failure(surfaceId, at, `${type} ${quote(name)} is true or false${prop.literal ? "" : `, or {"path": "/…"}`}; got ${typeName(value)}.`));
|
|
449
|
+
case "number":
|
|
450
|
+
if (typeof value === "number" && Number.isFinite(value)) return;
|
|
451
|
+
if (!prop.literal && binding(rules, surfaceId, value, at, issues)) return;
|
|
452
|
+
return issues.errors.push(failure(surfaceId, at, `${type} ${quote(name)} is a number${prop.literal ? "" : ` or {"path": "/…"}`}; got ${typeName(value)}.`));
|
|
453
|
+
case "integer":
|
|
454
|
+
if (Number.isInteger(value) && value >= prop.minimum && value <= prop.maximum) return;
|
|
455
|
+
return issues.errors.push(failure(surfaceId, at, `${type} ${quote(name)} is a whole number from ${prop.minimum} to ${prop.maximum}; got ${quote(value)}.`));
|
|
456
|
+
case "date":
|
|
457
|
+
if (isDate(value)) return;
|
|
458
|
+
if (!prop.literal && binding(rules, surfaceId, value, at, issues)) return;
|
|
459
|
+
return issues.errors.push(failure(surfaceId, at, `${type} ${quote(name)} is a day written as an ISO date, like "2026-10-12"${prop.literal ? "" : `, or {"path": "/…"}`}; got ${quote(value)}.`));
|
|
460
|
+
case "enum":
|
|
461
|
+
if (typeof value === "string" && prop.values.includes(value)) return;
|
|
462
|
+
return issues.errors.push(failure(surfaceId, at, enumMessage(rules, type, name, prop, value)));
|
|
463
|
+
case "children":
|
|
464
|
+
return checkChildren(rules, surfaceId, type, name, prop, value, at, issues);
|
|
465
|
+
case "action":
|
|
466
|
+
return checkAction(rules, surfaceId, value, at, issues);
|
|
467
|
+
case "options":
|
|
468
|
+
return checkOptions(rules, surfaceId, type, prop, value, at, issues);
|
|
469
|
+
case "columns":
|
|
470
|
+
return checkColumns(rules, surfaceId, prop, value, at, issues);
|
|
471
|
+
case "rows":
|
|
472
|
+
return checkRows(rules, surfaceId, value, at, issues);
|
|
473
|
+
default:
|
|
474
|
+
return issues.errors.push(failure(surfaceId, at, `${type} ${quote(name)} has a kind this validator does not know (${prop.kind}).`));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function enumMessage(rules, type, name, prop, value) {
|
|
479
|
+
if (typeof value === "string") {
|
|
480
|
+
const near = prop.values.find((option) => option.toLowerCase() === value.toLowerCase());
|
|
481
|
+
if (near) return `${type} ${quote(name)} is ${quote(near)}, not ${quote(value)}: values are case-sensitive.`;
|
|
482
|
+
const hint = own(rules.borrowed.props, `${type}.${name}=${value}`);
|
|
483
|
+
if (hint) return `${type} ${quote(name)} ${quote(value)} is not in the catalog. ${hint}`;
|
|
484
|
+
if (prop.excluded?.includes(value)) return `${type} ${quote(name)} ${quote(value)} is not in the catalog: ${prop.description ?? "it needs content the catalog does not offer."} Use ${list(prop.values)}.`;
|
|
485
|
+
}
|
|
486
|
+
return `${type} ${quote(name)} ${typeof value === "string" ? `${quote(value)} does not exist` : `is a name, not ${typeName(value)}`}; use ${list(prop.values)}.`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function checkText(rules, surfaceId, value, at, prop, required, issues, label = "This text") {
|
|
490
|
+
if (typeof value === "string") {
|
|
491
|
+
const max = prop.maxLength ?? rules.limits.textLength;
|
|
492
|
+
if (value.length > max) issues.errors.push(failure(surfaceId, at, `${label} is ${value.length} characters; keep it to ${max}.`));
|
|
493
|
+
else if (required && !value.trim()) issues.errors.push(failure(surfaceId, at, `${label} is empty. It is what a person sees and a screen reader reads; write the words.`));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (binding(rules, surfaceId, value, at, issues)) return;
|
|
497
|
+
if (isObject(value) && "call" in value) {
|
|
498
|
+
issues.errors.push(failure(surfaceId, at, `Functions are not in the Mlola catalog yet; use a literal string or {"path": "/…"}.`));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
issues.errors.push(failure(surfaceId, at, `${label} is a string or {"path": "/…"}; got ${typeName(value)}.`));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A data binding, {"path": "…"}: true when it is one (reporting a bad path), false when the value is something else. */
|
|
505
|
+
function binding(rules, surfaceId, value, at, issues) {
|
|
506
|
+
if (!isObject(value) || !("path" in value)) return false;
|
|
507
|
+
const extra = Object.keys(value).filter((key) => key !== "path");
|
|
508
|
+
if (extra.length) issues.errors.push(failure(surfaceId, at, `A binding holds only "path"; remove ${list(extra)}.`));
|
|
509
|
+
if (typeof value.path !== "string" || !validPointer(value.path, { rules })) {
|
|
510
|
+
issues.errors.push(failure(surfaceId, pointer(at, "path"), `${quote(value.path)} is not a data path. Write a JSON Pointer like "/form/email" (or "name" inside a template), at most ${rules.limits.pathLength} characters.`));
|
|
511
|
+
}
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** JSON Pointer (RFC 6901), or a relative one inside a template; no segment that reaches an object's prototype. */
|
|
516
|
+
function validPointer(path, { absolute = false, rules }) {
|
|
517
|
+
if (!path || path.length > rules.limits.pathLength) return false;
|
|
518
|
+
if (absolute && path === "/") return true;
|
|
519
|
+
if (absolute && !path.startsWith("/")) return false;
|
|
520
|
+
const segments = (path.startsWith("/") ? path.slice(1) : path).split("/");
|
|
521
|
+
for (const segment of segments) {
|
|
522
|
+
if (/~(?![01])/.test(segment)) return false;
|
|
523
|
+
if (UNSAFE_SEGMENTS.has(unescapeSegment(segment))) return false;
|
|
524
|
+
}
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const unescapeSegment = (segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
529
|
+
|
|
530
|
+
function checkChildren(rules, surfaceId, type, name, prop, value, at, issues) {
|
|
531
|
+
if (Array.isArray(value)) {
|
|
532
|
+
if (value.length < (prop.minItems ?? 0)) issues.errors.push(failure(surfaceId, at, `${type} needs at least ${prop.minItems} ${prop.minItems === 1 ? "child" : "children"}.`));
|
|
533
|
+
if (value.length > rules.limits.components) issues.errors.push(failure(surfaceId, at, `${type} lists ${value.length} children; a surface holds at most ${rules.limits.components} components.`));
|
|
534
|
+
const seen = new Set();
|
|
535
|
+
for (const [index, id] of value.entries()) {
|
|
536
|
+
if (typeof id !== "string" || !id) issues.errors.push(failure(surfaceId, pointer(at, index), `A child is a component id (a string); got ${typeName(id)}.`));
|
|
537
|
+
else if (seen.has(id)) issues.errors.push(failure(surfaceId, pointer(at, index), `${quote(id)} is listed twice in ${type}'s children; a component appears in one place.`));
|
|
538
|
+
else seen.add(id);
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (isObject(value) && ("componentId" in value || "path" in value)) {
|
|
543
|
+
onlyKeys(value, ["componentId", "path"], at, surfaceId, issues, "A template");
|
|
544
|
+
if (typeof value.componentId !== "string" || !value.componentId) issues.errors.push(failure(surfaceId, pointer(at, "componentId"), `A template names the component to repeat in "componentId".`));
|
|
545
|
+
// Relative inside another template (an item's own list); checkBindingsScope holds that.
|
|
546
|
+
if (typeof value.path !== "string" || !validPointer(value.path, { rules })) issues.errors.push(failure(surfaceId, pointer(at, "path"), `A template's "path" points at an array in the data model, like "/items".`));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
issues.errors.push(failure(surfaceId, at, `${type} ${quote(name)} is an array of component ids, or a template {"componentId", "path"}; got ${typeName(value)}.`));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function checkAction(rules, surfaceId, value, at, issues) {
|
|
553
|
+
if (!isObject(value)) {
|
|
554
|
+
issues.errors.push(failure(surfaceId, at, `"action" is {"event": {"name": "…", "context": {…}}}; got ${typeName(value)}.`));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if ("functionCall" in value) {
|
|
558
|
+
issues.errors.push(failure(surfaceId, pointer(at, "functionCall"), `Client-side functions are not in the Mlola catalog yet; send an event and handle it in the agent: {"event": {"name": "…"}}.`));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
onlyKeys(value, ["event"], at, surfaceId, issues, "An action");
|
|
562
|
+
const event = value.event;
|
|
563
|
+
const eventAt = pointer(at, "event");
|
|
564
|
+
if (!isObject(event)) {
|
|
565
|
+
issues.errors.push(failure(surfaceId, eventAt, `An action sends an event: {"name": "…", "context": {…}}.`));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
onlyKeys(event, ["name", "context"], eventAt, surfaceId, issues, "An event");
|
|
569
|
+
if (typeof event.name !== "string" || !ACTION_NAME.test(event.name)) {
|
|
570
|
+
issues.errors.push(failure(surfaceId, pointer(eventAt, "name"), `An event's "name" starts with a letter and uses letters, digits, _ . : or - (at most 64), like "create_workspace".`));
|
|
571
|
+
}
|
|
572
|
+
if (event.context === undefined) return;
|
|
573
|
+
const contextAt = pointer(eventAt, "context");
|
|
574
|
+
if (!isObject(event.context)) {
|
|
575
|
+
issues.errors.push(failure(surfaceId, contextAt, `An event's "context" is an object of values or {"path": …} bindings.`));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const keys = Object.keys(event.context);
|
|
579
|
+
if (keys.length > rules.limits.contextKeys) issues.errors.push(failure(surfaceId, contextAt, `An event's context holds at most ${rules.limits.contextKeys} values; bind a whole object with {"path": "/form"} instead.`));
|
|
580
|
+
for (const key of keys.slice(0, rules.limits.contextKeys)) {
|
|
581
|
+
const entry = event.context[key];
|
|
582
|
+
const entryAt = pointer(contextAt, key);
|
|
583
|
+
if (UNSAFE_SEGMENTS.has(key)) issues.errors.push(failure(surfaceId, entryAt, `${quote(key)} cannot be a context key.`));
|
|
584
|
+
else if (isObject(entry) && !binding(rules, surfaceId, entry, entryAt, issues)) {
|
|
585
|
+
issues.errors.push(failure(surfaceId, entryAt, `A context value is a string, number, boolean, array or {"path": …}; an object needs a binding.`));
|
|
586
|
+
} else if (Array.isArray(entry) && entry.some((item) => item !== null && typeof item === "object")) {
|
|
587
|
+
// Nothing inside an array is resolved, so a binding or an object there would reach the agent as written.
|
|
588
|
+
issues.errors.push(failure(surfaceId, entryAt, `An array in the context holds strings, numbers and true or false; for a list from the data model use {"path": "/…"}.`));
|
|
589
|
+
} else if (typeof entry === "string" && entry.length > rules.limits.textLength) {
|
|
590
|
+
issues.errors.push(failure(surfaceId, entryAt, `This value is ${entry.length} characters; keep it to ${rules.limits.textLength}.`));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function checkOptions(rules, surfaceId, type, prop, value, at, issues) {
|
|
596
|
+
if (binding(rules, surfaceId, value, at, issues)) return;
|
|
597
|
+
if (!Array.isArray(value)) {
|
|
598
|
+
issues.errors.push(failure(surfaceId, at, `${type} "options" is an array of {"label", "value"}, or {"path": "/…"}; got ${typeName(value)}.`));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (value.length < (prop.minItems ?? 1)) issues.errors.push(failure(surfaceId, at, `${type} needs at least ${prop.minItems} options${type === "RadioGroup" ? "; for one yes or no choice use a Checkbox" : ""}.`));
|
|
602
|
+
if (value.length > rules.limits.options) {
|
|
603
|
+
issues.errors.push(failure(surfaceId, at, `${type} lists ${value.length} options; keep it to ${rules.limits.options}.`));
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const seen = new Set();
|
|
607
|
+
for (const [index, option] of value.entries()) {
|
|
608
|
+
const optionAt = pointer(at, index);
|
|
609
|
+
if (!isObject(option)) {
|
|
610
|
+
issues.errors.push(failure(surfaceId, optionAt, `An option is {"label": "…", "value": "…"}; got ${typeName(option)}.`));
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
onlyKeys(option, ["label", "value"], optionAt, surfaceId, issues, "An option");
|
|
614
|
+
if (typeof option.label !== "string" || !option.label.trim() || option.label.length > rules.limits.labelLength) issues.errors.push(failure(surfaceId, pointer(optionAt, "label"), `An option's "label" is the words a person picks, 1 to ${rules.limits.labelLength} characters.`));
|
|
615
|
+
if (typeof option.value !== "string" || !option.value || option.value.length > rules.limits.labelLength) issues.errors.push(failure(surfaceId, pointer(optionAt, "value"), `An option's "value" is the string sent back, 1 to ${rules.limits.labelLength} characters.`));
|
|
616
|
+
else if (seen.has(option.value)) issues.errors.push(failure(surfaceId, pointer(optionAt, "value"), `Two options have the value ${quote(option.value)}; give each its own.`));
|
|
617
|
+
else seen.add(option.value);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function checkColumns(rules, surfaceId, prop, value, at, issues) {
|
|
622
|
+
if (!Array.isArray(value) || value.length < (prop.minItems ?? 1)) {
|
|
623
|
+
issues.errors.push(failure(surfaceId, at, `"columns" is a non-empty array of {"key", "label", "align"?}.`));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (value.length > rules.limits.columns) {
|
|
627
|
+
issues.errors.push(failure(surfaceId, at, `A table shows at most ${rules.limits.columns} columns; more cannot be read on a phone.`));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const seen = new Set();
|
|
631
|
+
for (const [index, column] of value.entries()) {
|
|
632
|
+
const columnAt = pointer(at, index);
|
|
633
|
+
if (!isObject(column)) {
|
|
634
|
+
issues.errors.push(failure(surfaceId, columnAt, `A column is {"key": "…", "label": "…"}; got ${typeName(column)}.`));
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
onlyKeys(column, ["key", "label", "align"], columnAt, surfaceId, issues, "A column");
|
|
638
|
+
if (typeof column.key !== "string" || !COLUMN_KEY.test(column.key) || UNSAFE_SEGMENTS.has(column.key)) issues.errors.push(failure(surfaceId, pointer(columnAt, "key"), `A column's "key" names the row field it shows: letters, digits, _ or -, like "order".`));
|
|
639
|
+
else if (seen.has(column.key)) issues.errors.push(failure(surfaceId, pointer(columnAt, "key"), `Two columns have the key ${quote(column.key)}.`));
|
|
640
|
+
else seen.add(column.key);
|
|
641
|
+
checkText(rules, surfaceId, column.label, pointer(columnAt, "label"), { maxLength: rules.limits.labelLength }, true, issues, `The column's "label"`);
|
|
642
|
+
if (column.align !== undefined && !prop.align.values.includes(column.align)) issues.errors.push(failure(surfaceId, pointer(columnAt, "align"), `"align" is ${list(prop.align.values)}.`));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function checkRows(rules, surfaceId, value, at, issues) {
|
|
647
|
+
if (binding(rules, surfaceId, value, at, issues)) return;
|
|
648
|
+
if (!Array.isArray(value)) {
|
|
649
|
+
issues.errors.push(failure(surfaceId, at, `"rows" is an array of objects, or {"path": "/…"} to one in the data model; got ${typeName(value)}.`));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (value.length > rules.limits.rows) {
|
|
653
|
+
issues.errors.push(failure(surfaceId, at, `A table holds at most ${rules.limits.rows} rows; page through the rest.`));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
for (const [index, row] of value.entries()) {
|
|
657
|
+
const rowAt = pointer(at, index);
|
|
658
|
+
if (!isObject(row)) {
|
|
659
|
+
issues.errors.push(failure(surfaceId, rowAt, `A row is an object keyed by column key; got ${typeName(row)}.`));
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
for (const [key, cell] of Object.entries(row)) {
|
|
663
|
+
if (cell === null || typeof cell === "number" || typeof cell === "boolean") continue;
|
|
664
|
+
if (typeof cell === "string" && cell.length <= rules.limits.textLength) continue;
|
|
665
|
+
issues.errors.push(failure(surfaceId, pointer(rowAt, key), typeof cell === "string" ? `A cell holds at most ${rules.limits.textLength} characters.` : `A cell holds text, a number or true/false; got ${typeName(cell)}.`));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Rules between props of one component: a range that holds its value, a value among the options. */
|
|
671
|
+
function checkValues(rules, surfaceId, type, definition, component, at, issues) {
|
|
672
|
+
const literal = (name) => (typeof component[name] === "number" ? component[name] : definition.props[name]?.default);
|
|
673
|
+
if (type === "Slider") {
|
|
674
|
+
const min = literal("min");
|
|
675
|
+
const max = literal("max");
|
|
676
|
+
const step = literal("step");
|
|
677
|
+
if (typeof min === "number" && typeof max === "number" && min >= max) issues.errors.push(failure(surfaceId, pointer(at, "max"), `Slider "max" (${max}) must be greater than "min" (${min}).`));
|
|
678
|
+
if (typeof step === "number" && (step <= 0 || step > max - min)) issues.errors.push(failure(surfaceId, pointer(at, "step"), `Slider "step" is greater than 0 and at most max minus min.`));
|
|
679
|
+
if (typeof component.value === "number" && (component.value < min || component.value > max)) issues.errors.push(failure(surfaceId, pointer(at, "value"), `Slider "value" ${component.value} is outside ${min} to ${max}.`));
|
|
680
|
+
}
|
|
681
|
+
if (type === "Progress") {
|
|
682
|
+
const max = literal("max");
|
|
683
|
+
if (typeof max === "number" && max <= 0) issues.errors.push(failure(surfaceId, pointer(at, "max"), `Progress "max" is greater than 0.`));
|
|
684
|
+
if (typeof component.value === "number" && (component.value < 0 || component.value > max)) issues.errors.push(failure(surfaceId, pointer(at, "value"), `Progress "value" ${component.value} is outside 0 to ${max}.`));
|
|
685
|
+
}
|
|
686
|
+
if (type === "DatePicker") {
|
|
687
|
+
// ISO dates sort as text, so the range is compared as written.
|
|
688
|
+
const { min, max, value } = component;
|
|
689
|
+
if (isDate(min) && isDate(max) && min > max) issues.errors.push(failure(surfaceId, pointer(at, "max"), `DatePicker "max" (${max}) comes before "min" (${min}); make it the later day.`));
|
|
690
|
+
else if (isDate(value) && ((isDate(min) && value < min) || (isDate(max) && value > max))) issues.errors.push(failure(surfaceId, pointer(at, "value"), `DatePicker "value" ${value} is outside ${isDate(min) ? min : "any day"} to ${isDate(max) ? max : "any day"}.`));
|
|
691
|
+
}
|
|
692
|
+
if ((type === "Select" || type === "RadioGroup") && typeof component.value === "string" && Array.isArray(component.options)) {
|
|
693
|
+
const values = component.options.filter(isObject).map((option) => option.value);
|
|
694
|
+
if (!values.includes(component.value)) issues.errors.push(failure(surfaceId, pointer(at, "value"), `${type} "value" ${quote(component.value)} is not one of its options (${list(values)}).`));
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/* The tree ---------------------------------------------------------------- */
|
|
699
|
+
|
|
700
|
+
function childIds(node) {
|
|
701
|
+
const value = node.props.children;
|
|
702
|
+
if (Array.isArray(value)) return { ids: value.filter((id) => typeof id === "string"), template: null };
|
|
703
|
+
if (isObject(value) && typeof value.componentId === "string") return { ids: [value.componentId], template: value };
|
|
704
|
+
return { ids: [], template: null };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function checkTree(rules, surface, issues) {
|
|
708
|
+
const { components } = surface;
|
|
709
|
+
const root = components.get("root");
|
|
710
|
+
if (!root) {
|
|
711
|
+
issues.errors.push(failure(surface.id, surface.at, `No component has the id "root". One component is the root of the tree; give it "id": "root".`));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const parents = new Map();
|
|
715
|
+
for (const node of components.values()) {
|
|
716
|
+
const { ids, template } = childIds(node);
|
|
717
|
+
const childrenAt = pointer(node.at, "children");
|
|
718
|
+
for (const [index, id] of ids.entries()) {
|
|
719
|
+
const at = template ? pointer(childrenAt, "componentId") : pointer(childrenAt, index);
|
|
720
|
+
const child = components.get(id);
|
|
721
|
+
if (!child) {
|
|
722
|
+
issues.errors.push(failure(surface.id, at, `No component has the id ${quote(id)}. Add it, or remove it from ${node.type}'s children.`));
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (id === node.id) {
|
|
726
|
+
issues.errors.push(failure(surface.id, at, `${quote(id)} lists itself as a child: the children form a loop. A component cannot be inside its own tree.`));
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (id === "root") {
|
|
730
|
+
issues.errors.push(failure(surface.id, at, `"root" is the top of the tree and cannot be a child.`));
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
const parent = parents.get(id);
|
|
734
|
+
if (parent && parent !== node.id) {
|
|
735
|
+
issues.errors.push(failure(surface.id, at, `${quote(id)} is already a child of ${quote(parent)}. A component appears in one place; give the second one its own id.`));
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
parents.set(id, node.id);
|
|
739
|
+
if (child.definition?.parents && node.definition && !child.definition.parents.includes(node.type)) {
|
|
740
|
+
issues.errors.push(failure(surface.id, at, `${child.type} goes directly inside ${child.definition.parents.join(" or ")}, not inside ${node.type}.`));
|
|
741
|
+
}
|
|
742
|
+
if (node.definition?.accepts && child.definition && !node.definition.accepts.includes(child.type)) {
|
|
743
|
+
issues.errors.push(failure(surface.id, at, `${node.type} holds only ${node.definition.accepts.join(" and ")} components; ${quote(id)} is a ${child.type}.`));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
for (const node of components.values()) {
|
|
748
|
+
if (node.definition?.parents && node.id !== "root" && !parents.has(node.id)) {
|
|
749
|
+
issues.errors.push(failure(surface.id, node.at, `${node.type} goes directly inside ${node.definition.parents.join(" or ")}; list ${quote(node.id)} in one's children.`));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Depth-first from the root, in reading order, without recursion: a cycle
|
|
754
|
+
// or a tree deeper than the limit is reported once, and the walk still ends.
|
|
755
|
+
const visited = new Set(["root"]);
|
|
756
|
+
const inTemplate = new Set();
|
|
757
|
+
const order = [];
|
|
758
|
+
const stack = [{ node: root, depth: 1, templated: false }];
|
|
759
|
+
while (stack.length) {
|
|
760
|
+
const { node, depth, templated } = stack.pop();
|
|
761
|
+
order.push(node);
|
|
762
|
+
if (templated) inTemplate.add(node.id);
|
|
763
|
+
if (depth > rules.limits.depth) {
|
|
764
|
+
issues.errors.push(failure(surface.id, node.at, `The tree is more than ${rules.limits.depth} levels deep at ${quote(node.id)}; flatten it.`));
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const { ids, template } = childIds(node);
|
|
768
|
+
for (let index = ids.length - 1; index >= 0; index -= 1) {
|
|
769
|
+
const child = components.get(ids[index]);
|
|
770
|
+
if (!child || parents.get(child.id) !== node.id) continue;
|
|
771
|
+
if (visited.has(child.id)) {
|
|
772
|
+
issues.errors.push(failure(surface.id, pointer(node.at, "children"), `${quote(child.id)} contains itself: the children form a loop. A component cannot be inside its own tree.`));
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
visited.add(child.id);
|
|
776
|
+
stack.push({ node: child, depth: depth + 1, templated: templated || Boolean(template) });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
for (const node of components.values()) {
|
|
780
|
+
if (!visited.has(node.id) && !(node.definition?.parents && !parents.has(node.id))) {
|
|
781
|
+
issues.advice.push(advice(surface.id, node.at, `${quote(node.id)} is not inside "root", so it will not show. List it in a parent's children, or remove it.`));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
checkBindingsScope(rules, surface, order, inTemplate, issues);
|
|
785
|
+
checkOutline(surface, order, issues);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Relative paths ("name") resolve against a template's item, so they only
|
|
790
|
+
* mean something inside one: in a prop, a template's own path, an action's
|
|
791
|
+
* context or the accessibility words.
|
|
792
|
+
*/
|
|
793
|
+
function checkBindingsScope(rules, surface, order, inTemplate, issues) {
|
|
794
|
+
const relative = (value) => isObject(value) && typeof value.path === "string" && !value.path.startsWith("/");
|
|
795
|
+
const report = (value, at) => issues.errors.push(failure(surface.id, pointer(at, "path"), `${quote(value.path)} is relative, which only works inside a template; write it from the top of the data model, like "/${value.path}".`));
|
|
796
|
+
for (const node of order) {
|
|
797
|
+
if (inTemplate.has(node.id) || !node.definition) continue;
|
|
798
|
+
for (const [name, value] of Object.entries(node.props)) {
|
|
799
|
+
const at = pointer(node.at, name);
|
|
800
|
+
if (name === "children") {
|
|
801
|
+
if (relative(value)) issues.errors.push(failure(surface.id, pointer(at, "path"), `A template's "path" points at an array from the top of the data model, like "/${value.path}"; a relative path works only inside another template.`));
|
|
802
|
+
} else if (name === "accessibility" && isObject(value)) {
|
|
803
|
+
for (const key of ["label", "description"]) if (relative(value[key])) report(value[key], pointer(at, key));
|
|
804
|
+
} else if (name === "action" && isObject(value) && isObject(value.event) && isObject(value.event.context)) {
|
|
805
|
+
for (const [key, entry] of Object.entries(value.event.context)) if (relative(entry)) report(entry, pointer(at, "event", "context", key));
|
|
806
|
+
} else if (relative(value)) report(value, at);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** What a reader meets in order: headings that do not skip a level, one main action, fields told apart. */
|
|
812
|
+
function checkOutline(surface, order, issues) {
|
|
813
|
+
let previous = 0;
|
|
814
|
+
let primary = null;
|
|
815
|
+
const labels = new Map();
|
|
816
|
+
// A prop left out takes the catalog's default: a Button with no variant is primary.
|
|
817
|
+
const value = (node, name) => node.props[name] ?? node.definition?.props[name]?.default;
|
|
818
|
+
for (const node of order) {
|
|
819
|
+
if (node.type === "Heading" && Number.isInteger(value(node, "level"))) {
|
|
820
|
+
const level = value(node, "level");
|
|
821
|
+
if (previous && level > previous + 1) {
|
|
822
|
+
issues.errors.push(failure(surface.id, pointer(node.at, "level"), `This heading jumps from level ${previous} to ${level}; use ${previous + 1}. A screen reader uses the levels to move through the surface.`));
|
|
823
|
+
}
|
|
824
|
+
previous = level;
|
|
825
|
+
}
|
|
826
|
+
if (node.type === "Button" && value(node, "variant") === "primary") {
|
|
827
|
+
if (primary) issues.advice.push(advice(surface.id, pointer(node.at, "variant"), `${quote(primary)} is already the primary action; keep one primary Button per surface and make this one "secondary".`));
|
|
828
|
+
else primary = node.id;
|
|
829
|
+
}
|
|
830
|
+
const label = typeof node.props.label === "string" ? node.props.label.trim().toLowerCase() : null;
|
|
831
|
+
if (label && node.definition?.group === "form") {
|
|
832
|
+
if (labels.has(label)) issues.advice.push(advice(surface.id, pointer(node.at, "label"), `${quote(labels.get(label))} has the same label; give each field its own so they can be told apart.`));
|
|
833
|
+
else labels.set(label, node.id);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/* Data model -------------------------------------------------------------- */
|
|
839
|
+
|
|
840
|
+
const ARRAY_INDEX = /^(0|[1-9][0-9]{0,5})$/;
|
|
841
|
+
|
|
842
|
+
/** The value at a JSON Pointer, or undefined; only the model's own keys, never a prototype's. */
|
|
843
|
+
function getAt(root, path) {
|
|
844
|
+
if (path === "" || path === "/") return root;
|
|
845
|
+
let target = root;
|
|
846
|
+
for (const segment of path.slice(1).split("/").map(unescapeSegment)) {
|
|
847
|
+
if (!isObject(target) && !Array.isArray(target)) return undefined;
|
|
848
|
+
if (Array.isArray(target) && !ARRAY_INDEX.test(segment)) return undefined;
|
|
849
|
+
target = Object.hasOwn(target, segment) ? target[segment] : undefined;
|
|
850
|
+
}
|
|
851
|
+
return target;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Writes `value` at a JSON Pointer, creating objects on the way, as a
|
|
856
|
+
* renderer applies updateDataModel. Inside an array a segment is an index
|
|
857
|
+
* up to its length (the length appends), so a write never leaves a gap.
|
|
858
|
+
*/
|
|
859
|
+
function setAt(root, path, value) {
|
|
860
|
+
if (path === "" || path === "/") return { root: value === undefined ? {} : value };
|
|
861
|
+
const segments = path.slice(1).split("/").map(unescapeSegment);
|
|
862
|
+
const top = isObject(root) || Array.isArray(root) ? root : {};
|
|
863
|
+
let target = top;
|
|
864
|
+
for (const [index, segment] of segments.entries()) {
|
|
865
|
+
if (Array.isArray(target) && !ARRAY_INDEX.test(segment)) {
|
|
866
|
+
return { error: `${quote(segments.slice(0, index).join("/") || "/")} holds an array, so the next segment is an index like 0, not ${quote(segment)}.` };
|
|
867
|
+
}
|
|
868
|
+
if (Array.isArray(target) && Number(segment) > target.length) {
|
|
869
|
+
return { error: `${quote(segments.slice(0, index).join("/") || "/")} holds ${target.length} ${target.length === 1 ? "item" : "items"}, so the next index is at most ${target.length}, not ${segment}.` };
|
|
870
|
+
}
|
|
871
|
+
if (index === segments.length - 1) break;
|
|
872
|
+
if (!isObject(target[segment]) && !Array.isArray(target[segment])) target[segment] = {};
|
|
873
|
+
target = target[segment];
|
|
874
|
+
}
|
|
875
|
+
const last = segments.at(-1);
|
|
876
|
+
if (value === undefined) delete target[last];
|
|
877
|
+
else target[last] = value;
|
|
878
|
+
return { root: top };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/* For the renderer's core (core.js), which reads and writes the same data model. */
|
|
882
|
+
export { childIds, getAt, isObject, setAt, unescapeSegment };
|
|
883
|
+
|
|
884
|
+
/* For people and agents --------------------------------------------------- */
|
|
885
|
+
|
|
886
|
+
/** The catalog as a guide an agent reads before composing a surface. */
|
|
887
|
+
export function renderCatalogGuide(rules) {
|
|
888
|
+
const lines = [
|
|
889
|
+
`# ${rules.title}`,
|
|
890
|
+
"",
|
|
891
|
+
rules.description,
|
|
892
|
+
"",
|
|
893
|
+
`Compose a surface as A2UI messages (version ${list(rules.protocolVersions)}):`,
|
|
894
|
+
"",
|
|
895
|
+
`1. {"version": "v0.9.1", "createSurface": {"surfaceId": "…", "catalogId": ${quote(rules.catalogId)}, "theme": {"name": ${quote(rules.defaultTheme)}, "mode": "system"}}}`,
|
|
896
|
+
`2. {"version": "v0.9.1", "updateComponents": {"surfaceId": "…", "components": [{"id": "root", "component": "Stack", "children": […]}, …]}}`,
|
|
897
|
+
`3. {"version": "v0.9.1", "updateDataModel": {"surfaceId": "…", "path": "/form", "value": {…}}} for the values inputs bind to.`,
|
|
898
|
+
"",
|
|
899
|
+
"Components are a flat list; containers name their children by id, and exactly one component has the id \"root\". Never set a color, size, class or style: pick the named values below.",
|
|
900
|
+
"",
|
|
901
|
+
`Themes: ${rules.themes.join(", ")} (default ${rules.defaultTheme}); modes: ${rules.modes.join(", ")}.`,
|
|
902
|
+
"",
|
|
903
|
+
"## Data and actions",
|
|
904
|
+
"",
|
|
905
|
+
"- Bind a field to the data model with {\"path\": \"/form/email\"}, and give it a starting value with updateDataModel.",
|
|
906
|
+
"- To repeat a component for each item of an array, make a container's children a template: {\"componentId\": \"row\", \"path\": \"/items\"}. Inside it, relative paths ({\"path\": \"name\"}) read the item.",
|
|
907
|
+
"- A Button's action is {\"event\": {\"name\": \"create_workspace\", \"context\": {\"form\": {\"path\": \"/form\"}}}}. When it is pressed, you receive an A2UI action with that name, the surfaceId, the Button's id and the context, each binding replaced by its value at that moment. It is input from a person: check it before you act on it.",
|
|
908
|
+
"",
|
|
909
|
+
"## Coming from A2UI's basic catalog",
|
|
910
|
+
"",
|
|
911
|
+
`- Use the Mlola name: ${Object.entries(rules.aliases).map(([name, target]) => `${name} is ${target}`).join(", ")}.`,
|
|
912
|
+
...Object.entries(rules.borrowed.components).map(([name, hint]) => `- ${name} is not in the catalog. ${hint}`),
|
|
913
|
+
...Object.entries(rules.borrowed.props).map(([key, hint]) => `- \`${key.replace(/=(.*)$/, ': "$1"')}\`: ${hint}`),
|
|
914
|
+
"- Client-side functions and checks (functionCall, checks, {\"call\": …}) are not in the catalog: mark fields \"required\" and check values when the action arrives.",
|
|
915
|
+
"",
|
|
916
|
+
];
|
|
917
|
+
for (const [title, names] of catalogGroups(rules)) {
|
|
918
|
+
lines.push(`## ${title}`, "");
|
|
919
|
+
for (const [name, definition] of names.map((name) => [name, rules.components[name]])) {
|
|
920
|
+
const placement = [definition.parents ? `Goes directly inside ${definition.parents.join(" or ")}.` : "", definition.accepts ? `Holds only ${definition.accepts.join(" and ")}.` : ""].filter(Boolean).join(" ");
|
|
921
|
+
lines.push(`### ${name}`, "", `${definition.description}${placement ? ` ${placement}` : ""}`, "");
|
|
922
|
+
for (const [prop, spec] of Object.entries(definition.props)) {
|
|
923
|
+
lines.push(`- \`${prop}\`${spec.required ? " (required)" : ""}: ${describeKind(spec)}${spec.default !== undefined ? `, default ${quote(spec.default)}` : ""}${spec.description ? `. ${spec.description}` : ""}`);
|
|
924
|
+
}
|
|
925
|
+
lines.push("");
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (rules.examples?.length) {
|
|
929
|
+
lines.push("## Examples", "");
|
|
930
|
+
for (const example of rules.examples) {
|
|
931
|
+
lines.push(`### ${example.title}`, "", example.description, "", "```json", "[", example.messages.map((message) => ` ${JSON.stringify(message)}`).join(",\n"), "]", "```", "");
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
lines.push(
|
|
935
|
+
"## What the check holds",
|
|
936
|
+
"",
|
|
937
|
+
"- Every component and prop is in the catalog; values are ones the component offers.",
|
|
938
|
+
"- Every field, control, table and tab group has a name a screen reader reads.",
|
|
939
|
+
"- One root, every child exists, a component appears in one place, no loops.",
|
|
940
|
+
"- Headings do not skip a level going deeper.",
|
|
941
|
+
"- A Slider's or DatePicker's value sits in its range; a Select's value is one of its options.",
|
|
942
|
+
"- Data paths are JSON Pointers; relative paths only inside a template.",
|
|
943
|
+
`- At most ${rules.limits.components} components and ${rules.limits.depth} levels on a surface, ${rules.limits.surfaces} surfaces open at once and ${rules.limits.messages} messages in a call.`,
|
|
944
|
+
);
|
|
945
|
+
return `${lines.join("\n")}\n`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function describeKind(spec) {
|
|
949
|
+
switch (spec.kind) {
|
|
950
|
+
case "enum":
|
|
951
|
+
return list(spec.values);
|
|
952
|
+
case "text":
|
|
953
|
+
return `text or {"path"}${spec.maxLength ? `, at most ${spec.maxLength} characters` : ""}`;
|
|
954
|
+
case "boolean":
|
|
955
|
+
return spec.literal ? "true or false" : `true, false or {"path"}`;
|
|
956
|
+
case "number":
|
|
957
|
+
return spec.literal ? "a number" : `a number or {"path"}`;
|
|
958
|
+
case "integer":
|
|
959
|
+
return `a whole number from ${spec.minimum} to ${spec.maximum}`;
|
|
960
|
+
case "date":
|
|
961
|
+
return spec.literal ? `an ISO date like "2026-10-12"` : `an ISO date like "2026-10-12", or {"path"}`;
|
|
962
|
+
case "children":
|
|
963
|
+
return `an array of component ids, or a template {"componentId", "path"}${spec.minItems ? `, at least ${spec.minItems}` : ""}`;
|
|
964
|
+
case "action":
|
|
965
|
+
return `{"event": {"name", "context"}}`;
|
|
966
|
+
case "options":
|
|
967
|
+
return `an array of {"label", "value"}, or {"path"}${spec.minItems ? `, at least ${spec.minItems}` : ""}`;
|
|
968
|
+
case "columns":
|
|
969
|
+
return `an array of {"key", "label", "align"?}; align is ${list(spec.align.values)}`;
|
|
970
|
+
case "rows":
|
|
971
|
+
return `an array of objects keyed by column key, or {"path"}`;
|
|
972
|
+
default:
|
|
973
|
+
return spec.kind;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** A report as lines a person reads in a terminal or an agent reads in a tool result. */
|
|
978
|
+
export function formatRenderReport(result) {
|
|
979
|
+
const lines = [];
|
|
980
|
+
for (const error of result.errors) lines.push(`error ${error.path || "/"}${error.surfaceId ? ` [${error.surfaceId}]` : ""}: ${error.message}`);
|
|
981
|
+
for (const note of result.advice) lines.push(`advice ${note.path || "/"}${note.surfaceId ? ` [${note.surfaceId}]` : ""}: ${note.message}`);
|
|
982
|
+
const live = result.surfaces.filter((surface) => !surface.deleted);
|
|
983
|
+
if (result.valid) {
|
|
984
|
+
lines.unshift(`Valid: ${live.map((surface) => `${quote(surface.surfaceId)} (${surface.components} ${surface.components === 1 ? "component" : "components"}, ${surface.theme.name} ${surface.theme.mode})`).join(", ") || "no surface left after these messages"}.`);
|
|
985
|
+
} else {
|
|
986
|
+
lines.unshift(`${result.errors.length} ${result.errors.length === 1 ? "error" : "errors"}: nothing renders until ${result.errors.length === 1 ? "it is" : "they are"} fixed.`);
|
|
987
|
+
}
|
|
988
|
+
return lines.join("\n");
|
|
989
|
+
}
|