api_binding_ui 3.1.0 → 3.3.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 +119 -94
- package/SKILL.md +45 -45
- package/dist/app/{index-4mgcb4wd.js → index-xww2x0kd.js} +72 -72
- package/dist/app/index.html +1 -1
- package/dist/cli/scan.d.ts +2 -0
- package/dist/cli.js +218 -129
- package/dist/codegen/write.d.ts +18 -0
- package/dist/framework/contract.d.ts +25 -1
- package/dist/framework/file.d.ts +6 -0
- package/dist/{index-jj8twdw9.js → index-gfw08965.js} +5 -2
- package/dist/{index-jj8twdw9.js.map → index-gfw08965.js.map} +3 -3
- package/dist/index.js +11 -4
- package/dist/index.js.map +3 -3
- package/dist/overlay.js +3 -3
- package/dist/server/handler.d.ts +8 -1
- package/dist/studio/index.js +1 -1
- package/dist/vite/plugin.d.ts +19 -2
- package/dist/vite.js +942 -11
- package/package.json +2 -2
- package/spec/FRAMEWORK.md +9 -8
package/dist/vite.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// src/vite/plugin.ts
|
|
2
|
-
import
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path4 from "node:path";
|
|
3
4
|
|
|
4
5
|
// src/server/handler.ts
|
|
5
6
|
import { promises as fs } from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
|
|
10
|
+
// src/framework/names.ts
|
|
11
|
+
function humanize(id) {
|
|
12
|
+
const s = id.replace(/^\$\.?/, "").replace(/\[\*\]/g, "").split(/[./]/).filter(Boolean).pop() ?? id;
|
|
13
|
+
const w = s.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").toLowerCase().trim();
|
|
14
|
+
return w ? w[0].toUpperCase() + w.slice(1) : id;
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
// src/framework/file.ts
|
|
18
|
+
var ID = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/;
|
|
10
19
|
var BINDING_TYPES = new Set(["operation", "expression", "steps", "none"]);
|
|
11
20
|
function parseBindingsFile(text) {
|
|
12
21
|
let data;
|
|
@@ -15,6 +24,9 @@ function parseBindingsFile(text) {
|
|
|
15
24
|
} catch {
|
|
16
25
|
throw new Error("The bindings file is not valid JSON.");
|
|
17
26
|
}
|
|
27
|
+
return checkBindingsFile(data);
|
|
28
|
+
}
|
|
29
|
+
function checkBindingsFile(data) {
|
|
18
30
|
const f = data;
|
|
19
31
|
if (!f || typeof f !== "object")
|
|
20
32
|
throw new Error("The bindings file must be a JSON object.");
|
|
@@ -34,6 +46,100 @@ function parseBindingsFile(text) {
|
|
|
34
46
|
throw new Error(`"output" needs "dir" and "client".`);
|
|
35
47
|
return f;
|
|
36
48
|
}
|
|
49
|
+
var REGISTRY_KEYS = ["kinds", "screens", "components"];
|
|
50
|
+
function parseRegistry(text) {
|
|
51
|
+
let r;
|
|
52
|
+
try {
|
|
53
|
+
r = JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error("The registry file is not valid JSON.");
|
|
56
|
+
}
|
|
57
|
+
if (!r || r.version !== 1)
|
|
58
|
+
throw new Error(`The registry file must have "version": 1.`);
|
|
59
|
+
if (!Array.isArray(r.kinds) || !Array.isArray(r.screens))
|
|
60
|
+
throw new Error(`The registry file needs "kinds" and "screens" lists.`);
|
|
61
|
+
const screens = [];
|
|
62
|
+
const components = [];
|
|
63
|
+
const under = (id, owner, what) => {
|
|
64
|
+
if (typeof id !== "string" || !id)
|
|
65
|
+
throw new Error(`A ${what} in the registry has no "id".`);
|
|
66
|
+
if (owner && !id.startsWith(owner + "."))
|
|
67
|
+
throw new Error(`"${id}" must start with "${owner}.", the id it is nested under.`);
|
|
68
|
+
};
|
|
69
|
+
const node = (n, owner, parent) => {
|
|
70
|
+
under(n.id, owner, "component");
|
|
71
|
+
if (typeof n.kind !== "string" || typeof n.label !== "string")
|
|
72
|
+
throw new Error(`"${n.id}" needs "kind" and "label".`);
|
|
73
|
+
const { children, ...c } = n;
|
|
74
|
+
components.push(parent ? { ...c, parent } : c);
|
|
75
|
+
for (const ch of children ?? [])
|
|
76
|
+
node(ch, n.id, n.id);
|
|
77
|
+
};
|
|
78
|
+
const screen = (s, owner) => {
|
|
79
|
+
under(s.id, owner, "screen");
|
|
80
|
+
const { components: cs, screens: subs, ...rest } = s;
|
|
81
|
+
screens.push(rest);
|
|
82
|
+
for (const c of cs ?? [])
|
|
83
|
+
node(c, s.id);
|
|
84
|
+
for (const sub of subs ?? [])
|
|
85
|
+
screen(sub, s.id);
|
|
86
|
+
};
|
|
87
|
+
for (const s of r.screens)
|
|
88
|
+
screen(s);
|
|
89
|
+
return { kinds: r.kinds, screens, components };
|
|
90
|
+
}
|
|
91
|
+
function screenOf(id, screens) {
|
|
92
|
+
let best;
|
|
93
|
+
for (const s of screens)
|
|
94
|
+
if (id.startsWith(s + ".") && (!best || s.length > best.length))
|
|
95
|
+
best = s;
|
|
96
|
+
return best;
|
|
97
|
+
}
|
|
98
|
+
function productLabel(seg, catalog) {
|
|
99
|
+
const names = new Set(catalog.operations.map((o) => o.product).filter((p) => !!p));
|
|
100
|
+
return [...names].find((n) => n.toLowerCase().replace(/[^a-z0-9]+/g, "-") === seg) ?? humanize(seg);
|
|
101
|
+
}
|
|
102
|
+
function fromFile(file, catalog) {
|
|
103
|
+
const issues = [];
|
|
104
|
+
const ids = file.screens.map((s) => s.id);
|
|
105
|
+
const screens = file.screens.map((s) => ({
|
|
106
|
+
id: s.id,
|
|
107
|
+
label: s.label,
|
|
108
|
+
description: s.description,
|
|
109
|
+
context: s.context ?? [],
|
|
110
|
+
components: [],
|
|
111
|
+
path: [productLabel(s.id.split(".")[0], catalog)],
|
|
112
|
+
parent: screenOf(s.id, ids)
|
|
113
|
+
}));
|
|
114
|
+
const byId = new Map(screens.map((s) => [s.id, s]));
|
|
115
|
+
const screenIds = new Set(ids);
|
|
116
|
+
for (const c of file.components) {
|
|
117
|
+
if (screenIds.has(c.id)) {
|
|
118
|
+
issues.push({ severity: "error", screen: c.id, component: c.id, code: "registry.id-clash", message: `"${c.id}" is both a screen and a component id.` });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const s = screenOf(c.id, ids);
|
|
122
|
+
if (!s) {
|
|
123
|
+
issues.push({ severity: "error", screen: "", component: c.id, code: "registry.no-screen", message: `"${c.id}" starts with no registered screen id.` });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
byId.get(s).components.push({ bindId: c.id, kind: c.kind, label: c.label, parent: c.parent, info: c.info });
|
|
127
|
+
}
|
|
128
|
+
const index = {};
|
|
129
|
+
for (const b of file.bindings) {
|
|
130
|
+
const s = screenOf(b.component, ids);
|
|
131
|
+
if (!s) {
|
|
132
|
+
issues.push({ severity: "error", screen: "", component: b.component, slot: b.slot, code: "binding.no-screen", message: `Binding for "${b.component}" fits no screen.` });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
(index[s] ??= []).push(b);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
fw: { descriptors: file.kinds, registry: { registryVersion: 1, screens }, catalog, glossaries: file.glossaries },
|
|
139
|
+
index,
|
|
140
|
+
issues
|
|
141
|
+
};
|
|
142
|
+
}
|
|
37
143
|
|
|
38
144
|
// src/server/handler.ts
|
|
39
145
|
var TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".map": "application/json", ".woff2": "font/woff2" };
|
|
@@ -52,6 +158,30 @@ async function readDocs(src, base) {
|
|
|
52
158
|
throw new Error(`"${src}" is not a .yaml, .yml or .json file.`);
|
|
53
159
|
return fs.readFile(path.resolve(base, src), "utf8");
|
|
54
160
|
}
|
|
161
|
+
async function readBindings(file) {
|
|
162
|
+
const text = await fs.readFile(file, "utf8");
|
|
163
|
+
let raw;
|
|
164
|
+
try {
|
|
165
|
+
raw = JSON.parse(text);
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error("The bindings file is not valid JSON.");
|
|
168
|
+
}
|
|
169
|
+
if (typeof raw?.registry !== "string")
|
|
170
|
+
return parseBindingsFile(text);
|
|
171
|
+
const registry = parseRegistry(await fs.readFile(path.resolve(path.dirname(file), raw.registry), "utf8"));
|
|
172
|
+
return checkBindingsFile({ ...raw, ...registry });
|
|
173
|
+
}
|
|
174
|
+
async function saveBindings(file, merged) {
|
|
175
|
+
let out = merged;
|
|
176
|
+
if (merged.registry) {
|
|
177
|
+
const rest = { ...merged };
|
|
178
|
+
for (const k of REGISTRY_KEYS)
|
|
179
|
+
delete rest[k];
|
|
180
|
+
out = rest;
|
|
181
|
+
}
|
|
182
|
+
await writeFileAtomic(file, JSON.stringify(out, null, 2) + `
|
|
183
|
+
`);
|
|
184
|
+
}
|
|
55
185
|
async function writeFileAtomic(file, text) {
|
|
56
186
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
57
187
|
await fs.writeFile(tmp, text, "utf8");
|
|
@@ -71,7 +201,7 @@ async function readBody(req, limit = 20 * 1024 * 1024) {
|
|
|
71
201
|
function send(res, status, body, type = "text/plain; charset=utf-8") {
|
|
72
202
|
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }).end(body);
|
|
73
203
|
}
|
|
74
|
-
function createStudioHandler({ file, prefix, allowed }) {
|
|
204
|
+
function createStudioHandler({ file, prefix, allowed, onSave }) {
|
|
75
205
|
const base = path.dirname(file);
|
|
76
206
|
return async (req, res) => {
|
|
77
207
|
const url = new URL(req.url ?? "/", "http://local");
|
|
@@ -89,17 +219,18 @@ function createStudioHandler({ file, prefix, allowed }) {
|
|
|
89
219
|
}
|
|
90
220
|
if (rel === "/api/bindings") {
|
|
91
221
|
if (req.method === "GET")
|
|
92
|
-
send(res, 200, await
|
|
222
|
+
send(res, 200, JSON.stringify(await readBindings(file)), "application/json");
|
|
93
223
|
else if (req.method === "PUT") {
|
|
94
|
-
|
|
224
|
+
let merged;
|
|
95
225
|
try {
|
|
96
|
-
parseBindingsFile(
|
|
226
|
+
merged = parseBindingsFile(await readBody(req));
|
|
97
227
|
} catch (e) {
|
|
98
228
|
send(res, 400, e.message);
|
|
99
229
|
return true;
|
|
100
230
|
}
|
|
101
|
-
await
|
|
231
|
+
await saveBindings(file, merged);
|
|
102
232
|
send(res, 204, "");
|
|
233
|
+
onSave?.();
|
|
103
234
|
} else
|
|
104
235
|
send(res, 405, "Method not allowed.");
|
|
105
236
|
return true;
|
|
@@ -139,6 +270,750 @@ function createStudioHandler({ file, prefix, allowed }) {
|
|
|
139
270
|
};
|
|
140
271
|
}
|
|
141
272
|
|
|
273
|
+
// src/codegen/write.ts
|
|
274
|
+
import { promises as fs3 } from "node:fs";
|
|
275
|
+
import path3 from "node:path";
|
|
276
|
+
|
|
277
|
+
// src/framework/loadCatalog.ts
|
|
278
|
+
import { parse } from "yaml";
|
|
279
|
+
|
|
280
|
+
// src/framework/openapi.ts
|
|
281
|
+
var METHODS = ["get", "put", "post", "delete", "patch", "head", "options", "trace", "query"];
|
|
282
|
+
function hashString(s) {
|
|
283
|
+
let h = 2166136261;
|
|
284
|
+
for (let i = 0;i < s.length; i++) {
|
|
285
|
+
h ^= s.charCodeAt(i);
|
|
286
|
+
h = Math.imul(h, 16777619);
|
|
287
|
+
}
|
|
288
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
289
|
+
}
|
|
290
|
+
function pointerGet(doc, ref) {
|
|
291
|
+
if (!ref.startsWith("#/"))
|
|
292
|
+
return;
|
|
293
|
+
return ref.slice(2).split("/").map((k) => k.replace(/~1/g, "/").replace(/~0/g, "~")).reduce((o, k) => o == null ? undefined : o[k], doc);
|
|
294
|
+
}
|
|
295
|
+
function deref(doc, node, seen = []) {
|
|
296
|
+
if (Array.isArray(node))
|
|
297
|
+
return node.map((n) => deref(doc, n, seen));
|
|
298
|
+
if (!node || typeof node !== "object")
|
|
299
|
+
return node;
|
|
300
|
+
const n = node;
|
|
301
|
+
if (typeof n.$ref === "string") {
|
|
302
|
+
if (seen.includes(n.$ref) || seen.length > 24)
|
|
303
|
+
return { $ref: n.$ref };
|
|
304
|
+
const { $ref, ...rest } = n;
|
|
305
|
+
const target = deref(doc, pointerGet(doc, $ref), [...seen, $ref]);
|
|
306
|
+
return Object.keys(rest).length ? { ...target, ...deref(doc, rest, seen) } : target;
|
|
307
|
+
}
|
|
308
|
+
const out = {};
|
|
309
|
+
for (const [k, v] of Object.entries(n))
|
|
310
|
+
out[k] = deref(doc, v, seen);
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
function jsonContent(content) {
|
|
314
|
+
if (!content)
|
|
315
|
+
return;
|
|
316
|
+
const key = Object.keys(content).find((k) => /json/i.test(k)) ?? Object.keys(content)[0];
|
|
317
|
+
return key ? content[key]?.schema : undefined;
|
|
318
|
+
}
|
|
319
|
+
function intentOf(method, op) {
|
|
320
|
+
if (typeof op["x-binding-intent"] === "string")
|
|
321
|
+
return op["x-binding-intent"];
|
|
322
|
+
if (method === "get" || method === "head" || method === "query")
|
|
323
|
+
return "read";
|
|
324
|
+
if (["put", "patch", "delete"].includes(method))
|
|
325
|
+
return "write";
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
function catalogFromOpenApi(input, source = {}) {
|
|
329
|
+
const doc = input;
|
|
330
|
+
if (!doc || typeof doc !== "object" || !doc.paths)
|
|
331
|
+
throw new Error("This file is not an OpenAPI document: it has no paths.");
|
|
332
|
+
const operations = [];
|
|
333
|
+
const productOf = new Map;
|
|
334
|
+
for (const g of doc["x-tagGroups"] ?? [])
|
|
335
|
+
g.tags?.forEach((t) => {
|
|
336
|
+
if (g.name)
|
|
337
|
+
productOf.set(t, g.name);
|
|
338
|
+
});
|
|
339
|
+
for (const [path, rawItem] of Object.entries(doc.paths)) {
|
|
340
|
+
const item = deref(doc, rawItem);
|
|
341
|
+
for (const method of METHODS) {
|
|
342
|
+
const op = item?.[method];
|
|
343
|
+
if (!op || !op.operationId)
|
|
344
|
+
continue;
|
|
345
|
+
const params = [];
|
|
346
|
+
for (const p of [...item.parameters ?? [], ...op.parameters ?? []]) {
|
|
347
|
+
if (!p?.name || !p?.in)
|
|
348
|
+
continue;
|
|
349
|
+
const pointer = `/${p.in}/${p.name}`;
|
|
350
|
+
const at = params.findIndex((x) => x.pointer === pointer);
|
|
351
|
+
const entry = { pointer, name: p.name, required: !!p.required || p.in === "path", schema: p.schema ?? {}, description: p.description };
|
|
352
|
+
if (at >= 0)
|
|
353
|
+
params[at] = entry;
|
|
354
|
+
else
|
|
355
|
+
params.push(entry);
|
|
356
|
+
}
|
|
357
|
+
for (const m of path.matchAll(/\{([^}]+)\}/g)) {
|
|
358
|
+
if (!params.some((x) => x.pointer === `/path/${m[1]}`))
|
|
359
|
+
params.unshift({ pointer: `/path/${m[1]}`, name: m[1], required: true, schema: { type: "string" } });
|
|
360
|
+
}
|
|
361
|
+
const bodySchema = jsonContent(op.requestBody?.content);
|
|
362
|
+
const responses = Object.entries(op.responses ?? {}).map(([status, r]) => ({ status, schema: jsonContent(r?.content) }));
|
|
363
|
+
const extensions = {};
|
|
364
|
+
for (const [k, v] of Object.entries(op))
|
|
365
|
+
if (k.startsWith("x-"))
|
|
366
|
+
extensions[k] = v;
|
|
367
|
+
operations.push({
|
|
368
|
+
id: op.operationId,
|
|
369
|
+
method: method.toUpperCase(),
|
|
370
|
+
path,
|
|
371
|
+
summary: op.summary,
|
|
372
|
+
description: op.description,
|
|
373
|
+
tags: op.tags ?? [],
|
|
374
|
+
product: op.tags?.map((t) => productOf.get(t)).find(Boolean),
|
|
375
|
+
intent: intentOf(method, op),
|
|
376
|
+
parameters: params,
|
|
377
|
+
body: bodySchema ? { required: !!op.requestBody?.required, schema: bodySchema } : undefined,
|
|
378
|
+
responses,
|
|
379
|
+
extensions
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const body = JSON.stringify(operations);
|
|
384
|
+
return {
|
|
385
|
+
catalogVersion: 1,
|
|
386
|
+
source: { title: doc.info?.title ?? "API", version: doc.info?.version ?? "", url: source.url },
|
|
387
|
+
hash: hashString(body),
|
|
388
|
+
operations,
|
|
389
|
+
schemas: {}
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/framework/loadCatalog.ts
|
|
394
|
+
class CatalogLoadError extends Error {
|
|
395
|
+
source;
|
|
396
|
+
constructor(message, source) {
|
|
397
|
+
super(message);
|
|
398
|
+
this.source = source;
|
|
399
|
+
this.name = "CatalogLoadError";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function catalogFromText(text, source) {
|
|
403
|
+
const body = text.trim();
|
|
404
|
+
if (!body)
|
|
405
|
+
throw new CatalogLoadError("Nothing to read: the text is empty.", source);
|
|
406
|
+
let doc;
|
|
407
|
+
try {
|
|
408
|
+
doc = body.startsWith("{") ? JSON.parse(body) : parse(body);
|
|
409
|
+
} catch {
|
|
410
|
+
throw new CatalogLoadError("The text is not valid JSON or YAML.", source);
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
return catalogFromOpenApi(doc, source.includes("://") ? { url: source } : {});
|
|
414
|
+
} catch (e) {
|
|
415
|
+
throw new CatalogLoadError(e instanceof Error ? e.message : "The text is not an OpenAPI document.", source);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/framework/expr.ts
|
|
420
|
+
var cache = new Map;
|
|
421
|
+
function segments(path) {
|
|
422
|
+
return path.replace(/\[(\*|\d+)\]/g, ".$1").split(".").filter(Boolean);
|
|
423
|
+
}
|
|
424
|
+
function parse2(expr) {
|
|
425
|
+
const hit = cache.get(expr);
|
|
426
|
+
if (hit)
|
|
427
|
+
return hit;
|
|
428
|
+
const e = expr.trim();
|
|
429
|
+
let parsed;
|
|
430
|
+
if (e.startsWith("$")) {
|
|
431
|
+
const segs = segments(e);
|
|
432
|
+
const [h, id] = segs;
|
|
433
|
+
if (h === "$item")
|
|
434
|
+
parsed = { head: { type: "item" }, path: segs.slice(1) };
|
|
435
|
+
else if ((h === "$context" || h === "$steps") && id)
|
|
436
|
+
parsed = { head: { type: h === "$context" ? "context" : "steps", id }, path: segs.slice(2) };
|
|
437
|
+
else
|
|
438
|
+
throw new Error(`Unknown source "${h}" in "${expr}".`);
|
|
439
|
+
} else {
|
|
440
|
+
const colon = e.indexOf(":");
|
|
441
|
+
if (colon < 1)
|
|
442
|
+
throw new Error(`"${expr}" must read "<componentId>:<slot>".`);
|
|
443
|
+
const [slot, ...path] = segments(e.slice(colon + 1));
|
|
444
|
+
if (!slot)
|
|
445
|
+
throw new Error(`"${expr}" names no slot.`);
|
|
446
|
+
parsed = { head: { type: "slot", component: e.slice(0, colon), slot }, path };
|
|
447
|
+
}
|
|
448
|
+
cache.set(expr, parsed);
|
|
449
|
+
return parsed;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/framework/schema.ts
|
|
453
|
+
var obj = (s) => s && typeof s === "object" ? s : {};
|
|
454
|
+
function flat(s) {
|
|
455
|
+
const o = obj(s);
|
|
456
|
+
const parts = [o, ...(o.allOf ?? []).map(flat)];
|
|
457
|
+
if (parts.length === 1 && !o.oneOf && !o.anyOf)
|
|
458
|
+
return o;
|
|
459
|
+
const props = {};
|
|
460
|
+
const required = [];
|
|
461
|
+
parts.forEach((p) => {
|
|
462
|
+
Object.assign(props, p.properties ?? {});
|
|
463
|
+
required.push(...p.required ?? []);
|
|
464
|
+
});
|
|
465
|
+
const alt = (o.oneOf ?? o.anyOf)?.map(flat).find((x) => x.type !== "null");
|
|
466
|
+
return { ...alt ?? {}, ...o, properties: Object.keys(props).length ? props : alt?.properties, required };
|
|
467
|
+
}
|
|
468
|
+
var views = new WeakMap;
|
|
469
|
+
|
|
470
|
+
// src/framework/validate.ts
|
|
471
|
+
function validateRegistry(fw) {
|
|
472
|
+
const kinds = new Set(fw.descriptors.map((d) => d.kind));
|
|
473
|
+
const issues = [];
|
|
474
|
+
const ids = new Set(fw.registry.screens.map((s) => s.id));
|
|
475
|
+
const seen = new Set;
|
|
476
|
+
const screens = fw.registry.screens.map((S) => {
|
|
477
|
+
if (!ID.test(S.id)) {
|
|
478
|
+
issues.push({ severity: "error", screen: S.id, code: "registry.invalid-id", message: `Screen id "${S.id}" must be lowercase kebab-case segments joined by dots.` });
|
|
479
|
+
return { ...S, components: [] };
|
|
480
|
+
}
|
|
481
|
+
const components = S.components.filter((c) => {
|
|
482
|
+
if (!ID.test(c.bindId)) {
|
|
483
|
+
issues.push({ severity: "error", screen: S.id, component: c.bindId, code: "registry.invalid-id", message: `Component id "${c.bindId}" must be lowercase kebab-case segments joined by dots.` });
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
if (!kinds.has(c.kind)) {
|
|
487
|
+
issues.push({ severity: "error", screen: S.id, component: c.bindId, code: "registry.unknown-kind", message: `"${c.label}" uses ${c.kind}, which has no descriptor.` });
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
if (seen.has(c.bindId)) {
|
|
491
|
+
issues.push({ severity: "error", screen: S.id, component: c.bindId, code: "registry.duplicate-id", message: `Component id "${c.bindId}" is used twice.` });
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
seen.add(c.bindId);
|
|
495
|
+
return true;
|
|
496
|
+
}).filter((c) => !c.parent || seen.has(c.parent) || (issues.push({ severity: "error", screen: S.id, component: c.bindId, code: "registry.missing-parent", message: `"${c.label}" names a parent that is not on the screen.` }), false));
|
|
497
|
+
const parent = S.parent && ids.has(S.parent) ? S.parent : undefined;
|
|
498
|
+
return { ...S, components, parent };
|
|
499
|
+
});
|
|
500
|
+
return { registry: { ...fw.registry, screens }, issues };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/codegen/helpers.ts
|
|
504
|
+
var HELPERS = `import { useMutation, useQuery } from "@tanstack/react-query";
|
|
505
|
+
|
|
506
|
+
/** Data for a slot. \`bound\` is false when no API feeds it; keep the UI's own value then. */
|
|
507
|
+
export interface Data<T> { bound: boolean; data: T | undefined; loading: boolean; error: unknown }
|
|
508
|
+
/** An action. \`run\` rejects when a call fails; it resolves at once when no API is connected. */
|
|
509
|
+
export interface Action { bound: boolean; run: (payload?: any) => Promise<void>; running: boolean; error: unknown }
|
|
510
|
+
/** A per-item value, such as a cell in a row. */
|
|
511
|
+
export interface Cell<T> { bound: boolean; read: (item: any) => T | undefined }
|
|
512
|
+
|
|
513
|
+
export const UNBOUND_DATA: Data<never> = { bound: false, data: undefined, loading: false, error: undefined };
|
|
514
|
+
export const UNBOUND_ACTION: Action = { bound: false, run: async () => {}, running: false, error: undefined };
|
|
515
|
+
export const UNBOUND_CELL: Cell<never> = { bound: false, read: () => undefined };
|
|
516
|
+
|
|
517
|
+
export class ApiError extends Error {
|
|
518
|
+
constructor(readonly status: number, readonly data: unknown) { super(\`API call failed (\${status})\`); this.name = "ApiError"; }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Reads a path; "*" maps the rest of the path over a list. */
|
|
522
|
+
export function at(value: unknown, path: readonly string[]): any {
|
|
523
|
+
let v: any = value;
|
|
524
|
+
for (let i = 0; i < path.length; i++) {
|
|
525
|
+
if (v == null) return undefined;
|
|
526
|
+
if (path[i] === "*") return Array.isArray(v) ? v.map(x => at(x, path.slice(i + 1))) : undefined;
|
|
527
|
+
v = v[path[i]];
|
|
528
|
+
}
|
|
529
|
+
return v;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** The body of a client response; throws on a non-2xx status. Passes through clients that return the body. */
|
|
533
|
+
export function ok(res: any): any {
|
|
534
|
+
if (res && typeof res === "object" && typeof res.status === "number" && "data" in res && "headers" in res) {
|
|
535
|
+
if (res.status < 200 || res.status >= 300) throw new ApiError(res.status, res.data);
|
|
536
|
+
return res.data;
|
|
537
|
+
}
|
|
538
|
+
return res;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** True when every required input has a value. */
|
|
542
|
+
export const has = (inputs: Record<string, unknown>) => Object.values(inputs).every(v => v !== undefined);
|
|
543
|
+
|
|
544
|
+
/** Throws when a required input has no value. */
|
|
545
|
+
export function need(operation: string, inputs: Record<string, unknown>) {
|
|
546
|
+
const missing = Object.keys(inputs).filter(k => inputs[k] === undefined);
|
|
547
|
+
if (missing.length) throw new Error(\`\${operation}: missing \${missing.join(", ")}\`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Drops undefined values. */
|
|
551
|
+
export const defined = (o: Record<string, unknown>): any => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
552
|
+
|
|
553
|
+
export const path = (v: unknown): any => (v === undefined ? undefined : encodeURIComponent(String(v)));
|
|
554
|
+
|
|
555
|
+
/** A body from a base value and [keys, value] entries; undefined values are skipped. */
|
|
556
|
+
export function assemble(base: unknown, entries: Array<[string[], unknown]>): any {
|
|
557
|
+
const out: any = base && typeof base === "object" ? { ...base } : {};
|
|
558
|
+
for (const [keys, value] of entries) {
|
|
559
|
+
if (value === undefined) continue;
|
|
560
|
+
let node = out;
|
|
561
|
+
keys.slice(0, -1).forEach(k => { node = node[k] = node[k] && typeof node[k] === "object" ? { ...node[k] } : {}; });
|
|
562
|
+
node[keys[keys.length - 1]] = value;
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Adds mapped fields to each item, keeping the fields the API returned. */
|
|
568
|
+
export function mapItems(data: any, map: (item: any) => Record<string, unknown>): any {
|
|
569
|
+
const one = (item: any) => ({ ...(item && typeof item === "object" ? item : {}), ...map(item) });
|
|
570
|
+
return Array.isArray(data) ? data.map(one) : data == null ? data : one(data);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v === undefined ? [] : [v]);
|
|
574
|
+
|
|
575
|
+
export function finish(failed: unknown[]) {
|
|
576
|
+
if (failed.length === 1) throw failed[0];
|
|
577
|
+
if (failed.length) throw new AggregateError(failed, \`\${failed.length} API calls failed\`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function useData<T>(key: readonly unknown[], fetch: () => Promise<unknown>, select: (data: any) => T, options: { enabled: boolean; everySeconds?: number }): Data<T> {
|
|
581
|
+
const q = useQuery({ queryKey: key, queryFn: fetch, select, enabled: options.enabled, refetchInterval: options.everySeconds ? options.everySeconds * 1000 : false });
|
|
582
|
+
return { bound: true, data: q.data, loading: q.isLoading, error: q.error ?? undefined };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function useAction(run: (payload?: any) => Promise<void>): Action {
|
|
586
|
+
const m = useMutation({ mutationFn: run });
|
|
587
|
+
return { bound: true, run: payload => m.mutateAsync(payload), running: m.isPending, error: m.error ?? undefined };
|
|
588
|
+
}
|
|
589
|
+
`;
|
|
590
|
+
|
|
591
|
+
// src/codegen/generate.ts
|
|
592
|
+
var GENERATED_MARK = "@generated";
|
|
593
|
+
var HEADER = `// ${GENERATED_MARK} Do not edit: change the bindings and regenerate.
|
|
594
|
+
`;
|
|
595
|
+
var words = (s) => s.split(/[.\-_]+/).filter(Boolean);
|
|
596
|
+
var pascal = (s) => words(s).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
597
|
+
var camel = (s) => {
|
|
598
|
+
const p = pascal(s);
|
|
599
|
+
return p[0].toLowerCase() + p.slice(1);
|
|
600
|
+
};
|
|
601
|
+
var str = (v) => JSON.stringify(v);
|
|
602
|
+
var IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
603
|
+
var prop = (k) => IDENT.test(k) ? k : str(k);
|
|
604
|
+
var orvalName = (id) => IDENT.test(id) ? id[0].toLowerCase() + id.slice(1) : camel(id);
|
|
605
|
+
function tsType(schema, many = false, depth = 0) {
|
|
606
|
+
const one = () => {
|
|
607
|
+
if (!schema || typeof schema !== "object" || depth > 3)
|
|
608
|
+
return "any";
|
|
609
|
+
const s = schema;
|
|
610
|
+
if (Array.isArray(s.enum) && s.enum.length && s.enum.every((v) => typeof v === "string"))
|
|
611
|
+
return s.enum.map(str).join(" | ");
|
|
612
|
+
switch (s.type) {
|
|
613
|
+
case "string":
|
|
614
|
+
return "string";
|
|
615
|
+
case "integer":
|
|
616
|
+
case "number":
|
|
617
|
+
return "number";
|
|
618
|
+
case "boolean":
|
|
619
|
+
return "boolean";
|
|
620
|
+
case "array":
|
|
621
|
+
return `Array<${tsType(s.items, false, depth + 1)}>`;
|
|
622
|
+
case "object": {
|
|
623
|
+
const props = Object.entries(s.properties ?? {});
|
|
624
|
+
if (!props.length)
|
|
625
|
+
return "Record<string, any>";
|
|
626
|
+
return `{ ${props.map(([k, v]) => `${prop(k)}?: ${tsType(v, false, depth + 1)}`).join("; ")}; [key: string]: any }`;
|
|
627
|
+
}
|
|
628
|
+
default:
|
|
629
|
+
return "any";
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
return many ? `Array<${one()}>` : one();
|
|
633
|
+
}
|
|
634
|
+
function compileExpr(expr, cx, scope, at) {
|
|
635
|
+
let p;
|
|
636
|
+
try {
|
|
637
|
+
p = parse2(expr);
|
|
638
|
+
} catch (e) {
|
|
639
|
+
cx.issue("expr.invalid", e.message, at);
|
|
640
|
+
return "undefined";
|
|
641
|
+
}
|
|
642
|
+
const path = p.path.map(str);
|
|
643
|
+
const h = p.head;
|
|
644
|
+
const read = (root, keys) => {
|
|
645
|
+
cx.use.add("at");
|
|
646
|
+
return `at(${root}, [${keys.join(", ")}])`;
|
|
647
|
+
};
|
|
648
|
+
if (h.type === "item") {
|
|
649
|
+
if (!scope.item) {
|
|
650
|
+
cx.issue("expr.no-item", `"${expr}" reads $item, which this slot does not have.`, at);
|
|
651
|
+
return "undefined";
|
|
652
|
+
}
|
|
653
|
+
return path.length ? read("item", path) : "item";
|
|
654
|
+
}
|
|
655
|
+
if (h.type === "context") {
|
|
656
|
+
if (!cx.screen.context.some((c) => c.id === h.id)) {
|
|
657
|
+
cx.issue("expr.unknown-context", `"${expr}": ${cx.screen.id} declares no context "${h.id}".`, at);
|
|
658
|
+
return "undefined";
|
|
659
|
+
}
|
|
660
|
+
return read("state.context", [str(h.id), ...path]);
|
|
661
|
+
}
|
|
662
|
+
if (h.type === "steps") {
|
|
663
|
+
if (!scope.steps?.has(h.id)) {
|
|
664
|
+
cx.issue("expr.unknown-step", `"${expr}" reads step "${h.id}", which does not run before it.`, at);
|
|
665
|
+
return "undefined";
|
|
666
|
+
}
|
|
667
|
+
return read("steps", [str(h.id), ...path]);
|
|
668
|
+
}
|
|
669
|
+
if (!cx.produces.get(h.component)?.has(h.slot)) {
|
|
670
|
+
cx.issue("expr.unknown-slot", `"${expr}" reads a value that is not produced on ${cx.screen.id}.`, at);
|
|
671
|
+
return "undefined";
|
|
672
|
+
}
|
|
673
|
+
return read("state", [str(cx.keys.get(h.component)), str(h.slot), ...path]);
|
|
674
|
+
}
|
|
675
|
+
var decode = (k) => k.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
676
|
+
function compileCall(op, request, cx, scope, at) {
|
|
677
|
+
const e = (x) => compileExpr(x, cx, scope, at);
|
|
678
|
+
const known = new Set(op.parameters.map((p) => p.pointer));
|
|
679
|
+
const body = [];
|
|
680
|
+
let whole;
|
|
681
|
+
const query = [];
|
|
682
|
+
const headers = [];
|
|
683
|
+
for (const [pointer, x] of Object.entries(request)) {
|
|
684
|
+
const [, where, ...rest] = pointer.split("/");
|
|
685
|
+
const keys = rest.map(decode);
|
|
686
|
+
if (where === "body") {
|
|
687
|
+
if (!op.body) {
|
|
688
|
+
cx.issue("request.no-body", `${op.id} takes no body, but "${pointer}" is set.`, at);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (!keys.length)
|
|
692
|
+
whole = e(x);
|
|
693
|
+
else
|
|
694
|
+
body.push(`[[${keys.map(str).join(", ")}], ${e(x)}]`);
|
|
695
|
+
} else if (!known.has(pointer))
|
|
696
|
+
cx.issue("request.unknown-input", `${op.id} has no input "${pointer}".`, at);
|
|
697
|
+
else if (where === "query")
|
|
698
|
+
query.push(`${prop(keys[0])}: ${e(x)}`);
|
|
699
|
+
else if (where === "header")
|
|
700
|
+
headers.push(`${str(keys[0])}: ${e(x)}`);
|
|
701
|
+
}
|
|
702
|
+
const args = [...op.path.matchAll(/\{([^}]+)\}/g)].map((m) => {
|
|
703
|
+
const x = request[`/path/${m[1]}`];
|
|
704
|
+
if (x === undefined) {
|
|
705
|
+
cx.issue("request.missing-path", `${op.id} needs "${m[1]}" in its path; it is not connected.`, at);
|
|
706
|
+
return "undefined";
|
|
707
|
+
}
|
|
708
|
+
cx.use.add("path");
|
|
709
|
+
return `path(${e(x)})`;
|
|
710
|
+
});
|
|
711
|
+
if (op.body) {
|
|
712
|
+
const set = new Set(Object.keys(request).filter((k) => k.startsWith("/body/")).map((k) => decode(k.split("/")[2])));
|
|
713
|
+
const schema = flat(op.body.schema);
|
|
714
|
+
const unset = whole ? [] : (schema.required ?? []).filter((f) => !set.has(f));
|
|
715
|
+
const empty = unset.filter((f) => {
|
|
716
|
+
const p = flat(schema.properties?.[f]);
|
|
717
|
+
return p.type === "object" && !p.required?.length;
|
|
718
|
+
});
|
|
719
|
+
const gaps = unset.filter((f) => !empty.includes(f));
|
|
720
|
+
if (gaps.length)
|
|
721
|
+
cx.warn("request.required-body", `${op.id} requires ${gaps.join(", ")} in its body; not connected.`, at);
|
|
722
|
+
cx.use.add("assemble");
|
|
723
|
+
const base = whole ?? (empty.length ? `{ ${empty.map((f) => `${prop(f)}: {}`).join(", ")} }` : "undefined");
|
|
724
|
+
args.push(`assemble(${base}, [${body.join(", ")}])`);
|
|
725
|
+
}
|
|
726
|
+
if (op.parameters.some((p) => p.pointer.startsWith("/query/"))) {
|
|
727
|
+
cx.use.add("defined");
|
|
728
|
+
args.push(`defined({ ${query.join(", ")} })`);
|
|
729
|
+
}
|
|
730
|
+
const key = `[${[str(op.id), ...args].join(", ")}]`;
|
|
731
|
+
if (headers.length) {
|
|
732
|
+
cx.use.add("defined");
|
|
733
|
+
args.push(`{ headers: defined({ ${headers.join(", ")} }) }`);
|
|
734
|
+
}
|
|
735
|
+
const required = op.parameters.filter((p) => p.required).map((p) => `${prop(p.name)}: ${request[p.pointer] === undefined ? "undefined" : e(request[p.pointer])}`);
|
|
736
|
+
return { fn: orvalName(op.id), call: `${orvalName(op.id)}(${args.join(", ")})`, key, required: required.length ? `{ ${required.join(", ")} }` : "" };
|
|
737
|
+
}
|
|
738
|
+
var selectPath = (selector) => segments(selector.replace(/^\$\.?/, "").replace(/\[\*\]$/, ""));
|
|
739
|
+
function compileSteps(steps, cx, at, ops, imports) {
|
|
740
|
+
const done = new Set;
|
|
741
|
+
const blocks = [];
|
|
742
|
+
let usesPayload = false;
|
|
743
|
+
cx.use.add("ok");
|
|
744
|
+
cx.use.add("finish");
|
|
745
|
+
for (const st of steps) {
|
|
746
|
+
const op = ops.get(st.operation);
|
|
747
|
+
if (!op) {
|
|
748
|
+
cx.issue("catalog.operation-missing", `${st.operation} is not in the API docs.`, at);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const scope = { item: true, steps: done };
|
|
752
|
+
const call = compileCall(op, st.request, cx, scope, at);
|
|
753
|
+
imports.add(call.fn);
|
|
754
|
+
const each = st.forEach ? compileExpr(st.forEach, cx, scope, at) : undefined;
|
|
755
|
+
if (each)
|
|
756
|
+
cx.use.add("list");
|
|
757
|
+
else
|
|
758
|
+
usesPayload = true;
|
|
759
|
+
if (call.required)
|
|
760
|
+
cx.use.add("need");
|
|
761
|
+
const readsItem = /\bitem\b/.test(call.call + call.required);
|
|
762
|
+
blocks.push([
|
|
763
|
+
` {`,
|
|
764
|
+
` const responses: unknown[] = [];`,
|
|
765
|
+
` for (const item of ${each ? `list(${each})` : "[payload]"}) {`,
|
|
766
|
+
readsItem ? "" : ` void item;`,
|
|
767
|
+
` try {`,
|
|
768
|
+
call.required ? ` need(${str(op.id)}, ${call.required});` : "",
|
|
769
|
+
` responses.push(ok(await ${call.call}));`,
|
|
770
|
+
` } catch (error) {`,
|
|
771
|
+
st.onError === "stop" ? ` throw error;` : ` failed.push(error);`,
|
|
772
|
+
` }`,
|
|
773
|
+
` }`,
|
|
774
|
+
` steps[${str(st.id)}] = { response: ${each ? "responses" : "responses[0]"} };`,
|
|
775
|
+
` }`
|
|
776
|
+
].filter(Boolean).join(`
|
|
777
|
+
`));
|
|
778
|
+
done.add(st.id);
|
|
779
|
+
}
|
|
780
|
+
return [
|
|
781
|
+
`async (${usesPayload ? "payload" : "_payload"}?: any) => {`,
|
|
782
|
+
` const steps: Record<string, unknown> = {};`,
|
|
783
|
+
` const failed: unknown[] = [];`,
|
|
784
|
+
...blocks,
|
|
785
|
+
` finish(failed);`,
|
|
786
|
+
` }`
|
|
787
|
+
].join(`
|
|
788
|
+
`);
|
|
789
|
+
}
|
|
790
|
+
function generate(file, catalog) {
|
|
791
|
+
const issues = [];
|
|
792
|
+
const { fw, index, issues: fileIssues } = fromFile(file, catalog);
|
|
793
|
+
const valid = validateRegistry(fw);
|
|
794
|
+
issues.push(...fileIssues, ...valid.issues);
|
|
795
|
+
const ops = new Map(catalog.operations.map((o) => [o.id, o]));
|
|
796
|
+
const DESC = new Map(fw.descriptors.map((d) => [d.kind, d]));
|
|
797
|
+
const client = file.output?.client;
|
|
798
|
+
if (!client)
|
|
799
|
+
issues.push({ severity: "error", screen: "", code: "output.missing", message: `Set "output": { "dir", "client" } in the bindings file.` });
|
|
800
|
+
const files = [{ path: "screen-helpers.ts", content: HEADER + HELPERS }];
|
|
801
|
+
const exports = [];
|
|
802
|
+
for (const screen of valid.registry.screens) {
|
|
803
|
+
const name = pascal(screen.id);
|
|
804
|
+
const keys = new Map;
|
|
805
|
+
const taken = new Map;
|
|
806
|
+
for (const c of screen.components) {
|
|
807
|
+
const k = camel(c.bindId.slice(screen.id.length + 1));
|
|
808
|
+
if (taken.has(k))
|
|
809
|
+
issues.push({ severity: "error", screen: screen.id, component: c.bindId, code: "codegen.name-clash", message: `"${c.bindId}" and "${taken.get(k)}" both become "${k}".` });
|
|
810
|
+
taken.set(k, c.bindId);
|
|
811
|
+
keys.set(c.bindId, k);
|
|
812
|
+
}
|
|
813
|
+
const cx = {
|
|
814
|
+
screen,
|
|
815
|
+
keys,
|
|
816
|
+
use: new Set,
|
|
817
|
+
produces: new Map(screen.components.map((c) => [c.bindId, new Set(DESC.get(c.kind).slots.filter((s) => s.role === "produce").map((s) => s.id))])),
|
|
818
|
+
issue: (code, message, at) => issues.push({ severity: "error", screen: screen.id, code, message, ...at }),
|
|
819
|
+
warn: (code, message, at) => issues.push({ severity: "warning", screen: screen.id, code, message, ...at })
|
|
820
|
+
};
|
|
821
|
+
const bindings = new Map;
|
|
822
|
+
for (const b of index[screen.id] ?? []) {
|
|
823
|
+
if (!keys.has(b.component)) {
|
|
824
|
+
cx.issue("binding.unknown-component", `A binding names "${b.component}", which is not registered.`, b);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (b.state !== "confirmed") {
|
|
828
|
+
issues.push({ severity: "warning", screen: screen.id, component: b.component, slot: b.slot, code: "binding.draft", message: `${b.component} · ${b.slot} is a draft; it is not generated.` });
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
bindings.set(`${b.component}|${b.slot}`, b);
|
|
832
|
+
}
|
|
833
|
+
const imports = new Set;
|
|
834
|
+
const types = new Set;
|
|
835
|
+
const body = [];
|
|
836
|
+
const ret = [];
|
|
837
|
+
const apiType = [];
|
|
838
|
+
const stateType = [];
|
|
839
|
+
for (const c of screen.components) {
|
|
840
|
+
const d = DESC.get(c.kind);
|
|
841
|
+
const k = keys.get(c.bindId);
|
|
842
|
+
const produce = d.slots.filter((s) => s.role === "produce");
|
|
843
|
+
if (produce.length)
|
|
844
|
+
stateType.push(` ${prop(k)}?: { ${produce.map((s) => `${prop(s.id)}?: ${tsType(s.schema, s.many)}`).join("; ")} };`);
|
|
845
|
+
const out = [];
|
|
846
|
+
const outT = [];
|
|
847
|
+
const unbound = (slot, v) => {
|
|
848
|
+
cx.use.add(v);
|
|
849
|
+
out.push(`${prop(slot)}: ${v}`);
|
|
850
|
+
};
|
|
851
|
+
for (const s of d.slots.filter((x) => x.role !== "produce")) {
|
|
852
|
+
const b = bindings.get(`${c.bindId}|${s.id}`);
|
|
853
|
+
const at = { component: c.bindId, slot: s.id };
|
|
854
|
+
const v = `${k}${pascal(s.id)}`;
|
|
855
|
+
if (s.role === "trigger") {
|
|
856
|
+
types.add("Action");
|
|
857
|
+
outT.push(`${prop(s.id)}: Action`);
|
|
858
|
+
if (b?.type !== "steps") {
|
|
859
|
+
unbound(s.id, "UNBOUND_ACTION");
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
cx.use.add("useAction");
|
|
863
|
+
body.push(` const ${v} = useAction(${compileSteps(b.steps, cx, at, ops, imports)});`);
|
|
864
|
+
out.push(`${prop(s.id)}: ${v}`);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const T = tsType(s.schema, s.many);
|
|
868
|
+
if (s.scope) {
|
|
869
|
+
types.add("Cell");
|
|
870
|
+
outT.push(`${prop(s.id)}: Cell<${T}>`);
|
|
871
|
+
if (b?.type !== "expression") {
|
|
872
|
+
unbound(s.id, "UNBOUND_CELL");
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
out.push(`${prop(s.id)}: { bound: true, read: (item: any) => ${compileExpr(b.value, cx, { item: true }, at)} }`);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
types.add("Data");
|
|
879
|
+
outT.push(`${prop(s.id)}: Data<${T}>`);
|
|
880
|
+
if (b?.type === "expression") {
|
|
881
|
+
out.push(`${prop(s.id)}: { bound: true, data: ${compileExpr(b.value, cx, { item: false }, at)}, loading: false, error: undefined }`);
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
const op = b?.type === "operation" ? ops.get(b.operation) : undefined;
|
|
885
|
+
if (b?.type === "operation" && !op)
|
|
886
|
+
cx.issue("catalog.operation-missing", `${b.operation} is not in the API docs.`, at);
|
|
887
|
+
if (b?.type !== "operation" || !op) {
|
|
888
|
+
unbound(s.id, "UNBOUND_DATA");
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const call = compileCall(op, b.request, cx, { item: false }, at);
|
|
892
|
+
imports.add(call.fn);
|
|
893
|
+
["useData", "ok"].forEach((h) => cx.use.add(h));
|
|
894
|
+
if (call.required)
|
|
895
|
+
cx.use.add("has");
|
|
896
|
+
const sel = selectPath(b.select).map(str);
|
|
897
|
+
if (sel.length)
|
|
898
|
+
cx.use.add("at");
|
|
899
|
+
const picked = sel.length ? `at(data, [${sel.join(", ")}])` : "data";
|
|
900
|
+
const map = Object.entries(b.map ?? {});
|
|
901
|
+
if (map.length)
|
|
902
|
+
cx.use.add("mapItems");
|
|
903
|
+
const select = map.length ? `mapItems(${picked}, (item: any) => ({ ${map.map(([f, x]) => `${prop(f)}: ${compileExpr(x, cx, { item: true }, at)}`).join(", ")} }))` : picked;
|
|
904
|
+
const every = typeof b.load === "object" ? `, everySeconds: ${b.load.everySeconds}` : "";
|
|
905
|
+
body.push(` const ${v} = useData<${T}>(${call.key}, async () => ok(await ${call.call}), (data: any) => ${select}, { enabled: ${call.required ? `has(${call.required})` : "true"}${every} });`);
|
|
906
|
+
out.push(`${prop(s.id)}: ${v}`);
|
|
907
|
+
}
|
|
908
|
+
if (out.length) {
|
|
909
|
+
ret.push(` ${prop(k)}: { ${out.join(", ")} },`);
|
|
910
|
+
apiType.push(` ${prop(k)}: { ${outT.join("; ")} };`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (screen.context.length)
|
|
914
|
+
stateType.push(` context?: { ${screen.context.map((c) => `${prop(c.id)}?: ${"schema" in c.shape ? tsType(c.shape.schema, c.many) : c.many ? "Array<any>" : "any"}`).join("; ")} };`);
|
|
915
|
+
const usesState = [...body, ...ret].some((l) => /\bstate\b/.test(l));
|
|
916
|
+
const lines = [
|
|
917
|
+
HEADER.trimEnd(),
|
|
918
|
+
imports.size ? `import { ${[...imports].sort().join(", ")} } from ${str(client ?? "")};` : null,
|
|
919
|
+
cx.use.size ? `import { ${[...cx.use].sort().join(", ")} } from "./screen-helpers";` : null,
|
|
920
|
+
types.size ? `import type { ${[...types].sort().join(", ")} } from "./screen-helpers";` : null,
|
|
921
|
+
"",
|
|
922
|
+
`/** ${screen.label}: values its components produce, and what it was opened with. */`,
|
|
923
|
+
stateType.length ? `export interface ${name}State {
|
|
924
|
+
${stateType.join(`
|
|
925
|
+
`)}
|
|
926
|
+
}` : `export type ${name}State = Record<string, never>;`,
|
|
927
|
+
"",
|
|
928
|
+
`export interface ${name}Api {
|
|
929
|
+
${apiType.join(`
|
|
930
|
+
`)}
|
|
931
|
+
}`,
|
|
932
|
+
"",
|
|
933
|
+
`/** API data and actions for ${screen.label} (${screen.id}). */`,
|
|
934
|
+
`export function use${name}Api(${usesState ? "state" : "_state"}: ${name}State = {}): ${name}Api {`,
|
|
935
|
+
...body,
|
|
936
|
+
` return {`,
|
|
937
|
+
...ret,
|
|
938
|
+
` };`,
|
|
939
|
+
`}`,
|
|
940
|
+
""
|
|
941
|
+
].filter((l) => l !== null);
|
|
942
|
+
files.push({ path: `${screen.id}.ts`, content: lines.join(`
|
|
943
|
+
`) });
|
|
944
|
+
exports.push(`export * from "./${screen.id}";`);
|
|
945
|
+
}
|
|
946
|
+
files.push({ path: "index.ts", content: HEADER + exports.join(`
|
|
947
|
+
`) + `
|
|
948
|
+
` });
|
|
949
|
+
return { files, issues };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/cli/scan.ts
|
|
953
|
+
import { promises as fs2 } from "node:fs";
|
|
954
|
+
import path2 from "node:path";
|
|
955
|
+
var LITERAL = /componentId\s*(?:=\s*\{?\s*|:\s*)(["'`])([a-z0-9.-]+)\1/g;
|
|
956
|
+
var SKIP = new Set(["node_modules", "dist", "build", ".git", ".next", ".output"]);
|
|
957
|
+
async function* sources(dir) {
|
|
958
|
+
for (const e of await fs2.readdir(dir, { withFileTypes: true })) {
|
|
959
|
+
if (SKIP.has(e.name))
|
|
960
|
+
continue;
|
|
961
|
+
const p = path2.join(dir, e.name);
|
|
962
|
+
if (e.isDirectory())
|
|
963
|
+
yield* sources(p);
|
|
964
|
+
else if (/\.(tsx?|jsx?)$/.test(e.name))
|
|
965
|
+
yield p;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
async function scanIds(dir, file) {
|
|
969
|
+
const found = new Map;
|
|
970
|
+
for await (const p of sources(dir))
|
|
971
|
+
for (const m of (await fs2.readFile(p, "utf8")).matchAll(LITERAL))
|
|
972
|
+
if (!found.has(m[2]))
|
|
973
|
+
found.set(m[2], path2.relative(dir, p));
|
|
974
|
+
const registered = new Set(file.components.map((c) => c.id));
|
|
975
|
+
const issues = [];
|
|
976
|
+
for (const [id, where] of found)
|
|
977
|
+
if (!registered.has(id))
|
|
978
|
+
issues.push({ severity: "warning", screen: "", component: id, code: "scan.unregistered", message: `In ${where}, but not registered.` });
|
|
979
|
+
for (const id of registered)
|
|
980
|
+
if (!found.has(id))
|
|
981
|
+
issues.push({ severity: "warning", screen: "", component: id, code: "scan.not-found", message: "Registered, but no componentId literal in the source (fine if the id is built from config)." });
|
|
982
|
+
return issues;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/codegen/write.ts
|
|
986
|
+
async function writeScreens(filePath, opts = {}) {
|
|
987
|
+
const base = path3.dirname(filePath);
|
|
988
|
+
const file = await readBindings(filePath);
|
|
989
|
+
const catalog = catalogFromText(await readDocs(file.openapi, base), file.openapi);
|
|
990
|
+
const { files, issues } = generate(file, catalog);
|
|
991
|
+
if (opts.src)
|
|
992
|
+
issues.push(...await scanIds(path3.resolve(opts.src), file));
|
|
993
|
+
const out = path3.resolve(base, file.output?.dir ?? ".");
|
|
994
|
+
const result = { issues, out, screens: files.length - 2, changed: [], stale: [] };
|
|
995
|
+
if (issues.some((i) => i.severity === "error"))
|
|
996
|
+
return result;
|
|
997
|
+
await fs3.mkdir(out, { recursive: true });
|
|
998
|
+
const wanted = new Map(files.map((f) => [path3.join(out, f.path), f.content]));
|
|
999
|
+
for (const [p, content] of wanted)
|
|
1000
|
+
if (await fs3.readFile(p, "utf8").catch(() => null) !== content)
|
|
1001
|
+
result.changed.push(p);
|
|
1002
|
+
for (const n of await fs3.readdir(out)) {
|
|
1003
|
+
const p = path3.join(out, n);
|
|
1004
|
+
if (n.endsWith(".ts") && !wanted.has(p) && (await fs3.readFile(p, "utf8")).startsWith(`// ${GENERATED_MARK}`))
|
|
1005
|
+
result.stale.push(p);
|
|
1006
|
+
}
|
|
1007
|
+
if (opts.check)
|
|
1008
|
+
return result;
|
|
1009
|
+
for (const p of result.changed)
|
|
1010
|
+
await writeFileAtomic(p, wanted.get(p));
|
|
1011
|
+
for (const p of result.stale)
|
|
1012
|
+
await fs3.unlink(p);
|
|
1013
|
+
return result;
|
|
1014
|
+
}
|
|
1015
|
+
var describeIssue = (i) => `${i.severity === "error" ? "error " : "warning"} ${[i.component ?? i.screen, i.slot].filter(Boolean).join(" · ")}: ${i.message}`;
|
|
1016
|
+
|
|
142
1017
|
// src/vite/plugin.ts
|
|
143
1018
|
function injectOverlay(res, tag) {
|
|
144
1019
|
let done = false;
|
|
@@ -166,23 +1041,77 @@ function injectOverlay(res, tag) {
|
|
|
166
1041
|
res.write = (chunk, ...rest) => write(patch(chunk), ...rest);
|
|
167
1042
|
res.end = (chunk, ...rest) => chunk == null || typeof chunk === "function" ? end(chunk, ...rest) : end(patch(chunk), ...rest);
|
|
168
1043
|
}
|
|
1044
|
+
async function regenerate(file, log = console.log) {
|
|
1045
|
+
try {
|
|
1046
|
+
const r = await writeScreens(file);
|
|
1047
|
+
for (const i of r.issues)
|
|
1048
|
+
if (i.severity === "error")
|
|
1049
|
+
log(`[api-binding] ${describeIssue(i)}`);
|
|
1050
|
+
if (r.issues.some((i) => i.severity === "error"))
|
|
1051
|
+
log("[api-binding] Screen hooks not regenerated: fix the errors above.");
|
|
1052
|
+
else if (r.changed.length || r.stale.length)
|
|
1053
|
+
log(`[api-binding] Screen hooks regenerated: ${r.changed.length} written, ${r.stale.length} removed.`);
|
|
1054
|
+
} catch (e) {
|
|
1055
|
+
log(`[api-binding] Screen hooks not regenerated: ${e.message}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
async function generateClient(root, log = console.log) {
|
|
1059
|
+
const config = path4.join(root, "orval.config.ts");
|
|
1060
|
+
if (!existsSync(config))
|
|
1061
|
+
return;
|
|
1062
|
+
try {
|
|
1063
|
+
const orval = "orval";
|
|
1064
|
+
const { generate } = await import(orval);
|
|
1065
|
+
await generate(config, root);
|
|
1066
|
+
log("[api-binding] API client generated.");
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
log(`[api-binding] API client not generated: ${e.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
169
1071
|
function bindingStudio(options = {}) {
|
|
170
1072
|
const base = (options.base ?? "/__binding").replace(/\/+$/, "");
|
|
171
1073
|
const tag = `<script type="module" src="${base}/overlay.js"></script>`;
|
|
172
|
-
let file = "";
|
|
1074
|
+
let file = "", root = "";
|
|
1075
|
+
const norm = (s) => process.platform === "win32" ? path4.resolve(s).toLowerCase() : path4.resolve(s);
|
|
1076
|
+
const isBindingsFile = (p) => {
|
|
1077
|
+
const abs = norm(p), f = norm(file);
|
|
1078
|
+
return !!file && (abs === f || abs.startsWith(f + ".") && abs.endsWith(".tmp"));
|
|
1079
|
+
};
|
|
1080
|
+
const isRegistryFile = (p) => {
|
|
1081
|
+
try {
|
|
1082
|
+
const registry = JSON.parse(readFileSync(file, "utf8")).registry;
|
|
1083
|
+
return typeof registry === "string" && norm(p) === norm(path4.resolve(path4.dirname(file), registry));
|
|
1084
|
+
} catch {
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
173
1088
|
return {
|
|
174
1089
|
name: "api-binding-studio",
|
|
175
1090
|
apply: "serve",
|
|
176
1091
|
configResolved(config) {
|
|
177
|
-
|
|
1092
|
+
root = config.root;
|
|
1093
|
+
file = path4.resolve(root, options.file ?? "bindings.json");
|
|
178
1094
|
},
|
|
179
1095
|
transformIndexHtml: (html) => html.includes(tag) ? html : html.replace(/<\/body>/i, `${tag}</body>`),
|
|
180
|
-
|
|
1096
|
+
config: () => ({ server: { watch: { ignored: [(p) => isBindingsFile(p)] } } }),
|
|
1097
|
+
async configureServer(server) {
|
|
1098
|
+
if (existsSync(file)) {
|
|
1099
|
+
await generateClient(root);
|
|
1100
|
+
await regenerate(file);
|
|
1101
|
+
}
|
|
181
1102
|
const allowed = (req) => {
|
|
182
1103
|
const origin = req.headers.origin;
|
|
183
1104
|
return req.method === "GET" || !origin || new URL(origin).host === req.headers.host;
|
|
184
1105
|
};
|
|
185
|
-
|
|
1106
|
+
let queue = Promise.resolve();
|
|
1107
|
+
const onSave = () => {
|
|
1108
|
+
queue = queue.then(() => regenerate(file));
|
|
1109
|
+
};
|
|
1110
|
+
const handle = createStudioHandler({ file, prefix: base, allowed, onSave });
|
|
1111
|
+
server.watcher?.on("change", (p) => {
|
|
1112
|
+
if (p.endsWith(".json") && isRegistryFile(p))
|
|
1113
|
+
onSave();
|
|
1114
|
+
});
|
|
186
1115
|
server.middlewares.use((req, res, next) => {
|
|
187
1116
|
handle(req, res).then((handled) => {
|
|
188
1117
|
if (handled)
|
|
@@ -197,5 +1126,7 @@ function bindingStudio(options = {}) {
|
|
|
197
1126
|
}
|
|
198
1127
|
export {
|
|
199
1128
|
bindingStudio,
|
|
200
|
-
|
|
1129
|
+
generateClient,
|
|
1130
|
+
injectOverlay,
|
|
1131
|
+
regenerate
|
|
201
1132
|
};
|