@vinena/vectoys-cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/SKILL.md +4 -4
  2. package/dist/index.js +368 -42
  3. package/package.json +1 -1
package/SKILL.md CHANGED
@@ -11,10 +11,10 @@ vectoys draws 3D objects as plain SVG paths. An item is a small React component
11
11
 
12
12
  1. `npx @vinena/vectoys-cli create <name>` writes `./<name>/` (or under `--dir <dir>`): `item.json`, `src/geometry.ts`, `src/<name>.tsx`, `src/index.ts`, a test, `package.json` and `tsconfig.json`. It renders a shaded, tilted slab. Under `registry/` of a vectoys checkout it uses the workspace's packages, which `bun install` links. Anywhere else it asks for `@vinena/vectoys` and `@vinena/vectoys-react` from npm by version: install them with `npm install` (or your package manager) in the item folder before you check it.
13
13
  2. Edit `src/geometry.ts` (parts, camera, poses) and `src/<name>.tsx` (looks, motion, interaction). Write a real `title` and `description` in `item.json`. List every source file in `files`.
14
- 3. `npx @vinena/vectoys-cli check <dir>` runs 8 checks: item.json is valid, the files are under 96 KB together, item.json names no brand, the component renders on the server with no props, the markup has no `NaN` or `Infinity`, two renders give the same markup, the rendered text names no brand, and the root has an accessible name. It needs bun. Fix every FAIL line.
14
+ 3. `npx @vinena/vectoys-cli check <dir>` runs 10 checks: item.json is valid, the files are under 96 KB together, item.json names no brand, the code has none of the unsafe patterns check knows, the component renders on the server with no props, the markup has no `NaN` or `Infinity`, two renders give the same markup, the markup has no scripts or outside links, the rendered text names no brand, and the root has an accessible name. `check` catches common unsafe patterns, and a maintainer reviews every submission before it is listed. It fails any code file in the folder, tests included, that uses `Bun`, `process`, `require`, `globalThis`, `window`, `self`, `opener` or `defaultView`, `<script>`, frames, objects, embeds or forms, `innerHTML` or `dangerouslySetInnerHTML`, `eval` or `Function`, string timers, network calls, workers, `postMessage`, the clipboard, `window.open` or `location`, dynamic `import()`, browser storage or cookies, or outside or `javascript:` URLs. The files item.json lists import only each other, `react`, `@vinena/vectoys` and `@vinena/vectoys-react`, which are also the only dependencies item.json may list; tests may also import `bun:test` and `react-dom/server`. Inside a vectoys checkout, tests under `registry/<item>/test/` also use the helpers in `packages/react/test/dom.tsx` (`installGlobal`, `htmlOf`, `fireOnWindow`, and `readSibling`, which reads only files under `registry/`). An item made outside the repository has no such helpers, so keep its tests to rendering with `react-dom/server`. Keep `package.json` and `tsconfig.json` as `create` writes them, and add no dot files but `.gitignore`. It needs bun. Fix every FAIL line.
15
15
  4. `npx @vinena/vectoys-cli preview <dir>` writes `<dir>/preview.html` (`--open` opens it): the item rendered on the server in light and dark, and at a row of yaws when the component takes a `yaw` prop. Look at it. Faces drawn in the wrong order, gaps, and parts that float show here, not in the checks.
16
16
  5. Iterate on 2 to 4 until it looks right in both themes and at every yaw.
17
- 6. `npx @vinena/vectoys-cli submit <dir>` runs the checks, then opens a pull request (it needs the GitHub CLI, `gh`) that adds the item to vinenastudio/vectoys. If you host your own registry, `npx @vinena/vectoys-cli submit --registry <url>` opens a pull request that lists it. Until submissions open at launch, submit prints what it would send and sends nothing. `--dry-run` does the same at any time.
17
+ 6. `npx @vinena/vectoys-cli submit <dir>` runs the checks, then opens a pull request (it needs the GitHub CLI, `gh`, and bun 1.3.2 or newer) that adds the item to vinenastudio/vectoys: the files `item.json` lists, its `package.json`, `tsconfig.json`, `src/index.ts` and `test/`, and the item's package in `bun.lock`. If you host your own registry, `npx @vinena/vectoys-cli submit --registry <url>` opens a pull request that lists it. Until submissions open at launch, submit prints what it would send and sends nothing. `--dry-run` does the same at any time.
18
18
 
19
19
  ## The model
20
20
 
@@ -89,7 +89,7 @@ The same props must give the same markup on the server and the client, every tim
89
89
 
90
90
  ## Example
91
91
 
92
- A badge that lifts toward the mouse and shows live HTML on its face. It passes all 8 checks.
92
+ A badge that lifts toward the mouse and shows live HTML on its face. It passes all 10 checks.
93
93
 
94
94
  `src/geometry.ts`:
95
95
 
