@vinena/vectoys-cli 0.1.0 → 0.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/SKILL.md +9 -9
- package/dist/index.js +217 -83
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -11,7 +11,7 @@ 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
|
|
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.
|
|
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
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.
|
|
@@ -20,7 +20,7 @@ vectoys draws 3D objects as plain SVG paths. An item is a small React component
|
|
|
20
20
|
|
|
21
21
|
Units are scene units. The camera's `width` (100 by default) and `height` are the SVG viewBox. Pick a scale such as `const mm = (v: number) => v * 0.4` and write real sizes.
|
|
22
22
|
|
|
23
|
-
Axes. A part's own plane is x right, y down, z out of its top face. The model is x across, y
|
|
23
|
+
Axes. A part's own plane is x right, y down, z out of its top face. The model is x across, y toward the front, z up. A part lying flat faces up. Stand it upright toward the eye with `rotation: rotateX(-90)`.
|
|
24
24
|
|
|
25
25
|
Parts (`@vinena/vectoys`):
|
|
26
26
|
|
|
@@ -35,7 +35,7 @@ Poses and joints. A `Pose` is `{ position: [x, y, z], rotation }`. Build rotatio
|
|
|
35
35
|
|
|
36
36
|
Camera. `camera({ width, height, perspective, yaw, pitch, roll, anchorX, anchorY, back, originX, originY })`. `perspective` about 280 to 400 for a scene 100 wide. `yaw` turns the model about its up axis and `pitch` tips it to or from the eye. A device at rest usually sits near `yaw: -24, pitch: -16`.
|
|
37
37
|
|
|
38
|
-
Drawing. `draw(scene, eye)` returns `{ faces, hulls }`: the faces that face the eye, far to near, and each part's silhouette. Parts sort by `order` first, then by depth. Leave `order` out unless two parts draw in the wrong order: parts that interlock, or one that swings past another's
|
|
38
|
+
Drawing. `draw(scene, eye)` returns `{ faces, hulls }`: the faces that face the eye, far to near, and each part's silhouette. Parts sort by `order` first, then by depth. Leave `order` out unless two parts draw in the wrong order: parts that interlock, or one that swings past another's center (a lid over its base). Then give the part in front a higher `order`.
|
|
39
39
|
|
|
40
40
|
`footprint(scene, eye, { gap, spread, ahead })` is the shadow on the ground under the model. Draw it first, blurred if you like. `poses(scene)` maps each part's name to its pose in the scene, for laying a screen on a child part.
|
|
41
41
|
|
|
@@ -45,13 +45,13 @@ Keep `seen(...)` in `geometry.ts`: one pure function from the item's state (open
|
|
|
45
45
|
|
|
46
46
|
`<Faces faces hulls styles shading />` draws what `draw` returned. `styles` maps a role to SVG path props: `top`, `side`, `bottom`, a plate's `role`, or `"part.role"` for one part only (`"body.top"`, `"knot.side"`). Write fills as `style: { fill }`.
|
|
47
47
|
|
|
48
|
-
Take
|
|
48
|
+
Take colors from the page's CSS variables, each with a fallback so the item shows before the page sets them:
|
|
49
49
|
|
|
50
50
|
- `--vectoys-finish`: the main material. Expose a `finish` prop that sets it.
|
|
51
51
|
- `--vectoys-surface`, `--vectoys-hairline`, `--vectoys-shadow`, `--vectoys-accent`, `--vectoys-ink`, `--vectoys-bg`. Add an item variable such as `--vectoys-paper` only when none of these fits.
|
|
52
52
|
- `--vectoys-focus` and `--vectoys-focus-width` for focus rings.
|
|
53
53
|
|
|
54
|
-
Mix tones with `color-mix(in srgb, ...)` instead of new
|
|
54
|
+
Mix tones with `color-mix(in srgb, ...)` instead of new colors. `shading` darkens faces turned from the light and lifts the few turned right at it. The page tunes it with `--vectoys-shade-strength` (0.5), `--vectoys-shade-color`, `--vectoys-highlight-strength` (0.25) and `--vectoys-highlight`. Draw a hairline outline over the main body's hull with `vectorEffect: "non-scaling-stroke"`. Check the item on a dark page too.
|
|
55
55
|
|
|
56
56
|
## Live HTML on a face
|
|
57
57
|
|
|
@@ -75,21 +75,21 @@ The same props must give the same markup on the server and the client, every tim
|
|
|
75
75
|
## Accessibility
|
|
76
76
|
|
|
77
77
|
- A picture: the root is `role="img"` with an `aria-label` (Stage takes `role` and `label`). With live content inside, use `role="group"` so its links stay reachable.
|
|
78
|
-
- Interactive: use a real `<button>` or `<a href>` with a label, laid over the item's bounds. Give it a visible `:focus-visible` ring in `--vectoys-focus`. Enter and Space
|
|
78
|
+
- Interactive: use a real `<button>` or `<a href>` with a label, laid over the item's bounds. Give it a visible `:focus-visible` ring in `--vectoys-focus`. A `<button>` must do on Enter and Space what a click does, and an `<a href>` on Enter: both already do this natively, so do not call `preventDefault` on those keys or the click is cancelled. A link does nothing on Space; if it acts as a button, make it a `<button>`.
|
|
79
79
|
- Respect reduced motion: the state still changes, only without the motion.
|
|
80
80
|
- Start hover effects only when `event.pointerType === "mouse"`, so a tap does not stick in the hover state.
|
|
81
81
|
|
|
82
82
|
## Rules
|
|
83
83
|
|
|
84
84
|
- No brand, maker or product names (no trademarked device names) in item.json, the code or the rendered text. Make a generic phone, not a named one.
|
|
85
|
-
- Keep the files under
|
|
85
|
+
- Keep the files under 96 KB together. Most items need far less; the limit leaves room for a device item with a full 3D model and a hand-drawn front.
|
|
86
86
|
- React 19. Depend only on `@vinena/vectoys`, `@vinena/vectoys-react` and React. No other packages.
|
|
87
87
|
- TypeScript strict. One component per item, named after the item (`my-toy` exports `MyToy`).
|
|
88
88
|
- Props: `label`, `finish`, `yaw` (and `pitch`) for a device that turns, and the state as controlled plus default (`open` and `defaultOpen` with `onOpenChange`), as the registry items do.
|
|
89
89
|
|
|
90
90
|
## Example
|
|
91
91
|
|
|
92
|
-
A badge that lifts
|
|
92
|
+
A badge that lifts toward the mouse and shows live HTML on its face. It passes all 8 checks.
|
|
93
93
|
|
|
94
94
|
`src/geometry.ts`:
|
|
95
95
|
|
|
@@ -136,7 +136,7 @@ const SHADOW = "var(--vectoys-shadow, rgba(0, 0, 0, 0.08))";
|
|
|
136
136
|
const fill = (color: string): Look => ({ style: { fill: color } });
|
|
137
137
|
const STYLES: Record<string, Look> = { top: fill(FINISH), side: fill(FINISH), bottom: fill(FINISH), face: fill(SURFACE) };
|
|
138
138
|
|
|
139
|
-
/** A badge that lifts and turns
|
|
139
|
+
/** A badge that lifts and turns toward a mouse over it, with live HTML on its face. */
|
|
140
140
|
export function Badge({ children, label = "Badge" }: { children?: ReactNode; label?: string }) {
|
|
141
141
|
const [over, setOver] = useState(false);
|
|
142
142
|
// Springs to its target each frame; under reduced motion it jumps there.
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { resolve as
|
|
4
|
+
import { resolve as resolve7 } from "node:path";
|
|
5
5
|
// package.json
|
|
6
|
-
var version = "0.
|
|
6
|
+
var version = "0.2.0";
|
|
7
7
|
|
|
8
8
|
// src/args.ts
|
|
9
9
|
var VALUES = new Map([
|
|
@@ -58,9 +58,9 @@ function parseArgs(argv) {
|
|
|
58
58
|
|
|
59
59
|
// src/check.ts
|
|
60
60
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
61
|
-
import { existsSync as
|
|
61
|
+
import { existsSync as existsSync6, lstatSync, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
62
62
|
import { tmpdir } from "node:os";
|
|
63
|
-
import { basename, extname, join as
|
|
63
|
+
import { basename, extname, join as join6, relative as relative3, resolve as resolve4, sep as sep3 } from "node:path";
|
|
64
64
|
|
|
65
65
|
// ../registry-build/src/index.ts
|
|
66
66
|
function checkItem(value, file) {
|
|
@@ -76,8 +76,8 @@ function checkItem(value, file) {
|
|
|
76
76
|
|
|
77
77
|
// src/commands.ts
|
|
78
78
|
import { spawnSync } from "node:child_process";
|
|
79
|
-
import { existsSync as
|
|
80
|
-
import { dirname, relative, resolve, sep } from "node:path";
|
|
79
|
+
import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
|
|
80
|
+
import { dirname as dirname3, relative, resolve as resolve2, sep } from "node:path";
|
|
81
81
|
|
|
82
82
|
// src/config.ts
|
|
83
83
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -149,9 +149,121 @@ function shared(dirs) {
|
|
|
149
149
|
return first.slice(0, length).join("/") || ".";
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// src/imports.ts
|
|
153
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
154
|
+
import { dirname, isAbsolute, join as join2, posix as posix2, resolve } from "node:path";
|
|
155
|
+
var NODE = new Set(["node16", "node18", "node20", "nodenext"]);
|
|
156
|
+
function importExtension(cwd) {
|
|
157
|
+
const path = nearest(cwd, "tsconfig.json");
|
|
158
|
+
if (!path)
|
|
159
|
+
return;
|
|
160
|
+
const options = compilerOptions(path, new Set);
|
|
161
|
+
const resolution = lower(options.moduleResolution) ?? (NODE.has(lower(options.module) ?? "") ? "nodenext" : undefined);
|
|
162
|
+
if (!resolution || !NODE.has(resolution))
|
|
163
|
+
return;
|
|
164
|
+
return options.allowImportingTsExtensions === true ? "ts" : "js";
|
|
165
|
+
}
|
|
166
|
+
var SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(\.\.?\/[^"'\n]*)\2/g;
|
|
167
|
+
var SOURCE = /\.tsx?$/;
|
|
168
|
+
function withExtensions(files, extension) {
|
|
169
|
+
if (!extension)
|
|
170
|
+
return files;
|
|
171
|
+
const paths = new Set(files.map((file) => file.to));
|
|
172
|
+
return files.map((file) => {
|
|
173
|
+
if (!SOURCE.test(file.to))
|
|
174
|
+
return file;
|
|
175
|
+
const content = file.content.replace(SPECIFIER, (match, before, quote, specifier) => {
|
|
176
|
+
const base = posix2.join(posix2.dirname(file.to), specifier).replace(/\/$/, "");
|
|
177
|
+
if (paths.has(base))
|
|
178
|
+
return match;
|
|
179
|
+
const suffix = ["", "/index"].flatMap((index) => [`${index}.ts`, `${index}.tsx`]).find((candidate) => paths.has(base + candidate));
|
|
180
|
+
if (!suffix)
|
|
181
|
+
return match;
|
|
182
|
+
const named = extension === "ts" ? suffix : suffix.replace(SOURCE, ".js");
|
|
183
|
+
return `${before}${quote}${specifier.replace(/\/$/, "")}${named}${quote}`;
|
|
184
|
+
});
|
|
185
|
+
return { ...file, content };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function nearest(from, name) {
|
|
189
|
+
for (let dir = from;; dir = dirname(dir)) {
|
|
190
|
+
if (existsSync2(join2(dir, name)))
|
|
191
|
+
return join2(dir, name);
|
|
192
|
+
if (dirname(dir) === dir)
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function compilerOptions(path, seen) {
|
|
197
|
+
if (seen.has(path) || !existsSync2(path))
|
|
198
|
+
return {};
|
|
199
|
+
let config;
|
|
200
|
+
try {
|
|
201
|
+
config = JSON.parse(jsonc(readFileSync2(path, "utf8")));
|
|
202
|
+
} catch {
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
const parents = (Array.isArray(config.extends) ? config.extends : [config.extends]).filter((parent) => typeof parent === "string");
|
|
206
|
+
const inherited = parents.reduce((options, parent) => {
|
|
207
|
+
const file = extended(path, parent);
|
|
208
|
+
return file ? { ...options, ...compilerOptions(file, new Set([...seen, path])) } : options;
|
|
209
|
+
}, {});
|
|
210
|
+
return { ...inherited, ...config.compilerOptions };
|
|
211
|
+
}
|
|
212
|
+
function extended(path, parent) {
|
|
213
|
+
const named = (file) => [file, `${file}.json`].find((candidate) => existsSync2(candidate) && statSync(candidate).isFile());
|
|
214
|
+
if (/^\.\.?\//.test(parent) || isAbsolute(parent))
|
|
215
|
+
return named(resolve(dirname(path), parent));
|
|
216
|
+
for (let dir = dirname(path);; dir = dirname(dir)) {
|
|
217
|
+
const base = join2(dir, "node_modules", parent);
|
|
218
|
+
const found = named(base) ?? named(join2(base, packageConfig(base)));
|
|
219
|
+
if (found)
|
|
220
|
+
return found;
|
|
221
|
+
if (dirname(dir) === dir)
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function packageConfig(folder) {
|
|
226
|
+
try {
|
|
227
|
+
const { tsconfig } = JSON.parse(readFileSync2(join2(folder, "package.json"), "utf8"));
|
|
228
|
+
return typeof tsconfig === "string" ? tsconfig : "tsconfig.json";
|
|
229
|
+
} catch {
|
|
230
|
+
return "tsconfig.json";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function jsonc(text) {
|
|
234
|
+
let out = "";
|
|
235
|
+
for (let i = 0;i < text.length; i++) {
|
|
236
|
+
const char = text[i];
|
|
237
|
+
if (char === '"') {
|
|
238
|
+
let end = i + 1;
|
|
239
|
+
while (end < text.length && text[end] !== '"')
|
|
240
|
+
end += text[end] === "\\" ? 2 : 1;
|
|
241
|
+
out += text.slice(i, end + 1);
|
|
242
|
+
i = end;
|
|
243
|
+
} else if (char === "/" && text[i + 1] === "/") {
|
|
244
|
+
while (i < text.length && text[i] !== `
|
|
245
|
+
`)
|
|
246
|
+
i++;
|
|
247
|
+
out += `
|
|
248
|
+
`;
|
|
249
|
+
} else if (char === "/" && text[i + 1] === "*") {
|
|
250
|
+
const end = text.indexOf("*/", i + 2);
|
|
251
|
+
i = end === -1 ? text.length : end + 1;
|
|
252
|
+
} else if (char === "}" || char === "]") {
|
|
253
|
+
out = out.replace(/,\s*$/, "") + char;
|
|
254
|
+
} else {
|
|
255
|
+
out += char;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
function lower(value) {
|
|
261
|
+
return typeof value === "string" ? value.toLowerCase() : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
152
264
|
// src/pm.ts
|
|
153
|
-
import { existsSync as
|
|
154
|
-
import { join as
|
|
265
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
266
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
155
267
|
var LOCKFILES = [
|
|
156
268
|
["bun.lock", "bun"],
|
|
157
269
|
["bun.lockb", "bun"],
|
|
@@ -164,16 +276,37 @@ function isPackageSpec(value) {
|
|
|
164
276
|
return typeof value === "string" && SPEC.test(value);
|
|
165
277
|
}
|
|
166
278
|
function detect(cwd) {
|
|
167
|
-
|
|
279
|
+
for (let dir = cwd;; dir = dirname2(dir)) {
|
|
280
|
+
const pkg = readPackage(join3(dir, "package.json"));
|
|
281
|
+
const named = typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0] : undefined;
|
|
282
|
+
if (named === "bun" || named === "pnpm" || named === "yarn" || named === "npm")
|
|
283
|
+
return named;
|
|
284
|
+
const locked = LOCKFILES.find(([file]) => existsSync3(join3(dir, file)))?.[1];
|
|
285
|
+
if (locked)
|
|
286
|
+
return locked;
|
|
287
|
+
if (existsSync3(join3(dir, "pnpm-workspace.yaml")))
|
|
288
|
+
return "pnpm";
|
|
289
|
+
if (pkg?.packageManager || pkg?.workspaces || existsSync3(join3(dir, ".git")) || dirname2(dir) === dir)
|
|
290
|
+
return "npm";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function readPackage(path) {
|
|
294
|
+
if (!existsSync3(path))
|
|
295
|
+
return;
|
|
296
|
+
try {
|
|
297
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
168
301
|
}
|
|
169
302
|
function installCommand(pm, packages) {
|
|
170
303
|
return [pm, [pm === "npm" ? "install" : "add", ...packages]];
|
|
171
304
|
}
|
|
172
305
|
function missing(cwd, wanted) {
|
|
173
|
-
const path =
|
|
174
|
-
if (!
|
|
306
|
+
const path = join3(cwd, "package.json");
|
|
307
|
+
if (!existsSync3(path))
|
|
175
308
|
return wanted;
|
|
176
|
-
const pkg = JSON.parse(
|
|
309
|
+
const pkg = JSON.parse(readFileSync3(path, "utf8"));
|
|
177
310
|
const have = new Set(["dependencies", "devDependencies", "peerDependencies"].flatMap((field) => Object.keys(pkg[field] ?? {})));
|
|
178
311
|
return wanted.filter((spec) => !have.has(packageName(spec)));
|
|
179
312
|
}
|
|
@@ -186,10 +319,10 @@ function packageName(spec) {
|
|
|
186
319
|
import { createInterface } from "node:readline";
|
|
187
320
|
function confirm(question) {
|
|
188
321
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
189
|
-
return new Promise((
|
|
322
|
+
return new Promise((resolve2) => {
|
|
190
323
|
rl.question(`${question} (y/N) `, (answer) => {
|
|
191
324
|
rl.close();
|
|
192
|
-
|
|
325
|
+
resolve2(/^y(es)?$/i.test(answer.trim()));
|
|
193
326
|
});
|
|
194
327
|
});
|
|
195
328
|
}
|
|
@@ -200,13 +333,13 @@ function itemUrl(spec, config, registry = config.registry) {
|
|
|
200
333
|
return spec;
|
|
201
334
|
const scoped = /^(@[^/]+)\/(.+)$/.exec(spec);
|
|
202
335
|
if (scoped)
|
|
203
|
-
return
|
|
336
|
+
return join4(registryFor(scoped[1], config), scoped[2]);
|
|
204
337
|
if (!/^[\w.-]+$/.test(spec))
|
|
205
338
|
throw new Error(`"${spec}" is not an item name, an @scope/name or a URL.`);
|
|
206
|
-
return
|
|
339
|
+
return join4(registry, spec);
|
|
207
340
|
}
|
|
208
341
|
function indexUrl(scope, config, registry = config.registry) {
|
|
209
|
-
return
|
|
342
|
+
return join4(scope ? registryFor(scope, config) : registry, "index");
|
|
210
343
|
}
|
|
211
344
|
function registryFor(scope, config) {
|
|
212
345
|
const registry = config.registries[scope];
|
|
@@ -215,7 +348,7 @@ function registryFor(scope, config) {
|
|
|
215
348
|
}
|
|
216
349
|
return registry;
|
|
217
350
|
}
|
|
218
|
-
function
|
|
351
|
+
function join4(registry, name) {
|
|
219
352
|
if (registry.includes("{name}"))
|
|
220
353
|
return registry.replace("{name}", name);
|
|
221
354
|
return `${registry.replace(/\/+$/, "")}/${name}.json`;
|
|
@@ -244,11 +377,12 @@ async function add(context, specs) {
|
|
|
244
377
|
const url = itemUrl(spec, config, context.registry);
|
|
245
378
|
return { url, item: check(await fetchJson(url), url) };
|
|
246
379
|
}));
|
|
247
|
-
const root =
|
|
380
|
+
const root = resolve2(context.cwd, config.dir);
|
|
381
|
+
const extension = importExtension(context.cwd);
|
|
248
382
|
const writes = items.flatMap(({ url, item }) => {
|
|
249
|
-
const folder =
|
|
250
|
-
return place(item.files).map((file) => {
|
|
251
|
-
const path =
|
|
383
|
+
const folder = resolve2(root, item.name);
|
|
384
|
+
return withExtensions(place(item.files), extension).map((file) => {
|
|
385
|
+
const path = resolve2(folder, file.to);
|
|
252
386
|
if (!folder.startsWith(root + sep) || !path.startsWith(folder + sep))
|
|
253
387
|
throw new Error(`${url} has a file that would land outside ${config.dir}.`);
|
|
254
388
|
return { path, content: file.content };
|
|
@@ -290,8 +424,8 @@ function check(value, url) {
|
|
|
290
424
|
}
|
|
291
425
|
async function write(context, path, content) {
|
|
292
426
|
const shown = relative(context.cwd, path);
|
|
293
|
-
if (
|
|
294
|
-
if (
|
|
427
|
+
if (existsSync4(path)) {
|
|
428
|
+
if (readFileSync4(path, "utf8") === content) {
|
|
295
429
|
context.log(`Same ${shown}`);
|
|
296
430
|
return;
|
|
297
431
|
}
|
|
@@ -301,7 +435,7 @@ async function write(context, path, content) {
|
|
|
301
435
|
return;
|
|
302
436
|
}
|
|
303
437
|
}
|
|
304
|
-
mkdirSync(
|
|
438
|
+
mkdirSync(dirname3(path), { recursive: true });
|
|
305
439
|
writeFileSync2(path, content);
|
|
306
440
|
context.log(`Wrote ${shown}`);
|
|
307
441
|
}
|
|
@@ -324,8 +458,8 @@ function installMissing(context, dependencies) {
|
|
|
324
458
|
|
|
325
459
|
// src/create.ts
|
|
326
460
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
327
|
-
import { existsSync as
|
|
328
|
-
import { dirname as
|
|
461
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "node:fs";
|
|
462
|
+
import { dirname as dirname4, join as join5, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
|
|
329
463
|
// ../../package.json
|
|
330
464
|
var devDependencies = {
|
|
331
465
|
"@happy-dom/global-registrator": "20.14.5",
|
|
@@ -334,7 +468,7 @@ var devDependencies = {
|
|
|
334
468
|
};
|
|
335
469
|
|
|
336
470
|
// ../../tsconfig.base.json
|
|
337
|
-
var
|
|
471
|
+
var compilerOptions2 = {
|
|
338
472
|
target: "ES2022",
|
|
339
473
|
lib: ["ES2022", "DOM"],
|
|
340
474
|
module: "ESNext",
|
|
@@ -360,19 +494,19 @@ function itemTsconfig(inRepo) {
|
|
|
360
494
|
const include = ["src", "test"];
|
|
361
495
|
if (inRepo)
|
|
362
496
|
return { extends: BASE_TSCONFIG, compilerOptions: { jsx: "react-jsx" }, include };
|
|
363
|
-
const { paths, rewriteRelativeImportExtensions, ...own } =
|
|
497
|
+
const { paths, rewriteRelativeImportExtensions, ...own } = compilerOptions2;
|
|
364
498
|
return { compilerOptions: { ...own, jsx: "react-jsx" }, include };
|
|
365
499
|
}
|
|
366
500
|
function inVectoysRepo(folder) {
|
|
367
|
-
for (let dir =
|
|
368
|
-
const pkg =
|
|
369
|
-
if (
|
|
370
|
-
const value = JSON.parse(
|
|
501
|
+
for (let dir = dirname4(folder);; dir = dirname4(dir)) {
|
|
502
|
+
const pkg = join5(dir, "package.json");
|
|
503
|
+
if (existsSync5(pkg) && existsSync5(join5(dir, "registry"))) {
|
|
504
|
+
const value = JSON.parse(readFileSync5(pkg, "utf8"));
|
|
371
505
|
const named = typeof value === "object" && value !== null && "name" in value && value.name === "vectoys-workspace";
|
|
372
506
|
if (named)
|
|
373
|
-
return folder.startsWith(
|
|
507
|
+
return folder.startsWith(join5(dir, "registry") + sep2);
|
|
374
508
|
}
|
|
375
|
-
if (
|
|
509
|
+
if (dirname4(dir) === dir)
|
|
376
510
|
return false;
|
|
377
511
|
}
|
|
378
512
|
}
|
|
@@ -439,7 +573,7 @@ export const HEIGHT = 40;
|
|
|
439
573
|
export const THICKNESS = 4;
|
|
440
574
|
const INSET = 6;
|
|
441
575
|
|
|
442
|
-
/** How the slab turns
|
|
576
|
+
/** How the slab turns toward the eye at rest, in degrees. */
|
|
443
577
|
export const TILT = { yaw: -24, pitch: -16 } as const;
|
|
444
578
|
|
|
445
579
|
function parts(pose: Pose): Part[] {
|
|
@@ -470,7 +604,7 @@ export type View = ReturnType<typeof seen>;
|
|
|
470
604
|
import { useMemo, type CSSProperties } from "react";
|
|
471
605
|
import { TILT, seen } from "./geometry";
|
|
472
606
|
|
|
473
|
-
// The page's
|
|
607
|
+
// The page's colors, as CSS variables, each with a tone so the item shows as
|
|
474
608
|
// meant before they are set.
|
|
475
609
|
const HAIRLINE = "var(--vectoys-hairline, #8a8a8a)";
|
|
476
610
|
const SURFACE = "var(--vectoys-surface, #f5f5f5)";
|
|
@@ -494,17 +628,17 @@ const OUTLINE: Look = {
|
|
|
494
628
|
export type ${component}Props = {
|
|
495
629
|
/** What the item shows, for assistive technology. */
|
|
496
630
|
label?: string;
|
|
497
|
-
/** The slab's
|
|
631
|
+
/** The slab's color; by default the page's \`--vectoys-finish\`. */
|
|
498
632
|
finish?: string;
|
|
499
633
|
/** How far it turns about its up axis, in degrees. */
|
|
500
634
|
yaw?: number;
|
|
501
|
-
/** How far it tips
|
|
635
|
+
/** How far it tips toward or away from the eye, in degrees. */
|
|
502
636
|
pitch?: number;
|
|
503
637
|
className?: string;
|
|
504
638
|
style?: CSSProperties;
|
|
505
639
|
};
|
|
506
640
|
|
|
507
|
-
/** ${title}: a slab tilted
|
|
641
|
+
/** ${title}: a slab tilted toward the eye, drawn the same on the server and the client. */
|
|
508
642
|
export function ${component}({ label = ${JSON.stringify(title)}, finish, yaw = TILT.yaw, pitch = TILT.pitch, className, style }: ${component}Props) {
|
|
509
643
|
const view = useMemo(() => seen(yaw, pitch), [yaw, pitch]);
|
|
510
644
|
const body = view.drawn.hulls.find((hull) => hull.part === "body");
|
|
@@ -540,13 +674,13 @@ function create(context, name, under) {
|
|
|
540
674
|
throw new Error('Name the new item, as "vectoys create spinner".');
|
|
541
675
|
if (!ITEM_NAME.test(name))
|
|
542
676
|
throw new Error(`"${name}" is not an item name. Use letters, digits, dots, dashes and underscores.`);
|
|
543
|
-
const folder =
|
|
677
|
+
const folder = resolve3(context.cwd, under ?? ".", name);
|
|
544
678
|
const shown = relative2(context.cwd, folder) || ".";
|
|
545
|
-
if (
|
|
679
|
+
if (existsSync5(folder) && !context.overwrite)
|
|
546
680
|
throw new Error(`${shown} is already there. Pass --overwrite to write over it.`);
|
|
547
681
|
for (const [path, content] of Object.entries(scaffold(name, gitAuthor(context.cwd), inVectoysRepo(folder)))) {
|
|
548
|
-
const to =
|
|
549
|
-
mkdirSync2(
|
|
682
|
+
const to = join5(folder, path);
|
|
683
|
+
mkdirSync2(dirname4(to), { recursive: true });
|
|
550
684
|
writeFileSync3(to, content);
|
|
551
685
|
context.log(`Wrote ${relative2(context.cwd, to)}`);
|
|
552
686
|
}
|
|
@@ -573,7 +707,7 @@ var BRANDS = [
|
|
|
573
707
|
"nintendo"
|
|
574
708
|
];
|
|
575
709
|
var BRAND = new RegExp(`\\b(${BRANDS.map((brand) => brand.replace(" ", "\\s+")).join("|")})\\b`, "gi");
|
|
576
|
-
var MAX_SOURCE =
|
|
710
|
+
var MAX_SOURCE = 96 * 1024;
|
|
577
711
|
function brands(text) {
|
|
578
712
|
return [...new Set([...text.matchAll(BRAND)].map((match) => match[0].toLowerCase().replace(/\s+/g, " ")))];
|
|
579
713
|
}
|
|
@@ -605,9 +739,9 @@ function isRendered(value) {
|
|
|
605
739
|
return "name" in value && typeof value.name === "string" && "html" in value && Array.isArray(value.html) && value.html.length === 2 && value.html.every((html) => typeof html === "string");
|
|
606
740
|
}
|
|
607
741
|
function runInBun(script, entry, dir, wanted) {
|
|
608
|
-
const work = mkdtempSync(
|
|
742
|
+
const work = mkdtempSync(join6(tmpdir(), "vectoys-check-"));
|
|
609
743
|
try {
|
|
610
|
-
const runner =
|
|
744
|
+
const runner = join6(work, "render.mjs");
|
|
611
745
|
writeFileSync4(runner, script);
|
|
612
746
|
const result = spawnSync3("bun", [runner, entry, dir, wanted], { cwd: dir, encoding: "utf8", timeout: 60000, maxBuffer: 64 * 1024 * 1024 });
|
|
613
747
|
if (result.error) {
|
|
@@ -661,12 +795,12 @@ function accessibleName(html) {
|
|
|
661
795
|
function itemProblem(item, dir) {
|
|
662
796
|
if (!ITEM_NAME.test(item.name))
|
|
663
797
|
return `"${item.name}" is not an item name`;
|
|
664
|
-
const file = item.files.find((path) => typeof path !== "string" || !
|
|
798
|
+
const file = item.files.find((path) => typeof path !== "string" || !resolve4(dir, path).startsWith(dir + sep3) || !existsSync6(resolve4(dir, path)));
|
|
665
799
|
if (file !== undefined)
|
|
666
800
|
return `files lists ${JSON.stringify(file)}, which is not a file in the folder`;
|
|
667
|
-
const other = item.files.find((path) => !lstatSync(
|
|
801
|
+
const other = item.files.find((path) => !lstatSync(resolve4(dir, path)).isFile());
|
|
668
802
|
if (other !== undefined) {
|
|
669
|
-
const stat = lstatSync(
|
|
803
|
+
const stat = lstatSync(resolve4(dir, other));
|
|
670
804
|
const kind = stat.isSymbolicLink() ? "a symlink, " : stat.isDirectory() ? "a folder, " : "";
|
|
671
805
|
return `files lists ${JSON.stringify(other)}, which is ${kind}not a regular file`;
|
|
672
806
|
}
|
|
@@ -676,12 +810,12 @@ function itemProblem(item, dir) {
|
|
|
676
810
|
return;
|
|
677
811
|
}
|
|
678
812
|
function readItem(dir) {
|
|
679
|
-
const path =
|
|
680
|
-
if (!
|
|
813
|
+
const path = join6(dir, "item.json");
|
|
814
|
+
if (!existsSync6(path))
|
|
681
815
|
return "there is no item.json";
|
|
682
816
|
let value;
|
|
683
817
|
try {
|
|
684
|
-
value = JSON.parse(
|
|
818
|
+
value = JSON.parse(readFileSync6(path, "utf8"));
|
|
685
819
|
} catch (error) {
|
|
686
820
|
return `item.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`;
|
|
687
821
|
}
|
|
@@ -702,7 +836,7 @@ function checks(dir) {
|
|
|
702
836
|
if (typeof item === "string")
|
|
703
837
|
return [{ pass: false, line: item }];
|
|
704
838
|
const results = [{ pass: true, line: "item.json has what the registry needs" }];
|
|
705
|
-
const size = item.files.reduce((sum, file) => sum + lstatSync(
|
|
839
|
+
const size = item.files.reduce((sum, file) => sum + lstatSync(resolve4(dir, file)).size, 0);
|
|
706
840
|
const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
707
841
|
results.push({ pass: size < MAX_SOURCE, line: `the files are ${kb(size)}, ${size < MAX_SOURCE ? "under" : "over"} ${kb(MAX_SOURCE)}` });
|
|
708
842
|
const named = brands([item.name, item.title, item.description].join(" "));
|
|
@@ -710,7 +844,7 @@ function checks(dir) {
|
|
|
710
844
|
const component = componentFile(item);
|
|
711
845
|
if (!component)
|
|
712
846
|
return [...results, { pass: false, line: "files lists no .tsx component to render" }];
|
|
713
|
-
const rendered = render(
|
|
847
|
+
const rendered = render(resolve4(dir, component), dir, componentName(item.name));
|
|
714
848
|
if ("error" in rendered)
|
|
715
849
|
return [...results, { pass: false, line: `${component} does not render on the server: ${rendered.error}` }];
|
|
716
850
|
const [html, again] = rendered.html;
|
|
@@ -725,7 +859,7 @@ function checks(dir) {
|
|
|
725
859
|
return results;
|
|
726
860
|
}
|
|
727
861
|
function check2(context, given) {
|
|
728
|
-
const dir =
|
|
862
|
+
const dir = resolve4(context.cwd, given ?? ".");
|
|
729
863
|
context.log(`Checking ${relative3(context.cwd, dir) || "."}`);
|
|
730
864
|
const results = checks(dir);
|
|
731
865
|
for (const result of results)
|
|
@@ -739,7 +873,7 @@ function check2(context, given) {
|
|
|
739
873
|
// src/preview.ts
|
|
740
874
|
import { spawn } from "node:child_process";
|
|
741
875
|
import { writeFileSync as writeFileSync5 } from "node:fs";
|
|
742
|
-
import { join as
|
|
876
|
+
import { join as join7, relative as relative4, resolve as resolve5 } from "node:path";
|
|
743
877
|
var YAWS = [-45, -20, 0, 20, 45];
|
|
744
878
|
var RUNNER2 = bunScript(`const render = (props) => renderToStaticMarkup(createElement(mod[name], props));
|
|
745
879
|
const yaws = ${JSON.stringify(YAWS)}.map((yaw) => ({ yaw, html: render({ yaw }) }));
|
|
@@ -810,19 +944,19 @@ function open(path) {
|
|
|
810
944
|
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
811
945
|
}
|
|
812
946
|
function preview(context, given, opening = false) {
|
|
813
|
-
const dir =
|
|
947
|
+
const dir = resolve5(context.cwd, given ?? ".");
|
|
814
948
|
const item = readItem(dir);
|
|
815
949
|
if (typeof item === "string")
|
|
816
950
|
throw new Error(`${relative4(context.cwd, dir) || "."} is not an item to preview: ${item}.`);
|
|
817
951
|
const component = componentFile(item);
|
|
818
952
|
if (!component)
|
|
819
953
|
throw new Error("item.json lists no .tsx component to render.");
|
|
820
|
-
const ran = runInBun(RUNNER2,
|
|
954
|
+
const ran = runInBun(RUNNER2, resolve5(dir, component), dir, componentName(item.name));
|
|
821
955
|
if ("error" in ran)
|
|
822
956
|
throw new Error(`${component} does not render on the server: ${ran.error}`);
|
|
823
957
|
if (!isPreview(ran.value))
|
|
824
958
|
throw new Error("bun gave back something other than markup.");
|
|
825
|
-
const path =
|
|
959
|
+
const path = join7(dir, "preview.html");
|
|
826
960
|
writeFileSync5(path, previewPage(item.title, ran.value));
|
|
827
961
|
context.log(path);
|
|
828
962
|
if (opening)
|
|
@@ -831,20 +965,20 @@ function preview(context, given, opening = false) {
|
|
|
831
965
|
}
|
|
832
966
|
|
|
833
967
|
// src/skill.ts
|
|
834
|
-
import { existsSync as
|
|
968
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
|
|
835
969
|
import { fileURLToPath } from "node:url";
|
|
836
970
|
var SKILL = fileURLToPath(new URL("../SKILL.md", import.meta.url));
|
|
837
971
|
function skill(context, path = SKILL) {
|
|
838
|
-
if (!
|
|
972
|
+
if (!existsSync7(path))
|
|
839
973
|
throw new Error(`${path} is missing. Reinstall vectoys.`);
|
|
840
|
-
context.log(
|
|
974
|
+
context.log(readFileSync7(path, "utf8").trimEnd());
|
|
841
975
|
}
|
|
842
976
|
|
|
843
977
|
// src/submit.ts
|
|
844
978
|
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
845
|
-
import { cpSync, existsSync as
|
|
979
|
+
import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "node:fs";
|
|
846
980
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
847
|
-
import { dirname as
|
|
981
|
+
import { dirname as dirname5, join as join8, relative as relative5, resolve as resolve6 } from "node:path";
|
|
848
982
|
var SUBMISSIONS_OPEN = false;
|
|
849
983
|
var UPSTREAM = "vinenastudio/vectoys";
|
|
850
984
|
var CLOSED = "Submissions open when vectoys launches. Nothing was sent.";
|
|
@@ -871,9 +1005,9 @@ function openPullRequest(change, runner, cwd) {
|
|
|
871
1005
|
const login = must(runner, "gh", ["api", "user", "--jq", ".login"], cwd);
|
|
872
1006
|
must(runner, "gh", ["repo", "fork", UPSTREAM, "--clone=false"], cwd);
|
|
873
1007
|
must(runner, "gh", ["repo", "sync", `${login}/vectoys`, "--branch", "main"], cwd);
|
|
874
|
-
const work = mkdtempSync2(
|
|
1008
|
+
const work = mkdtempSync2(join8(tmpdir2(), "vectoys-submit-"));
|
|
875
1009
|
try {
|
|
876
|
-
const clone =
|
|
1010
|
+
const clone = join8(work, "vectoys");
|
|
877
1011
|
must(runner, "gh", ["repo", "clone", `${login}/vectoys`, clone, "--", "--depth", "1"], work);
|
|
878
1012
|
must(runner, "git", ["checkout", "-b", change.branch], clone);
|
|
879
1013
|
change.write(clone);
|
|
@@ -902,14 +1036,14 @@ function steps(change, own) {
|
|
|
902
1036
|
];
|
|
903
1037
|
}
|
|
904
1038
|
function itemFiles(dir, listed) {
|
|
905
|
-
const extra = ["package.json", "tsconfig.json", "test"].filter((path) =>
|
|
1039
|
+
const extra = ["package.json", "tsconfig.json", "test"].filter((path) => existsSync8(join8(dir, path)));
|
|
906
1040
|
return [...new Set(["item.json", ...listed, ...extra])];
|
|
907
1041
|
}
|
|
908
1042
|
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
909
1043
|
function inRepoForm(folder) {
|
|
910
|
-
const pkgPath =
|
|
911
|
-
if (
|
|
912
|
-
const pkg = JSON.parse(
|
|
1044
|
+
const pkgPath = join8(folder, "package.json");
|
|
1045
|
+
if (existsSync8(pkgPath)) {
|
|
1046
|
+
const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
|
|
913
1047
|
if (!isRecord(pkg))
|
|
914
1048
|
throw new Error("The item's package.json is not an object.");
|
|
915
1049
|
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
@@ -925,13 +1059,13 @@ function inRepoForm(folder) {
|
|
|
925
1059
|
writeFileSync6(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
926
1060
|
`);
|
|
927
1061
|
}
|
|
928
|
-
const tsPath =
|
|
929
|
-
if (
|
|
930
|
-
const tsconfig = JSON.parse(
|
|
1062
|
+
const tsPath = join8(folder, "tsconfig.json");
|
|
1063
|
+
if (existsSync8(tsPath)) {
|
|
1064
|
+
const tsconfig = JSON.parse(readFileSync8(tsPath, "utf8"));
|
|
931
1065
|
if (!isRecord(tsconfig))
|
|
932
1066
|
throw new Error("The item's tsconfig.json is not an object.");
|
|
933
1067
|
const own = isRecord(tsconfig.compilerOptions) ? tsconfig.compilerOptions : {};
|
|
934
|
-
const kept = Object.fromEntries(Object.entries(own).filter(([key]) => !(key in
|
|
1068
|
+
const kept = Object.fromEntries(Object.entries(own).filter(([key]) => !(key in compilerOptions2)));
|
|
935
1069
|
writeFileSync6(tsPath, `${JSON.stringify({ extends: BASE_TSCONFIG, ...tsconfig, compilerOptions: kept }, null, 2)}
|
|
936
1070
|
`);
|
|
937
1071
|
}
|
|
@@ -971,11 +1105,11 @@ function itemChange(context, dir) {
|
|
|
971
1105
|
steps: steps({ branch, title }, [`Copy ${files.join(", ")} into registry/${item.name}/`]),
|
|
972
1106
|
write: (clone) => {
|
|
973
1107
|
for (const file of files) {
|
|
974
|
-
const to =
|
|
975
|
-
mkdirSync3(
|
|
976
|
-
cpSync(
|
|
1108
|
+
const to = join8(clone, "registry", item.name, file);
|
|
1109
|
+
mkdirSync3(dirname5(to), { recursive: true });
|
|
1110
|
+
cpSync(join8(dir, file), to, { recursive: true });
|
|
977
1111
|
}
|
|
978
|
-
inRepoForm(
|
|
1112
|
+
inRepoForm(join8(clone, "registry", item.name));
|
|
979
1113
|
}
|
|
980
1114
|
};
|
|
981
1115
|
}
|
|
@@ -1031,8 +1165,8 @@ async function listingChange(context, given, runner) {
|
|
|
1031
1165
|
body,
|
|
1032
1166
|
steps: steps({ branch, title }, [`Add ${JSON.stringify(listing)} to registries.json, sorted by name`]),
|
|
1033
1167
|
write: (clone) => {
|
|
1034
|
-
const path =
|
|
1035
|
-
const found =
|
|
1168
|
+
const path = join8(clone, "registries.json");
|
|
1169
|
+
const found = existsSync8(path) ? JSON.parse(readFileSync8(path, "utf8")) : [];
|
|
1036
1170
|
if (!isListings(found))
|
|
1037
1171
|
throw new Error("registries.json in the fork is not a list of registries.");
|
|
1038
1172
|
writeFileSync6(path, `${JSON.stringify(listed(found, listing), null, 2)}
|
|
@@ -1042,7 +1176,7 @@ async function listingChange(context, given, runner) {
|
|
|
1042
1176
|
}
|
|
1043
1177
|
async function submit(context, given, options) {
|
|
1044
1178
|
const runner = options.run ?? run;
|
|
1045
|
-
const change = options.registry ? await listingChange(context, options.registry, runner) : itemChange(context,
|
|
1179
|
+
const change = options.registry ? await listingChange(context, options.registry, runner) : itemChange(context, resolve6(context.cwd, given ?? "."));
|
|
1046
1180
|
if (!(options.submissionsOpen ?? SUBMISSIONS_OPEN) || options.dryRun) {
|
|
1047
1181
|
context.log("");
|
|
1048
1182
|
context.log("submit would:");
|
|
@@ -1101,7 +1235,7 @@ async function main(argv) {
|
|
|
1101
1235
|
if (flags.help || !command)
|
|
1102
1236
|
return console.log(HELP);
|
|
1103
1237
|
const context = {
|
|
1104
|
-
cwd:
|
|
1238
|
+
cwd: resolve7(flags.cwd ?? process.cwd()),
|
|
1105
1239
|
registry: flags.registry ?? (process.env.VECTOYS_REGISTRY || undefined),
|
|
1106
1240
|
registryFlag: flags.registry,
|
|
1107
1241
|
yes: flags.yes,
|