api_binding_ui 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -80
- package/SKILL.md +45 -45
- package/dist/app/{index-0n23yy5r.js → index-xww2x0kd.js} +138 -138
- package/dist/app/index.html +1 -1
- package/dist/cli/scan.d.ts +2 -0
- package/dist/cli.js +333 -207
- 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 +24 -0
- package/dist/server/handler.d.ts +25 -0
- package/dist/studio/BindingStudio.d.ts +3 -1
- package/dist/studio/index.js +8 -6
- package/dist/studio/index.js.map +3 -3
- package/dist/vite/plugin.d.ts +49 -0
- package/dist/vite.js +1113 -0
- package/package.json +7 -3
- package/spec/FRAMEWORK.md +178 -176
package/dist/app/index.html
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Bind API</title>
|
|
4
4
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;600&display=swap" />
|
|
5
|
-
<style>html, body, #root { height: 100%; margin: 0; overflow: hidden; background: #f8fafc; }</style><link rel="stylesheet" crossorigin href="./index-aaxy714b.css"><script type="module" crossorigin src="./index-
|
|
5
|
+
<style>html, body, #root { height: 100%; margin: 0; overflow: hidden; background: #f8fafc; }</style><link rel="stylesheet" crossorigin href="./index-aaxy714b.css"><script type="module" crossorigin src="./index-xww2x0kd.js"></script></head>
|
|
6
6
|
<body><div id="root"></div></body>
|
|
7
7
|
</html>
|
package/dist/cli.js
CHANGED
|
@@ -2,98 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli/cli.ts
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import
|
|
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
|
-
}
|
|
5
|
+
import path4 from "node:path";
|
|
15
6
|
|
|
16
|
-
// src/
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
}
|
|
7
|
+
// src/codegen/write.ts
|
|
8
|
+
import { promises as fs3 } from "node:fs";
|
|
9
|
+
import path3 from "node:path";
|
|
97
10
|
|
|
98
11
|
// src/framework/loadCatalog.ts
|
|
99
12
|
import { parse } from "yaml";
|
|
@@ -270,6 +183,140 @@ function parse2(expr) {
|
|
|
270
183
|
return parsed;
|
|
271
184
|
}
|
|
272
185
|
|
|
186
|
+
// src/framework/names.ts
|
|
187
|
+
function humanize(id) {
|
|
188
|
+
const s = id.replace(/^\$\.?/, "").replace(/\[\*\]/g, "").split(/[./]/).filter(Boolean).pop() ?? id;
|
|
189
|
+
const w = s.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").toLowerCase().trim();
|
|
190
|
+
return w ? w[0].toUpperCase() + w.slice(1) : id;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/framework/file.ts
|
|
194
|
+
var ID = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/;
|
|
195
|
+
var BINDING_TYPES = new Set(["operation", "expression", "steps", "none"]);
|
|
196
|
+
function parseBindingsFile(text) {
|
|
197
|
+
let data;
|
|
198
|
+
try {
|
|
199
|
+
data = JSON.parse(text);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error("The bindings file is not valid JSON.");
|
|
202
|
+
}
|
|
203
|
+
return checkBindingsFile(data);
|
|
204
|
+
}
|
|
205
|
+
function checkBindingsFile(data) {
|
|
206
|
+
const f = data;
|
|
207
|
+
if (!f || typeof f !== "object")
|
|
208
|
+
throw new Error("The bindings file must be a JSON object.");
|
|
209
|
+
if (f.version !== 3)
|
|
210
|
+
throw new Error(`The bindings file must have "version": 3.`);
|
|
211
|
+
if (typeof f.openapi !== "string" || !f.openapi)
|
|
212
|
+
throw new Error(`The bindings file needs "openapi": the docs URL or a path.`);
|
|
213
|
+
for (const k of ["kinds", "screens", "components", "bindings"])
|
|
214
|
+
if (!Array.isArray(f[k]))
|
|
215
|
+
throw new Error(`"${k}" must be a list.`);
|
|
216
|
+
f.bindings.forEach((b, i) => {
|
|
217
|
+
const x = b;
|
|
218
|
+
if (!x || typeof x.component !== "string" || typeof x.slot !== "string" || !BINDING_TYPES.has(x.type))
|
|
219
|
+
throw new Error(`Binding ${i + 1} needs "component", "slot" and a known "type".`);
|
|
220
|
+
});
|
|
221
|
+
if (f.output && (typeof f.output.dir !== "string" || typeof f.output.client !== "string"))
|
|
222
|
+
throw new Error(`"output" needs "dir" and "client".`);
|
|
223
|
+
return f;
|
|
224
|
+
}
|
|
225
|
+
var REGISTRY_KEYS = ["kinds", "screens", "components"];
|
|
226
|
+
function parseRegistry(text) {
|
|
227
|
+
let r;
|
|
228
|
+
try {
|
|
229
|
+
r = JSON.parse(text);
|
|
230
|
+
} catch {
|
|
231
|
+
throw new Error("The registry file is not valid JSON.");
|
|
232
|
+
}
|
|
233
|
+
if (!r || r.version !== 1)
|
|
234
|
+
throw new Error(`The registry file must have "version": 1.`);
|
|
235
|
+
if (!Array.isArray(r.kinds) || !Array.isArray(r.screens))
|
|
236
|
+
throw new Error(`The registry file needs "kinds" and "screens" lists.`);
|
|
237
|
+
const screens = [];
|
|
238
|
+
const components = [];
|
|
239
|
+
const under = (id, owner, what) => {
|
|
240
|
+
if (typeof id !== "string" || !id)
|
|
241
|
+
throw new Error(`A ${what} in the registry has no "id".`);
|
|
242
|
+
if (owner && !id.startsWith(owner + "."))
|
|
243
|
+
throw new Error(`"${id}" must start with "${owner}.", the id it is nested under.`);
|
|
244
|
+
};
|
|
245
|
+
const node = (n, owner, parent) => {
|
|
246
|
+
under(n.id, owner, "component");
|
|
247
|
+
if (typeof n.kind !== "string" || typeof n.label !== "string")
|
|
248
|
+
throw new Error(`"${n.id}" needs "kind" and "label".`);
|
|
249
|
+
const { children, ...c } = n;
|
|
250
|
+
components.push(parent ? { ...c, parent } : c);
|
|
251
|
+
for (const ch of children ?? [])
|
|
252
|
+
node(ch, n.id, n.id);
|
|
253
|
+
};
|
|
254
|
+
const screen = (s, owner) => {
|
|
255
|
+
under(s.id, owner, "screen");
|
|
256
|
+
const { components: cs, screens: subs, ...rest } = s;
|
|
257
|
+
screens.push(rest);
|
|
258
|
+
for (const c of cs ?? [])
|
|
259
|
+
node(c, s.id);
|
|
260
|
+
for (const sub of subs ?? [])
|
|
261
|
+
screen(sub, s.id);
|
|
262
|
+
};
|
|
263
|
+
for (const s of r.screens)
|
|
264
|
+
screen(s);
|
|
265
|
+
return { kinds: r.kinds, screens, components };
|
|
266
|
+
}
|
|
267
|
+
function screenOf(id, screens) {
|
|
268
|
+
let best;
|
|
269
|
+
for (const s of screens)
|
|
270
|
+
if (id.startsWith(s + ".") && (!best || s.length > best.length))
|
|
271
|
+
best = s;
|
|
272
|
+
return best;
|
|
273
|
+
}
|
|
274
|
+
function productLabel(seg, catalog) {
|
|
275
|
+
const names = new Set(catalog.operations.map((o) => o.product).filter((p) => !!p));
|
|
276
|
+
return [...names].find((n) => n.toLowerCase().replace(/[^a-z0-9]+/g, "-") === seg) ?? humanize(seg);
|
|
277
|
+
}
|
|
278
|
+
function fromFile(file, catalog) {
|
|
279
|
+
const issues = [];
|
|
280
|
+
const ids = file.screens.map((s) => s.id);
|
|
281
|
+
const screens = file.screens.map((s) => ({
|
|
282
|
+
id: s.id,
|
|
283
|
+
label: s.label,
|
|
284
|
+
description: s.description,
|
|
285
|
+
context: s.context ?? [],
|
|
286
|
+
components: [],
|
|
287
|
+
path: [productLabel(s.id.split(".")[0], catalog)],
|
|
288
|
+
parent: screenOf(s.id, ids)
|
|
289
|
+
}));
|
|
290
|
+
const byId = new Map(screens.map((s) => [s.id, s]));
|
|
291
|
+
const screenIds = new Set(ids);
|
|
292
|
+
for (const c of file.components) {
|
|
293
|
+
if (screenIds.has(c.id)) {
|
|
294
|
+
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.` });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const s = screenOf(c.id, ids);
|
|
298
|
+
if (!s) {
|
|
299
|
+
issues.push({ severity: "error", screen: "", component: c.id, code: "registry.no-screen", message: `"${c.id}" starts with no registered screen id.` });
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
byId.get(s).components.push({ bindId: c.id, kind: c.kind, label: c.label, parent: c.parent, info: c.info });
|
|
303
|
+
}
|
|
304
|
+
const index = {};
|
|
305
|
+
for (const b of file.bindings) {
|
|
306
|
+
const s = screenOf(b.component, ids);
|
|
307
|
+
if (!s) {
|
|
308
|
+
issues.push({ severity: "error", screen: "", component: b.component, slot: b.slot, code: "binding.no-screen", message: `Binding for "${b.component}" fits no screen.` });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
(index[s] ??= []).push(b);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
fw: { descriptors: file.kinds, registry: { registryVersion: 1, screens }, catalog, glossaries: file.glossaries },
|
|
315
|
+
index,
|
|
316
|
+
issues
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
273
320
|
// src/framework/schema.ts
|
|
274
321
|
var obj = (s) => s && typeof s === "object" ? s : {};
|
|
275
322
|
function flat(s) {
|
|
@@ -531,11 +578,18 @@ function compileCall(op, request, cx, scope, at) {
|
|
|
531
578
|
});
|
|
532
579
|
if (op.body) {
|
|
533
580
|
const set = new Set(Object.keys(request).filter((k) => k.startsWith("/body/")).map((k) => decode(k.split("/")[2])));
|
|
534
|
-
const
|
|
581
|
+
const schema = flat(op.body.schema);
|
|
582
|
+
const unset = whole ? [] : (schema.required ?? []).filter((f) => !set.has(f));
|
|
583
|
+
const empty = unset.filter((f) => {
|
|
584
|
+
const p = flat(schema.properties?.[f]);
|
|
585
|
+
return p.type === "object" && !p.required?.length;
|
|
586
|
+
});
|
|
587
|
+
const gaps = unset.filter((f) => !empty.includes(f));
|
|
535
588
|
if (gaps.length)
|
|
536
589
|
cx.warn("request.required-body", `${op.id} requires ${gaps.join(", ")} in its body; not connected.`, at);
|
|
537
590
|
cx.use.add("assemble");
|
|
538
|
-
|
|
591
|
+
const base = whole ?? (empty.length ? `{ ${empty.map((f) => `${prop(f)}: {}`).join(", ")} }` : "undefined");
|
|
592
|
+
args.push(`assemble(${base}, [${body.join(", ")}])`);
|
|
539
593
|
}
|
|
540
594
|
if (op.parameters.some((p) => p.pointer.startsWith("/query/"))) {
|
|
541
595
|
cx.use.add("defined");
|
|
@@ -763,16 +817,148 @@ ${apiType.join(`
|
|
|
763
817
|
return { files, issues };
|
|
764
818
|
}
|
|
765
819
|
|
|
766
|
-
// src/
|
|
820
|
+
// src/server/handler.ts
|
|
767
821
|
import { promises as fs } from "node:fs";
|
|
768
822
|
import path from "node:path";
|
|
823
|
+
import { fileURLToPath } from "node:url";
|
|
824
|
+
var TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".map": "application/json", ".woff2": "font/woff2" };
|
|
825
|
+
var DIST = fileURLToPath(new URL(".", import.meta.url));
|
|
826
|
+
var APP_DIR = path.join(DIST, "app");
|
|
827
|
+
var OVERLAY_FILE = path.join(DIST, "overlay.js");
|
|
828
|
+
var isUrl = (s) => /^https?:\/\//i.test(s);
|
|
829
|
+
async function readDocs(src, base) {
|
|
830
|
+
if (isUrl(src)) {
|
|
831
|
+
const res = await fetch(src);
|
|
832
|
+
if (!res.ok)
|
|
833
|
+
throw new Error(`${src} returned HTTP ${res.status}.`);
|
|
834
|
+
return res.text();
|
|
835
|
+
}
|
|
836
|
+
if (!/\.(ya?ml|json)$/i.test(src))
|
|
837
|
+
throw new Error(`"${src}" is not a .yaml, .yml or .json file.`);
|
|
838
|
+
return fs.readFile(path.resolve(base, src), "utf8");
|
|
839
|
+
}
|
|
840
|
+
async function readBindings(file) {
|
|
841
|
+
const text = await fs.readFile(file, "utf8");
|
|
842
|
+
let raw;
|
|
843
|
+
try {
|
|
844
|
+
raw = JSON.parse(text);
|
|
845
|
+
} catch {
|
|
846
|
+
throw new Error("The bindings file is not valid JSON.");
|
|
847
|
+
}
|
|
848
|
+
if (typeof raw?.registry !== "string")
|
|
849
|
+
return parseBindingsFile(text);
|
|
850
|
+
const registry = parseRegistry(await fs.readFile(path.resolve(path.dirname(file), raw.registry), "utf8"));
|
|
851
|
+
return checkBindingsFile({ ...raw, ...registry });
|
|
852
|
+
}
|
|
853
|
+
async function saveBindings(file, merged) {
|
|
854
|
+
let out = merged;
|
|
855
|
+
if (merged.registry) {
|
|
856
|
+
const rest = { ...merged };
|
|
857
|
+
for (const k of REGISTRY_KEYS)
|
|
858
|
+
delete rest[k];
|
|
859
|
+
out = rest;
|
|
860
|
+
}
|
|
861
|
+
await writeFileAtomic(file, JSON.stringify(out, null, 2) + `
|
|
862
|
+
`);
|
|
863
|
+
}
|
|
864
|
+
async function writeFileAtomic(file, text) {
|
|
865
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
866
|
+
await fs.writeFile(tmp, text, "utf8");
|
|
867
|
+
await fs.rename(tmp, file);
|
|
868
|
+
}
|
|
869
|
+
async function readBody(req, limit = 20 * 1024 * 1024) {
|
|
870
|
+
const chunks = [];
|
|
871
|
+
let size = 0;
|
|
872
|
+
for await (const c of req) {
|
|
873
|
+
size += c.length;
|
|
874
|
+
if (size > limit)
|
|
875
|
+
throw new Error("Body too large.");
|
|
876
|
+
chunks.push(c);
|
|
877
|
+
}
|
|
878
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
879
|
+
}
|
|
880
|
+
function send(res, status, body, type = "text/plain; charset=utf-8") {
|
|
881
|
+
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }).end(body);
|
|
882
|
+
}
|
|
883
|
+
function createStudioHandler({ file, prefix, allowed, onSave }) {
|
|
884
|
+
const base = path.dirname(file);
|
|
885
|
+
return async (req, res) => {
|
|
886
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
887
|
+
if (prefix && url.pathname !== prefix && !url.pathname.startsWith(prefix + "/"))
|
|
888
|
+
return false;
|
|
889
|
+
const rel = url.pathname.slice(prefix.length) || "/";
|
|
890
|
+
try {
|
|
891
|
+
if (prefix && url.pathname === prefix) {
|
|
892
|
+
res.writeHead(302, { Location: prefix + "/" + url.search }).end();
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
if (rel.startsWith("/api/") && !allowed(req)) {
|
|
896
|
+
send(res, 403, "Forbidden.");
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
if (rel === "/api/bindings") {
|
|
900
|
+
if (req.method === "GET")
|
|
901
|
+
send(res, 200, JSON.stringify(await readBindings(file)), "application/json");
|
|
902
|
+
else if (req.method === "PUT") {
|
|
903
|
+
let merged;
|
|
904
|
+
try {
|
|
905
|
+
merged = parseBindingsFile(await readBody(req));
|
|
906
|
+
} catch (e) {
|
|
907
|
+
send(res, 400, e.message);
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
await saveBindings(file, merged);
|
|
911
|
+
send(res, 204, "");
|
|
912
|
+
onSave?.();
|
|
913
|
+
} else
|
|
914
|
+
send(res, 405, "Method not allowed.");
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
if (rel === "/api/openapi" && req.method === "GET") {
|
|
918
|
+
const src = url.searchParams.get("src") ?? "";
|
|
919
|
+
try {
|
|
920
|
+
send(res, 200, await readDocs(src, base), src.endsWith(".json") ? "application/json" : "text/yaml; charset=utf-8");
|
|
921
|
+
} catch (e) {
|
|
922
|
+
send(res, 502, e.message);
|
|
923
|
+
}
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
927
|
+
send(res, 405, "Method not allowed.");
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
if (rel === "/overlay.js") {
|
|
931
|
+
send(res, 200, await fs.readFile(OVERLAY_FILE), TYPES[".js"]);
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
const target = path.resolve(APP_DIR, rel === "/" ? "index.html" : decodeURIComponent(rel.slice(1)));
|
|
935
|
+
if (!target.startsWith(APP_DIR + path.sep)) {
|
|
936
|
+
send(res, 404, "Not found.");
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
939
|
+
const data = await fs.readFile(target).catch(() => null);
|
|
940
|
+
if (!data) {
|
|
941
|
+
send(res, 404, "Not found.");
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
send(res, 200, data, TYPES[path.extname(target)] ?? "application/octet-stream");
|
|
945
|
+
} catch (e) {
|
|
946
|
+
send(res, 500, e.message);
|
|
947
|
+
}
|
|
948
|
+
return true;
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/cli/scan.ts
|
|
953
|
+
import { promises as fs2 } from "node:fs";
|
|
954
|
+
import path2 from "node:path";
|
|
769
955
|
var LITERAL = /componentId\s*(?:=\s*\{?\s*|:\s*)(["'`])([a-z0-9.-]+)\1/g;
|
|
770
956
|
var SKIP = new Set(["node_modules", "dist", "build", ".git", ".next", ".output"]);
|
|
771
957
|
async function* sources(dir) {
|
|
772
|
-
for (const e of await
|
|
958
|
+
for (const e of await fs2.readdir(dir, { withFileTypes: true })) {
|
|
773
959
|
if (SKIP.has(e.name))
|
|
774
960
|
continue;
|
|
775
|
-
const p =
|
|
961
|
+
const p = path2.join(dir, e.name);
|
|
776
962
|
if (e.isDirectory())
|
|
777
963
|
yield* sources(p);
|
|
778
964
|
else if (/\.(tsx?|jsx?)$/.test(e.name))
|
|
@@ -782,9 +968,9 @@ async function* sources(dir) {
|
|
|
782
968
|
async function scanIds(dir, file) {
|
|
783
969
|
const found = new Map;
|
|
784
970
|
for await (const p of sources(dir))
|
|
785
|
-
for (const m of (await
|
|
971
|
+
for (const m of (await fs2.readFile(p, "utf8")).matchAll(LITERAL))
|
|
786
972
|
if (!found.has(m[2]))
|
|
787
|
-
found.set(m[2],
|
|
973
|
+
found.set(m[2], path2.relative(dir, p));
|
|
788
974
|
const registered = new Set(file.components.map((c) => c.id));
|
|
789
975
|
const issues = [];
|
|
790
976
|
for (const [id, where] of found)
|
|
@@ -796,12 +982,45 @@ async function scanIds(dir, file) {
|
|
|
796
982
|
return issues;
|
|
797
983
|
}
|
|
798
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
|
+
|
|
799
1017
|
// src/cli/cli.ts
|
|
800
1018
|
var HELP = `Usage:
|
|
801
1019
|
api-binding studio [bindings.json] [--port 5180]
|
|
802
1020
|
api-binding generate [bindings.json] [--check] [--src <dir>]
|
|
803
1021
|
|
|
804
1022
|
studio Opens the binding studio on http://localhost:<port>. Edits are saved to the file.
|
|
1023
|
+
Inside a Vite app, the dev plugin (api_binding_ui/vite) serves the same studio instead.
|
|
805
1024
|
generate Writes one React Query hook per screen into output.dir, calling the Orval client.
|
|
806
1025
|
--check writes nothing; exits 1 when the files are out of date (for CI).
|
|
807
1026
|
--src also checks that every registered id appears in the app's source, and the reverse.`;
|
|
@@ -819,133 +1038,40 @@ function args(argv) {
|
|
|
819
1038
|
}
|
|
820
1039
|
return { rest, flags };
|
|
821
1040
|
}
|
|
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
1041
|
async function runGenerate(filePath, flags) {
|
|
844
|
-
const
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
const
|
|
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;
|
|
1042
|
+
const r = await writeScreens(filePath, { check: !!flags.check, src: typeof flags.src === "string" ? flags.src : undefined });
|
|
1043
|
+
for (const i of r.issues)
|
|
1044
|
+
console.log(describeIssue(i));
|
|
1045
|
+
const errors = r.issues.filter((i) => i.severity === "error").length;
|
|
852
1046
|
if (errors) {
|
|
853
1047
|
console.log(`
|
|
854
1048
|
${errors} error(s). Nothing was written.`);
|
|
855
1049
|
return 1;
|
|
856
1050
|
}
|
|
857
|
-
const
|
|
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);
|
|
1051
|
+
const rel = (p) => path4.relative(process.cwd(), p) || ".";
|
|
869
1052
|
if (flags.check) {
|
|
870
|
-
if (changed.length || stale.length) {
|
|
871
|
-
console.log(`Out of date: ${[...changed, ...stale].map(
|
|
1053
|
+
if (r.changed.length || r.stale.length) {
|
|
1054
|
+
console.log(`Out of date: ${[...r.changed, ...r.stale].map(rel).join(", ")}`);
|
|
872
1055
|
return 1;
|
|
873
1056
|
}
|
|
874
1057
|
console.log("Generated files are up to date.");
|
|
875
1058
|
return 0;
|
|
876
1059
|
}
|
|
877
|
-
|
|
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.`);
|
|
1060
|
+
console.log(`${r.screens} screen hook(s) in ${rel(r.out)}: ${r.changed.length} written, ${r.stale.length} removed.`);
|
|
882
1061
|
return 0;
|
|
883
1062
|
}
|
|
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
1063
|
async function runStudio(filePath, flags) {
|
|
897
1064
|
const port = Number(flags.port ?? 5180);
|
|
898
|
-
|
|
899
|
-
parseBindingsFile(await fs2.readFile(filePath, "utf8"));
|
|
900
|
-
const app = fileURLToPath(new URL("./app/", import.meta.url));
|
|
1065
|
+
await readBindings(filePath);
|
|
901
1066
|
const origins = new Set([`http://localhost:${port}`, `http://127.0.0.1:${port}`]);
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
};
|
|
1067
|
+
const allowed = (req) => origins.has(`http://${req.headers.host}`) && (!req.headers.origin || origins.has(String(req.headers.origin)));
|
|
1068
|
+
const handle = createStudioHandler({ file: filePath, prefix: "", allowed });
|
|
905
1069
|
const server = createServer(async (req, res) => {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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);
|
|
1070
|
+
if (!origins.has(`http://${req.headers.host}`)) {
|
|
1071
|
+
res.writeHead(403).end("Forbidden host.");
|
|
1072
|
+
return;
|
|
948
1073
|
}
|
|
1074
|
+
await handle(req, res);
|
|
949
1075
|
});
|
|
950
1076
|
server.listen(port, "127.0.0.1", () => console.log(`Binding studio: http://localhost:${port}
|
|
951
1077
|
Editing ${filePath}. Ctrl+C to stop.`));
|
|
@@ -953,7 +1079,7 @@ Editing ${filePath}. Ctrl+C to stop.`));
|
|
|
953
1079
|
async function main() {
|
|
954
1080
|
const [cmd, ...argv] = process.argv.slice(2);
|
|
955
1081
|
const { rest, flags } = args(argv);
|
|
956
|
-
const filePath =
|
|
1082
|
+
const filePath = path4.resolve(rest[0] ?? "bindings.json");
|
|
957
1083
|
if (cmd === "studio")
|
|
958
1084
|
return runStudio(filePath, flags);
|
|
959
1085
|
if (cmd === "generate")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ValidationIssue } from "../framework/contract";
|
|
2
|
+
export interface WriteResult {
|
|
3
|
+
issues: ValidationIssue[];
|
|
4
|
+
/** Directory the hooks go to. */
|
|
5
|
+
out: string;
|
|
6
|
+
/** Number of screen hooks generated. */
|
|
7
|
+
screens: number;
|
|
8
|
+
/** Files that differ from what is on disk (written unless `check`). */
|
|
9
|
+
changed: string[];
|
|
10
|
+
/** Generated files no screen produces any more (removed unless `check`). */
|
|
11
|
+
stale: string[];
|
|
12
|
+
}
|
|
13
|
+
/** On any error nothing is written. With `check`, nothing is written either; `changed` and `stale` say what is out of date. */
|
|
14
|
+
export declare function writeScreens(filePath: string, opts?: {
|
|
15
|
+
check?: boolean;
|
|
16
|
+
src?: string;
|
|
17
|
+
}): Promise<WriteResult>;
|
|
18
|
+
export declare const describeIssue: (i: ValidationIssue) => string;
|