@shortwind/cli 0.1.0-beta.0 → 0.1.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/{bench-a_9WmuOE.js → bench-BGTQAha8.js} +618 -86
- package/dist/bench-BGTQAha8.js.map +1 -0
- package/dist/bin.js +75 -5
- package/dist/bin.js.map +1 -1
- package/dist/catalog.generated-B_ds7MPV.js +83 -0
- package/dist/catalog.generated-B_ds7MPV.js.map +1 -0
- package/dist/index.d.ts +55 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/package.json +13 -3
- package/dist/bench-a_9WmuOE.js.map +0 -1
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
5
|
-
import { buildRegistry, parseRecipeFile, renderSkillMarkdown } from "@shortwind/core";
|
|
6
|
+
import { PLACEHOLDER_SHA, RECIPE_SHA_HEX_LENGTH, buildRegistry, isReservedRecipeName, normalizeRecipeBody, parseRecipeFile, renderSkillMarkdown } from "@shortwind/core";
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import chokidar from "chokidar";
|
|
9
8
|
import { glob } from "tinyglobby";
|
|
9
|
+
import chokidar from "chokidar";
|
|
10
10
|
import { Tiktoken } from "js-tiktoken/lite";
|
|
11
11
|
import cl100k_base from "js-tiktoken/ranks/cl100k_base";
|
|
12
|
-
import { loadRegistryFromDir, transformContent } from "@shortwind/tailwind";
|
|
12
|
+
import { loadRegistryFromDir, modeForFile, transformContent } from "@shortwind/tailwind";
|
|
13
13
|
//#region src/fingerprint.ts
|
|
14
14
|
const HEADER_PATTERN = /^\/\*\s*shortwind:\s+(\S+)@(\S+)\s+sha:([^\s*]+)(?:\s+—\s+DO NOT EDIT THIS LINE)?\s*\*\/\s*$/;
|
|
15
15
|
function extractHeader(source) {
|
|
@@ -26,12 +26,19 @@ function bodyAfterHeader(source) {
|
|
|
26
26
|
const eol = source.indexOf("\n");
|
|
27
27
|
return eol === -1 ? "" : source.slice(eol + 1);
|
|
28
28
|
}
|
|
29
|
-
function normalizeBody(body) {
|
|
30
|
-
return body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").map((line) => line.replace(/[\t ]+$/, "")).join("\n");
|
|
31
|
-
}
|
|
32
29
|
function computeBodySha(source) {
|
|
33
|
-
const normalized =
|
|
34
|
-
return createHash("sha256").update(normalized).digest("hex").slice(0,
|
|
30
|
+
const normalized = normalizeRecipeBody(bodyAfterHeader(source));
|
|
31
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, RECIPE_SHA_HEX_LENGTH);
|
|
32
|
+
}
|
|
33
|
+
function isLegacyFingerprint(sha) {
|
|
34
|
+
return sha !== PLACEHOLDER_SHA && sha.length < RECIPE_SHA_HEX_LENGTH && /^[0-9a-f]+$/.test(sha);
|
|
35
|
+
}
|
|
36
|
+
function verifyFetchedFamily(source, family) {
|
|
37
|
+
const header = extractHeader(source);
|
|
38
|
+
if (!header || header.sha === PLACEHOLDER_SHA) return;
|
|
39
|
+
if (header.family !== family) throw new Error(`integrity check failed for "${family}": registry returned a recipe sealed as "${header.family}" — wrong family or a tampered/corrupted response`);
|
|
40
|
+
const actual = computeBodySha(source);
|
|
41
|
+
if (header.sha !== actual) throw new Error(`integrity check failed for "${family}": header sha ${header.sha} does not match content sha ${actual} — the registry response was tampered with or corrupted in transit`);
|
|
35
42
|
}
|
|
36
43
|
function buildHeaderLine(family, version, sha) {
|
|
37
44
|
return `/* shortwind: ${family}@${version} sha:${sha} — DO NOT EDIT THIS LINE */`;
|
|
@@ -51,13 +58,20 @@ function sealRecipeFile(source, family, version) {
|
|
|
51
58
|
}
|
|
52
59
|
//#endregion
|
|
53
60
|
//#region src/detect.ts
|
|
61
|
+
function parsePackageJson(pkgPath) {
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new Error(`${pkgPath}: invalid JSON — ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
54
71
|
function detectProject(cwd) {
|
|
55
72
|
const pkgPath = path.join(cwd, "package.json");
|
|
56
73
|
const hasPackageJson = existsSync(pkgPath);
|
|
57
|
-
const pkg = hasPackageJson ?
|
|
58
|
-
dependencies: {},
|
|
59
|
-
devDependencies: {}
|
|
60
|
-
};
|
|
74
|
+
const pkg = hasPackageJson ? parsePackageJson(pkgPath) : {};
|
|
61
75
|
const deps = {
|
|
62
76
|
...pkg.dependencies ?? {},
|
|
63
77
|
...pkg.devDependencies ?? {}
|
|
@@ -113,9 +127,64 @@ function assertValidFamilyName(family) {
|
|
|
113
127
|
if (!FAMILY_RE.test(family)) throw new Error(`invalid family name: ${JSON.stringify(family)} (must match ${FAMILY_RE})`);
|
|
114
128
|
}
|
|
115
129
|
function createRegistrySource(origin) {
|
|
116
|
-
if (origin.startsWith("http://")
|
|
130
|
+
if (origin.startsWith("http://")) {
|
|
131
|
+
console.warn(`[shortwind] registry origin ${origin} uses plaintext http:// — recipe content is unauthenticated and tamperable in transit; prefer https://`);
|
|
132
|
+
return httpSource(origin);
|
|
133
|
+
}
|
|
134
|
+
if (origin.startsWith("https://")) return httpSource(origin);
|
|
117
135
|
return fileSource(origin);
|
|
118
136
|
}
|
|
137
|
+
async function resolveSource(origin) {
|
|
138
|
+
if (origin && origin !== "bundled:@shortwind/catalog") return createRegistrySource(origin);
|
|
139
|
+
return defaultCatalogSource();
|
|
140
|
+
}
|
|
141
|
+
const CATALOG_PACKAGE = "@shortwind/catalog";
|
|
142
|
+
const NPM_TIMEOUT_MS = 3e3;
|
|
143
|
+
const FETCH_TIMEOUT_MS = 1e4;
|
|
144
|
+
async function defaultCatalogSource() {
|
|
145
|
+
try {
|
|
146
|
+
const cdn = httpSource(`https://cdn.jsdelivr.net/npm/${CATALOG_PACKAGE}@${await resolveCatalogVersion()}/dist/registry`);
|
|
147
|
+
await cdn.loadPresets();
|
|
148
|
+
return cdn;
|
|
149
|
+
} catch {
|
|
150
|
+
return bundledSource();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function resolveCatalogVersion() {
|
|
154
|
+
const res = await fetch(`https://registry.npmjs.org/${CATALOG_PACKAGE}`, {
|
|
155
|
+
signal: AbortSignal.timeout(NPM_TIMEOUT_MS),
|
|
156
|
+
headers: { accept: "application/vnd.npm.install-v1+json" }
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) throw new Error(`npm: ${res.status}`);
|
|
159
|
+
const tags = (await res.json())["dist-tags"] ?? {};
|
|
160
|
+
const version = tags["latest"] ?? tags["beta"];
|
|
161
|
+
if (!version) throw new Error("no published catalog version");
|
|
162
|
+
return version;
|
|
163
|
+
}
|
|
164
|
+
const BUNDLED_ORIGIN = "bundled:@shortwind/catalog";
|
|
165
|
+
function bundledSource() {
|
|
166
|
+
let cache = null;
|
|
167
|
+
const load = () => {
|
|
168
|
+
cache ??= import("./catalog.generated-B_ds7MPV.js");
|
|
169
|
+
return cache;
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
origin: BUNDLED_ORIGIN,
|
|
173
|
+
async loadPresets() {
|
|
174
|
+
return (await load()).CATALOG_PRESETS;
|
|
175
|
+
},
|
|
176
|
+
async loadFamily(family) {
|
|
177
|
+
assertValidFamilyName(family);
|
|
178
|
+
const { CATALOG_RECIPES } = await load();
|
|
179
|
+
const css = CATALOG_RECIPES[family];
|
|
180
|
+
if (css === void 0) throw new Error(`unknown family: ${family}`);
|
|
181
|
+
return css;
|
|
182
|
+
},
|
|
183
|
+
async listAllFamilies() {
|
|
184
|
+
return [...(await load()).CATALOG_FAMILIES];
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
119
188
|
function fileSource(origin) {
|
|
120
189
|
const root = origin.startsWith("file://") ? fileURLToPath(origin) : origin;
|
|
121
190
|
return {
|
|
@@ -136,21 +205,26 @@ function fileSource(origin) {
|
|
|
136
205
|
}
|
|
137
206
|
function httpSource(origin) {
|
|
138
207
|
const base = origin.replace(/\/+$/, "");
|
|
208
|
+
const get = (url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
209
|
+
let presetsCache = null;
|
|
139
210
|
return {
|
|
140
211
|
origin,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
212
|
+
loadPresets() {
|
|
213
|
+
presetsCache ??= (async () => {
|
|
214
|
+
const res = await get(`${base}/presets.json`);
|
|
215
|
+
if (!res.ok) throw new Error(`presets.json: ${res.status} ${res.statusText}`);
|
|
216
|
+
return await res.json();
|
|
217
|
+
})();
|
|
218
|
+
return presetsCache;
|
|
145
219
|
},
|
|
146
220
|
async loadFamily(family) {
|
|
147
221
|
assertValidFamilyName(family);
|
|
148
|
-
const res = await
|
|
222
|
+
const res = await get(`${base}/recipes/${family}.css`);
|
|
149
223
|
if (!res.ok) throw new Error(`${family}.css: ${res.status} ${res.statusText}`);
|
|
150
224
|
return res.text();
|
|
151
225
|
},
|
|
152
226
|
async listAllFamilies() {
|
|
153
|
-
const res = await
|
|
227
|
+
const res = await get(`${base}/index.json`);
|
|
154
228
|
if (!res.ok) throw new Error(`index.json: ${res.status} ${res.statusText}`);
|
|
155
229
|
return (await res.json()).families.filter((name) => FAMILY_RE.test(name));
|
|
156
230
|
}
|
|
@@ -207,18 +281,277 @@ async function writeLockfile(recipesDir, lock) {
|
|
|
207
281
|
};
|
|
208
282
|
await writeFile(lockPath(recipesDir), JSON.stringify(sorted, null, 2) + "\n");
|
|
209
283
|
}
|
|
284
|
+
const THEME_BLOCK = `/* shortwind:theme — default tokens for the recipe catalog. Edit freely. */
|
|
285
|
+
@custom-variant dark (&:is(.dark *));
|
|
286
|
+
|
|
287
|
+
:root {
|
|
288
|
+
--radius: 0.625rem;
|
|
289
|
+
--background: oklch(1 0 0);
|
|
290
|
+
--foreground: oklch(0.145 0 0);
|
|
291
|
+
--card: oklch(1 0 0);
|
|
292
|
+
--card-foreground: oklch(0.145 0 0);
|
|
293
|
+
--popover: oklch(1 0 0);
|
|
294
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
295
|
+
--primary: oklch(0.205 0 0);
|
|
296
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
297
|
+
--secondary: oklch(0.97 0 0);
|
|
298
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
299
|
+
--muted: oklch(0.97 0 0);
|
|
300
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
301
|
+
--accent: oklch(0.97 0 0);
|
|
302
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
303
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
304
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
305
|
+
--border: oklch(0.922 0 0);
|
|
306
|
+
--input: oklch(0.922 0 0);
|
|
307
|
+
--ring: oklch(0.708 0 0);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.dark {
|
|
311
|
+
--background: oklch(0.145 0 0);
|
|
312
|
+
--foreground: oklch(0.985 0 0);
|
|
313
|
+
--card: oklch(0.205 0 0);
|
|
314
|
+
--card-foreground: oklch(0.985 0 0);
|
|
315
|
+
--popover: oklch(0.205 0 0);
|
|
316
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
317
|
+
--primary: oklch(0.922 0 0);
|
|
318
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
319
|
+
--secondary: oklch(0.269 0 0);
|
|
320
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
321
|
+
--muted: oklch(0.269 0 0);
|
|
322
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
323
|
+
--accent: oklch(0.269 0 0);
|
|
324
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
325
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
326
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
327
|
+
--border: oklch(1 0 0 / 10%);
|
|
328
|
+
--input: oklch(1 0 0 / 15%);
|
|
329
|
+
--ring: oklch(0.556 0 0);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
@theme inline {
|
|
333
|
+
--color-background: var(--background);
|
|
334
|
+
--color-foreground: var(--foreground);
|
|
335
|
+
--color-card: var(--card);
|
|
336
|
+
--color-card-foreground: var(--card-foreground);
|
|
337
|
+
--color-popover: var(--popover);
|
|
338
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
339
|
+
--color-primary: var(--primary);
|
|
340
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
341
|
+
--color-secondary: var(--secondary);
|
|
342
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
343
|
+
--color-muted: var(--muted);
|
|
344
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
345
|
+
--color-accent: var(--accent);
|
|
346
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
347
|
+
--color-destructive: var(--destructive);
|
|
348
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
349
|
+
--color-border: var(--border);
|
|
350
|
+
--color-input: var(--input);
|
|
351
|
+
--color-ring: var(--ring);
|
|
352
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
353
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
354
|
+
--radius-lg: var(--radius);
|
|
355
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
@layer base {
|
|
359
|
+
body {
|
|
360
|
+
@apply bg-background text-foreground;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/* end shortwind theme */
|
|
364
|
+
`;
|
|
365
|
+
const TAILWIND_IMPORT_RE = /@import\s+["']tailwindcss["'][^;\n]*;?/;
|
|
366
|
+
async function scaffoldTheme(cwd) {
|
|
367
|
+
const cssFiles = await glob(["**/*.css"], {
|
|
368
|
+
cwd,
|
|
369
|
+
absolute: true,
|
|
370
|
+
onlyFiles: true,
|
|
371
|
+
ignore: [
|
|
372
|
+
"**/node_modules/**",
|
|
373
|
+
"**/dist/**",
|
|
374
|
+
"**/.next/**",
|
|
375
|
+
"**/.output/**",
|
|
376
|
+
"recipes/**"
|
|
377
|
+
]
|
|
378
|
+
});
|
|
379
|
+
for (const file of cssFiles) {
|
|
380
|
+
const source = await readFile(file, "utf8");
|
|
381
|
+
if (!TAILWIND_IMPORT_RE.test(source)) continue;
|
|
382
|
+
if (source.includes("/* shortwind:theme")) return {
|
|
383
|
+
themePath: file,
|
|
384
|
+
action: "skipped",
|
|
385
|
+
reason: "already scaffolded"
|
|
386
|
+
};
|
|
387
|
+
if (/--background\s*:/.test(source) || /@theme\b/.test(source)) return {
|
|
388
|
+
themePath: file,
|
|
389
|
+
action: "skipped",
|
|
390
|
+
reason: "project already defines a theme"
|
|
391
|
+
};
|
|
392
|
+
const m = source.match(TAILWIND_IMPORT_RE);
|
|
393
|
+
const at = (m.index ?? 0) + m[0].length;
|
|
394
|
+
await writeFile(file, source.slice(0, at) + "\n\n" + THEME_BLOCK + source.slice(at));
|
|
395
|
+
return {
|
|
396
|
+
themePath: file,
|
|
397
|
+
action: "injected"
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (!isTailwindV4(cwd)) return {
|
|
401
|
+
themePath: null,
|
|
402
|
+
action: "skipped",
|
|
403
|
+
reason: "no Tailwind v4 CSS entry found"
|
|
404
|
+
};
|
|
405
|
+
const target = path.join(cwd, "src", "index.css");
|
|
406
|
+
if (existsSync(target)) return {
|
|
407
|
+
themePath: target,
|
|
408
|
+
action: "skipped",
|
|
409
|
+
reason: "src/index.css exists without a tailwindcss import"
|
|
410
|
+
};
|
|
411
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
412
|
+
await writeFile(target, `@import "tailwindcss";\n\n${THEME_BLOCK}`);
|
|
413
|
+
return {
|
|
414
|
+
themePath: target,
|
|
415
|
+
action: "created"
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function isTailwindV4(cwd) {
|
|
419
|
+
try {
|
|
420
|
+
const pkg = JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf8"));
|
|
421
|
+
const m = (pkg.devDependencies?.["tailwindcss"] ?? pkg.dependencies?.["tailwindcss"] ?? "").match(/(\d+)/);
|
|
422
|
+
return m ? Number(m[1]) >= 4 : false;
|
|
423
|
+
} catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/bundler-config.ts
|
|
429
|
+
const VITE_CONFIGS = [
|
|
430
|
+
"vite.config.ts",
|
|
431
|
+
"vite.config.mts",
|
|
432
|
+
"vite.config.cts",
|
|
433
|
+
"vite.config.js",
|
|
434
|
+
"vite.config.mjs",
|
|
435
|
+
"vite.config.cjs"
|
|
436
|
+
];
|
|
437
|
+
const VITE_SNIPPET = [
|
|
438
|
+
`import { shortwind } from "@shortwind/vite";`,
|
|
439
|
+
`// add shortwind() to the Vite plugins array — it runs in the pre phase,`,
|
|
440
|
+
`// before Tailwind's scan:`,
|
|
441
|
+
`// plugins: [shortwind(), tailwindcss(), react()]`
|
|
442
|
+
].join("\n");
|
|
443
|
+
async function wireBundler(cwd, bundler) {
|
|
444
|
+
if (bundler === "vite") return wireVite(cwd);
|
|
445
|
+
if (bundler === "next") return {
|
|
446
|
+
configPath: null,
|
|
447
|
+
action: "manual",
|
|
448
|
+
snippet: `import { withShortwind } from "@shortwind/next";\n// wrap your Next config: export default withShortwind(nextConfig);`,
|
|
449
|
+
reason: "Next config wiring is manual"
|
|
450
|
+
};
|
|
451
|
+
if (bundler === "astro") return {
|
|
452
|
+
configPath: null,
|
|
453
|
+
action: "manual",
|
|
454
|
+
snippet: `import shortwind from "@shortwind/astro";\n// add to integrations: integrations: [shortwind()]`,
|
|
455
|
+
reason: "Astro config wiring is manual"
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
configPath: null,
|
|
459
|
+
action: "skipped",
|
|
460
|
+
reason: "no supported bundler detected"
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
async function wireVite(cwd) {
|
|
464
|
+
const configPath = VITE_CONFIGS.map((f) => path.join(cwd, f)).find((p) => existsSync(p));
|
|
465
|
+
if (!configPath) return {
|
|
466
|
+
configPath: null,
|
|
467
|
+
action: "manual",
|
|
468
|
+
snippet: VITE_SNIPPET,
|
|
469
|
+
reason: "no vite config found"
|
|
470
|
+
};
|
|
471
|
+
const source = await readFile(configPath, "utf8");
|
|
472
|
+
if (/@shortwind\/vite/.test(source)) return {
|
|
473
|
+
configPath,
|
|
474
|
+
action: "skipped",
|
|
475
|
+
reason: "plugin already wired"
|
|
476
|
+
};
|
|
477
|
+
const pluginsMatch = source.match(/plugins\s*:\s*\[/);
|
|
478
|
+
if (!pluginsMatch) return {
|
|
479
|
+
configPath,
|
|
480
|
+
action: "manual",
|
|
481
|
+
snippet: VITE_SNIPPET,
|
|
482
|
+
reason: "no plugins array found"
|
|
483
|
+
};
|
|
484
|
+
const withImport = addImport(source, `import { shortwind } from "@shortwind/vite";`);
|
|
485
|
+
const at = withImport.indexOf(pluginsMatch[0]) + pluginsMatch[0].length;
|
|
486
|
+
await writeFile(configPath, withImport.slice(0, at) + "shortwind(), " + withImport.slice(at));
|
|
487
|
+
return {
|
|
488
|
+
configPath,
|
|
489
|
+
action: "patched"
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function addImport(source, line) {
|
|
493
|
+
const importRe = /^[ \t]*import[\s\S]*?from\s+["'][^"']+["'];?[ \t]*$/gm;
|
|
494
|
+
let lastEnd = -1;
|
|
495
|
+
for (const m of source.matchAll(importRe)) lastEnd = (m.index ?? 0) + m[0].length;
|
|
496
|
+
if (lastEnd === -1) return `${line}\n${source}`;
|
|
497
|
+
return source.slice(0, lastEnd) + `\n${line}` + source.slice(lastEnd);
|
|
498
|
+
}
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/agents-file.ts
|
|
501
|
+
const MARKER = "skills/shortwind/SKILL.md";
|
|
502
|
+
function line(skillRel) {
|
|
503
|
+
return `For UI, prefer Shortwind \`@recipe\` class names (e.g. \`@card\`, \`@btn-primary\`, \`@row\`) over raw Tailwind where a recipe fits — full catalog in \`${skillRel}\`.`;
|
|
504
|
+
}
|
|
505
|
+
const CANDIDATES = ["AGENTS.md", "CLAUDE.md"];
|
|
506
|
+
async function wireAgentsInstructions(cwd, skillPath) {
|
|
507
|
+
const pointer = line(path.relative(cwd, skillPath).split(path.sep).join("/"));
|
|
508
|
+
let touched = null;
|
|
509
|
+
for (const name of CANDIDATES) {
|
|
510
|
+
const file = path.join(cwd, name);
|
|
511
|
+
if (!existsSync(file)) continue;
|
|
512
|
+
const current = await readFile(file, "utf8");
|
|
513
|
+
if (current.includes(MARKER)) {
|
|
514
|
+
touched ??= {
|
|
515
|
+
path: file,
|
|
516
|
+
action: "skipped"
|
|
517
|
+
};
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
await writeFile(file, current + (current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n") + pointer + "\n");
|
|
521
|
+
return {
|
|
522
|
+
path: file,
|
|
523
|
+
action: "appended"
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
if (touched) return touched;
|
|
527
|
+
const target = path.join(cwd, "AGENTS.md");
|
|
528
|
+
await writeFile(target, `# AGENTS.md\n\n${pointer}\n`);
|
|
529
|
+
return {
|
|
530
|
+
path: target,
|
|
531
|
+
action: "created"
|
|
532
|
+
};
|
|
533
|
+
}
|
|
210
534
|
//#endregion
|
|
211
535
|
//#region src/init.ts
|
|
212
|
-
const DEFAULT_REGISTRY =
|
|
536
|
+
const DEFAULT_REGISTRY = BUNDLED_ORIGIN;
|
|
213
537
|
async function init(options) {
|
|
214
538
|
const cwd = path.resolve(options.cwd);
|
|
215
|
-
const registry = options.registry ??
|
|
216
|
-
const source =
|
|
539
|
+
const registry = options.registry ?? DEFAULT_REGISTRY;
|
|
540
|
+
const source = await resolveSource(registry);
|
|
217
541
|
const shape = detectProject(cwd);
|
|
218
542
|
const families = await resolveFamilies(options.preset, source);
|
|
219
543
|
const pkgs = pickPackages(shape.bundler);
|
|
544
|
+
const version = cliVersion();
|
|
545
|
+
const specs = version ? pkgs.map((p) => `${p}@${version}`) : pkgs;
|
|
220
546
|
const installer = options.installPackages ?? defaultInstall;
|
|
221
|
-
|
|
547
|
+
let installOk = true;
|
|
548
|
+
let installError = null;
|
|
549
|
+
if (specs.length > 0) try {
|
|
550
|
+
await installer(shape.packageManager, specs, cwd);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
installOk = false;
|
|
553
|
+
installError = err instanceof Error ? err.message : String(err);
|
|
554
|
+
}
|
|
222
555
|
const recipesDir = path.join(cwd, "recipes");
|
|
223
556
|
const { installed, skipped } = await copyRecipes(source, families, recipesDir);
|
|
224
557
|
await updateLockfile(recipesDir, registry, installed);
|
|
@@ -233,6 +566,9 @@ async function init(options) {
|
|
|
233
566
|
await installHuskyHook(huskyPath);
|
|
234
567
|
const skillPath = path.join(cwd, "skills", "shortwind", "SKILL.md");
|
|
235
568
|
await writeSkillMd(skillPath, recipesDir, families);
|
|
569
|
+
const theme = await scaffoldTheme(cwd);
|
|
570
|
+
const bundlerConfig = await wireBundler(cwd, shape.bundler);
|
|
571
|
+
const agentsFile = await wireAgentsInstructions(cwd, skillPath);
|
|
236
572
|
return {
|
|
237
573
|
packageManager: shape.packageManager,
|
|
238
574
|
preset: options.preset,
|
|
@@ -244,13 +580,30 @@ async function init(options) {
|
|
|
244
580
|
configPath,
|
|
245
581
|
vscodePath,
|
|
246
582
|
huskyPath,
|
|
247
|
-
skillPath
|
|
583
|
+
skillPath,
|
|
584
|
+
themePath: theme.themePath,
|
|
585
|
+
themeAction: theme.action,
|
|
586
|
+
bundlerConfigPath: bundlerConfig.configPath,
|
|
587
|
+
bundlerConfigAction: bundlerConfig.action,
|
|
588
|
+
...bundlerConfig.snippet ? { bundlerConfigSnippet: bundlerConfig.snippet } : {},
|
|
589
|
+
agentsFilePath: agentsFile.path,
|
|
590
|
+
agentsFileAction: agentsFile.action,
|
|
591
|
+
installOk,
|
|
592
|
+
installError
|
|
248
593
|
};
|
|
249
594
|
}
|
|
250
595
|
async function resolveFamilies(preset, source) {
|
|
251
596
|
if (preset === "none") return [];
|
|
252
597
|
return resolvePresetFamilies(preset, await source.loadPresets(), await source.listAllFamilies());
|
|
253
598
|
}
|
|
599
|
+
function cliVersion() {
|
|
600
|
+
try {
|
|
601
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
602
|
+
return JSON.parse(readFileSync(fileURLToPath(pkgUrl), "utf8")).version ?? null;
|
|
603
|
+
} catch {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
254
607
|
function pickPackages(bundler) {
|
|
255
608
|
const base = ["@shortwind/tailwind"];
|
|
256
609
|
switch (bundler) {
|
|
@@ -322,6 +675,7 @@ async function copyRecipes(source, families, recipesDir) {
|
|
|
322
675
|
continue;
|
|
323
676
|
}
|
|
324
677
|
const body = await source.loadFamily(family);
|
|
678
|
+
verifyFetchedFamily(body, family);
|
|
325
679
|
await writeFile(target, rewriteHeaderSha(body, computeBodySha(body)));
|
|
326
680
|
installed.push(family);
|
|
327
681
|
}
|
|
@@ -340,8 +694,14 @@ async function writeConfig(configPath, next) {
|
|
|
340
694
|
await writeFile(configPath, JSON.stringify(desired, null, 2) + "\n");
|
|
341
695
|
return;
|
|
342
696
|
}
|
|
697
|
+
let current;
|
|
698
|
+
try {
|
|
699
|
+
current = JSON.parse(await readFile(configPath, "utf8"));
|
|
700
|
+
} catch (err) {
|
|
701
|
+
throw new Error(`${configPath}: invalid JSON — ${err.message}`);
|
|
702
|
+
}
|
|
343
703
|
const merged = {
|
|
344
|
-
...
|
|
704
|
+
...current !== null && typeof current === "object" && !Array.isArray(current) ? current : {},
|
|
345
705
|
...desired
|
|
346
706
|
};
|
|
347
707
|
await writeFile(configPath, JSON.stringify(merged, null, 2) + "\n");
|
|
@@ -373,9 +733,9 @@ async function installHuskyHook(huskyPath) {
|
|
|
373
733
|
await writeFile(huskyPath, current.endsWith("\n") ? current + HUSKY_LINE + "\n" : current + "\nnpx shortwind build\n", { mode: 493 });
|
|
374
734
|
}
|
|
375
735
|
async function writeSkillMd(skillPath, recipesDir, families) {
|
|
376
|
-
await mkdir(path.dirname(skillPath), { recursive: true });
|
|
377
736
|
const allRecipes = [];
|
|
378
737
|
const guidance = {};
|
|
738
|
+
const problems = [];
|
|
379
739
|
for (const family of families) {
|
|
380
740
|
const filePath = path.join(recipesDir, `${family}.css`);
|
|
381
741
|
if (!existsSync(filePath)) continue;
|
|
@@ -383,23 +743,33 @@ async function writeSkillMd(skillPath, recipesDir, families) {
|
|
|
383
743
|
if (parsed.ok) {
|
|
384
744
|
allRecipes.push(...parsed.value.recipes);
|
|
385
745
|
if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
|
|
386
|
-
}
|
|
746
|
+
} else problems.push(`${family}.css: ${parsed.errors.map((e) => e.message).join("; ")}`);
|
|
387
747
|
}
|
|
388
|
-
let registry = {
|
|
389
|
-
families: {},
|
|
390
|
-
flattened: {}
|
|
391
|
-
};
|
|
392
748
|
const resolved = buildRegistry(allRecipes, { guidance });
|
|
393
|
-
if (
|
|
394
|
-
|
|
749
|
+
if (problems.length > 0 || !resolved.ok) {
|
|
750
|
+
const all = resolved.ok ? problems : [...problems, ...resolved.errors.map((e) => e.message)];
|
|
751
|
+
console.warn(`[shortwind] SKILL.md not generated — recipe errors:\n ${all.join("\n ")}`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
await mkdir(path.dirname(skillPath), { recursive: true });
|
|
755
|
+
await writeFile(skillPath, renderSkillMarkdown(resolved.value, { order: families }));
|
|
395
756
|
}
|
|
396
757
|
//#endregion
|
|
397
758
|
//#region src/project.ts
|
|
398
759
|
const DEFAULT_CONFIG = {
|
|
399
|
-
registry:
|
|
760
|
+
registry: BUNDLED_ORIGIN,
|
|
400
761
|
recipesDir: "recipes",
|
|
401
762
|
outputPath: "skills/shortwind/SKILL.md"
|
|
402
763
|
};
|
|
764
|
+
function assertConfigString(value, field, configPath) {
|
|
765
|
+
if (typeof value !== "string") throw new Error(`${configPath}: "${field}" must be a string`);
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
function assertWithinCwd(cwd, value, field, configPath) {
|
|
769
|
+
const rel = path.relative(cwd, path.resolve(cwd, value));
|
|
770
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`${configPath}: "${field}" (${JSON.stringify(value)}) must be a path inside the project directory`);
|
|
771
|
+
return value;
|
|
772
|
+
}
|
|
403
773
|
async function readConfig(cwd) {
|
|
404
774
|
const configPath = path.join(cwd, "shortwind.config.json");
|
|
405
775
|
if (!existsSync(configPath)) return DEFAULT_CONFIG;
|
|
@@ -410,10 +780,16 @@ async function readConfig(cwd) {
|
|
|
410
780
|
} catch (err) {
|
|
411
781
|
throw new Error(`${configPath}: invalid JSON — ${err.message}`);
|
|
412
782
|
}
|
|
413
|
-
|
|
783
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${configPath}: expected a JSON object`);
|
|
784
|
+
const merged = {
|
|
414
785
|
...DEFAULT_CONFIG,
|
|
415
786
|
...parsed
|
|
416
787
|
};
|
|
788
|
+
return {
|
|
789
|
+
registry: assertConfigString(merged.registry, "registry", configPath),
|
|
790
|
+
recipesDir: assertWithinCwd(cwd, assertConfigString(merged.recipesDir, "recipesDir", configPath), "recipesDir", configPath),
|
|
791
|
+
outputPath: assertWithinCwd(cwd, assertConfigString(merged.outputPath, "outputPath", configPath), "outputPath", configPath)
|
|
792
|
+
};
|
|
417
793
|
}
|
|
418
794
|
function installedFamilies(recipesDir) {
|
|
419
795
|
if (!existsSync(recipesDir)) return [];
|
|
@@ -433,24 +809,25 @@ async function regenerateSkillMd(cwd, config) {
|
|
|
433
809
|
const recipesDir = path.join(cwd, config.recipesDir);
|
|
434
810
|
const families = installedFamilies(recipesDir);
|
|
435
811
|
const skillPath = path.join(cwd, config.outputPath);
|
|
436
|
-
const { mkdir } = await import("node:fs/promises");
|
|
437
|
-
await mkdir(path.dirname(skillPath), { recursive: true });
|
|
438
812
|
const allRecipes = [];
|
|
439
813
|
const guidance = {};
|
|
814
|
+
const problems = [];
|
|
440
815
|
for (const family of families) {
|
|
441
816
|
const parsed = parseRecipeFile(readFileSync(path.join(recipesDir, `${family}.css`), "utf8"), `${family}.css`);
|
|
442
817
|
if (parsed.ok) {
|
|
443
818
|
allRecipes.push(...parsed.value.recipes);
|
|
444
819
|
if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
|
|
445
|
-
}
|
|
820
|
+
} else problems.push(`${family}.css: ${parsed.errors.map((e) => e.message).join("; ")}`);
|
|
446
821
|
}
|
|
447
|
-
let registry = {
|
|
448
|
-
families: {},
|
|
449
|
-
flattened: {}
|
|
450
|
-
};
|
|
451
822
|
const resolved = buildRegistry(allRecipes, { guidance });
|
|
452
|
-
if (
|
|
453
|
-
|
|
823
|
+
if (problems.length > 0 || !resolved.ok) {
|
|
824
|
+
const all = resolved.ok ? problems : [...problems, ...resolved.errors.map((e) => e.message)];
|
|
825
|
+
console.warn(`[shortwind] SKILL.md not regenerated — fix these recipe errors first:\n ${all.join("\n ")}\n ${path.relative(cwd, skillPath)} left unchanged.`);
|
|
826
|
+
return skillPath;
|
|
827
|
+
}
|
|
828
|
+
const { mkdir } = await import("node:fs/promises");
|
|
829
|
+
await mkdir(path.dirname(skillPath), { recursive: true });
|
|
830
|
+
await writeFile(skillPath, renderSkillMarkdown(resolved.value, { order: families }));
|
|
454
831
|
return skillPath;
|
|
455
832
|
}
|
|
456
833
|
/**
|
|
@@ -476,7 +853,7 @@ async function add(options) {
|
|
|
476
853
|
const cwd = path.resolve(options.cwd);
|
|
477
854
|
const config = await readConfig(cwd);
|
|
478
855
|
const registry = options.registry ?? config.registry;
|
|
479
|
-
const source =
|
|
856
|
+
const source = await resolveSource(registry);
|
|
480
857
|
const recipesDir = path.join(cwd, config.recipesDir);
|
|
481
858
|
await mkdir(recipesDir, { recursive: true });
|
|
482
859
|
const lock = await readLockfile(recipesDir);
|
|
@@ -484,6 +861,7 @@ async function add(options) {
|
|
|
484
861
|
const requested = options.all ? await source.listAllFamilies() : options.families;
|
|
485
862
|
if (options.all && options.as) throw new Error("--as cannot be combined with --all");
|
|
486
863
|
if (options.as && requested.length !== 1) throw new Error("--as requires exactly one family argument");
|
|
864
|
+
if (options.as !== void 0) assertValidFamilyName(options.as);
|
|
487
865
|
const added = [];
|
|
488
866
|
const skipped = [];
|
|
489
867
|
const overwritten = [];
|
|
@@ -498,6 +876,7 @@ async function add(options) {
|
|
|
498
876
|
continue;
|
|
499
877
|
}
|
|
500
878
|
const sourceCss = await source.loadFamily(family);
|
|
879
|
+
verifyFetchedFamily(sourceCss, family);
|
|
501
880
|
const renamed = options.as ? renameFamilyInSource(sourceCss, family, options.as) : sourceCss;
|
|
502
881
|
const sha = computeBodySha(renamed);
|
|
503
882
|
const finalCss = rewriteHeaderSha(renamed, sha);
|
|
@@ -595,11 +974,92 @@ function collectBrokenDependents(recipesDir, removedRecipeNames) {
|
|
|
595
974
|
return out;
|
|
596
975
|
}
|
|
597
976
|
//#endregion
|
|
977
|
+
//#region src/commands/new.ts
|
|
978
|
+
var NewFamilyError = class extends Error {
|
|
979
|
+
constructor(message) {
|
|
980
|
+
super(message);
|
|
981
|
+
this.name = "NewFamilyError";
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
function template(family) {
|
|
985
|
+
return [
|
|
986
|
+
`/* shortwind: ${family}@0.0.1 sha:000000 */`,
|
|
987
|
+
``,
|
|
988
|
+
`/* @guide`,
|
|
989
|
+
` TODO: one or two lines on when to reach for these recipes, and which`,
|
|
990
|
+
` easy-to-confuse name to prefer. */`,
|
|
991
|
+
``,
|
|
992
|
+
`/* TODO: describe this recipe. */`,
|
|
993
|
+
`@recipe ${family} {`,
|
|
994
|
+
` p-4`,
|
|
995
|
+
`}`,
|
|
996
|
+
``
|
|
997
|
+
].join("\n");
|
|
998
|
+
}
|
|
999
|
+
async function newFamily(options) {
|
|
1000
|
+
assertValidFamilyName(options.family);
|
|
1001
|
+
const cwd = path.resolve(options.cwd);
|
|
1002
|
+
const config = await readConfig(cwd);
|
|
1003
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
1004
|
+
const familyPath = path.join(recipesDir, `${options.family}.css`);
|
|
1005
|
+
if (existsSync(familyPath) && !options.force) throw new NewFamilyError(`${path.join(config.recipesDir, `${options.family}.css`)} already exists (use --force to overwrite)`);
|
|
1006
|
+
await mkdir(recipesDir, { recursive: true });
|
|
1007
|
+
await writeFile(familyPath, template(options.family));
|
|
1008
|
+
return {
|
|
1009
|
+
familyPath,
|
|
1010
|
+
skillPath: await regenerateSkillMd(cwd, config)
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
//#endregion
|
|
1014
|
+
//#region src/commands/reseal.ts
|
|
1015
|
+
async function reseal(options) {
|
|
1016
|
+
const cwd = path.resolve(options.cwd);
|
|
1017
|
+
const config = await readConfig(cwd);
|
|
1018
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
1019
|
+
const families = options.families && options.families.length > 0 ? options.families : installedFamilies(recipesDir);
|
|
1020
|
+
const lock = await readLockfile(recipesDir);
|
|
1021
|
+
const resealed = [];
|
|
1022
|
+
const unchanged = [];
|
|
1023
|
+
const notFound = [];
|
|
1024
|
+
const noHeader = [];
|
|
1025
|
+
for (const family of families) {
|
|
1026
|
+
const file = path.join(recipesDir, `${family}.css`);
|
|
1027
|
+
if (!existsSync(file)) {
|
|
1028
|
+
notFound.push(family);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
const source = readFileSync(file, "utf8");
|
|
1032
|
+
const header = extractHeader(source);
|
|
1033
|
+
if (!header) {
|
|
1034
|
+
noHeader.push(family);
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const sha = computeBodySha(source);
|
|
1038
|
+
if (sha === header.sha && lock.families[family]?.sha === sha) {
|
|
1039
|
+
unchanged.push(family);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
await writeFile(file, rewriteHeaderSha(source, sha));
|
|
1043
|
+
lock.families[family] = {
|
|
1044
|
+
version: header.version,
|
|
1045
|
+
sha
|
|
1046
|
+
};
|
|
1047
|
+
resealed.push(family);
|
|
1048
|
+
}
|
|
1049
|
+
await writeLockfile(recipesDir, lock);
|
|
1050
|
+
return {
|
|
1051
|
+
resealed,
|
|
1052
|
+
unchanged,
|
|
1053
|
+
notFound,
|
|
1054
|
+
noHeader
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
//#endregion
|
|
598
1058
|
//#region src/commands/preset.ts
|
|
599
1059
|
async function preset(options) {
|
|
600
1060
|
const cwd = path.resolve(options.cwd);
|
|
601
1061
|
const config = await readConfig(cwd);
|
|
602
|
-
const source =
|
|
1062
|
+
const source = await resolveSource(options.registry ?? config.registry);
|
|
603
1063
|
if (options.name === "none") throw new Error("Use `shortwind remove` to uninstall families; preset 'none' is for `init` only.");
|
|
604
1064
|
const presets = await source.loadPresets();
|
|
605
1065
|
const all = await source.listAllFamilies();
|
|
@@ -628,7 +1088,7 @@ async function ls(options) {
|
|
|
628
1088
|
});
|
|
629
1089
|
let available = [];
|
|
630
1090
|
if (!options.installedOnly) {
|
|
631
|
-
const source =
|
|
1091
|
+
const source = await resolveSource(options.registry ?? config.registry);
|
|
632
1092
|
try {
|
|
633
1093
|
available = await source.listAllFamilies();
|
|
634
1094
|
} catch {
|
|
@@ -762,6 +1222,12 @@ async function dev(options) {
|
|
|
762
1222
|
timer = setTimeout(() => void runBuild(false), debounceMs);
|
|
763
1223
|
};
|
|
764
1224
|
watcher.on("add", schedule).on("change", schedule).on("unlink", schedule);
|
|
1225
|
+
watcher.on("error", (err) => {
|
|
1226
|
+
status({
|
|
1227
|
+
kind: "error",
|
|
1228
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
765
1231
|
let stopped = false;
|
|
766
1232
|
const stop = async () => {
|
|
767
1233
|
if (stopped) return;
|
|
@@ -802,7 +1268,7 @@ async function upgrade(options) {
|
|
|
802
1268
|
const cwd = path.resolve(options.cwd);
|
|
803
1269
|
const config = await readConfig(cwd);
|
|
804
1270
|
const registry = options.registry ?? config.registry;
|
|
805
|
-
const source = options.source ??
|
|
1271
|
+
const source = options.source ?? await resolveSource(registry);
|
|
806
1272
|
const recipesDir = path.join(cwd, config.recipesDir);
|
|
807
1273
|
const installed = installedFamilies(recipesDir);
|
|
808
1274
|
const targets = options.families && options.families.length > 0 ? options.families : installed;
|
|
@@ -831,6 +1297,7 @@ async function upgrade(options) {
|
|
|
831
1297
|
let incomingBody;
|
|
832
1298
|
try {
|
|
833
1299
|
incomingBody = await source.loadFamily(family);
|
|
1300
|
+
verifyFetchedFamily(incomingBody, family);
|
|
834
1301
|
} catch (err) {
|
|
835
1302
|
errors.push({
|
|
836
1303
|
family,
|
|
@@ -852,7 +1319,9 @@ async function upgrade(options) {
|
|
|
852
1319
|
const recordedSha = localHeader?.sha ?? "";
|
|
853
1320
|
const actualSha = computeBodySha(localBody);
|
|
854
1321
|
const lockedVersion = lock.families[family]?.version ?? localHeader?.version ?? "";
|
|
855
|
-
const
|
|
1322
|
+
const recordedIsLegacy = isLegacyFingerprint(recordedSha);
|
|
1323
|
+
if (recordedIsLegacy) console.warn(`[shortwind] ${family}.css uses an older fingerprint format — run \`shortwind reseal\` to upgrade its seal (the recipe body is unchanged).`);
|
|
1324
|
+
const isTouched = recordedSha !== "" && recordedSha !== actualSha && !recordedIsLegacy;
|
|
856
1325
|
if ((isTouched ? "touched" : lockedVersion === incomingVersion ? "unchanged" : "pristine") === "unchanged" && !isTouched) {
|
|
857
1326
|
outcomes.push({
|
|
858
1327
|
family,
|
|
@@ -949,8 +1418,9 @@ async function upgrade(options) {
|
|
|
949
1418
|
skillPath
|
|
950
1419
|
};
|
|
951
1420
|
}
|
|
1421
|
+
let atomicWriteSeq = 0;
|
|
952
1422
|
async function atomicWrite(filePath, body) {
|
|
953
|
-
const tmp = filePath
|
|
1423
|
+
const tmp = `${filePath}.${process.pid}.${atomicWriteSeq++}.tmp`;
|
|
954
1424
|
const fh = await open(tmp, "w");
|
|
955
1425
|
try {
|
|
956
1426
|
await fh.writeFile(body);
|
|
@@ -984,13 +1454,24 @@ async function verify(options) {
|
|
|
984
1454
|
continue;
|
|
985
1455
|
}
|
|
986
1456
|
const actual = computeBodySha(source);
|
|
987
|
-
if (header.sha !== actual)
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1457
|
+
if (header.sha !== actual) {
|
|
1458
|
+
if (isLegacyFingerprint(header.sha)) {
|
|
1459
|
+
issues.push({
|
|
1460
|
+
family,
|
|
1461
|
+
kind: "legacy-fingerprint",
|
|
1462
|
+
file: filePath,
|
|
1463
|
+
recorded: header.sha
|
|
1464
|
+
});
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
issues.push({
|
|
1468
|
+
family,
|
|
1469
|
+
kind: "header-tampered",
|
|
1470
|
+
file: filePath,
|
|
1471
|
+
recorded: header.sha,
|
|
1472
|
+
actual
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
994
1475
|
const locked = lock.families[family];
|
|
995
1476
|
if (!locked) issues.push({
|
|
996
1477
|
family,
|
|
@@ -1038,7 +1519,7 @@ const DEFAULT_RECIPES_CSS = {
|
|
|
1038
1519
|
"navigation.css": "/* shortwind: navigation@0.0.1 sha:000000 */\n\n/* @guide\n @nav is the container; links are @nav-link with @nav-link-active for the\n current page. Tabs mirror that pair: @tab and @tab-active. Use @breadcrumb\n for trail navigation. Active and inactive are separate recipes — swap the\n whole class rather than combining them.\n*/\n\n/* Top-level nav container. */\n@recipe nav {\n flex items-center gap-1\n}\n\n/* Inactive nav link with hover/focus states. */\n@recipe nav-link {\n inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\n}\n\n/* Active nav link. */\n@recipe nav-link-active {\n inline-flex items-center gap-2 rounded-md bg-muted px-3 py-1.5 text-sm font-medium text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\n}\n\n/* Breadcrumb trail container. */\n@recipe breadcrumb {\n flex items-center gap-1.5 text-sm text-muted-foreground\n}\n\n/* Inactive tab control. */\n@recipe tab {\n inline-flex items-center gap-2 border-b-2 border-transparent px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\n}\n\n/* Active tab control. */\n@recipe tab-active {\n inline-flex items-center gap-2 border-b-2 border-primary px-3 py-2 text-sm font-medium text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\n}\n",
|
|
1039
1520
|
"progress.css": "/* shortwind: progress@0.0.1 sha:000000 */\n\n/* @guide\n A bar is two pieces: @progress-track (the background) wrapping @progress-bar\n (the fill). For an indeterminate state use @spinner instead — it's a\n standalone loader, not a bar.\n*/\n\n/* Progress bar track (background). */\n@recipe progress-track {\n h-2 w-full overflow-hidden rounded-full bg-muted\n}\n\n/* Progress bar fill. */\n@recipe progress-bar {\n h-full rounded-full bg-primary transition-all\n}\n\n/* Indeterminate loading spinner. */\n@recipe spinner {\n inline-block h-4 w-4 animate-spin rounded-full border-2 border-border border-t-primary\n}\n",
|
|
1040
1521
|
"skeleton.css": "/* shortwind: skeleton@0.0.1 sha:000000 */\n\n/* @guide\n Match the skeleton to the shape it stands in for: @skeleton (block),\n @skeleton-text (a text line), @skeleton-circle (avatar/icon). Size block and\n text skeletons with raw width/height utilities.\n*/\n\n/* Default rectangular skeleton placeholder. */\n@recipe skeleton {\n animate-pulse rounded-md bg-muted\n}\n\n/* Single-line text skeleton. */\n@recipe skeleton-text {\n h-4 w-full animate-pulse rounded bg-muted\n}\n\n/* Circular skeleton (avatar/icon). */\n@recipe skeleton-circle {\n h-10 w-10 animate-pulse rounded-full bg-muted\n}\n",
|
|
1041
|
-
"surface.css": "/* shortwind: surface@0.0.1 sha:000000 */\n\n/* @guide\n @surface / @surface-muted / @surface-accent set a background+foreground pair\n for a region — one per section. @
|
|
1522
|
+
"surface.css": "/* shortwind: surface@0.0.1 sha:000000 */\n\n/* @guide\n @surface / @surface-muted / @surface-accent set a background+foreground pair\n for a region — one per section. @wrapper (or @wrapper-tight for prose)\n centers and width-caps content; there is no @wrapper-lg, set a different cap\n with max-w-* yourself. (Note: @container is reserved for Tailwind's\n container-query utility, so the content wrapper is @wrapper.) @divider-h and\n @divider-v are hairline rules.\n*/\n\n/* Default page/section surface. */\n@recipe surface {\n bg-background text-foreground\n}\n\n/* Muted surface — secondary background. */\n@recipe surface-muted {\n bg-muted text-foreground\n}\n\n/* Accent surface — soft brand background. */\n@recipe surface-accent {\n bg-accent text-accent-foreground\n}\n\n/* Centered content wrapper with a max width. */\n@recipe wrapper {\n mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8\n}\n\n/* Narrow content wrapper for prose. */\n@recipe wrapper-tight {\n mx-auto w-full max-w-3xl px-4 sm:px-6\n}\n\n/* Horizontal divider line. */\n@recipe divider-h {\n shrink-0 h-px w-full bg-border\n}\n\n/* Vertical divider line. */\n@recipe divider-v {\n shrink-0 h-full w-px bg-border\n}\n",
|
|
1042
1523
|
"table.css": "/* shortwind: table@0.0.1 sha:000000 */\n\n/* @guide\n Wrap the table in @table-container for horizontal overflow, then put @table\n (or @table-zebra for striped rows) on the <table>. Cells are @th (header) and\n @td (body); add @tr-hover to a <tr> for row highlighting.\n*/\n\n/* Scroll container for a wide table — keeps overflow horizontal. */\n@recipe table-container {\n w-full overflow-x-auto rounded-md border border-border\n}\n\n/* Data table base. */\n@recipe table {\n w-full border-collapse text-left text-sm text-foreground\n}\n\n/* Table header cell. */\n@recipe th {\n border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground\n}\n\n/* Table body cell. */\n@recipe td {\n border-b border-border px-3 py-2\n}\n\n/* Row hover state. */\n@recipe tr-hover {\n transition-colors hover:bg-muted\n}\n\n/* Table with zebra striping on alternating rows. */\n@recipe table-zebra {\n w-full border-collapse text-left text-sm text-foreground [&_tbody_tr:nth-child(odd)]:bg-muted\n}\n",
|
|
1043
1524
|
"text.css": "/* shortwind: text@0.0.1 sha:000000 */\n\n/* @guide\n Headings are sized by weight, not HTML level: @heading-xl/lg/md/sm — there\n is no @h1..@h6. Body copy: @body (default), @lead (intro paragraphs), @muted\n (secondary), @caption (fine print). Use @label for form labels and @link for\n inline links. Don't append a -text suffix: it's @body not @body-text, @muted\n not @muted-text, @link not @link-text.\n*/\n\n/* Top-level page heading. */\n@recipe heading-xl {\n text-4xl font-bold tracking-tight text-foreground\n}\n\n/* Large section heading. */\n@recipe heading-lg {\n text-2xl font-semibold tracking-tight text-foreground\n}\n\n/* Medium heading. */\n@recipe heading-md {\n text-xl font-semibold text-foreground\n}\n\n/* Small heading. */\n@recipe heading-sm {\n text-base font-semibold text-foreground\n}\n\n/* Default body text. */\n@recipe body {\n text-sm leading-6 text-foreground\n}\n\n/* Lead paragraph — larger body copy for hero/intro sections. */\n@recipe lead {\n text-lg leading-relaxed text-muted-foreground\n}\n\n/* Muted secondary text. */\n@recipe muted {\n text-sm text-muted-foreground\n}\n\n/* Form label text. */\n@recipe label {\n text-sm font-medium text-foreground\n}\n\n/* Caption — small supporting text. */\n@recipe caption {\n text-xs text-muted-foreground\n}\n\n/* Inline link with hover/focus states. */\n@recipe link {\n text-primary underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\n}\n",
|
|
1044
1525
|
"tooltip.css": "/* shortwind: tooltip@0.0.1 sha:000000 */\n\n/* @guide\n @tooltip is the floating label bubble — it styles appearance only, so pair it\n with your own positioning.\n*/\n\n/* Floating tooltip bubble. */\n@recipe tooltip {\n pointer-events-none z-50 rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md\n}\n"
|
|
@@ -1162,7 +1643,7 @@ const CORPUS_FILES = {
|
|
|
1162
1643
|
<div className="h-8 w-8 rounded-full bg-primary/20" />
|
|
1163
1644
|
</div>
|
|
1164
1645
|
</header>
|
|
1165
|
-
<main className="@
|
|
1646
|
+
<main className="@wrapper @stack-lg py-8">
|
|
1166
1647
|
<div className="@grid-3">
|
|
1167
1648
|
<div className="@card-elevated">Card 1</div>
|
|
1168
1649
|
<div className="@card-elevated">Card 2</div>
|
|
@@ -1186,7 +1667,8 @@ const ALL_RULES = [
|
|
|
1186
1667
|
"recipe/bad-suffix-order",
|
|
1187
1668
|
"recipe/conflicting-intent",
|
|
1188
1669
|
"recipe/dynamic-class",
|
|
1189
|
-
"recipe/no-sibling-overlap"
|
|
1670
|
+
"recipe/no-sibling-overlap",
|
|
1671
|
+
"recipe/reserved-name"
|
|
1190
1672
|
];
|
|
1191
1673
|
const DEFAULT_CONTENT = ["src/**/*.{html,js,jsx,ts,tsx,vue,svelte,astro,md,mdx}"];
|
|
1192
1674
|
async function lint(options) {
|
|
@@ -1198,6 +1680,7 @@ async function lint(options) {
|
|
|
1198
1680
|
const { registry, parseFindings } = loadRegistry(recipesDir, enabledRules);
|
|
1199
1681
|
findings.push(...parseFindings);
|
|
1200
1682
|
findings.push(...checkRecipeNames(registry, recipesDir, enabledRules));
|
|
1683
|
+
findings.push(...checkReservedNames(registry, recipesDir, enabledRules));
|
|
1201
1684
|
const files = await glob(options.content ?? DEFAULT_CONTENT, {
|
|
1202
1685
|
cwd,
|
|
1203
1686
|
absolute: true,
|
|
@@ -1218,7 +1701,7 @@ async function lint(options) {
|
|
|
1218
1701
|
for (const token of u.tokens) {
|
|
1219
1702
|
if (!token.value.startsWith("@")) continue;
|
|
1220
1703
|
const name = token.value.slice(1);
|
|
1221
|
-
if (registry.flattened
|
|
1704
|
+
if (Object.hasOwn(registry.flattened, name)) usedRecipes.add(name);
|
|
1222
1705
|
else if (enabledRules.has("recipe/unknown")) findings.push({
|
|
1223
1706
|
rule: "recipe/unknown",
|
|
1224
1707
|
severity: "error",
|
|
@@ -1387,12 +1870,28 @@ function checkRecipeNames(registry, recipesDir, enabledRules) {
|
|
|
1387
1870
|
}
|
|
1388
1871
|
return findings;
|
|
1389
1872
|
}
|
|
1873
|
+
function checkReservedNames(registry, recipesDir, enabledRules) {
|
|
1874
|
+
if (!enabledRules.has("recipe/reserved-name")) return [];
|
|
1875
|
+
const findings = [];
|
|
1876
|
+
for (const recipes of Object.values(registry.families)) for (const recipe of recipes) {
|
|
1877
|
+
if (!isReservedRecipeName(recipe.name)) continue;
|
|
1878
|
+
findings.push({
|
|
1879
|
+
rule: "recipe/reserved-name",
|
|
1880
|
+
severity: "error",
|
|
1881
|
+
file: path.join(recipesDir, recipe.sourceFile),
|
|
1882
|
+
line: recipe.sourceLine,
|
|
1883
|
+
column: 1,
|
|
1884
|
+
message: `recipe @${recipe.name} collides with a reserved Tailwind @-utility; rename it`
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
return findings;
|
|
1888
|
+
}
|
|
1390
1889
|
function checkUsageSuffixOrder(file, tokens, registry) {
|
|
1391
1890
|
const findings = [];
|
|
1392
1891
|
for (const token of tokens) {
|
|
1393
1892
|
if (!token.value.startsWith("@")) continue;
|
|
1394
1893
|
const name = token.value.slice(1);
|
|
1395
|
-
if (!registry.flattened
|
|
1894
|
+
if (!Object.hasOwn(registry.flattened, name)) continue;
|
|
1396
1895
|
const meta = recipeMeta(name, familyForRecipe(registry, name));
|
|
1397
1896
|
if (!meta.badOrder) continue;
|
|
1398
1897
|
findings.push({
|
|
@@ -1411,7 +1910,7 @@ function checkConflictingIntent(file, tokens, registry) {
|
|
|
1411
1910
|
for (const token of tokens) {
|
|
1412
1911
|
if (!token.value.startsWith("@")) continue;
|
|
1413
1912
|
const name = token.value.slice(1);
|
|
1414
|
-
if (!registry.flattened
|
|
1913
|
+
if (!Object.hasOwn(registry.flattened, name)) continue;
|
|
1415
1914
|
const meta = recipeMeta(name, familyForRecipe(registry, name));
|
|
1416
1915
|
if (!meta.intent) continue;
|
|
1417
1916
|
const familyIntents = byFamily.get(meta.family) ?? /* @__PURE__ */ new Map();
|
|
@@ -1445,7 +1944,7 @@ function checkSiblingOverlap(file, tokens, registry) {
|
|
|
1445
1944
|
for (const token of tokens) {
|
|
1446
1945
|
if (!token.value.startsWith("@")) continue;
|
|
1447
1946
|
const name = token.value.slice(1);
|
|
1448
|
-
if (!registry.flattened
|
|
1947
|
+
if (!Object.hasOwn(registry.flattened, name)) continue;
|
|
1449
1948
|
const family = familyForRecipe(registry, name) ?? name.split("-")[0] ?? name;
|
|
1450
1949
|
const arr = byFamily.get(family) ?? [];
|
|
1451
1950
|
arr.push({
|
|
@@ -1489,6 +1988,7 @@ function checkDynamicClass(file, dynamicTokens) {
|
|
|
1489
1988
|
const CLASS_ATTR_STR_RE = /\b(?:class|className)\s*=\s*(["'])([^"']*)\1/g;
|
|
1490
1989
|
const CLASS_ATTR_BRACE_RE = /\b(?:class|className)\s*=\s*\{/g;
|
|
1491
1990
|
const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\])*)\1/g;
|
|
1991
|
+
const CALL_EXPANDER_RE = /\b(?:cva|tv)\s*\(/g;
|
|
1492
1992
|
function extractClassUsages(source) {
|
|
1493
1993
|
const usages = [];
|
|
1494
1994
|
for (const m of source.matchAll(CLASS_ATTR_STR_RE)) {
|
|
@@ -1527,6 +2027,28 @@ function extractClassUsages(source) {
|
|
|
1527
2027
|
});
|
|
1528
2028
|
}
|
|
1529
2029
|
}
|
|
2030
|
+
for (const m of source.matchAll(CALL_EXPANDER_RE)) {
|
|
2031
|
+
const openParen = (m.index ?? 0) + m[0].length - 1;
|
|
2032
|
+
const close = findMatchingDelimiter(source, openParen, "(", ")");
|
|
2033
|
+
if (close === -1) continue;
|
|
2034
|
+
const inner = source.slice(openParen + 1, close);
|
|
2035
|
+
for (const sm of inner.matchAll(STRING_LITERAL_RE)) {
|
|
2036
|
+
const value = sm[2] ?? "";
|
|
2037
|
+
if (value.length === 0) continue;
|
|
2038
|
+
const literalStart = openParen + 1 + (sm.index ?? 0);
|
|
2039
|
+
const valueStart = literalStart + 1;
|
|
2040
|
+
const { tokens, dynamicTokens } = tokenizeClassString(source, value, valueStart);
|
|
2041
|
+
if (!tokens.some((t) => t.value.startsWith("@")) && dynamicTokens.length === 0) continue;
|
|
2042
|
+
usages.push({
|
|
2043
|
+
fileOffset: literalStart,
|
|
2044
|
+
valueStart,
|
|
2045
|
+
raw: value,
|
|
2046
|
+
tokens,
|
|
2047
|
+
dynamicTokens,
|
|
2048
|
+
fixable: false
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
1530
2052
|
return usages;
|
|
1531
2053
|
}
|
|
1532
2054
|
function tokenizeClassString(source, value, valueStart) {
|
|
@@ -1561,6 +2083,23 @@ function tokenizeClassString(source, value, valueStart) {
|
|
|
1561
2083
|
};
|
|
1562
2084
|
}
|
|
1563
2085
|
function findMatchingBrace(source, openIdx) {
|
|
2086
|
+
return findMatchingDelimiter(source, openIdx, "{", "}");
|
|
2087
|
+
}
|
|
2088
|
+
function spliceRedundantTokens(raw, isRedundant) {
|
|
2089
|
+
const pieces = raw.split(/(\s+)/);
|
|
2090
|
+
const drop = new Array(pieces.length).fill(false);
|
|
2091
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
2092
|
+
const piece = pieces[i] ?? "";
|
|
2093
|
+
if (piece.length === 0 || /^\s+$/.test(piece)) continue;
|
|
2094
|
+
if (piece.startsWith("@") || piece.includes("${")) continue;
|
|
2095
|
+
if (!isRedundant(piece)) continue;
|
|
2096
|
+
drop[i] = true;
|
|
2097
|
+
if (i > 0 && /^\s+$/.test(pieces[i - 1] ?? "")) drop[i - 1] = true;
|
|
2098
|
+
else if (/^\s+$/.test(pieces[i + 1] ?? "")) drop[i + 1] = true;
|
|
2099
|
+
}
|
|
2100
|
+
return pieces.filter((_, i) => !drop[i]).join("");
|
|
2101
|
+
}
|
|
2102
|
+
function findMatchingDelimiter(source, openIdx, open, close) {
|
|
1564
2103
|
let depth = 1;
|
|
1565
2104
|
let i = openIdx + 1;
|
|
1566
2105
|
while (i < source.length && depth > 0) {
|
|
@@ -1606,8 +2145,8 @@ function findMatchingBrace(source, openIdx) {
|
|
|
1606
2145
|
}
|
|
1607
2146
|
continue;
|
|
1608
2147
|
}
|
|
1609
|
-
if (ch ===
|
|
1610
|
-
else if (ch ===
|
|
2148
|
+
if (ch === open) depth++;
|
|
2149
|
+
else if (ch === close) depth--;
|
|
1611
2150
|
i++;
|
|
1612
2151
|
}
|
|
1613
2152
|
return depth === 0 ? i - 1 : -1;
|
|
@@ -1639,25 +2178,18 @@ function checkRedundantUtility(file, source, registry, applyFix) {
|
|
|
1639
2178
|
for (const t of exp) expansions.add(t);
|
|
1640
2179
|
}
|
|
1641
2180
|
if (expansions.size === 0) continue;
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
column: tok.column,
|
|
1651
|
-
message: `${tok.value} is already included by a recipe on this element`
|
|
1652
|
-
});
|
|
1653
|
-
continue;
|
|
1654
|
-
}
|
|
1655
|
-
kept.push(tok.value);
|
|
1656
|
-
}
|
|
2181
|
+
for (const tok of usage.tokens) if (!tok.value.startsWith("@") && expansions.has(tok.value)) findings.push({
|
|
2182
|
+
rule: "recipe/no-redundant-utility",
|
|
2183
|
+
severity: "info",
|
|
2184
|
+
file,
|
|
2185
|
+
line: tok.line,
|
|
2186
|
+
column: tok.column,
|
|
2187
|
+
message: `${tok.value} is already included by a recipe on this element`
|
|
2188
|
+
});
|
|
1657
2189
|
if (fixed !== null && usage.fixable) {
|
|
1658
2190
|
if (usage.valueStart < cursor) continue;
|
|
1659
2191
|
fixed += source.slice(cursor, usage.valueStart);
|
|
1660
|
-
fixed +=
|
|
2192
|
+
fixed += spliceRedundantTokens(usage.raw, (t) => expansions.has(t));
|
|
1661
2193
|
cursor = usage.valueStart + usage.raw.length;
|
|
1662
2194
|
}
|
|
1663
2195
|
}
|
|
@@ -1724,7 +2256,7 @@ async function bench(options) {
|
|
|
1724
2256
|
expandedLlmTokens: 0
|
|
1725
2257
|
};
|
|
1726
2258
|
for (const { filename, content } of filesToBench) {
|
|
1727
|
-
const expanded = transformContent(content, registry, { mode: filename
|
|
2259
|
+
const expanded = transformContent(content, registry, { mode: modeForFile(filename) });
|
|
1728
2260
|
const compactUsages = extractClassUsages(content);
|
|
1729
2261
|
const expandedUsages = extractClassUsages(expanded);
|
|
1730
2262
|
const fileResult = {
|
|
@@ -1831,6 +2363,6 @@ function formatBenchTable(result) {
|
|
|
1831
2363
|
return lines.join("\n");
|
|
1832
2364
|
}
|
|
1833
2365
|
//#endregion
|
|
1834
|
-
export {
|
|
2366
|
+
export { buildHeaderLine as A, cliVersion as C, createRegistrySource as D, writeLockfile as E, verifyFetchedFamily as F, extractHeader as M, rewriteHeaderSha as N, resolvePresetFamilies as O, sealRecipeFile as P, DEFAULT_REGISTRY as S, readLockfile as T, NewFamilyError as _, formatFindingsText as a, add as b, UpgradeError as c, BuildError as d, build as f, reseal as g, preset as h, extractClassUsages as i, computeBodySha as j, detectProject as k, upgrade as l, ls as m, formatBenchTable as n, lint as o, formatLsText as p, ALL_RULES as r, verify as s, bench as t, dev as u, newFamily as v, init as w, renameFamilyInSource as x, remove as y };
|
|
1835
2367
|
|
|
1836
|
-
//# sourceMappingURL=bench-
|
|
2368
|
+
//# sourceMappingURL=bench-BGTQAha8.js.map
|