@@ -165,7 +165,7 @@ export function Badge({ children, label = "Badge" }: { children?: ReactNode; lab
165
165
 
166
166
  ## Before you submit
167
167
 
168
- - `npx @vinena/vectoys-cli check <dir>` passes all 8 checks.
168
+ - `npx @vinena/vectoys-cli check <dir>` passes all 10 checks.
169
169
  - The preview looks right in light and dark and at every yaw.
170
170
  - Pointer, touch and keyboard all work, and reduced motion keeps the state changes.
171
171
  - `item.json` has a plain title and a description that says what the item is and what it does.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { resolve as resolve7 } from "node:path";
5
5
  // package.json
6
- var version = "0.2.1";
6
+ var version = "0.3.0";
7
7
 
8
8
  // src/args.ts
9
9
  var VALUES = new Map([
@@ -58,10 +58,15 @@ function parseArgs(argv) {
58
58
 
59
59
  // src/check.ts
60
60
  import { spawnSync as spawnSync3 } from "node:child_process";
61
- import { existsSync as existsSync6, lstatSync, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
61
+ import { existsSync as existsSync6, lstatSync, mkdtempSync, readdirSync, readFileSync as readFileSync6, realpathSync, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
62
62
  import { tmpdir } from "node:os";
63
- import { basename, extname, join as join6, relative as relative3, resolve as resolve4, sep as sep3 } from "node:path";
63
+ import { basename, extname, join as join6, posix as posix3, relative as relative3, resolve as resolve4, sep as sep3 } from "node:path";
64
64
 
65
+ // ../registry-build/src/names.ts
66
+ function componentName(name) {
67
+ const joined = name.split(/[^a-zA-Z0-9]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
68
+ return /^[A-Za-z]/.test(joined) ? joined : `Item${joined}`;
69
+ }
65
70
  // ../registry-build/src/index.ts
66
71
  function checkItem(value, file) {
67
72
  const item = value;
@@ -71,8 +76,13 @@ function checkItem(value, file) {
71
76
  }
72
77
  if (!Array.isArray(item.files) || item.files.length === 0)
73
78
  throw new Error(`${file} needs "files"`);
79
+ if (item.author !== undefined && !isOneLine(item.author))
80
+ throw new Error(`${file} "author" must be one line of text like "Name (https://site)"`);
81
+ if (item.credit !== undefined && !isOneLine(item.credit))
82
+ throw new Error(`${file} "credit" must be one line of text`);
74
83
  return item;
75
84
  }
85
+ var isOneLine = (value) => typeof value === "string" && value.trim() !== "" && !/[\r\n\u2028\u2029]/.test(value) && !value.includes("*/");
76
86
 
77
87
  // src/commands.ts
78
88
  import { spawnSync } from "node:child_process";
@@ -307,8 +317,48 @@ function missing(cwd, wanted) {
307
317
  if (!existsSync3(path))
308
318
  return wanted;
309
319
  const pkg = JSON.parse(readFileSync3(path, "utf8"));
310
- const have = new Set(["dependencies", "devDependencies", "peerDependencies"].flatMap((field) => Object.keys(pkg[field] ?? {})));
311
- return wanted.filter((spec) => !have.has(packageName(spec)));
320
+ const listed = Object.assign({}, ...["peerDependencies", "devDependencies", "dependencies"].map((field) => pkg[field] ?? {}));
321
+ return wanted.filter((spec) => {
322
+ const name = packageName(spec);
323
+ if (!Object.hasOwn(listed, name))
324
+ return true;
325
+ const want = base(spec.slice(name.length + 1));
326
+ const range = listed[name];
327
+ if (!want || typeof range === "string" && range.startsWith("workspace:"))
328
+ return false;
329
+ const installed = readPackage(join3(cwd, "node_modules", name, "package.json"))?.version;
330
+ const have = [installed, range].flatMap((version2) => typeof version2 === "string" ? base(version2) ?? [] : []);
331
+ return !have.some((version2) => compare(version2, want) >= 0);
332
+ });
333
+ }
334
+ var base = (range) => {
335
+ const found = /^[\^~]?=?v?(\d+)(?:\.(\d+)(?:\.(\d+)(?:-([\w.-]+))?)?)?(?:\+[\w.-]*)?$/.exec(range.trim());
336
+ return found ? { parts: [found[1], found[2], found[3]].map((part) => Number(part ?? 0)), pre: found[4]?.split(".") ?? [] } : undefined;
337
+ };
338
+ function compare(a, b) {
339
+ for (const [at, part] of a.parts.entries()) {
340
+ const other = b.parts[at];
341
+ if (other === undefined)
342
+ return 1;
343
+ if (part !== other)
344
+ return part - other;
345
+ }
346
+ if (a.pre.length === 0 || b.pre.length === 0)
347
+ return b.pre.length - a.pre.length;
348
+ for (const [at, id] of a.pre.entries()) {
349
+ const other = b.pre[at];
350
+ if (other === undefined)
351
+ return 1;
352
+ if (id === other)
353
+ continue;
354
+ const [x, y] = [/^\d+$/.test(id), /^\d+$/.test(other)];
355
+ if (x && y)
356
+ return Number(id) - Number(other);
357
+ if (x !== y)
358
+ return x ? -1 : 1;
359
+ return id < other ? -1 : 1;
360
+ }
361
+ return a.pre.length - b.pre.length;
312
362
  }
313
363
  function packageName(spec) {
314
364
  const at = spec.indexOf("@", 1);
@@ -463,6 +513,7 @@ import { dirname as dirname4, join as join5, relative as relative2, resolve as r
463
513
  // ../../package.json
464
514
  var devDependencies = {
465
515
  "@happy-dom/global-registrator": "20.14.5",
516
+ "@playwright/test": "1.63.0",
466
517
  "@types/bun": "1.3.14",
467
518
  typescript: "7.0.2"
468
519
  };
@@ -510,10 +561,6 @@ function inVectoysRepo(folder) {
510
561
  return false;
511
562
  }
512
563
  }
513
- function componentName(name) {
514
- const joined = name.split(/[^a-zA-Z0-9]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
515
- return /^[A-Za-z]/.test(joined) ? joined : `Item${joined}`;
516
- }
517
564
  function titleOf(name) {
518
565
  const words = name.replace(/[-_.]+/g, " ").trim() || "item";
519
566
  return words.charAt(0).toUpperCase() + words.slice(1);
@@ -522,24 +569,13 @@ function gitAuthor(cwd) {
522
569
  const result = spawnSync2("git", ["config", "user.name"], { cwd, encoding: "utf8" });
523
570
  return result.status === 0 ? result.stdout.trim() || undefined : undefined;
524
571
  }
525
- function scaffold(name, author, inRepo = false) {
526
- const component = componentName(name);
527
- const title = titleOf(name);
528
- const item = {
529
- name,
530
- title,
531
- description: `${title}: a tilted slab drawn as SVG paths. Say what it is and what it does.`,
532
- files: ["src/geometry.ts", `src/${name}.tsx`],
533
- dependencies: ["@vinena/vectoys", "@vinena/vectoys-react"],
534
- license: "MIT",
535
- ...author ? { author } : {}
536
- };
572
+ function itemPackage(name, description, inRepo) {
537
573
  const vectoys = inRepo ? "workspace:*" : `^${version}`;
538
- const pkg = {
574
+ return {
539
575
  name: `@vectoys/${name}`,
540
576
  version: "0.0.0",
541
577
  private: true,
542
- description: `${title} drawn with the vectoys engine.`,
578
+ description,
543
579
  type: "module",
544
580
  sideEffects: false,
545
581
  exports: { ".": "./src/index.ts" },
@@ -554,6 +590,20 @@ function scaffold(name, author, inRepo = false) {
554
590
  "react-dom": "19.0.0"
555
591
  }
556
592
  };
593
+ }
594
+ function scaffold(name, author, inRepo = false) {
595
+ const component = componentName(name);
596
+ const title = titleOf(name);
597
+ const item = {
598
+ name,
599
+ title,
600
+ description: `${title}: a tilted slab drawn as SVG paths. Say what it is and what it does.`,
601
+ files: ["src/geometry.ts", `src/${name}.tsx`],
602
+ dependencies: ["@vinena/vectoys", "@vinena/vectoys-react"],
603
+ license: "MIT",
604
+ ...author ? { author } : {}
605
+ };
606
+ const pkg = itemPackage(name, `${title} drawn with the vectoys engine.`, inRepo);
557
607
  const tsconfig = itemTsconfig(inRepo);
558
608
  const json = (value) => `${JSON.stringify(value, null, 2)}
559
609
  `;
@@ -652,9 +702,12 @@ export function ${component}({ label = ${JSON.stringify(title)}, finish, yaw = T
652
702
  </div>
653
703
  );
654
704
  }
705
+
706
+ /** How the vectoys.com gallery shows the item on its card, with no props. Change it to show the item at its best. */
707
+ export const Demo = () => <${component} />;
655
708
  `,
656
709
  "src/index.ts": `export { HEIGHT, THICKNESS, TILT, WIDTH, seen, type View } from "./geometry";
657
- export { ${component}, type ${component}Props } from "./${name}";
710
+ export { Demo, ${component}, type ${component}Props } from "./${name}";
658
711
  `,
659
712
  [`test/${name}.test.tsx`]: `import { expect, test } from "bun:test";
660
713
  import { renderToStaticMarkup } from "react-dom/server";
@@ -743,7 +796,7 @@ function runInBun(script, entry, dir, wanted) {
743
796
  try {
744
797
  const runner = join6(work, "render.mjs");
745
798
  writeFileSync4(runner, script);
746
- const result = spawnSync3("bun", [runner, entry, dir, wanted], { cwd: dir, encoding: "utf8", timeout: 60000, maxBuffer: 64 * 1024 * 1024 });
799
+ const result = spawnSync3("bun", [runner, entry, dir, wanted], { cwd: work, env: { PATH: process.env.PATH ?? "" }, encoding: "utf8", timeout: 60000, maxBuffer: 64 * 1024 * 1024 });
747
800
  if (result.error) {
748
801
  const missing2 = "code" in result.error && result.error.code === "ENOENT";
749
802
  return { error: missing2 ? "it needs bun to load the item's TSX: https://bun.sh" : result.error.message };
@@ -804,11 +857,242 @@ function itemProblem(item, dir) {
804
857
  const kind = stat.isSymbolicLink() ? "a symlink, " : stat.isDirectory() ? "a folder, " : "";
805
858
  return `files lists ${JSON.stringify(other)}, which is ${kind}not a regular file`;
806
859
  }
860
+ const outside = item.files.find((path) => !realpathSync(resolve4(dir, path)).startsWith(realpathSync(dir) + sep3));
861
+ if (outside !== undefined)
862
+ return `files lists ${JSON.stringify(outside)}, which is outside the folder`;
807
863
  if (item.dependencies !== undefined && !(Array.isArray(item.dependencies) && item.dependencies.every(isPackageSpec))) {
808
864
  return "dependencies are not all plain npm package names";
809
865
  }
810
866
  return;
811
867
  }
868
+ var ALLOWED_DEPENDENCIES = ["@vinena/vectoys", "@vinena/vectoys-react"];
869
+ var ALLOWED_IMPORTS = [...ALLOWED_DEPENDENCIES, "react"];
870
+ var ALLOWED_TEST_IMPORTS = ["bun:test", "react-dom/server"];
871
+ var TEST_HELPERS = [/^\.\.\/\.\.\/\.\.\/packages\/react\/test\/dom$/, /^\.\.\/\.\.\/[a-z0-9][a-z0-9._-]*\/src$/];
872
+ var SCANNER = `
873
+ const [, dir, list] = process.argv.slice(2);
874
+ const { join } = await import("node:path");
875
+ const code = (path) => /\\.[cm]?[jt]sx?$/.test(path);
876
+ const sources = [];
877
+ for (const path of JSON.parse(list)) {
878
+ const text = await Bun.file(join(dir, path)).text();
879
+ if (!code(path)) { sources.push({ path, text, code: text, imports: [] }); continue; }
880
+ try {
881
+ const transpiler = new Bun.Transpiler({ loader: path.endsWith("x") ? "tsx" : "ts", deadCodeElimination: false });
882
+ sources.push({ path, text, code: transpiler.transformSync(text), imports: transpiler.scan(text).imports });
883
+ } catch (error) {
884
+ sources.push({ path, error: error instanceof Error ? error.message : String(error) });
885
+ }
886
+ }
887
+ process.stdout.write("\\n${MARK}" + JSON.stringify(sources));
888
+ `;
889
+ function isSource(value) {
890
+ if (typeof value !== "object" || value === null || !("path" in value) || typeof value.path !== "string")
891
+ return false;
892
+ if ("error" in value)
893
+ return typeof value.error === "string";
894
+ return "text" in value && typeof value.text === "string" && "code" in value && typeof value.code === "string" && "imports" in value && Array.isArray(value.imports) && value.imports.every((entry) => typeof entry === "object" && entry !== null && ("path" in entry) && typeof entry.path === "string" && ("kind" in entry) && typeof entry.kind === "string");
895
+ }
896
+ function sources(dir, files) {
897
+ const ran = runInBun(SCANNER, dir, dir, JSON.stringify(files));
898
+ if ("error" in ran)
899
+ return ran.error;
900
+ return Array.isArray(ran.value) && ran.value.every(isSource) ? ran.value : "bun gave back something other than the files";
901
+ }
902
+ var entries = (dir, under) => readdirSync(join6(dir, under), { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
903
+ function walk(dir, under = "") {
904
+ return entries(dir, under).flatMap((entry) => {
905
+ const path = under ? `${under}/${entry.name}` : entry.name;
906
+ if (entry.isDirectory())
907
+ return entry.name === "node_modules" ? [] : walk(dir, path);
908
+ return entry.isFile() ? [path] : [];
909
+ });
910
+ }
911
+ var CODE = /\.[cm]?[jt]sx?$/;
912
+ function codeFiles(dir) {
913
+ return walk(dir).filter((path) => CODE.test(path));
914
+ }
915
+ var STRING = "[\"'`]";
916
+ var GLOBALS = ["opener", "defaultView", "fetch", "eval", "Function", "XMLHttpRequest", "WebSocket", "EventSource", "sendBeacon", "importScripts", "localStorage", "sessionStorage", "indexedDB", "cookie", "constructor", "open", "location", "postMessage", "clipboard"];
917
+ var ELEMENTS = "script|iframe|frame|object|embed|form";
918
+ var UNSAFE = [
919
+ [/\b(Bun|process|Deno|module|exports|require|__dirname|__filename|globalThis)\b/, "uses the server runtime"],
920
+ [/\bdefaultView\b|(?<![.\w$])(window|self|opener)(?=\s*[?.[(=;,)\]}])/, "reaches the window object"],
921
+ [/<script\b/i, "has a <script> tag"],
922
+ [/<(iframe|frame|object|embed|form)\b/i, "has a frame, object, embed or form tag"],
923
+ [new RegExp(`\\b(jsx\\w*|createElement)\\s*\\(\\s*${STRING}(${ELEMENTS})${STRING}`, "i"), "makes a script, frame, object, embed or form element"],
924
+ [new RegExp(`${STRING}(script|iframe|frame|embed|form)${STRING}`, "i"), "names a script, frame, embed or form element in a string"],
925
+ [/\bsrcdoc\b/i, "sets srcdoc"],
926
+ [/\b(dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML)\b/, "sets HTML from a string"],
927
+ [/\bdocument\s*\.\s*write/, "writes into the document"],
928
+ [/\beval\b/, "uses eval"],
929
+ [/\bFunction\b|\.\s*constructor\b|\bconstructor\s*[:,}]/, "reaches the Function constructor"],
930
+ [new RegExp(`\\bset(Timeout|Interval)\\s*\\(\\s*${STRING}`), "passes a string to setTimeout or setInterval"],
931
+ [/\bfetch\b/, "uses fetch"],
932
+ [/\b(XMLHttpRequest|WebSocket|EventSource|sendBeacon|importScripts)\b/, "makes a network call"],
933
+ [/\b(Worker|SharedWorker|serviceWorker|postMessage)\b/, "talks to a worker or another window"],
934
+ [/\bclipboard\b/, "uses the clipboard"],
935
+ [/(?<![\w$])open\s*\(/, "opens a window"],
936
+ [/\breadSibling\s*\((?!\s*import\.meta\.url\s*,)/, "reads a file from somewhere other than its own test"],
937
+ [/\blocation\s*(\.\s*(href|assign|replace|reload)\b|=(?!=))|\b(window|document)\s*\.\s*location\b/, "moves the page"],
938
+ [/\bimport\s*\(/, "has a dynamic import"],
939
+ [/\b(localStorage|sessionStorage|indexedDB)\b|\bdocument\s*\.\s*cookie\b/, "uses browser storage or cookies"],
940
+ [new RegExp(`${STRING}(${GLOBALS.join("|")})${STRING}`), "names a blocked browser feature in a string"],
941
+ [/javascript\s*:/i, "has a javascript: URL"],
942
+ [new RegExp(`\\bhttps?://(?!www\\.w3\\.org/)|[${"\"'`"}}]\\s*//[a-z0-9]|${STRING}\\s*data:\\s*text/html`, "i"), "has an outside URL"],
943
+ [/["'`}]\s*(?:\/\\\\|\\\\[\/\\])/, "has an outside URL"],
944
+ [/url\(\s*["']?\s*(https?:|\/\/)|@import\b/i, "loads a style from outside"]
945
+ ];
946
+ var UNSAFE_MARKUP = [
947
+ [/<script\b/i, "has a <script> tag"],
948
+ [/<(iframe|frame|object|embed|form)\b/i, "has a frame, object, embed or form tag"],
949
+ [/\ssrcdoc=/i, "sets srcdoc"],
950
+ [/javascript\s*:/i, "has a javascript: URL"],
951
+ [/\s(href|xlink:href|src|srcset|action|formaction)="\s*(https?:|[\/\\]{2}|data:\s*text\/html)/i, "links outside"],
952
+ [/url\(\s*(&quot;|&#x27;|["'])?\s*(https?:|\/\/)/i, "loads a style from outside"]
953
+ ];
954
+ var allowed = (name, list2) => list2.some((pkg) => name === pkg || name.startsWith(`${pkg}/`));
955
+ function names(specifier, from, files) {
956
+ const path = posix3.join(posix3.dirname(from), specifier);
957
+ const base2 = path.replace(CODE, "");
958
+ return files.some((file) => file === path || CODE.test(file) && [base2, `${base2}/index`].includes(file.replace(CODE, "")));
959
+ }
960
+ function lineOf(text, found) {
961
+ const lines = text.split(`
962
+ `);
963
+ for (const word of [found, ...(found.match(/[@A-Za-z_$][\w$]*|\/\/|<\w+/g) ?? []).sort((a, b) => b.length - a.length)]) {
964
+ const at = lines.findIndex((line) => line.includes(word));
965
+ if (at !== -1)
966
+ return `:${at + 1}`;
967
+ }
968
+ return "";
969
+ }
970
+ function safety(files, item) {
971
+ const problems = (item.dependencies ?? []).filter((name) => !ALLOWED_DEPENDENCIES.includes(name)).map((name) => `item.json depends on ${name}, which is not ${ALLOWED_DEPENDENCIES.join(" or ")}`);
972
+ const all = files.map((file) => file.path);
973
+ for (const file of files) {
974
+ if ("error" in file) {
975
+ problems.push(`${file.path} does not parse: ${file.error}`);
976
+ continue;
977
+ }
978
+ const shipped = item.files.includes(file.path);
979
+ const test = !shipped && file.path.startsWith("test/");
980
+ const reachable = shipped ? item.files : test ? all : all.filter((path) => !path.startsWith("test/"));
981
+ const packages = shipped ? ALLOWED_IMPORTS : [...ALLOWED_IMPORTS, ...ALLOWED_TEST_IMPORTS];
982
+ for (const entry of file.imports) {
983
+ if (entry.kind !== "import-statement")
984
+ continue;
985
+ const needed = ALLOWED_DEPENDENCIES.find((pkg) => entry.path === pkg || entry.path.startsWith(`${pkg}/`));
986
+ if (shipped && needed && !(item.dependencies ?? []).includes(needed))
987
+ problems.push(`${file.path}${lineOf(file.text, JSON.stringify(entry.path))} imports ${entry.path}, but item.json's dependencies do not list ${needed}`);
988
+ const fine = entry.path.startsWith(".") ? names(entry.path, file.path, reachable) || test && TEST_HELPERS.some((helper) => helper.test(entry.path)) : allowed(entry.path, packages);
989
+ const where = shipped ? "one of its files" : test ? "a file in its folder" : "a file in its folder outside test/";
990
+ if (!fine)
991
+ problems.push(`${file.path}${lineOf(file.text, JSON.stringify(entry.path))} imports ${entry.path}, which is not ${where} or ${packages.join(", ")}`);
992
+ }
993
+ const pragma = /@jsx\w*/.exec(file.text);
994
+ if (pragma)
995
+ problems.push(`${file.path}${lineOf(file.text, pragma[0])} sets the JSX runtime in a comment: ${pragma[0]}`);
996
+ for (const [pattern, what] of UNSAFE) {
997
+ const found = pattern.exec(file.code);
998
+ if (found)
999
+ problems.push(`${file.path}${lineOf(file.text, found[0])} ${what}: ${found[0].trim()}`);
1000
+ }
1001
+ }
1002
+ return problems;
1003
+ }
1004
+ function markupSafety(html) {
1005
+ return UNSAFE_MARKUP.flatMap(([pattern, what]) => {
1006
+ const found = pattern.exec(html);
1007
+ return found ? [`the markup ${what}: ${found[0].trim()}`] : [];
1008
+ });
1009
+ }
1010
+ function configFiles(dir, under = "") {
1011
+ return entries(dir, under).flatMap((entry) => {
1012
+ const path = under ? `${under}/${entry.name}` : entry.name;
1013
+ if (entry.name === "node_modules")
1014
+ return [];
1015
+ if (entry.name === "bunfig.toml" || entry.isSymbolicLink() || entry.name.startsWith(".") && entry.name !== ".gitignore")
1016
+ return [path];
1017
+ return entry.isDirectory() ? configFiles(dir, path) : [];
1018
+ });
1019
+ }
1020
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1021
+ var TSCONFIG = /^[tj]sconfig.*\.json$/;
1022
+ var TSCONFIG_KEYS = ["extends", "compilerOptions", "include", "exclude"];
1023
+ var TSCONFIG_OPTIONS = Object.keys(itemTsconfig(false).compilerOptions);
1024
+ function tsconfigProblems(dir) {
1025
+ return walk(dir).filter((path) => TSCONFIG.test(basename(path))).flatMap((file) => {
1026
+ let config;
1027
+ try {
1028
+ config = JSON.parse(readFileSync6(join6(dir, file), "utf8"));
1029
+ } catch {
1030
+ return [`${file} is not plain JSON, so check cannot tell what it sets`];
1031
+ }
1032
+ if (typeof config !== "object" || config === null || Array.isArray(config))
1033
+ return [`${file} is not an object`];
1034
+ const problems = [];
1035
+ if ("extends" in config && (file !== "tsconfig.json" || config.extends !== BASE_TSCONFIG))
1036
+ problems.push(`${file} extends ${JSON.stringify(config.extends)}, which is not ${BASE_TSCONFIG} from the folder's tsconfig.json`);
1037
+ for (const key of Object.keys(config))
1038
+ if (!TSCONFIG_KEYS.includes(key))
1039
+ problems.push(`${file} has ${key}, which items may not have`);
1040
+ const options = "compilerOptions" in config && isRecord(config.compilerOptions) ? config.compilerOptions : {};
1041
+ for (const [option, value] of Object.entries(options)) {
1042
+ if (["paths", "baseUrl", "rootDirs"].includes(option))
1043
+ problems.push(`${file} sets compilerOptions.${option}, which moves imports`);
1044
+ else if (!TSCONFIG_OPTIONS.includes(option))
1045
+ problems.push(`${file} sets compilerOptions.${option}, which items may not set`);
1046
+ else if (option === "jsx" && value !== "react-jsx")
1047
+ problems.push(`${file} sets compilerOptions.jsx to ${JSON.stringify(value)}, not "react-jsx"`);
1048
+ }
1049
+ return problems;
1050
+ });
1051
+ }
1052
+ function canonical(value) {
1053
+ if (Array.isArray(value))
1054
+ return `[${value.map(canonical).join(",")}]`;
1055
+ if (isRecord(value))
1056
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
1057
+ return JSON.stringify(value) ?? "undefined";
1058
+ }
1059
+ var PLAIN_VERSION = /^\^?\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
1060
+ function versionsFit(value, inRepo, outside) {
1061
+ if (canonical(value) === canonical(inRepo))
1062
+ return true;
1063
+ if (!isRecord(value) || !isRecord(outside))
1064
+ return false;
1065
+ const keys = Object.keys(value).sort();
1066
+ if (canonical(keys) !== canonical(Object.keys(outside).sort()))
1067
+ return false;
1068
+ return keys.every((key) => {
1069
+ const version2 = value[key];
1070
+ const want = outside[key];
1071
+ return version2 === want || typeof version2 === "string" && typeof want === "string" && PLAIN_VERSION.test(version2) && PLAIN_VERSION.test(want);
1072
+ });
1073
+ }
1074
+ function packageProblems(dir, item) {
1075
+ const path = join6(dir, "package.json");
1076
+ if (!existsSync6(path))
1077
+ return ["there is no package.json; vectoys create writes one"];
1078
+ let pkg;
1079
+ try {
1080
+ pkg = JSON.parse(readFileSync6(path, "utf8"));
1081
+ } catch {
1082
+ return ["package.json is not valid JSON"];
1083
+ }
1084
+ if (!isRecord(pkg))
1085
+ return ["package.json is not an object"];
1086
+ const description = typeof pkg.description === "string" ? pkg.description : "";
1087
+ const inRepo = { ...itemPackage(item.name, description, true) };
1088
+ const outside = { ...itemPackage(item.name, description, false) };
1089
+ return [...new Set([...Object.keys(inRepo), ...Object.keys(pkg)])].flatMap((key) => {
1090
+ if (!(key in inRepo))
1091
+ return [`package.json has ${key}, which items may not have`];
1092
+ const fits = key === "dependencies" || key === "devDependencies" ? versionsFit(pkg[key], inRepo[key], outside[key]) : canonical(pkg[key]) === canonical(inRepo[key]);
1093
+ return fits ? [] : [`package.json's ${key} is not what vectoys create writes`];
1094
+ });
1095
+ }
812
1096
  function readItem(dir) {
813
1097
  const path = join6(dir, "item.json");
814
1098
  if (!existsSync6(path))
@@ -836,22 +1120,39 @@ function checks(dir) {
836
1120
  if (typeof item === "string")
837
1121
  return [{ pass: false, line: item }];
838
1122
  const results = [{ pass: true, line: "item.json has what the registry needs" }];
1123
+ const config = [...configFiles(dir).map((file) => `the folder has ${file}, which items may not have`), ...tsconfigProblems(dir), ...packageProblems(dir, item)];
1124
+ if (config.length)
1125
+ return [...results, ...config.map((line) => ({ pass: false, line }))];
839
1126
  const size = item.files.reduce((sum, file) => sum + lstatSync(resolve4(dir, file)).size, 0);
840
1127
  const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
841
1128
  results.push({ pass: size < MAX_SOURCE, line: `the files are ${kb(size)}, ${size < MAX_SOURCE ? "under" : "over"} ${kb(MAX_SOURCE)}` });
842
1129
  const named = brands([item.name, item.title, item.description].join(" "));
843
1130
  results.push({ pass: named.length === 0, line: named.length ? `item.json names brands: ${named.join(", ")}` : "item.json names no brands" });
844
- const component = componentFile(item);
845
- if (!component)
846
- return [...results, { pass: false, line: "files lists no .tsx component to render" }];
847
- const rendered = render(resolve4(dir, component), dir, componentName(item.name));
1131
+ const read = sources(dir, [...new Set([...item.files, ...codeFiles(dir)])]);
1132
+ const unsafe = typeof read === "string" ? [`bun could not read the files: ${read}`] : safety(read, item);
1133
+ if (unsafe.length)
1134
+ return [...results, ...unsafe.map((line) => ({ pass: false, line }))];
1135
+ results.push({ pass: true, line: "the code uses no scripts, eval, network calls or outside imports that check can see" });
1136
+ const component = `src/${item.name}.tsx`;
1137
+ const wanted = componentName(item.name);
1138
+ const shape = { pass: false, line: `the component must be ${component} exporting ${wanted}` };
1139
+ if (!item.files.includes(component))
1140
+ return [...results, shape];
1141
+ const rendered = render(resolve4(dir, component), dir, wanted);
848
1142
  if ("error" in rendered)
849
1143
  return [...results, { pass: false, line: `${component} does not render on the server: ${rendered.error}` }];
1144
+ if (rendered.name !== wanted)
1145
+ return [...results, shape];
850
1146
  const [html, again] = rendered.html;
851
1147
  results.push({ pass: true, line: `${rendered.name} renders on the server` });
852
1148
  const broken = ["NaN", "Infinity"].filter((word) => html.includes(word));
853
1149
  results.push({ pass: broken.length === 0, line: broken.length ? `the markup has ${broken.join(" and ")} in it` : "the markup has no NaN or Infinity" });
854
1150
  results.push({ pass: html === again, line: html === again ? "two renders give the same markup" : "two renders give different markup" });
1151
+ const outside = markupSafety(html);
1152
+ if (outside.length)
1153
+ results.push(...outside.map((line) => ({ pass: false, line })));
1154
+ else
1155
+ results.push({ pass: true, line: "the markup has no scripts or outside links" });
855
1156
  const shown = brands(textOf(html));
856
1157
  results.push({ pass: shown.length === 0, line: shown.length ? `the rendered text names brands: ${shown.join(", ")}` : "the rendered text names no brands" });
857
1158
  const name = accessibleName(html);
@@ -1010,7 +1311,7 @@ function openPullRequest(change, runner, cwd) {
1010
1311
  const clone = join8(work, "vectoys");
1011
1312
  must(runner, "gh", ["repo", "clone", `${login}/vectoys`, clone, "--", "--depth", "1"], work);
1012
1313
  must(runner, "git", ["checkout", "-b", change.branch], clone);
1013
- change.write(clone);
1314
+ change.write(clone, runner);
1014
1315
  must(runner, "git", ["add", "--all"], clone);
1015
1316
  must(runner, "git", ["commit", "-m", change.title], clone);
1016
1317
  const ref = `refs/heads/${change.branch}`;
@@ -1035,20 +1336,38 @@ function steps(change, own) {
1035
1336
  `Open a pull request to ${UPSTREAM} main, or update the one already open from the branch`
1036
1337
  ];
1037
1338
  }
1339
+ var CI_BUN = "1.3.14";
1340
+ var MIN_BUN = "1.3.2";
1341
+ var OLD_BUN = `Update bun to ${CI_BUN}, the version CI uses, and submit again.`;
1342
+ function atLeast(have, want) {
1343
+ const parts = (version2) => (/^(\d+)\.(\d+)\.(\d+)/.exec(version2) ?? []).slice(1).map(Number);
1344
+ const [a, b] = [parts(have), parts(want)];
1345
+ if (a.length !== 3)
1346
+ return false;
1347
+ for (let at = 0;at < 3; at++)
1348
+ if ((a[at] ?? 0) !== (b[at] ?? 0))
1349
+ return (a[at] ?? 0) > (b[at] ?? 0);
1350
+ return true;
1351
+ }
1352
+ function needBun(runner, cwd) {
1353
+ const version2 = must(runner, "bun", ["--version"], cwd);
1354
+ if (!atLeast(version2, MIN_BUN))
1355
+ throw new Error(`bun ${version2} is older than ${MIN_BUN} and rewrites bun.lock beyond the item's lines. ${OLD_BUN}`);
1356
+ }
1038
1357
  function itemFiles(dir, listed) {
1039
- const extra = ["package.json", "tsconfig.json", "test"].filter((path) => existsSync8(join8(dir, path)));
1358
+ const extra = ["package.json", "tsconfig.json", "src/index.ts", "test"].filter((path) => existsSync8(join8(dir, path)));
1040
1359
  return [...new Set(["item.json", ...listed, ...extra])];
1041
1360
  }
1042
- var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1361
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1043
1362
  function inRepoForm(folder) {
1044
1363
  const pkgPath = join8(folder, "package.json");
1045
1364
  if (existsSync8(pkgPath)) {
1046
1365
  const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
1047
- if (!isRecord(pkg))
1366
+ if (!isRecord2(pkg))
1048
1367
  throw new Error("The item's package.json is not an object.");
1049
1368
  for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
1050
1369
  const deps = pkg[field];
1051
- if (!isRecord(deps))
1370
+ if (!isRecord2(deps))
1052
1371
  continue;
1053
1372
  for (const name of Object.keys(deps))
1054
1373
  if (name.startsWith("@vectoys/") || name === "@vinena/vectoys" || name.startsWith("@vinena/vectoys-"))
@@ -1062,9 +1381,9 @@ function inRepoForm(folder) {
1062
1381
  const tsPath = join8(folder, "tsconfig.json");
1063
1382
  if (existsSync8(tsPath)) {
1064
1383
  const tsconfig = JSON.parse(readFileSync8(tsPath, "utf8"));
1065
- if (!isRecord(tsconfig))
1384
+ if (!isRecord2(tsconfig))
1066
1385
  throw new Error("The item's tsconfig.json is not an object.");
1067
- const own = isRecord(tsconfig.compilerOptions) ? tsconfig.compilerOptions : {};
1386
+ const own = isRecord2(tsconfig.compilerOptions) ? tsconfig.compilerOptions : {};
1068
1387
  const kept = Object.fromEntries(Object.entries(own).filter(([key]) => !(key in compilerOptions2)));
1069
1388
  writeFileSync6(tsPath, `${JSON.stringify({ extends: BASE_TSCONFIG, ...tsconfig, compilerOptions: kept }, null, 2)}
1070
1389
  `);
@@ -1102,14 +1421,19 @@ function itemChange(context, dir) {
1102
1421
  branch,
1103
1422
  title,
1104
1423
  body,
1105
- steps: steps({ branch, title }, [`Copy ${files.join(", ")} into registry/${item.name}/`]),
1106
- write: (clone) => {
1424
+ steps: steps({ branch, title }, [`Copy ${files.join(", ")} into registry/${item.name}/`, "Add the item's package to bun.lock with bun install --lockfile-only"]),
1425
+ write: (clone, runner) => {
1107
1426
  for (const file of files) {
1108
1427
  const to = join8(clone, "registry", item.name, file);
1109
1428
  mkdirSync3(dirname5(to), { recursive: true });
1110
1429
  cpSync(join8(dir, file), to, { recursive: true });
1111
1430
  }
1112
1431
  inRepoForm(join8(clone, "registry", item.name));
1432
+ must(runner, "bun", ["install", "--lockfile-only", "--ignore-scripts"], clone);
1433
+ const deleted = must(runner, "git", ["diff", "--numstat", "bun.lock"], clone).split(`
1434
+ `).some((line) => Number(line.split("\t")[1]) > 0);
1435
+ if (deleted)
1436
+ throw new Error(`bun ${must(runner, "bun", ["--version"], clone)} rewrote bun.lock beyond the item's lines. ${OLD_BUN}`);
1113
1437
  }
1114
1438
  };
1115
1439
  }
@@ -1139,22 +1463,22 @@ function isListings(value) {
1139
1463
  return Array.isArray(value) && value.every((entry) => typeof entry === "object" && entry !== null && ("name" in entry) && typeof entry.name === "string");
1140
1464
  }
1141
1465
  async function listingChange(context, given, runner) {
1142
- const { index, base } = registryUrls(given);
1466
+ const { index, base: base2 } = registryUrls(given);
1143
1467
  context.log(`Reading ${index}`);
1144
1468
  const read = readIndex(await fetchJson(index));
1145
1469
  if (typeof read === "string")
1146
1470
  throw new Error(`${index} is not a vectoys registry index: ${read}.`);
1147
1471
  const { items } = read;
1148
- const name = read.name ?? new URL(base).hostname;
1472
+ const name = read.name ?? new URL(base2).hostname;
1149
1473
  context.log(` pass it lists ${items} item${items === 1 ? "" : "s"}`);
1150
1474
  const author = runner("git", ["config", "user.name"], context.cwd).stdout.trim();
1151
1475
  if (!author)
1152
1476
  throw new Error('Set your name for git first, as git config --global user.name "Your Name".');
1153
- const listing = { name, url: base, author };
1477
+ const listing = { name, url: base2, author };
1154
1478
  const branch = `registry/${name}`;
1155
1479
  const title = `List the ${name} registry`;
1156
1480
  const body = [
1157
- `Lists the ${name} registry, at ${base}, by ${author}. Its index lists ${items} item${items === 1 ? "" : "s"}.`,
1481
+ `Lists the ${name} registry, at ${base2}, by ${author}. Its index lists ${items} item${items === 1 ? "" : "s"}.`,
1158
1482
  "",
1159
1483
  "- [ ] I run this registry, and its items draw no maker's product."
1160
1484
  ].join(`
@@ -1191,6 +1515,8 @@ async function submit(context, given, options) {
1191
1515
  context.log(CLOSED);
1192
1516
  return;
1193
1517
  }
1518
+ if (!options.registry)
1519
+ needBun(runner, context.cwd);
1194
1520
  const { url, opened } = openPullRequest(change, runner, context.cwd);
1195
1521
  context.log(`${opened ? "Opened" : "Updated"} ${url}`);
1196
1522
  return url;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vinena/vectoys-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Copy vectoys devices and toys into your project.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/vinenastudio/vectoys#readme",