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