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.
@@ -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-0n23yy5r.js"></script></head>
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>
@@ -0,0 +1,2 @@
1
+ import type { BindingsFile, ValidationIssue } from "../framework/contract";
2
+ export declare function scanIds(dir: string, file: BindingsFile): Promise<ValidationIssue[]>;
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 { 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
- }
5
+ import path4 from "node:path";
15
6
 
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
- }
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 gaps = whole ? [] : (flat(op.body.schema).required ?? []).filter((f) => !set.has(f));
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
- args.push(`assemble(${whole ?? "undefined"}, [${body.join(", ")}])`);
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/cli/scan.ts
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 fs.readdir(dir, { withFileTypes: true })) {
958
+ for (const e of await fs2.readdir(dir, { withFileTypes: true })) {
773
959
  if (SKIP.has(e.name))
774
960
  continue;
775
- const p = path.join(dir, e.name);
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 fs.readFile(p, "utf8")).matchAll(LITERAL))
971
+ for (const m of (await fs2.readFile(p, "utf8")).matchAll(LITERAL))
786
972
  if (!found.has(m[2]))
787
- found.set(m[2], path.relative(dir, p));
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 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;
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 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);
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((p) => path2.relative(process.cwd(), p)).join(", ")}`);
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
- 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.`);
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
- const base = path2.dirname(filePath);
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 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
- };
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
- 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);
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 = path2.resolve(rest[0] ?? "bindings.json");
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;