@shortwind/cli 0.1.0-beta.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/dist/bench-a_9WmuOE.js +1836 -0
- package/dist/bench-a_9WmuOE.js.map +1 -0
- package/dist/bin.js +267 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/package.json +41 -0
|
@@ -0,0 +1,1836 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
5
|
+
import { buildRegistry, parseRecipeFile, renderSkillMarkdown } from "@shortwind/core";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import chokidar from "chokidar";
|
|
9
|
+
import { glob } from "tinyglobby";
|
|
10
|
+
import { Tiktoken } from "js-tiktoken/lite";
|
|
11
|
+
import cl100k_base from "js-tiktoken/ranks/cl100k_base";
|
|
12
|
+
import { loadRegistryFromDir, transformContent } from "@shortwind/tailwind";
|
|
13
|
+
//#region src/fingerprint.ts
|
|
14
|
+
const HEADER_PATTERN = /^\/\*\s*shortwind:\s+(\S+)@(\S+)\s+sha:([^\s*]+)(?:\s+—\s+DO NOT EDIT THIS LINE)?\s*\*\/\s*$/;
|
|
15
|
+
function extractHeader(source) {
|
|
16
|
+
const eol = source.indexOf("\n");
|
|
17
|
+
const m = (eol === -1 ? source : source.slice(0, eol)).replace(/\r$/, "").match(HEADER_PATTERN);
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
return {
|
|
20
|
+
family: m[1],
|
|
21
|
+
version: m[2],
|
|
22
|
+
sha: m[3]
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function bodyAfterHeader(source) {
|
|
26
|
+
const eol = source.indexOf("\n");
|
|
27
|
+
return eol === -1 ? "" : source.slice(eol + 1);
|
|
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
|
+
function computeBodySha(source) {
|
|
33
|
+
const normalized = normalizeBody(bodyAfterHeader(source));
|
|
34
|
+
return createHash("sha256").update(normalized).digest("hex").slice(0, 6);
|
|
35
|
+
}
|
|
36
|
+
function buildHeaderLine(family, version, sha) {
|
|
37
|
+
return `/* shortwind: ${family}@${version} sha:${sha} — DO NOT EDIT THIS LINE */`;
|
|
38
|
+
}
|
|
39
|
+
function rewriteHeaderSha(source, sha) {
|
|
40
|
+
const header = extractHeader(source);
|
|
41
|
+
if (!header) return source;
|
|
42
|
+
const newHeader = buildHeaderLine(header.family, header.version, sha);
|
|
43
|
+
const eol = source.indexOf("\n");
|
|
44
|
+
if (eol === -1) return newHeader;
|
|
45
|
+
return newHeader + source.slice(eol);
|
|
46
|
+
}
|
|
47
|
+
function sealRecipeFile(source, family, version) {
|
|
48
|
+
const header = buildHeaderLine(family, version, computeBodySha(source));
|
|
49
|
+
const eol = source.indexOf("\n");
|
|
50
|
+
return header + (eol === -1 ? "" : source.slice(eol));
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/detect.ts
|
|
54
|
+
function detectProject(cwd) {
|
|
55
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
56
|
+
const hasPackageJson = existsSync(pkgPath);
|
|
57
|
+
const pkg = hasPackageJson ? JSON.parse(readFileSync(pkgPath, "utf8")) : {
|
|
58
|
+
dependencies: {},
|
|
59
|
+
devDependencies: {}
|
|
60
|
+
};
|
|
61
|
+
const deps = {
|
|
62
|
+
...pkg.dependencies ?? {},
|
|
63
|
+
...pkg.devDependencies ?? {}
|
|
64
|
+
};
|
|
65
|
+
const packageManager = detectPackageManager(cwd, pkg.packageManager);
|
|
66
|
+
const tailwindVersion = deps["tailwindcss"] ?? null;
|
|
67
|
+
return {
|
|
68
|
+
packageManager,
|
|
69
|
+
tailwindVersion,
|
|
70
|
+
tailwindMajor: parseMajor(tailwindVersion),
|
|
71
|
+
bundler: detectBundler(deps),
|
|
72
|
+
framework: detectFramework(deps),
|
|
73
|
+
hasPackageJson
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function detectPackageManager(cwd, declared) {
|
|
77
|
+
if (declared) {
|
|
78
|
+
const name = declared.split("@")[0];
|
|
79
|
+
if (name === "pnpm" || name === "yarn" || name === "bun" || name === "npm") return name;
|
|
80
|
+
}
|
|
81
|
+
if (existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
82
|
+
if (existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
|
|
83
|
+
if (existsSync(path.join(cwd, "bun.lockb"))) return "bun";
|
|
84
|
+
if (existsSync(path.join(cwd, "package-lock.json"))) return "npm";
|
|
85
|
+
return "npm";
|
|
86
|
+
}
|
|
87
|
+
function parseMajor(version) {
|
|
88
|
+
if (!version) return null;
|
|
89
|
+
const m = version.match(/(\d+)/);
|
|
90
|
+
if (!m) return null;
|
|
91
|
+
const n = Number(m[1]);
|
|
92
|
+
if (n === 3) return 3;
|
|
93
|
+
if (n === 4) return 4;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function detectBundler(deps) {
|
|
97
|
+
if (deps["next"]) return "next";
|
|
98
|
+
if (deps["astro"]) return "astro";
|
|
99
|
+
if (deps["vite"] || deps["@vitejs/plugin-react"] || deps["@vitejs/plugin-vue"]) return "vite";
|
|
100
|
+
return "unknown";
|
|
101
|
+
}
|
|
102
|
+
function detectFramework(deps) {
|
|
103
|
+
if (deps["astro"]) return "astro";
|
|
104
|
+
if (deps["react"] || deps["next"]) return "react";
|
|
105
|
+
if (deps["vue"]) return "vue";
|
|
106
|
+
if (deps["svelte"] || deps["@sveltejs/kit"]) return "svelte";
|
|
107
|
+
return "plain";
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/registry-source.ts
|
|
111
|
+
const FAMILY_RE = /^[a-z0-9][a-z0-9-]{0,63}$/i;
|
|
112
|
+
function assertValidFamilyName(family) {
|
|
113
|
+
if (!FAMILY_RE.test(family)) throw new Error(`invalid family name: ${JSON.stringify(family)} (must match ${FAMILY_RE})`);
|
|
114
|
+
}
|
|
115
|
+
function createRegistrySource(origin) {
|
|
116
|
+
if (origin.startsWith("http://") || origin.startsWith("https://")) return httpSource(origin);
|
|
117
|
+
return fileSource(origin);
|
|
118
|
+
}
|
|
119
|
+
function fileSource(origin) {
|
|
120
|
+
const root = origin.startsWith("file://") ? fileURLToPath(origin) : origin;
|
|
121
|
+
return {
|
|
122
|
+
origin,
|
|
123
|
+
async loadPresets() {
|
|
124
|
+
const body = await readFile(path.join(root, "presets.json"), "utf8");
|
|
125
|
+
return JSON.parse(body);
|
|
126
|
+
},
|
|
127
|
+
async loadFamily(family) {
|
|
128
|
+
assertValidFamilyName(family);
|
|
129
|
+
return readFile(path.join(root, "recipes", `${family}.css`), "utf8");
|
|
130
|
+
},
|
|
131
|
+
async listAllFamilies() {
|
|
132
|
+
const { readdir } = await import("node:fs/promises");
|
|
133
|
+
return (await readdir(path.join(root, "recipes"))).filter((f) => f.endsWith(".css")).map((f) => f.replace(/\.css$/, "")).filter((name) => FAMILY_RE.test(name)).sort();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function httpSource(origin) {
|
|
138
|
+
const base = origin.replace(/\/+$/, "");
|
|
139
|
+
return {
|
|
140
|
+
origin,
|
|
141
|
+
async loadPresets() {
|
|
142
|
+
const res = await fetch(`${base}/presets.json`);
|
|
143
|
+
if (!res.ok) throw new Error(`presets.json: ${res.status} ${res.statusText}`);
|
|
144
|
+
return await res.json();
|
|
145
|
+
},
|
|
146
|
+
async loadFamily(family) {
|
|
147
|
+
assertValidFamilyName(family);
|
|
148
|
+
const res = await fetch(`${base}/recipes/${family}.css`);
|
|
149
|
+
if (!res.ok) throw new Error(`${family}.css: ${res.status} ${res.statusText}`);
|
|
150
|
+
return res.text();
|
|
151
|
+
},
|
|
152
|
+
async listAllFamilies() {
|
|
153
|
+
const res = await fetch(`${base}/index.json`);
|
|
154
|
+
if (!res.ok) throw new Error(`index.json: ${res.status} ${res.statusText}`);
|
|
155
|
+
return (await res.json()).families.filter((name) => FAMILY_RE.test(name));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function resolvePresetFamilies(preset, presets, allFamilies) {
|
|
160
|
+
if (preset === "none") return [];
|
|
161
|
+
const entry = presets[preset];
|
|
162
|
+
if (entry === void 0) throw new Error(`Unknown preset '${preset}'. Available: ${Object.keys(presets).join(", ")}`);
|
|
163
|
+
if (entry === "*") return allFamilies;
|
|
164
|
+
return entry;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/lockfile.ts
|
|
168
|
+
const LOCK_FILENAME = ".shortwind-lock.json";
|
|
169
|
+
function lockPath(recipesDir) {
|
|
170
|
+
return path.join(recipesDir, LOCK_FILENAME);
|
|
171
|
+
}
|
|
172
|
+
async function readLockfile(recipesDir) {
|
|
173
|
+
const p = lockPath(recipesDir);
|
|
174
|
+
if (!existsSync(p)) return {
|
|
175
|
+
version: 1,
|
|
176
|
+
registry: "",
|
|
177
|
+
families: {}
|
|
178
|
+
};
|
|
179
|
+
const body = await readFile(p, "utf8");
|
|
180
|
+
const raw = JSON.parse(body);
|
|
181
|
+
if (typeof raw !== "object" || raw === null) throw new Error(`${p}: lockfile must be a JSON object`);
|
|
182
|
+
const r = raw;
|
|
183
|
+
const families = {};
|
|
184
|
+
if (r["families"] !== void 0) {
|
|
185
|
+
if (typeof r["families"] !== "object" || r["families"] === null) throw new Error(`${p}: "families" must be an object`);
|
|
186
|
+
for (const [name, entry] of Object.entries(r["families"])) {
|
|
187
|
+
if (typeof entry !== "object" || entry === null) throw new Error(`${p}: families["${name}"] must be an object`);
|
|
188
|
+
const e = entry;
|
|
189
|
+
if (typeof e["version"] !== "string" || typeof e["sha"] !== "string") throw new Error(`${p}: families["${name}"] must have string "version" and "sha"`);
|
|
190
|
+
families[name] = {
|
|
191
|
+
version: e["version"],
|
|
192
|
+
sha: e["sha"]
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
version: typeof r["version"] === "number" ? r["version"] : 1,
|
|
198
|
+
registry: typeof r["registry"] === "string" ? r["registry"] : "",
|
|
199
|
+
families
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async function writeLockfile(recipesDir, lock) {
|
|
203
|
+
const sorted = {
|
|
204
|
+
version: lock.version || 1,
|
|
205
|
+
registry: lock.registry,
|
|
206
|
+
families: Object.fromEntries(Object.entries(lock.families).sort(([a], [b]) => a.localeCompare(b)))
|
|
207
|
+
};
|
|
208
|
+
await writeFile(lockPath(recipesDir), JSON.stringify(sorted, null, 2) + "\n");
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/init.ts
|
|
212
|
+
const DEFAULT_REGISTRY = "https://shortwind.dev/registry";
|
|
213
|
+
async function init(options) {
|
|
214
|
+
const cwd = path.resolve(options.cwd);
|
|
215
|
+
const registry = options.registry ?? "https://shortwind.dev/registry";
|
|
216
|
+
const source = createRegistrySource(registry);
|
|
217
|
+
const shape = detectProject(cwd);
|
|
218
|
+
const families = await resolveFamilies(options.preset, source);
|
|
219
|
+
const pkgs = pickPackages(shape.bundler);
|
|
220
|
+
const installer = options.installPackages ?? defaultInstall;
|
|
221
|
+
if (pkgs.length > 0) await installer(shape.packageManager, pkgs, cwd);
|
|
222
|
+
const recipesDir = path.join(cwd, "recipes");
|
|
223
|
+
const { installed, skipped } = await copyRecipes(source, families, recipesDir);
|
|
224
|
+
await updateLockfile(recipesDir, registry, installed);
|
|
225
|
+
const configPath = path.join(cwd, "shortwind.config.json");
|
|
226
|
+
await writeConfig(configPath, {
|
|
227
|
+
registry,
|
|
228
|
+
recipesDir: "recipes"
|
|
229
|
+
});
|
|
230
|
+
const vscodePath = path.join(cwd, ".vscode", "settings.json");
|
|
231
|
+
await wireVscodeClassRegex(vscodePath);
|
|
232
|
+
const huskyPath = path.join(cwd, ".husky", "pre-commit");
|
|
233
|
+
await installHuskyHook(huskyPath);
|
|
234
|
+
const skillPath = path.join(cwd, "skills", "shortwind", "SKILL.md");
|
|
235
|
+
await writeSkillMd(skillPath, recipesDir, families);
|
|
236
|
+
return {
|
|
237
|
+
packageManager: shape.packageManager,
|
|
238
|
+
preset: options.preset,
|
|
239
|
+
registry,
|
|
240
|
+
families,
|
|
241
|
+
installedPackages: pkgs,
|
|
242
|
+
installedFamilies: installed,
|
|
243
|
+
skippedFamilies: skipped,
|
|
244
|
+
configPath,
|
|
245
|
+
vscodePath,
|
|
246
|
+
huskyPath,
|
|
247
|
+
skillPath
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async function resolveFamilies(preset, source) {
|
|
251
|
+
if (preset === "none") return [];
|
|
252
|
+
return resolvePresetFamilies(preset, await source.loadPresets(), await source.listAllFamilies());
|
|
253
|
+
}
|
|
254
|
+
function pickPackages(bundler) {
|
|
255
|
+
const base = ["@shortwind/tailwind"];
|
|
256
|
+
switch (bundler) {
|
|
257
|
+
case "vite": return [...base, "@shortwind/vite"];
|
|
258
|
+
case "next": return [...base, "@shortwind/next"];
|
|
259
|
+
case "astro": return [...base, "@shortwind/astro"];
|
|
260
|
+
default: return base;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const defaultInstall = async (pm, packages, cwd) => {
|
|
264
|
+
const { spawn } = await import("node:child_process");
|
|
265
|
+
const args = installArgs(pm, packages);
|
|
266
|
+
await new Promise((resolve, reject) => {
|
|
267
|
+
const child = spawn(pm, args, {
|
|
268
|
+
cwd,
|
|
269
|
+
stdio: "inherit"
|
|
270
|
+
});
|
|
271
|
+
child.on("error", reject);
|
|
272
|
+
child.on("exit", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`${pm} ${args.join(" ")} exited ${code}`)));
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
function installArgs(pm, packages) {
|
|
276
|
+
switch (pm) {
|
|
277
|
+
case "pnpm": return [
|
|
278
|
+
"add",
|
|
279
|
+
"-D",
|
|
280
|
+
...packages
|
|
281
|
+
];
|
|
282
|
+
case "yarn": return [
|
|
283
|
+
"add",
|
|
284
|
+
"-D",
|
|
285
|
+
...packages
|
|
286
|
+
];
|
|
287
|
+
case "bun": return [
|
|
288
|
+
"add",
|
|
289
|
+
"-d",
|
|
290
|
+
...packages
|
|
291
|
+
];
|
|
292
|
+
default: return [
|
|
293
|
+
"install",
|
|
294
|
+
"-D",
|
|
295
|
+
...packages
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function updateLockfile(recipesDir, registry, newlyInstalled) {
|
|
300
|
+
const lock = await readLockfile(recipesDir);
|
|
301
|
+
if (!lock.registry) lock.registry = registry;
|
|
302
|
+
for (const family of newlyInstalled) {
|
|
303
|
+
const target = path.join(recipesDir, `${family}.css`);
|
|
304
|
+
if (!existsSync(target)) continue;
|
|
305
|
+
const header = extractHeader(readFileSync(target, "utf8"));
|
|
306
|
+
if (!header) throw new Error(`recipe "${family}" has no fingerprint header — refusing to add to lockfile`);
|
|
307
|
+
lock.families[family] = {
|
|
308
|
+
version: header.version,
|
|
309
|
+
sha: header.sha
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
await writeLockfile(recipesDir, lock);
|
|
313
|
+
}
|
|
314
|
+
async function copyRecipes(source, families, recipesDir) {
|
|
315
|
+
await mkdir(recipesDir, { recursive: true });
|
|
316
|
+
const installed = [];
|
|
317
|
+
const skipped = [];
|
|
318
|
+
for (const family of families) {
|
|
319
|
+
const target = path.join(recipesDir, `${family}.css`);
|
|
320
|
+
if (existsSync(target)) {
|
|
321
|
+
skipped.push(family);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const body = await source.loadFamily(family);
|
|
325
|
+
await writeFile(target, rewriteHeaderSha(body, computeBodySha(body)));
|
|
326
|
+
installed.push(family);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
installed,
|
|
330
|
+
skipped
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
async function writeConfig(configPath, next) {
|
|
334
|
+
const desired = {
|
|
335
|
+
registry: next.registry,
|
|
336
|
+
recipesDir: next.recipesDir,
|
|
337
|
+
outputPath: "skills/shortwind/SKILL.md"
|
|
338
|
+
};
|
|
339
|
+
if (!existsSync(configPath)) {
|
|
340
|
+
await writeFile(configPath, JSON.stringify(desired, null, 2) + "\n");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const merged = {
|
|
344
|
+
...JSON.parse(await readFile(configPath, "utf8")),
|
|
345
|
+
...desired
|
|
346
|
+
};
|
|
347
|
+
await writeFile(configPath, JSON.stringify(merged, null, 2) + "\n");
|
|
348
|
+
}
|
|
349
|
+
const CLASS_REGEX_KEY = ["tailwindCSS.experimental.classRegex"];
|
|
350
|
+
const CLASS_REGEX_VALUE = [["class\\s*[=:]\\s*['\"]([^'\"]*)['\"]", "([\\w-@/:]+)"], ["className\\s*=\\s*['\"]([^'\"]*)['\"]", "([\\w-@/:]+)"]];
|
|
351
|
+
async function wireVscodeClassRegex(vscodePath) {
|
|
352
|
+
await mkdir(path.dirname(vscodePath), { recursive: true });
|
|
353
|
+
let body;
|
|
354
|
+
if (existsSync(vscodePath)) body = await readFile(vscodePath, "utf8");
|
|
355
|
+
else body = "{}\n";
|
|
356
|
+
const edits = modify(body, CLASS_REGEX_KEY, CLASS_REGEX_VALUE, { formattingOptions: {
|
|
357
|
+
tabSize: 2,
|
|
358
|
+
insertSpaces: true
|
|
359
|
+
} });
|
|
360
|
+
const next = applyEdits(body, edits);
|
|
361
|
+
parse(next);
|
|
362
|
+
await writeFile(vscodePath, next.endsWith("\n") ? next : next + "\n");
|
|
363
|
+
}
|
|
364
|
+
const HUSKY_LINE = "npx shortwind build";
|
|
365
|
+
async function installHuskyHook(huskyPath) {
|
|
366
|
+
await mkdir(path.dirname(huskyPath), { recursive: true });
|
|
367
|
+
if (!existsSync(huskyPath)) {
|
|
368
|
+
await writeFile(huskyPath, `${HUSKY_LINE}\n`, { mode: 493 });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const current = await readFile(huskyPath, "utf8");
|
|
372
|
+
if (current.includes(HUSKY_LINE)) return;
|
|
373
|
+
await writeFile(huskyPath, current.endsWith("\n") ? current + HUSKY_LINE + "\n" : current + "\nnpx shortwind build\n", { mode: 493 });
|
|
374
|
+
}
|
|
375
|
+
async function writeSkillMd(skillPath, recipesDir, families) {
|
|
376
|
+
await mkdir(path.dirname(skillPath), { recursive: true });
|
|
377
|
+
const allRecipes = [];
|
|
378
|
+
const guidance = {};
|
|
379
|
+
for (const family of families) {
|
|
380
|
+
const filePath = path.join(recipesDir, `${family}.css`);
|
|
381
|
+
if (!existsSync(filePath)) continue;
|
|
382
|
+
const parsed = parseRecipeFile(readFileSync(filePath, "utf8"), `${family}.css`);
|
|
383
|
+
if (parsed.ok) {
|
|
384
|
+
allRecipes.push(...parsed.value.recipes);
|
|
385
|
+
if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
let registry = {
|
|
389
|
+
families: {},
|
|
390
|
+
flattened: {}
|
|
391
|
+
};
|
|
392
|
+
const resolved = buildRegistry(allRecipes, { guidance });
|
|
393
|
+
if (resolved.ok) registry = resolved.value;
|
|
394
|
+
await writeFile(skillPath, renderSkillMarkdown(registry, { order: families }));
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/project.ts
|
|
398
|
+
const DEFAULT_CONFIG = {
|
|
399
|
+
registry: "https://shortwind.dev/registry",
|
|
400
|
+
recipesDir: "recipes",
|
|
401
|
+
outputPath: "skills/shortwind/SKILL.md"
|
|
402
|
+
};
|
|
403
|
+
async function readConfig(cwd) {
|
|
404
|
+
const configPath = path.join(cwd, "shortwind.config.json");
|
|
405
|
+
if (!existsSync(configPath)) return DEFAULT_CONFIG;
|
|
406
|
+
const body = await readFile(configPath, "utf8");
|
|
407
|
+
let parsed;
|
|
408
|
+
try {
|
|
409
|
+
parsed = JSON.parse(body);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
throw new Error(`${configPath}: invalid JSON — ${err.message}`);
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
...DEFAULT_CONFIG,
|
|
415
|
+
...parsed
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function installedFamilies(recipesDir) {
|
|
419
|
+
if (!existsSync(recipesDir)) return [];
|
|
420
|
+
return readdirSync(recipesDir).filter((f) => f.endsWith(".css")).map((f) => f.replace(/\.css$/, "")).sort();
|
|
421
|
+
}
|
|
422
|
+
function parseInstalledFamily(recipesDir, family) {
|
|
423
|
+
const filePath = path.join(recipesDir, `${family}.css`);
|
|
424
|
+
if (!existsSync(filePath)) return null;
|
|
425
|
+
const result = parseRecipeFile(readFileSync(filePath, "utf8"), `${family}.css`);
|
|
426
|
+
if (!result.ok) return null;
|
|
427
|
+
return {
|
|
428
|
+
recipes: result.value.recipes,
|
|
429
|
+
header: result.value.header
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
async function regenerateSkillMd(cwd, config) {
|
|
433
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
434
|
+
const families = installedFamilies(recipesDir);
|
|
435
|
+
const skillPath = path.join(cwd, config.outputPath);
|
|
436
|
+
const { mkdir } = await import("node:fs/promises");
|
|
437
|
+
await mkdir(path.dirname(skillPath), { recursive: true });
|
|
438
|
+
const allRecipes = [];
|
|
439
|
+
const guidance = {};
|
|
440
|
+
for (const family of families) {
|
|
441
|
+
const parsed = parseRecipeFile(readFileSync(path.join(recipesDir, `${family}.css`), "utf8"), `${family}.css`);
|
|
442
|
+
if (parsed.ok) {
|
|
443
|
+
allRecipes.push(...parsed.value.recipes);
|
|
444
|
+
if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
let registry = {
|
|
448
|
+
families: {},
|
|
449
|
+
flattened: {}
|
|
450
|
+
};
|
|
451
|
+
const resolved = buildRegistry(allRecipes, { guidance });
|
|
452
|
+
if (resolved.ok) registry = resolved.value;
|
|
453
|
+
await writeFile(skillPath, renderSkillMarkdown(registry, { order: families }));
|
|
454
|
+
return skillPath;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Rewrite a recipe file so that every occurrence of `<from>` is replaced with `<to>`:
|
|
458
|
+
* - fingerprint header family slot
|
|
459
|
+
* - recipe names (after `@recipe `)
|
|
460
|
+
* - cross-recipe `@from` / `@from-*` references in bodies
|
|
461
|
+
*
|
|
462
|
+
* Descriptions and unrelated text are untouched.
|
|
463
|
+
*/
|
|
464
|
+
function renameFamilyInSource(source, from, to) {
|
|
465
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
466
|
+
const f = escape(from);
|
|
467
|
+
let out = source;
|
|
468
|
+
out = out.replace(new RegExp(`(\\bshortwind:\\s+)${f}(\\b|@)`, "g"), `$1${to}$2`);
|
|
469
|
+
out = out.replace(new RegExp(`(@recipe\\s+)${f}(\\b|-)`, "g"), `$1${to}$2`);
|
|
470
|
+
out = out.replace(new RegExp(`@${f}(\\b|-)`, "g"), `@${to}$1`);
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
//#endregion
|
|
474
|
+
//#region src/commands/add.ts
|
|
475
|
+
async function add(options) {
|
|
476
|
+
const cwd = path.resolve(options.cwd);
|
|
477
|
+
const config = await readConfig(cwd);
|
|
478
|
+
const registry = options.registry ?? config.registry;
|
|
479
|
+
const source = createRegistrySource(registry);
|
|
480
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
481
|
+
await mkdir(recipesDir, { recursive: true });
|
|
482
|
+
const lock = await readLockfile(recipesDir);
|
|
483
|
+
if (!lock.registry) lock.registry = registry;
|
|
484
|
+
const requested = options.all ? await source.listAllFamilies() : options.families;
|
|
485
|
+
if (options.all && options.as) throw new Error("--as cannot be combined with --all");
|
|
486
|
+
if (options.as && requested.length !== 1) throw new Error("--as requires exactly one family argument");
|
|
487
|
+
const added = [];
|
|
488
|
+
const skipped = [];
|
|
489
|
+
const overwritten = [];
|
|
490
|
+
const missingDependencies = [];
|
|
491
|
+
const installedRecipeNames = readAllInstalledRecipeNames(recipesDir);
|
|
492
|
+
for (const family of requested) {
|
|
493
|
+
const targetName = options.as ?? family;
|
|
494
|
+
const targetPath = path.join(recipesDir, `${targetName}.css`);
|
|
495
|
+
const exists = existsSync(targetPath);
|
|
496
|
+
if (exists && !options.force) {
|
|
497
|
+
skipped.push(targetName);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const sourceCss = await source.loadFamily(family);
|
|
501
|
+
const renamed = options.as ? renameFamilyInSource(sourceCss, family, options.as) : sourceCss;
|
|
502
|
+
const sha = computeBodySha(renamed);
|
|
503
|
+
const finalCss = rewriteHeaderSha(renamed, sha);
|
|
504
|
+
await writeFile(targetPath, finalCss);
|
|
505
|
+
const header = extractHeader(finalCss);
|
|
506
|
+
if (header) lock.families[targetName] = {
|
|
507
|
+
version: header.version,
|
|
508
|
+
sha
|
|
509
|
+
};
|
|
510
|
+
if (exists) overwritten.push(targetName);
|
|
511
|
+
else added.push(targetName);
|
|
512
|
+
const parsed = parseInstalledFamily(recipesDir, targetName);
|
|
513
|
+
if (parsed) {
|
|
514
|
+
for (const r of parsed.recipes) installedRecipeNames.add(r.name);
|
|
515
|
+
const ownNames = new Set(parsed.recipes.map((r) => r.name));
|
|
516
|
+
const missing = /* @__PURE__ */ new Set();
|
|
517
|
+
for (const recipe of parsed.recipes) for (const ref of recipe.references) {
|
|
518
|
+
if (ownNames.has(ref)) continue;
|
|
519
|
+
if (installedRecipeNames.has(ref)) continue;
|
|
520
|
+
missing.add(ref);
|
|
521
|
+
}
|
|
522
|
+
if (missing.size > 0) missingDependencies.push({
|
|
523
|
+
family: targetName,
|
|
524
|
+
references: Array.from(missing).sort()
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
await writeLockfile(recipesDir, lock);
|
|
529
|
+
return {
|
|
530
|
+
added,
|
|
531
|
+
skipped,
|
|
532
|
+
overwritten,
|
|
533
|
+
missingDependencies,
|
|
534
|
+
lockfile: lock,
|
|
535
|
+
skillPath: await regenerateSkillMd(cwd, config)
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function readAllInstalledRecipeNames(recipesDir) {
|
|
539
|
+
const names = /* @__PURE__ */ new Set();
|
|
540
|
+
for (const fam of readDirFamilies(recipesDir)) {
|
|
541
|
+
const p = parseInstalledFamily(recipesDir, fam);
|
|
542
|
+
if (!p) continue;
|
|
543
|
+
for (const r of p.recipes) names.add(r.name);
|
|
544
|
+
}
|
|
545
|
+
return names;
|
|
546
|
+
}
|
|
547
|
+
function readDirFamilies(recipesDir) {
|
|
548
|
+
if (!existsSync(recipesDir)) return [];
|
|
549
|
+
return readdirSync(recipesDir).filter((f) => f.endsWith(".css")).map((f) => f.replace(/\.css$/, ""));
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
552
|
+
//#region src/commands/remove.ts
|
|
553
|
+
async function remove(options) {
|
|
554
|
+
const cwd = path.resolve(options.cwd);
|
|
555
|
+
const config = await readConfig(cwd);
|
|
556
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
557
|
+
const lock = await readLockfile(recipesDir);
|
|
558
|
+
const removed = [];
|
|
559
|
+
const notFound = [];
|
|
560
|
+
const removedRecipeNames = /* @__PURE__ */ new Set();
|
|
561
|
+
for (const family of options.families) {
|
|
562
|
+
const target = path.join(recipesDir, `${family}.css`);
|
|
563
|
+
if (!existsSync(target)) {
|
|
564
|
+
notFound.push(family);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const parsed = parseInstalledFamily(recipesDir, family);
|
|
568
|
+
if (parsed) for (const r of parsed.recipes) removedRecipeNames.add(r.name);
|
|
569
|
+
await rm(target);
|
|
570
|
+
delete lock.families[family];
|
|
571
|
+
removed.push(family);
|
|
572
|
+
}
|
|
573
|
+
await writeLockfile(recipesDir, lock);
|
|
574
|
+
return {
|
|
575
|
+
removed,
|
|
576
|
+
notFound,
|
|
577
|
+
brokenDependents: collectBrokenDependents(recipesDir, removedRecipeNames),
|
|
578
|
+
lockfile: lock,
|
|
579
|
+
skillPath: await regenerateSkillMd(cwd, config)
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function collectBrokenDependents(recipesDir, removedRecipeNames) {
|
|
583
|
+
if (removedRecipeNames.size === 0) return [];
|
|
584
|
+
const out = [];
|
|
585
|
+
for (const family of installedFamilies(recipesDir)) {
|
|
586
|
+
const parsed = parseInstalledFamily(recipesDir, family);
|
|
587
|
+
if (!parsed) continue;
|
|
588
|
+
const broken = /* @__PURE__ */ new Set();
|
|
589
|
+
for (const recipe of parsed.recipes) for (const ref of recipe.references) if (removedRecipeNames.has(ref)) broken.add(ref);
|
|
590
|
+
if (broken.size > 0) out.push({
|
|
591
|
+
dependent: family,
|
|
592
|
+
references: Array.from(broken).sort()
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/commands/preset.ts
|
|
599
|
+
async function preset(options) {
|
|
600
|
+
const cwd = path.resolve(options.cwd);
|
|
601
|
+
const config = await readConfig(cwd);
|
|
602
|
+
const source = createRegistrySource(options.registry ?? config.registry);
|
|
603
|
+
if (options.name === "none") throw new Error("Use `shortwind remove` to uninstall families; preset 'none' is for `init` only.");
|
|
604
|
+
const presets = await source.loadPresets();
|
|
605
|
+
const all = await source.listAllFamilies();
|
|
606
|
+
const addOptions = {
|
|
607
|
+
cwd,
|
|
608
|
+
families: resolvePresetFamilies(options.name, presets, all)
|
|
609
|
+
};
|
|
610
|
+
if (options.registry !== void 0) addOptions.registry = options.registry;
|
|
611
|
+
return {
|
|
612
|
+
...await add(addOptions),
|
|
613
|
+
preset: options.name
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
//#endregion
|
|
617
|
+
//#region src/commands/ls.ts
|
|
618
|
+
async function ls(options) {
|
|
619
|
+
const cwd = path.resolve(options.cwd);
|
|
620
|
+
const config = await readConfig(cwd);
|
|
621
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
622
|
+
const lock = await readLockfile(recipesDir);
|
|
623
|
+
const installed = options.availableOnly ? [] : installedFamilies(recipesDir).map((family) => {
|
|
624
|
+
return {
|
|
625
|
+
family,
|
|
626
|
+
version: lock.families[family]?.version ?? null
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
let available = [];
|
|
630
|
+
if (!options.installedOnly) {
|
|
631
|
+
const source = createRegistrySource(options.registry ?? config.registry);
|
|
632
|
+
try {
|
|
633
|
+
available = await source.listAllFamilies();
|
|
634
|
+
} catch {
|
|
635
|
+
available = [];
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
installed,
|
|
640
|
+
available
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function formatLsText(result) {
|
|
644
|
+
const installedSet = new Set(result.installed.map((i) => i.family));
|
|
645
|
+
const lines = [];
|
|
646
|
+
lines.push("Installed:");
|
|
647
|
+
if (result.installed.length === 0) lines.push(" (none)");
|
|
648
|
+
for (const { family, version } of result.installed) lines.push(` ${family}${version ? ` ${version}` : ""}`);
|
|
649
|
+
lines.push("");
|
|
650
|
+
lines.push("Available:");
|
|
651
|
+
if (result.available.length === 0) lines.push(" (registry unreachable)");
|
|
652
|
+
for (const family of result.available) {
|
|
653
|
+
const marker = installedSet.has(family) ? "*" : " ";
|
|
654
|
+
lines.push(` ${marker} ${family}`);
|
|
655
|
+
}
|
|
656
|
+
return lines.join("\n");
|
|
657
|
+
}
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/commands/build.ts
|
|
660
|
+
var BuildError = class extends Error {
|
|
661
|
+
diagnostics;
|
|
662
|
+
constructor(diagnostics) {
|
|
663
|
+
super(`shortwind build failed:\n${diagnostics.map((d) => ` ${d.file}:${d.line}${d.column ? `:${d.column}` : ""} ${d.code} — ${d.message}`).join("\n")}`);
|
|
664
|
+
this.diagnostics = diagnostics;
|
|
665
|
+
this.name = "BuildError";
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
async function build(options) {
|
|
669
|
+
const cwd = path.resolve(options.cwd);
|
|
670
|
+
const config = await readConfig(cwd);
|
|
671
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
672
|
+
const families = installedFamilies(recipesDir);
|
|
673
|
+
const allRecipes = [];
|
|
674
|
+
const guidance = {};
|
|
675
|
+
const errors = [];
|
|
676
|
+
for (const family of families) {
|
|
677
|
+
const parsed = parseRecipeFile(readFileSync(path.join(recipesDir, `${family}.css`), "utf8"), `${family}.css`);
|
|
678
|
+
if (!parsed.ok) {
|
|
679
|
+
errors.push(...parsed.errors);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
allRecipes.push(...parsed.value.recipes);
|
|
683
|
+
if (parsed.value.guidance) guidance[family] = parsed.value.guidance;
|
|
684
|
+
}
|
|
685
|
+
if (errors.length > 0) throw new BuildError(errors);
|
|
686
|
+
const resolved = buildRegistry(allRecipes, { guidance });
|
|
687
|
+
if (!resolved.ok) throw new BuildError(resolved.errors);
|
|
688
|
+
const skillPath = path.join(cwd, config.outputPath);
|
|
689
|
+
const next = renderSkillMarkdown(resolved.value, { order: families });
|
|
690
|
+
const current = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : null;
|
|
691
|
+
let changed = false;
|
|
692
|
+
if (current !== next) {
|
|
693
|
+
await mkdir(path.dirname(skillPath), { recursive: true });
|
|
694
|
+
await writeFile(skillPath, next);
|
|
695
|
+
changed = true;
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
changed,
|
|
699
|
+
families,
|
|
700
|
+
skillPath
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/commands/dev.ts
|
|
705
|
+
async function dev(options) {
|
|
706
|
+
options.signal?.throwIfAborted();
|
|
707
|
+
const cwd = path.resolve(options.cwd);
|
|
708
|
+
const config = await readConfig(cwd);
|
|
709
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
710
|
+
const debounceMs = options.debounceMs ?? 50;
|
|
711
|
+
const reconcileIntervalMs = options.reconcileIntervalMs ?? Math.max(1e3, debounceMs * 5);
|
|
712
|
+
const status = (s) => options.onStatus?.(s);
|
|
713
|
+
const watcher = chokidar.watch(recipesDir, {
|
|
714
|
+
ignoreInitial: true,
|
|
715
|
+
awaitWriteFinish: {
|
|
716
|
+
stabilityThreshold: 25,
|
|
717
|
+
pollInterval: 10
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
let timer = null;
|
|
721
|
+
let reconcileTimer = null;
|
|
722
|
+
let running = false;
|
|
723
|
+
let pending = false;
|
|
724
|
+
let pendingSilent = true;
|
|
725
|
+
const runBuild = async (silentNoChange = false) => {
|
|
726
|
+
if (running) {
|
|
727
|
+
pending = true;
|
|
728
|
+
pendingSilent = pendingSilent && silentNoChange;
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
running = true;
|
|
732
|
+
try {
|
|
733
|
+
let currentSilent = silentNoChange;
|
|
734
|
+
do {
|
|
735
|
+
pending = false;
|
|
736
|
+
pendingSilent = true;
|
|
737
|
+
try {
|
|
738
|
+
const result = await build({ cwd });
|
|
739
|
+
if (!currentSilent || result.changed) status({
|
|
740
|
+
kind: "rebuilt",
|
|
741
|
+
families: result.families,
|
|
742
|
+
changed: result.changed
|
|
743
|
+
});
|
|
744
|
+
} catch (err) {
|
|
745
|
+
if (err instanceof BuildError) status({
|
|
746
|
+
kind: "error",
|
|
747
|
+
message: err.message
|
|
748
|
+
});
|
|
749
|
+
else status({
|
|
750
|
+
kind: "error",
|
|
751
|
+
message: err instanceof Error ? err.message : String(err)
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
currentSilent = pendingSilent;
|
|
755
|
+
} while (pending);
|
|
756
|
+
} finally {
|
|
757
|
+
running = false;
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
const schedule = () => {
|
|
761
|
+
if (timer) clearTimeout(timer);
|
|
762
|
+
timer = setTimeout(() => void runBuild(false), debounceMs);
|
|
763
|
+
};
|
|
764
|
+
watcher.on("add", schedule).on("change", schedule).on("unlink", schedule);
|
|
765
|
+
let stopped = false;
|
|
766
|
+
const stop = async () => {
|
|
767
|
+
if (stopped) return;
|
|
768
|
+
stopped = true;
|
|
769
|
+
if (timer) clearTimeout(timer);
|
|
770
|
+
if (reconcileTimer) clearInterval(reconcileTimer);
|
|
771
|
+
await watcher.close();
|
|
772
|
+
};
|
|
773
|
+
if (options.signal) {
|
|
774
|
+
if (options.signal.aborted) {
|
|
775
|
+
await stop();
|
|
776
|
+
return { stop };
|
|
777
|
+
}
|
|
778
|
+
options.signal.addEventListener("abort", () => void stop(), { once: true });
|
|
779
|
+
}
|
|
780
|
+
await new Promise((resolve) => watcher.once("ready", () => resolve()));
|
|
781
|
+
if (stopped) return { stop };
|
|
782
|
+
await runBuild();
|
|
783
|
+
if (stopped) return { stop };
|
|
784
|
+
status({
|
|
785
|
+
kind: "ready",
|
|
786
|
+
recipesDir
|
|
787
|
+
});
|
|
788
|
+
reconcileTimer = setInterval(() => void runBuild(true), reconcileIntervalMs);
|
|
789
|
+
return { stop };
|
|
790
|
+
}
|
|
791
|
+
//#endregion
|
|
792
|
+
//#region src/commands/upgrade.ts
|
|
793
|
+
var UpgradeError = class extends Error {
|
|
794
|
+
errors;
|
|
795
|
+
constructor(errors) {
|
|
796
|
+
super(`shortwind upgrade failed:\n${errors.map((e) => ` ${e.family}: ${e.message}`).join("\n")}`);
|
|
797
|
+
this.errors = errors;
|
|
798
|
+
this.name = "UpgradeError";
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
async function upgrade(options) {
|
|
802
|
+
const cwd = path.resolve(options.cwd);
|
|
803
|
+
const config = await readConfig(cwd);
|
|
804
|
+
const registry = options.registry ?? config.registry;
|
|
805
|
+
const source = options.source ?? createRegistrySource(registry);
|
|
806
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
807
|
+
const installed = installedFamilies(recipesDir);
|
|
808
|
+
const targets = options.families && options.families.length > 0 ? options.families : installed;
|
|
809
|
+
const lock = await readLockfile(recipesDir);
|
|
810
|
+
let lockfileDirty = false;
|
|
811
|
+
if (!lock.registry) {
|
|
812
|
+
lock.registry = registry;
|
|
813
|
+
lockfileDirty = true;
|
|
814
|
+
}
|
|
815
|
+
const outcomes = [];
|
|
816
|
+
const errors = [];
|
|
817
|
+
let hasUpdates = false;
|
|
818
|
+
let hasTouched = false;
|
|
819
|
+
let anyWritten = false;
|
|
820
|
+
for (const family of targets) {
|
|
821
|
+
const filePath = path.join(recipesDir, `${family}.css`);
|
|
822
|
+
if (!existsSync(filePath)) {
|
|
823
|
+
outcomes.push({
|
|
824
|
+
family,
|
|
825
|
+
action: "skipped",
|
|
826
|
+
reason: "not installed",
|
|
827
|
+
state: "missing"
|
|
828
|
+
});
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
let incomingBody;
|
|
832
|
+
try {
|
|
833
|
+
incomingBody = await source.loadFamily(family);
|
|
834
|
+
} catch (err) {
|
|
835
|
+
errors.push({
|
|
836
|
+
family,
|
|
837
|
+
message: err.message
|
|
838
|
+
});
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
const incomingHeader = extractHeader(incomingBody);
|
|
842
|
+
if (!incomingHeader) {
|
|
843
|
+
errors.push({
|
|
844
|
+
family,
|
|
845
|
+
message: "registry recipe has no fingerprint header"
|
|
846
|
+
});
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const incomingVersion = incomingHeader.version;
|
|
850
|
+
const localBody = readFileSync(filePath, "utf8");
|
|
851
|
+
const localHeader = extractHeader(localBody);
|
|
852
|
+
const recordedSha = localHeader?.sha ?? "";
|
|
853
|
+
const actualSha = computeBodySha(localBody);
|
|
854
|
+
const lockedVersion = lock.families[family]?.version ?? localHeader?.version ?? "";
|
|
855
|
+
const isTouched = recordedSha !== "" && recordedSha !== actualSha;
|
|
856
|
+
if ((isTouched ? "touched" : lockedVersion === incomingVersion ? "unchanged" : "pristine") === "unchanged" && !isTouched) {
|
|
857
|
+
outcomes.push({
|
|
858
|
+
family,
|
|
859
|
+
action: "kept",
|
|
860
|
+
reason: "unchanged",
|
|
861
|
+
state: "unchanged"
|
|
862
|
+
});
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (options.check) {
|
|
866
|
+
if (isTouched) {
|
|
867
|
+
hasTouched = true;
|
|
868
|
+
if (lockedVersion !== incomingVersion) hasUpdates = true;
|
|
869
|
+
outcomes.push({
|
|
870
|
+
family,
|
|
871
|
+
action: "would-review",
|
|
872
|
+
from: lockedVersion,
|
|
873
|
+
to: incomingVersion,
|
|
874
|
+
state: "touched"
|
|
875
|
+
});
|
|
876
|
+
} else {
|
|
877
|
+
hasUpdates = true;
|
|
878
|
+
outcomes.push({
|
|
879
|
+
family,
|
|
880
|
+
action: "would-update",
|
|
881
|
+
from: lockedVersion,
|
|
882
|
+
to: incomingVersion,
|
|
883
|
+
state: "pristine"
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (isTouched && !options.force) {
|
|
889
|
+
hasTouched = true;
|
|
890
|
+
const choice = options.resolver ? await options.resolver({
|
|
891
|
+
family,
|
|
892
|
+
local: localBody,
|
|
893
|
+
baseline: {
|
|
894
|
+
version: lockedVersion,
|
|
895
|
+
sha: recordedSha
|
|
896
|
+
},
|
|
897
|
+
incoming: {
|
|
898
|
+
version: incomingVersion,
|
|
899
|
+
body: incomingBody
|
|
900
|
+
}
|
|
901
|
+
}) : "skip";
|
|
902
|
+
if (choice === "keep") {
|
|
903
|
+
outcomes.push({
|
|
904
|
+
family,
|
|
905
|
+
action: "kept",
|
|
906
|
+
reason: "user-chose-keep",
|
|
907
|
+
state: "touched"
|
|
908
|
+
});
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (choice === "skip") {
|
|
912
|
+
outcomes.push({
|
|
913
|
+
family,
|
|
914
|
+
action: "skipped",
|
|
915
|
+
reason: "touched",
|
|
916
|
+
state: "touched"
|
|
917
|
+
});
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const newSha = computeBodySha(incomingBody);
|
|
922
|
+
await atomicWrite(filePath, rewriteHeaderSha(incomingBody, newSha));
|
|
923
|
+
lock.families[family] = {
|
|
924
|
+
version: incomingVersion,
|
|
925
|
+
sha: newSha
|
|
926
|
+
};
|
|
927
|
+
outcomes.push({
|
|
928
|
+
family,
|
|
929
|
+
action: "updated",
|
|
930
|
+
from: lockedVersion,
|
|
931
|
+
to: incomingVersion,
|
|
932
|
+
state: isTouched ? "touched" : "pristine"
|
|
933
|
+
});
|
|
934
|
+
hasUpdates = true;
|
|
935
|
+
anyWritten = true;
|
|
936
|
+
lockfileDirty = true;
|
|
937
|
+
}
|
|
938
|
+
if (errors.length > 0) throw new UpgradeError(errors);
|
|
939
|
+
let skillPath = null;
|
|
940
|
+
if (!options.check) {
|
|
941
|
+
if (lockfileDirty) await writeLockfile(recipesDir, lock);
|
|
942
|
+
if (anyWritten) skillPath = await regenerateSkillMd(cwd, config);
|
|
943
|
+
}
|
|
944
|
+
return {
|
|
945
|
+
outcomes,
|
|
946
|
+
hasUpdates,
|
|
947
|
+
hasTouched,
|
|
948
|
+
lockfile: lock,
|
|
949
|
+
skillPath
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
async function atomicWrite(filePath, body) {
|
|
953
|
+
const tmp = filePath + ".tmp";
|
|
954
|
+
const fh = await open(tmp, "w");
|
|
955
|
+
try {
|
|
956
|
+
await fh.writeFile(body);
|
|
957
|
+
await fh.sync();
|
|
958
|
+
} finally {
|
|
959
|
+
await fh.close();
|
|
960
|
+
}
|
|
961
|
+
await rename(tmp, filePath);
|
|
962
|
+
}
|
|
963
|
+
//#endregion
|
|
964
|
+
//#region src/commands/verify.ts
|
|
965
|
+
async function verify(options) {
|
|
966
|
+
const cwd = path.resolve(options.cwd);
|
|
967
|
+
const config = await readConfig(cwd);
|
|
968
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
969
|
+
const installed = installedFamilies(recipesDir);
|
|
970
|
+
const lock = await readLockfile(recipesDir);
|
|
971
|
+
const issues = [];
|
|
972
|
+
const seen = /* @__PURE__ */ new Set();
|
|
973
|
+
for (const family of installed) {
|
|
974
|
+
seen.add(family);
|
|
975
|
+
const filePath = path.join(recipesDir, `${family}.css`);
|
|
976
|
+
const source = readFileSync(filePath, "utf8");
|
|
977
|
+
const header = extractHeader(source);
|
|
978
|
+
if (!header) {
|
|
979
|
+
issues.push({
|
|
980
|
+
family,
|
|
981
|
+
kind: "missing-header",
|
|
982
|
+
file: filePath
|
|
983
|
+
});
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const actual = computeBodySha(source);
|
|
987
|
+
if (header.sha !== actual) issues.push({
|
|
988
|
+
family,
|
|
989
|
+
kind: "header-tampered",
|
|
990
|
+
file: filePath,
|
|
991
|
+
recorded: header.sha,
|
|
992
|
+
actual
|
|
993
|
+
});
|
|
994
|
+
const locked = lock.families[family];
|
|
995
|
+
if (!locked) issues.push({
|
|
996
|
+
family,
|
|
997
|
+
kind: "missing-lock-entry",
|
|
998
|
+
file: filePath
|
|
999
|
+
});
|
|
1000
|
+
else if (locked.sha !== actual) issues.push({
|
|
1001
|
+
family,
|
|
1002
|
+
kind: "lockfile-mismatch",
|
|
1003
|
+
file: filePath,
|
|
1004
|
+
locked: locked.sha,
|
|
1005
|
+
actual
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
for (const family of Object.keys(lock.families)) {
|
|
1009
|
+
if (seen.has(family)) continue;
|
|
1010
|
+
const filePath = path.join(recipesDir, `${family}.css`);
|
|
1011
|
+
if (!existsSync(filePath)) issues.push({
|
|
1012
|
+
family,
|
|
1013
|
+
kind: "missing-file",
|
|
1014
|
+
file: filePath
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
ok: issues.length === 0,
|
|
1019
|
+
checked: installed,
|
|
1020
|
+
issues
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/bench-corpus/default-recipes.ts
|
|
1025
|
+
const DEFAULT_RECIPES_CSS = {
|
|
1026
|
+
"badge.css": "/* shortwind: badge@0.0.1 sha:000000 */\n\n/* @guide\n @badge is the neutral default; tone variants @badge-success/warning/danger/\n info carry their own color; @badge-outline is unfilled. One tone per badge.\n @badge-base is a color-less shell for building custom tones — not for direct\n use.\n*/\n\n/* Badge shell — shape, sizing, focus ring. No bg/text/border color so\n variants can supply their own tone without conflicts. */\n@recipe badge-base {\n inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\n}\n\n/* Default neutral badge. */\n@recipe badge {\n inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground\n}\n\n/* Success tone badge. */\n@recipe badge-success {\n inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-200\n}\n\n/* Warning tone badge. */\n@recipe badge-warning {\n inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200\n}\n\n/* Danger tone badge. */\n@recipe badge-danger {\n inline-flex items-center gap-1 rounded-full bg-destructive/15 px-2 py-0.5 text-xs font-medium text-destructive\n}\n\n/* Info tone badge. */\n@recipe badge-info {\n inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary\n}\n\n/* Outline badge — no fill. */\n@recipe badge-outline {\n inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs font-medium text-foreground\n}\n",
|
|
1027
|
+
"button.css": "/* shortwind: button@0.0.1 sha:000000 */\n\n/* @guide\n Name order is @btn-<intent>[-<size>]: intent first (primary/secondary/ghost/\n danger/outline), size second (sm/lg; omit for default). One intent per\n button — never combine @btn-primary with @btn-danger. @btn-ghost is text-only,\n @btn-outline is bordered with no fill, @btn-icon is a square icon button.\n @btn-base is the shared shell; don't use it on its own.\n*/\n\n/* Shared button base — sizing, focus ring, disabled state. */\n@recipe btn-base {\n inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-50\n}\n\n/* Primary call-to-action button. */\n@recipe btn-primary {\n @btn-base bg-primary text-primary-foreground hover:bg-primary/90\n}\n\n/* Small primary button. */\n@recipe btn-primary-sm {\n @btn-primary px-3 py-1.5 text-xs\n}\n\n/* Large primary button. */\n@recipe btn-primary-lg {\n @btn-primary px-6 py-3 text-base\n}\n\n/* Secondary button — bordered surface tone. */\n@recipe btn-secondary {\n @btn-base border border-border bg-secondary text-secondary-foreground hover:bg-secondary/80\n}\n\n/* Small secondary button. */\n@recipe btn-secondary-sm {\n @btn-secondary px-3 py-1.5 text-xs\n}\n\n/* Large secondary button. */\n@recipe btn-secondary-lg {\n @btn-secondary px-6 py-3 text-base\n}\n\n/* Ghost button — text only, no background. */\n@recipe btn-ghost {\n @btn-base text-foreground hover:bg-muted\n}\n\n/* Small ghost button. */\n@recipe btn-ghost-sm {\n @btn-ghost px-3 py-1.5 text-xs\n}\n\n/* Large ghost button. */\n@recipe btn-ghost-lg {\n @btn-ghost px-6 py-3 text-base\n}\n\n/* Destructive button. */\n@recipe btn-danger {\n @btn-base bg-destructive text-destructive-foreground hover:bg-destructive/90\n}\n\n/* Outline button — bordered without fill. */\n@recipe btn-outline {\n @btn-base border border-primary text-primary hover:bg-primary/10\n}\n\n/* Square icon-only button. */\n@recipe btn-icon {\n inline-flex h-9 w-9 items-center justify-center rounded-md text-foreground transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-50\n}\n",
|
|
1028
|
+
"card.css": "/* shortwind: card@0.0.1 sha:000000 */\n\n/* @guide\n Pick exactly one container: @card (default), @card-elevated (raised shadow),\n @card-flat (no border), or @card-interactive (clickable hover/focus) — don't\n stack two container variants on one element. Lay out the inside with\n @card-header / @card-body / @card-footer.\n*/\n\n/* Default content card with border, padding, and surface color. */\n@recipe card {\n rounded-lg border border-border bg-card text-card-foreground p-4\n}\n\n/* Card with raised shadow for emphasis. */\n@recipe card-elevated {\n @card shadow-md\n}\n\n/* Card without border, on a muted surface. */\n@recipe card-flat {\n rounded-lg bg-muted text-foreground p-4\n}\n\n/* Clickable card with hover and focus-visible states. */\n@recipe card-interactive {\n @card cursor-pointer transition-shadow hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\n}\n\n/* Card header region with bottom divider. */\n@recipe card-header {\n mb-3 border-b border-border pb-3\n}\n\n/* Card body region. */\n@recipe card-body {\n py-1\n}\n\n/* Card footer with top divider and right-aligned actions. */\n@recipe card-footer {\n mt-3 flex items-center justify-end gap-2 border-t border-border pt-3\n}\n",
|
|
1029
|
+
"code.css": "/* shortwind: code@0.0.1 sha:000000 */\n\n/* @guide\n @code-inline for a code span inside prose, @code-block for a multi-line\n preformatted block, @kbd for a keyboard-shortcut hint. Pick by context, not\n by size.\n*/\n\n/* Inline code span. */\n@recipe code-inline {\n rounded bg-muted px-1.5 py-0.5 font-mono text-[0.875em] text-foreground\n}\n\n/* Block of preformatted code. */\n@recipe code-block {\n overflow-x-auto rounded-md border border-border bg-muted p-4 font-mono text-sm leading-6 text-foreground\n}\n\n/* Keyboard shortcut hint. */\n@recipe kbd {\n inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground shadow-sm\n}\n",
|
|
1030
|
+
"dialog.css": "/* shortwind: dialog@0.0.1 sha:000000 */\n\n/* @guide\n A modal is three layers: @dialog-overlay (dimmed backdrop), @dialog (the\n centering wrapper), and @dialog-content (the panel). Structure the panel with\n @dialog-header and @dialog-footer. Don't put content styling on @dialog\n itself — it's only the positioner.\n*/\n\n/* Modal dialog wrapper — covers the viewport, centers content. */\n@recipe dialog {\n fixed inset-0 z-50 flex items-center justify-center p-4\n}\n\n/* Dimmed overlay behind the dialog. */\n@recipe dialog-overlay {\n fixed inset-0 z-40 bg-black/50\n}\n\n/* Dialog content panel. */\n@recipe dialog-content {\n relative z-50 w-full max-w-md rounded-lg border border-border bg-popover text-popover-foreground p-6 shadow-xl\n}\n\n/* Dialog header region with title. */\n@recipe dialog-header {\n mb-4 flex flex-col gap-1\n}\n\n/* Dialog footer with right-aligned actions. */\n@recipe dialog-footer {\n mt-6 flex items-center justify-end gap-2\n}\n",
|
|
1031
|
+
"empty.css": "/* shortwind: empty@0.0.1 sha:000000 */\n\n/* @guide\n @empty is the container for a no-data state; fill it with @empty-icon,\n @empty-title, and @empty-description. These are slots for that pattern — use\n @heading-* / @body from the text family for ordinary copy.\n*/\n\n/* Empty-state container. */\n@recipe empty {\n flex flex-col items-center justify-center gap-3 rounded-md border border-dashed border-border p-8 text-center\n}\n\n/* Empty-state icon slot. */\n@recipe empty-icon {\n flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground\n}\n\n/* Empty-state title text. */\n@recipe empty-title {\n text-base font-semibold text-foreground\n}\n\n/* Empty-state supporting description. */\n@recipe empty-description {\n max-w-sm text-sm text-muted-foreground\n}\n",
|
|
1032
|
+
"feedback.css": "/* shortwind: feedback@0.0.1 sha:000000 */\n\n/* @guide\n Inline messages use @alert (neutral) or a tone variant @alert-success/\n warning/danger/info — one tone each. @callout is a left-accent inline note,\n @toast is a floating notification, @banner spans the full viewport width.\n*/\n\n/* Default informational alert. */\n@recipe alert {\n flex items-start gap-3 rounded-md border border-border bg-card p-4 text-sm text-card-foreground\n}\n\n/* Success alert. */\n@recipe alert-success {\n flex items-start gap-3 rounded-md border border-green-200 bg-green-50 p-4 text-sm text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100\n}\n\n/* Warning alert. */\n@recipe alert-warning {\n flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100\n}\n\n/* Danger alert. */\n@recipe alert-danger {\n flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive\n}\n\n/* Informational alert. */\n@recipe alert-info {\n flex items-start gap-3 rounded-md border border-primary/30 bg-primary/10 p-4 text-sm text-primary\n}\n\n/* Inline callout — flush left edge accent. */\n@recipe callout {\n border-l-4 border-primary bg-primary/10 p-4 text-sm text-foreground\n}\n\n/* Floating toast notification. */\n@recipe toast {\n pointer-events-auto flex items-start gap-3 rounded-md border border-border bg-popover p-4 text-sm text-popover-foreground shadow-lg\n}\n\n/* Full-width banner spanning the viewport. */\n@recipe banner {\n w-full bg-primary px-4 py-2 text-center text-sm font-medium text-primary-foreground\n}\n",
|
|
1033
|
+
"form.css": "/* shortwind: form@0.0.1 sha:000000 */\n\n/* @guide\n Wrap each label+control+message in @field (use @field-error for the invalid\n state); group related fields with @fieldset. Controls are bare: @input,\n @textarea, @select, @checkbox, @radio, plus @input-error for invalid text\n and @input-shell for the transparent shadcn-style shell. Helper text is\n @help. There is no @form-group (use @field), @form-input (use @input),\n @form-helper (use @help) or @form-checkbox (use @checkbox); the field label\n recipe is @label, in the text family.\n*/\n\n/* Text input field. */\n@recipe input {\n block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-2 focus:outline-offset-2 focus:outline-ring disabled:cursor-not-allowed disabled:opacity-50\n}\n\n/* shadcn/dinachi-style input shell — transparent background, h-9, file/\n placeholder/selection/aria-invalid/focus-visible states baked in. */\n@recipe input-shell {\n flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40\n}\n\n/* Input in error state. */\n@recipe input-error {\n @input border-destructive focus:border-destructive focus:outline-destructive\n}\n\n/* Multi-line textarea. */\n@recipe textarea {\n block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-2 focus:outline-offset-2 focus:outline-ring disabled:cursor-not-allowed disabled:opacity-50\n}\n\n/* Native select control. */\n@recipe select {\n block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:border-ring focus:outline-2 focus:outline-offset-2 focus:outline-ring disabled:cursor-not-allowed disabled:opacity-50\n}\n\n/* Checkbox input. */\n@recipe checkbox {\n h-4 w-4 rounded border-input text-primary focus:outline-2 focus:outline-offset-2 focus:outline-ring\n}\n\n/* Radio input. */\n@recipe radio {\n h-4 w-4 border-input text-primary focus:outline-2 focus:outline-offset-2 focus:outline-ring\n}\n\n/* Form field wrapper — label + input + help/error. */\n@recipe field {\n flex flex-col gap-1.5\n}\n\n/* Form field in error state. */\n@recipe field-error {\n flex flex-col gap-1.5\n}\n\n/* Grouped form section with optional legend. */\n@recipe fieldset {\n flex flex-col gap-4 rounded-md border border-border p-4\n}\n\n/* Field-level helper text. */\n@recipe help {\n text-xs text-muted-foreground\n}\n",
|
|
1034
|
+
"icon.css": "/* shortwind: icon@0.0.1 sha:000000 */\n\n/* @guide\n Size an icon with @icon-sm/md/lg (16/20/24px) — these set width and height\n only; add @icon-muted for secondary color. They're for SVG/icon elements, not\n to be confused with @btn-icon (the icon button in the button family).\n*/\n\n/* Small icon — 16px. */\n@recipe icon-sm {\n h-4 w-4 shrink-0\n}\n\n/* Default icon size — 20px. */\n@recipe icon-md {\n h-5 w-5 shrink-0\n}\n\n/* Large icon — 24px. */\n@recipe icon-lg {\n h-6 w-6 shrink-0\n}\n\n/* Icon with muted color. */\n@recipe icon-muted {\n text-muted-foreground\n}\n",
|
|
1035
|
+
"layout.css": "/* shortwind: layout@0.0.1 sha:000000 */\n\n/* @guide\n Composition primitives. @stack-* stacks children vertically (flex-col);\n @row* lays them out horizontally (flex-row). Choose the gap with the size\n suffix (xs/sm/md/lg on stacks). Use @grid-2/3/4 only for true multi-column\n grids, @center to center on both axes, @full to fill the parent. Common\n slips: there is no @flex-row (use @row) or @flex-col (use a @stack-*), and\n the grids are @grid-3, not @grid-cols-3.\n*/\n\n/* Vertical stack with extra-small gap. */\n@recipe stack-xs {\n flex flex-col gap-1\n}\n\n/* Vertical stack with small gap. */\n@recipe stack-sm {\n flex flex-col gap-2\n}\n\n/* Vertical stack with medium gap. */\n@recipe stack-md {\n flex flex-col gap-4\n}\n\n/* Vertical stack with large gap. */\n@recipe stack-lg {\n flex flex-col gap-8\n}\n\n/* Horizontal row with default gap and centered items. */\n@recipe row {\n flex flex-row items-center gap-2\n}\n\n/* Horizontal row with space between children. */\n@recipe row-between {\n flex flex-row items-center justify-between gap-2\n}\n\n/* Horizontal row aligned to the end. */\n@recipe row-end {\n flex flex-row items-center justify-end gap-2\n}\n\n/* Two-column responsive grid. */\n@recipe grid-2 {\n grid grid-cols-1 gap-4 sm:grid-cols-2\n}\n\n/* Three-column responsive grid. */\n@recipe grid-3 {\n grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\n}\n\n/* Four-column responsive grid. */\n@recipe grid-4 {\n grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4\n}\n\n/* Center content horizontally and vertically. */\n@recipe center {\n flex items-center justify-center\n}\n\n/* Fill the available width and height. */\n@recipe full {\n h-full w-full\n}\n",
|
|
1036
|
+
"list.css": "/* shortwind: list@0.0.1 sha:000000 */\n\n/* @guide\n @list wraps a stack of @list-item rows; use @list-bordered for divided rows.\n Definition lists are separate: @dl with @dt (term) and @dd (description).\n For site navigation reach for the navigation family (@nav), not @list.\n*/\n\n/* Vertical list with default gap. */\n@recipe list {\n flex flex-col gap-1\n}\n\n/* Single list item. */\n@recipe list-item {\n flex items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground\n}\n\n/* List with dividing borders between items. */\n@recipe list-bordered {\n divide-y divide-border rounded-md border border-border\n}\n\n/* Definition list container. */\n@recipe dl {\n grid grid-cols-1 gap-2 sm:grid-cols-3 sm:gap-4\n}\n\n/* Definition term. */\n@recipe dt {\n text-sm font-medium text-muted-foreground\n}\n\n/* Definition description. */\n@recipe dd {\n text-sm text-foreground sm:col-span-2\n}\n",
|
|
1037
|
+
"media.css": "/* shortwind: media@0.0.1 sha:000000 */\n\n/* @guide\n @avatar (with @avatar-sm/lg) is a round user image; @thumb is a small square\n thumbnail. For responsive embeds use @aspect-square or @aspect-video. Avatars\n are circular by default — don't restyle the radius.\n*/\n\n/* User/profile avatar. */\n@recipe avatar {\n inline-flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-muted text-sm font-medium text-muted-foreground\n}\n\n/* Small avatar. */\n@recipe avatar-sm {\n @avatar h-6 w-6 text-xs\n}\n\n/* Large avatar. */\n@recipe avatar-lg {\n @avatar h-14 w-14 text-base\n}\n\n/* Small image thumbnail. */\n@recipe thumb {\n h-16 w-16 rounded-md object-cover\n}\n\n/* 1:1 aspect-ratio wrapper. */\n@recipe aspect-square {\n aspect-square w-full overflow-hidden rounded-md\n}\n\n/* 16:9 aspect-ratio wrapper. */\n@recipe aspect-video {\n aspect-video w-full overflow-hidden rounded-md\n}\n",
|
|
1038
|
+
"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
|
+
"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
|
+
"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. @container (or @container-tight for prose)\n centers and width-caps content; there is no @container-lg, set a different cap\n with max-w-* yourself. @divider-h and @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/* Standard content container with max width. */\n@recipe container {\n mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8\n}\n\n/* Narrow content container for prose. */\n@recipe container-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
|
+
"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
|
+
"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
|
+
"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"
|
|
1045
|
+
};
|
|
1046
|
+
//#endregion
|
|
1047
|
+
//#region src/bench-corpus/corpus.ts
|
|
1048
|
+
const CORPUS_FILES = {
|
|
1049
|
+
"button.tsx": `export function ButtonShowcase() {
|
|
1050
|
+
return (
|
|
1051
|
+
<div className="@row gap-4 p-6">
|
|
1052
|
+
<button className="@btn-primary">Primary Button</button>
|
|
1053
|
+
<button className="@btn-primary-sm">Small Primary</button>
|
|
1054
|
+
<button className="@btn-primary-lg">Large Primary</button>
|
|
1055
|
+
<button className="@btn-secondary">Secondary Button</button>
|
|
1056
|
+
<button className="@btn-outline">Outline Button</button>
|
|
1057
|
+
<button className="@btn-ghost">Ghost Button</button>
|
|
1058
|
+
<button className="@btn-danger">Danger Button</button>
|
|
1059
|
+
</div>
|
|
1060
|
+
);
|
|
1061
|
+
}`,
|
|
1062
|
+
"card.tsx": `export function ProductCard() {
|
|
1063
|
+
return (
|
|
1064
|
+
<div className="@card-interactive max-w-sm">
|
|
1065
|
+
<div className="@card-header">
|
|
1066
|
+
<h3 className="@heading-md">Premium Product</h3>
|
|
1067
|
+
<span className="@badge-success">In Stock</span>
|
|
1068
|
+
</div>
|
|
1069
|
+
<div className="@card-body">
|
|
1070
|
+
<p className="@body">
|
|
1071
|
+
This is a beautiful product description. It uses multiple utility classes and shortwind recipes to keep output clean and readable for agents.
|
|
1072
|
+
</p>
|
|
1073
|
+
<div className="@row-between mt-4">
|
|
1074
|
+
<span className="text-2xl font-bold">$99.99</span>
|
|
1075
|
+
<span className="@muted">Save 20%</span>
|
|
1076
|
+
</div>
|
|
1077
|
+
</div>
|
|
1078
|
+
<div className="@card-footer">
|
|
1079
|
+
<button className="@btn-secondary-sm">View Details</button>
|
|
1080
|
+
<button className="@btn-primary-sm">Buy Now</button>
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
1083
|
+
);
|
|
1084
|
+
}`,
|
|
1085
|
+
"form.tsx": `export function LoginForm() {
|
|
1086
|
+
return (
|
|
1087
|
+
<div className="@card max-w-md mx-auto p-8">
|
|
1088
|
+
<h2 className="@heading-lg mb-6 text-center">Welcome Back</h2>
|
|
1089
|
+
<form className="@stack-md">
|
|
1090
|
+
<div className="@field">
|
|
1091
|
+
<label className="@label" htmlFor="email">Email Address</label>
|
|
1092
|
+
<input className="@input" id="email" type="email" placeholder="you@example.com" required />
|
|
1093
|
+
<span className="@help">We will never share your email.</span>
|
|
1094
|
+
</div>
|
|
1095
|
+
<div className="@field">
|
|
1096
|
+
<label className="@label" htmlFor="password">Password</label>
|
|
1097
|
+
<input className="@input" id="password" type="password" required />
|
|
1098
|
+
</div>
|
|
1099
|
+
<div className="@row gap-2">
|
|
1100
|
+
<input className="@checkbox" id="remember" type="checkbox" />
|
|
1101
|
+
<label className="@label" htmlFor="remember">Remember me</label>
|
|
1102
|
+
</div>
|
|
1103
|
+
<button className="@btn-primary w-full mt-4" type="submit">Sign In</button>
|
|
1104
|
+
</form>
|
|
1105
|
+
</div>
|
|
1106
|
+
);
|
|
1107
|
+
}`,
|
|
1108
|
+
"table.tsx": `export function UsersTable() {
|
|
1109
|
+
return (
|
|
1110
|
+
<div className="@table-container">
|
|
1111
|
+
<table className="@table">
|
|
1112
|
+
<thead>
|
|
1113
|
+
<tr className="bg-muted/50">
|
|
1114
|
+
<th className="@th">User</th>
|
|
1115
|
+
<th className="@th">Status</th>
|
|
1116
|
+
<th className="@th">Role</th>
|
|
1117
|
+
<th className="@th">Actions</th>
|
|
1118
|
+
</tr>
|
|
1119
|
+
</thead>
|
|
1120
|
+
<tbody>
|
|
1121
|
+
<tr className="@tr-hover">
|
|
1122
|
+
<td className="@td font-medium">Alice Johnson</td>
|
|
1123
|
+
<td className="@td"><span className="@badge-success">Active</span></td>
|
|
1124
|
+
<td className="@td">Administrator</td>
|
|
1125
|
+
<td className="@td"><button className="@btn-ghost-sm">Edit</button></td>
|
|
1126
|
+
</tr>
|
|
1127
|
+
<tr className="@tr-hover">
|
|
1128
|
+
<td className="@td font-medium">Bob Smith</td>
|
|
1129
|
+
<td className="@td"><span className="@badge-warning">Pending</span></td>
|
|
1130
|
+
<td className="@td">Editor</td>
|
|
1131
|
+
<td className="@td"><button className="@btn-ghost-sm">Edit</button></td>
|
|
1132
|
+
</tr>
|
|
1133
|
+
<tr className="@tr-hover">
|
|
1134
|
+
<td className="@td font-medium">Charlie Brown</td>
|
|
1135
|
+
<td className="@td"><span className="@badge-danger">Suspended</span></td>
|
|
1136
|
+
<td className="@td">Subscriber</td>
|
|
1137
|
+
<td className="@td"><button className="@btn-ghost-sm">Edit</button></td>
|
|
1138
|
+
</tr>
|
|
1139
|
+
</tbody>
|
|
1140
|
+
</table>
|
|
1141
|
+
</div>
|
|
1142
|
+
);
|
|
1143
|
+
}`,
|
|
1144
|
+
"layout.tsx": `export function DashboardLayout({ children }) {
|
|
1145
|
+
return (
|
|
1146
|
+
<div className="@row min-h-screen items-stretch bg-background">
|
|
1147
|
+
<aside className="@stack-md w-64 border-r border-border bg-card p-4">
|
|
1148
|
+
<div className="flex h-12 items-center px-2 font-bold text-lg">
|
|
1149
|
+
Shortwind Console
|
|
1150
|
+
</div>
|
|
1151
|
+
<nav className="@nav flex-col items-stretch">
|
|
1152
|
+
<a className="@nav-link-active" href="/dashboard">Dashboard</a>
|
|
1153
|
+
<a className="@nav-link" href="/analytics">Analytics</a>
|
|
1154
|
+
<a className="@nav-link" href="/settings">Settings</a>
|
|
1155
|
+
</nav>
|
|
1156
|
+
</aside>
|
|
1157
|
+
<div className="@stack-md flex-1">
|
|
1158
|
+
<header className="@row-between h-16 border-b border-border bg-card px-6">
|
|
1159
|
+
<h1 className="@heading-sm">Overview</h1>
|
|
1160
|
+
<div className="@row gap-4">
|
|
1161
|
+
<button className="@btn-icon"><span className="sr-only">Notifications</span></button>
|
|
1162
|
+
<div className="h-8 w-8 rounded-full bg-primary/20" />
|
|
1163
|
+
</div>
|
|
1164
|
+
</header>
|
|
1165
|
+
<main className="@container @stack-lg py-8">
|
|
1166
|
+
<div className="@grid-3">
|
|
1167
|
+
<div className="@card-elevated">Card 1</div>
|
|
1168
|
+
<div className="@card-elevated">Card 2</div>
|
|
1169
|
+
<div className="@card-elevated">Card 3</div>
|
|
1170
|
+
</div>
|
|
1171
|
+
<div className="flex-1">{children}</div>
|
|
1172
|
+
</main>
|
|
1173
|
+
</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
);
|
|
1176
|
+
}`
|
|
1177
|
+
};
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region src/commands/lint.ts
|
|
1180
|
+
const ALL_RULES = [
|
|
1181
|
+
"recipe/unknown",
|
|
1182
|
+
"recipe/cycle",
|
|
1183
|
+
"recipe/duplicate",
|
|
1184
|
+
"recipe/unused",
|
|
1185
|
+
"recipe/no-redundant-utility",
|
|
1186
|
+
"recipe/bad-suffix-order",
|
|
1187
|
+
"recipe/conflicting-intent",
|
|
1188
|
+
"recipe/dynamic-class",
|
|
1189
|
+
"recipe/no-sibling-overlap"
|
|
1190
|
+
];
|
|
1191
|
+
const DEFAULT_CONTENT = ["src/**/*.{html,js,jsx,ts,tsx,vue,svelte,astro,md,mdx}"];
|
|
1192
|
+
async function lint(options) {
|
|
1193
|
+
const cwd = path.resolve(options.cwd);
|
|
1194
|
+
const config = await readConfig(cwd);
|
|
1195
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
1196
|
+
const enabledRules = new Set(options.rules ?? ALL_RULES);
|
|
1197
|
+
const findings = [];
|
|
1198
|
+
const { registry, parseFindings } = loadRegistry(recipesDir, enabledRules);
|
|
1199
|
+
findings.push(...parseFindings);
|
|
1200
|
+
findings.push(...checkRecipeNames(registry, recipesDir, enabledRules));
|
|
1201
|
+
const files = await glob(options.content ?? DEFAULT_CONTENT, {
|
|
1202
|
+
cwd,
|
|
1203
|
+
absolute: true,
|
|
1204
|
+
onlyFiles: true,
|
|
1205
|
+
ignore: [
|
|
1206
|
+
"**/node_modules/**",
|
|
1207
|
+
"**/dist/**",
|
|
1208
|
+
"**/.next/**",
|
|
1209
|
+
path.posix.join(path.relative(cwd, recipesDir).split(path.sep).join("/") || ".", "**")
|
|
1210
|
+
]
|
|
1211
|
+
});
|
|
1212
|
+
const usedRecipes = /* @__PURE__ */ new Set();
|
|
1213
|
+
const filesFixed = [];
|
|
1214
|
+
for (const file of files) {
|
|
1215
|
+
const source = await readFile(file, "utf8");
|
|
1216
|
+
const usages = extractClassUsages(source);
|
|
1217
|
+
for (const u of usages) {
|
|
1218
|
+
for (const token of u.tokens) {
|
|
1219
|
+
if (!token.value.startsWith("@")) continue;
|
|
1220
|
+
const name = token.value.slice(1);
|
|
1221
|
+
if (registry.flattened[name]) usedRecipes.add(name);
|
|
1222
|
+
else if (enabledRules.has("recipe/unknown")) findings.push({
|
|
1223
|
+
rule: "recipe/unknown",
|
|
1224
|
+
severity: "error",
|
|
1225
|
+
file,
|
|
1226
|
+
line: token.line,
|
|
1227
|
+
column: token.column,
|
|
1228
|
+
message: `unknown recipe @${name}`
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
if (enabledRules.has("recipe/bad-suffix-order")) findings.push(...checkUsageSuffixOrder(file, u.tokens, registry));
|
|
1232
|
+
if (enabledRules.has("recipe/conflicting-intent")) findings.push(...checkConflictingIntent(file, u.tokens, registry));
|
|
1233
|
+
if (enabledRules.has("recipe/no-sibling-overlap")) findings.push(...checkSiblingOverlap(file, u.tokens, registry));
|
|
1234
|
+
if (enabledRules.has("recipe/dynamic-class")) findings.push(...checkDynamicClass(file, u.dynamicTokens));
|
|
1235
|
+
}
|
|
1236
|
+
if (enabledRules.has("recipe/no-redundant-utility")) {
|
|
1237
|
+
const result = checkRedundantUtility(file, source, registry, options.fix === true);
|
|
1238
|
+
findings.push(...result.findings);
|
|
1239
|
+
if (options.fix && result.fixed !== null && result.fixed !== source) {
|
|
1240
|
+
await writeFile(file, result.fixed);
|
|
1241
|
+
filesFixed.push(file);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (enabledRules.has("recipe/unused")) {
|
|
1246
|
+
const recipesByName = /* @__PURE__ */ new Map();
|
|
1247
|
+
for (const recs of Object.values(registry.families)) for (const r of recs) recipesByName.set(r.name, r);
|
|
1248
|
+
for (const name of Object.keys(registry.flattened)) {
|
|
1249
|
+
if (usedRecipes.has(name)) continue;
|
|
1250
|
+
const recipe = recipesByName.get(name);
|
|
1251
|
+
if (!recipe) continue;
|
|
1252
|
+
findings.push({
|
|
1253
|
+
rule: "recipe/unused",
|
|
1254
|
+
severity: "info",
|
|
1255
|
+
file: path.join(recipesDir, recipe.sourceFile),
|
|
1256
|
+
line: recipe.sourceLine,
|
|
1257
|
+
column: 1,
|
|
1258
|
+
message: `recipe @${name} is defined but never referenced`
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
findings.sort((a, b) => {
|
|
1263
|
+
if (a.file !== b.file) return a.file.localeCompare(b.file);
|
|
1264
|
+
if (a.line !== b.line) return a.line - b.line;
|
|
1265
|
+
return a.column - b.column;
|
|
1266
|
+
});
|
|
1267
|
+
return {
|
|
1268
|
+
ok: !findings.some((f) => f.severity === "error"),
|
|
1269
|
+
findings,
|
|
1270
|
+
filesFixed
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
function loadRegistry(recipesDir, rules) {
|
|
1274
|
+
const families = installedFamilies(recipesDir);
|
|
1275
|
+
const allRecipes = [];
|
|
1276
|
+
const parseFindings = [];
|
|
1277
|
+
for (const family of families) {
|
|
1278
|
+
const filePath = path.join(recipesDir, `${family}.css`);
|
|
1279
|
+
const parsed = parseRecipeFile(readFileSync(filePath, "utf8"), `${family}.css`);
|
|
1280
|
+
if (!parsed.ok) {
|
|
1281
|
+
for (const err of parsed.errors) parseFindings.push({
|
|
1282
|
+
rule: "recipe/unknown",
|
|
1283
|
+
severity: "error",
|
|
1284
|
+
file: filePath,
|
|
1285
|
+
line: err.line,
|
|
1286
|
+
column: err.column ?? 1,
|
|
1287
|
+
message: err.message
|
|
1288
|
+
});
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
for (const r of parsed.value.recipes) allRecipes.push(r);
|
|
1292
|
+
}
|
|
1293
|
+
const built = buildRegistry(allRecipes);
|
|
1294
|
+
if (!built.ok) {
|
|
1295
|
+
for (const err of built.errors) {
|
|
1296
|
+
const rule = mapErrorCodeToRule(err.code);
|
|
1297
|
+
if (!rules.has(rule)) continue;
|
|
1298
|
+
parseFindings.push({
|
|
1299
|
+
rule,
|
|
1300
|
+
severity: "error",
|
|
1301
|
+
file: path.join(recipesDir, err.file),
|
|
1302
|
+
line: err.line,
|
|
1303
|
+
column: err.column ?? 1,
|
|
1304
|
+
message: err.message
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
return {
|
|
1308
|
+
registry: {
|
|
1309
|
+
flattened: {},
|
|
1310
|
+
families: {}
|
|
1311
|
+
},
|
|
1312
|
+
parseFindings
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
registry: built.value,
|
|
1317
|
+
parseFindings
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
const ERROR_CODE_RULE = {
|
|
1321
|
+
"resolve/cycle": "recipe/cycle",
|
|
1322
|
+
"resolve/duplicate-name": "recipe/duplicate",
|
|
1323
|
+
"resolve/unknown-reference": "recipe/unknown"
|
|
1324
|
+
};
|
|
1325
|
+
function mapErrorCodeToRule(code) {
|
|
1326
|
+
return ERROR_CODE_RULE[code] ?? "recipe/unknown";
|
|
1327
|
+
}
|
|
1328
|
+
const SIZE_SUFFIXES = new Set([
|
|
1329
|
+
"xs",
|
|
1330
|
+
"sm",
|
|
1331
|
+
"md",
|
|
1332
|
+
"lg",
|
|
1333
|
+
"xl"
|
|
1334
|
+
]);
|
|
1335
|
+
const INTENT_SUFFIXES = new Set([
|
|
1336
|
+
"primary",
|
|
1337
|
+
"secondary",
|
|
1338
|
+
"ghost",
|
|
1339
|
+
"danger",
|
|
1340
|
+
"warning",
|
|
1341
|
+
"success",
|
|
1342
|
+
"info"
|
|
1343
|
+
]);
|
|
1344
|
+
function recipeMeta(name, familyHint) {
|
|
1345
|
+
const family = familyHint && (name === familyHint || name.startsWith(`${familyHint}-`)) ? familyHint : name.split("-")[0] ?? name;
|
|
1346
|
+
const suffix = name === family ? [] : name.slice(family.length + 1).split("-").filter(Boolean);
|
|
1347
|
+
let intent = null;
|
|
1348
|
+
let firstSizeIdx = -1;
|
|
1349
|
+
let laterIntentIdx = -1;
|
|
1350
|
+
for (let i = 0; i < suffix.length; i++) {
|
|
1351
|
+
const part = suffix[i] ?? "";
|
|
1352
|
+
if (SIZE_SUFFIXES.has(part)) {
|
|
1353
|
+
if (firstSizeIdx === -1) firstSizeIdx = i;
|
|
1354
|
+
}
|
|
1355
|
+
if (INTENT_SUFFIXES.has(part)) {
|
|
1356
|
+
intent ??= part;
|
|
1357
|
+
if (firstSizeIdx !== -1) laterIntentIdx = i;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
let badOrder = null;
|
|
1361
|
+
if (firstSizeIdx !== -1 && laterIntentIdx !== -1) badOrder = [
|
|
1362
|
+
family,
|
|
1363
|
+
...suffix.filter((p) => INTENT_SUFFIXES.has(p)),
|
|
1364
|
+
...suffix.filter((p) => !INTENT_SUFFIXES.has(p) && !SIZE_SUFFIXES.has(p)),
|
|
1365
|
+
...suffix.filter((p) => SIZE_SUFFIXES.has(p))
|
|
1366
|
+
].join("-");
|
|
1367
|
+
return {
|
|
1368
|
+
family,
|
|
1369
|
+
intent,
|
|
1370
|
+
badOrder
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
function checkRecipeNames(registry, recipesDir, enabledRules) {
|
|
1374
|
+
if (!enabledRules.has("recipe/bad-suffix-order")) return [];
|
|
1375
|
+
const findings = [];
|
|
1376
|
+
for (const [family, recipes] of Object.entries(registry.families)) for (const recipe of recipes) {
|
|
1377
|
+
const meta = recipeMeta(recipe.name, family);
|
|
1378
|
+
if (!meta.badOrder) continue;
|
|
1379
|
+
findings.push({
|
|
1380
|
+
rule: "recipe/bad-suffix-order",
|
|
1381
|
+
severity: "warning",
|
|
1382
|
+
file: path.join(recipesDir, recipe.sourceFile),
|
|
1383
|
+
line: recipe.sourceLine,
|
|
1384
|
+
column: 1,
|
|
1385
|
+
message: `recipe @${recipe.name} uses size before intent; prefer @${meta.badOrder}`
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
return findings;
|
|
1389
|
+
}
|
|
1390
|
+
function checkUsageSuffixOrder(file, tokens, registry) {
|
|
1391
|
+
const findings = [];
|
|
1392
|
+
for (const token of tokens) {
|
|
1393
|
+
if (!token.value.startsWith("@")) continue;
|
|
1394
|
+
const name = token.value.slice(1);
|
|
1395
|
+
if (!registry.flattened[name]) continue;
|
|
1396
|
+
const meta = recipeMeta(name, familyForRecipe(registry, name));
|
|
1397
|
+
if (!meta.badOrder) continue;
|
|
1398
|
+
findings.push({
|
|
1399
|
+
rule: "recipe/bad-suffix-order",
|
|
1400
|
+
severity: "warning",
|
|
1401
|
+
file,
|
|
1402
|
+
line: token.line,
|
|
1403
|
+
column: token.column,
|
|
1404
|
+
message: `@${name} uses size before intent; prefer @${meta.badOrder}`
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
return findings;
|
|
1408
|
+
}
|
|
1409
|
+
function checkConflictingIntent(file, tokens, registry) {
|
|
1410
|
+
const byFamily = /* @__PURE__ */ new Map();
|
|
1411
|
+
for (const token of tokens) {
|
|
1412
|
+
if (!token.value.startsWith("@")) continue;
|
|
1413
|
+
const name = token.value.slice(1);
|
|
1414
|
+
if (!registry.flattened[name]) continue;
|
|
1415
|
+
const meta = recipeMeta(name, familyForRecipe(registry, name));
|
|
1416
|
+
if (!meta.intent) continue;
|
|
1417
|
+
const familyIntents = byFamily.get(meta.family) ?? /* @__PURE__ */ new Map();
|
|
1418
|
+
familyIntents.set(meta.intent, {
|
|
1419
|
+
token,
|
|
1420
|
+
name
|
|
1421
|
+
});
|
|
1422
|
+
byFamily.set(meta.family, familyIntents);
|
|
1423
|
+
}
|
|
1424
|
+
const findings = [];
|
|
1425
|
+
for (const [family, intents] of byFamily) {
|
|
1426
|
+
if (intents.size < 2) continue;
|
|
1427
|
+
const intentNames = Array.from(intents.values()).map((entry) => `@${entry.name}`).sort();
|
|
1428
|
+
const first = Array.from(intents.values()).map((entry) => entry.token).sort((a, b) => a.column - b.column)[0];
|
|
1429
|
+
findings.push({
|
|
1430
|
+
rule: "recipe/conflicting-intent",
|
|
1431
|
+
severity: "warning",
|
|
1432
|
+
file,
|
|
1433
|
+
line: first.line,
|
|
1434
|
+
column: first.column,
|
|
1435
|
+
message: `multiple ${family} intents on one element: ${intentNames.join(", ")}`
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
return findings;
|
|
1439
|
+
}
|
|
1440
|
+
function familyForRecipe(registry, name) {
|
|
1441
|
+
for (const [family, recipes] of Object.entries(registry.families)) if (recipes.some((recipe) => recipe.name === name)) return family;
|
|
1442
|
+
}
|
|
1443
|
+
function checkSiblingOverlap(file, tokens, registry) {
|
|
1444
|
+
const byFamily = /* @__PURE__ */ new Map();
|
|
1445
|
+
for (const token of tokens) {
|
|
1446
|
+
if (!token.value.startsWith("@")) continue;
|
|
1447
|
+
const name = token.value.slice(1);
|
|
1448
|
+
if (!registry.flattened[name]) continue;
|
|
1449
|
+
const family = familyForRecipe(registry, name) ?? name.split("-")[0] ?? name;
|
|
1450
|
+
const arr = byFamily.get(family) ?? [];
|
|
1451
|
+
arr.push({
|
|
1452
|
+
token,
|
|
1453
|
+
name
|
|
1454
|
+
});
|
|
1455
|
+
byFamily.set(family, arr);
|
|
1456
|
+
}
|
|
1457
|
+
const findings = [];
|
|
1458
|
+
for (const [family, entries] of byFamily) {
|
|
1459
|
+
const unique = new Set(entries.map((e) => e.name));
|
|
1460
|
+
if (unique.size < 2) continue;
|
|
1461
|
+
const first = entries.map((e) => e.token).sort((a, b) => a.column - b.column)[0];
|
|
1462
|
+
const names = Array.from(unique).map((n) => `@${n}`).sort();
|
|
1463
|
+
findings.push({
|
|
1464
|
+
rule: "recipe/no-sibling-overlap",
|
|
1465
|
+
severity: "warning",
|
|
1466
|
+
file,
|
|
1467
|
+
line: first.line,
|
|
1468
|
+
column: first.column,
|
|
1469
|
+
message: `multiple ${family} recipes on one element: ${names.join(", ")}`
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
return findings;
|
|
1473
|
+
}
|
|
1474
|
+
function checkDynamicClass(file, dynamicTokens) {
|
|
1475
|
+
const findings = [];
|
|
1476
|
+
for (const token of dynamicTokens) {
|
|
1477
|
+
if (!token.value.includes("@")) continue;
|
|
1478
|
+
findings.push({
|
|
1479
|
+
rule: "recipe/dynamic-class",
|
|
1480
|
+
severity: "warning",
|
|
1481
|
+
file,
|
|
1482
|
+
line: token.line,
|
|
1483
|
+
column: token.column,
|
|
1484
|
+
message: `dynamic recipe name ${token.value} — Tailwind cannot statically resolve this`
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
return findings;
|
|
1488
|
+
}
|
|
1489
|
+
const CLASS_ATTR_STR_RE = /\b(?:class|className)\s*=\s*(["'])([^"']*)\1/g;
|
|
1490
|
+
const CLASS_ATTR_BRACE_RE = /\b(?:class|className)\s*=\s*\{/g;
|
|
1491
|
+
const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\])*)\1/g;
|
|
1492
|
+
function extractClassUsages(source) {
|
|
1493
|
+
const usages = [];
|
|
1494
|
+
for (const m of source.matchAll(CLASS_ATTR_STR_RE)) {
|
|
1495
|
+
const value = m[2] ?? "";
|
|
1496
|
+
const attrStart = m.index ?? 0;
|
|
1497
|
+
const valueStart = attrStart + m[0].length - 1 - value.length;
|
|
1498
|
+
const { tokens, dynamicTokens } = tokenizeClassString(source, value, valueStart);
|
|
1499
|
+
usages.push({
|
|
1500
|
+
fileOffset: attrStart,
|
|
1501
|
+
valueStart,
|
|
1502
|
+
raw: value,
|
|
1503
|
+
tokens,
|
|
1504
|
+
dynamicTokens,
|
|
1505
|
+
fixable: true
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
for (const m of source.matchAll(CLASS_ATTR_BRACE_RE)) {
|
|
1509
|
+
const openBrace = (m.index ?? 0) + m[0].length - 1;
|
|
1510
|
+
const close = findMatchingBrace(source, openBrace);
|
|
1511
|
+
if (close === -1) continue;
|
|
1512
|
+
const inner = source.slice(openBrace + 1, close);
|
|
1513
|
+
for (const sm of inner.matchAll(STRING_LITERAL_RE)) {
|
|
1514
|
+
const value = sm[2] ?? "";
|
|
1515
|
+
if (value.length === 0) continue;
|
|
1516
|
+
const literalStart = openBrace + 1 + (sm.index ?? 0);
|
|
1517
|
+
const valueStart = literalStart + 1;
|
|
1518
|
+
const { tokens, dynamicTokens } = tokenizeClassString(source, value, valueStart);
|
|
1519
|
+
if (tokens.length === 0 && dynamicTokens.length === 0) continue;
|
|
1520
|
+
usages.push({
|
|
1521
|
+
fileOffset: literalStart,
|
|
1522
|
+
valueStart,
|
|
1523
|
+
raw: value,
|
|
1524
|
+
tokens,
|
|
1525
|
+
dynamicTokens,
|
|
1526
|
+
fixable: false
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return usages;
|
|
1531
|
+
}
|
|
1532
|
+
function tokenizeClassString(source, value, valueStart) {
|
|
1533
|
+
const tokens = [];
|
|
1534
|
+
const dynamicTokens = [];
|
|
1535
|
+
let offset = 0;
|
|
1536
|
+
for (const piece of value.split(/(\s+)/)) {
|
|
1537
|
+
if (/^\s+$/.test(piece) || piece.length === 0) {
|
|
1538
|
+
offset += piece.length;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
const { line, column } = offsetToLineCol(source, valueStart + offset);
|
|
1542
|
+
if (piece.includes("${")) {
|
|
1543
|
+
dynamicTokens.push({
|
|
1544
|
+
value: piece,
|
|
1545
|
+
line,
|
|
1546
|
+
column
|
|
1547
|
+
});
|
|
1548
|
+
offset += piece.length;
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
tokens.push({
|
|
1552
|
+
value: piece,
|
|
1553
|
+
line,
|
|
1554
|
+
column
|
|
1555
|
+
});
|
|
1556
|
+
offset += piece.length;
|
|
1557
|
+
}
|
|
1558
|
+
return {
|
|
1559
|
+
tokens,
|
|
1560
|
+
dynamicTokens
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
function findMatchingBrace(source, openIdx) {
|
|
1564
|
+
let depth = 1;
|
|
1565
|
+
let i = openIdx + 1;
|
|
1566
|
+
while (i < source.length && depth > 0) {
|
|
1567
|
+
const ch = source[i];
|
|
1568
|
+
if (ch === "\"" || ch === "'") {
|
|
1569
|
+
const quote = ch;
|
|
1570
|
+
i++;
|
|
1571
|
+
while (i < source.length) {
|
|
1572
|
+
if (source[i] === "\\") {
|
|
1573
|
+
i += 2;
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
if (source[i] === quote) {
|
|
1577
|
+
i++;
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
i++;
|
|
1581
|
+
}
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (ch === "`") {
|
|
1585
|
+
i++;
|
|
1586
|
+
while (i < source.length) {
|
|
1587
|
+
if (source[i] === "\\") {
|
|
1588
|
+
i += 2;
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
if (source[i] === "`") {
|
|
1592
|
+
i++;
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
if (source[i] === "$" && source[i + 1] === "{") {
|
|
1596
|
+
i += 2;
|
|
1597
|
+
let exprDepth = 1;
|
|
1598
|
+
while (i < source.length && exprDepth > 0) {
|
|
1599
|
+
if (source[i] === "{") exprDepth++;
|
|
1600
|
+
else if (source[i] === "}") exprDepth--;
|
|
1601
|
+
i++;
|
|
1602
|
+
}
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
i++;
|
|
1606
|
+
}
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
if (ch === "{") depth++;
|
|
1610
|
+
else if (ch === "}") depth--;
|
|
1611
|
+
i++;
|
|
1612
|
+
}
|
|
1613
|
+
return depth === 0 ? i - 1 : -1;
|
|
1614
|
+
}
|
|
1615
|
+
function offsetToLineCol(source, offset) {
|
|
1616
|
+
const limit = Math.min(offset, source.length);
|
|
1617
|
+
let line = 1;
|
|
1618
|
+
let lastNl = -1;
|
|
1619
|
+
for (let i = 0; i < limit; i++) if (source[i] === "\n") {
|
|
1620
|
+
line++;
|
|
1621
|
+
lastNl = i;
|
|
1622
|
+
}
|
|
1623
|
+
return {
|
|
1624
|
+
line,
|
|
1625
|
+
column: offset - lastNl
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
function checkRedundantUtility(file, source, registry, applyFix) {
|
|
1629
|
+
const findings = [];
|
|
1630
|
+
let fixed = applyFix ? "" : null;
|
|
1631
|
+
let cursor = 0;
|
|
1632
|
+
const usages = extractClassUsages(source).sort((a, b) => a.fileOffset - b.fileOffset);
|
|
1633
|
+
for (const usage of usages) {
|
|
1634
|
+
const expansions = /* @__PURE__ */ new Set();
|
|
1635
|
+
for (const tok of usage.tokens) {
|
|
1636
|
+
if (!tok.value.startsWith("@")) continue;
|
|
1637
|
+
const exp = registry.flattened[tok.value.slice(1)];
|
|
1638
|
+
if (!exp) continue;
|
|
1639
|
+
for (const t of exp) expansions.add(t);
|
|
1640
|
+
}
|
|
1641
|
+
if (expansions.size === 0) continue;
|
|
1642
|
+
const kept = [];
|
|
1643
|
+
for (const tok of usage.tokens) {
|
|
1644
|
+
if (!tok.value.startsWith("@") && expansions.has(tok.value)) {
|
|
1645
|
+
findings.push({
|
|
1646
|
+
rule: "recipe/no-redundant-utility",
|
|
1647
|
+
severity: "info",
|
|
1648
|
+
file,
|
|
1649
|
+
line: tok.line,
|
|
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
|
+
}
|
|
1657
|
+
if (fixed !== null && usage.fixable) {
|
|
1658
|
+
if (usage.valueStart < cursor) continue;
|
|
1659
|
+
fixed += source.slice(cursor, usage.valueStart);
|
|
1660
|
+
fixed += kept.join(" ");
|
|
1661
|
+
cursor = usage.valueStart + usage.raw.length;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (fixed !== null) fixed += source.slice(cursor);
|
|
1665
|
+
return {
|
|
1666
|
+
findings,
|
|
1667
|
+
fixed
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
function formatFindingsText(findings) {
|
|
1671
|
+
if (findings.length === 0) return "";
|
|
1672
|
+
return findings.map((f) => `${f.file}:${f.line}:${f.column} ${f.severity} ${f.message} [${f.rule}]`).join("\n");
|
|
1673
|
+
}
|
|
1674
|
+
//#endregion
|
|
1675
|
+
//#region src/commands/bench.ts
|
|
1676
|
+
const DEFAULT_CONTENT_GLOBS = ["src/**/*.{html,js,jsx,ts,tsx,vue,svelte,astro,md,mdx}"];
|
|
1677
|
+
async function bench(options) {
|
|
1678
|
+
const cwd = path.resolve(options.cwd);
|
|
1679
|
+
let registry;
|
|
1680
|
+
const runOnCorpus = options.corpus || !hasShortwindConfig(cwd);
|
|
1681
|
+
if (runOnCorpus) registry = loadDefaultRegistry();
|
|
1682
|
+
else {
|
|
1683
|
+
const config = await readConfig(cwd);
|
|
1684
|
+
registry = loadRegistryFromDir(path.join(cwd, config.recipesDir));
|
|
1685
|
+
}
|
|
1686
|
+
const filesToBench = [];
|
|
1687
|
+
if (runOnCorpus) for (const [filename, content] of Object.entries(CORPUS_FILES)) filesToBench.push({
|
|
1688
|
+
filename,
|
|
1689
|
+
content
|
|
1690
|
+
});
|
|
1691
|
+
else {
|
|
1692
|
+
const config = await readConfig(cwd);
|
|
1693
|
+
const recipesDir = path.join(cwd, config.recipesDir);
|
|
1694
|
+
const recipesIgnore = path.posix.join(path.relative(cwd, recipesDir).split(path.sep).join("/") || ".", "**");
|
|
1695
|
+
const matchedFiles = await glob(options.path ? [options.path] : DEFAULT_CONTENT_GLOBS, {
|
|
1696
|
+
cwd,
|
|
1697
|
+
absolute: true,
|
|
1698
|
+
onlyFiles: true,
|
|
1699
|
+
ignore: [
|
|
1700
|
+
"**/node_modules/**",
|
|
1701
|
+
"**/dist/**",
|
|
1702
|
+
"**/.next/**",
|
|
1703
|
+
recipesIgnore
|
|
1704
|
+
]
|
|
1705
|
+
});
|
|
1706
|
+
for (const file of matchedFiles) {
|
|
1707
|
+
const content = await readFile(file, "utf8");
|
|
1708
|
+
const relative = path.relative(cwd, file);
|
|
1709
|
+
filesToBench.push({
|
|
1710
|
+
filename: relative,
|
|
1711
|
+
content
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
const results = [];
|
|
1716
|
+
const totals = {
|
|
1717
|
+
compactClassTokens: 0,
|
|
1718
|
+
expandedClassTokens: 0,
|
|
1719
|
+
compactClassBytes: 0,
|
|
1720
|
+
expandedClassBytes: 0,
|
|
1721
|
+
compactFileBytes: 0,
|
|
1722
|
+
expandedFileBytes: 0,
|
|
1723
|
+
compactLlmTokens: 0,
|
|
1724
|
+
expandedLlmTokens: 0
|
|
1725
|
+
};
|
|
1726
|
+
for (const { filename, content } of filesToBench) {
|
|
1727
|
+
const expanded = transformContent(content, registry, { mode: filename.endsWith(".html") ? "html" : "jsx" });
|
|
1728
|
+
const compactUsages = extractClassUsages(content);
|
|
1729
|
+
const expandedUsages = extractClassUsages(expanded);
|
|
1730
|
+
const fileResult = {
|
|
1731
|
+
filename,
|
|
1732
|
+
compactClassTokens: sumTokens(compactUsages),
|
|
1733
|
+
expandedClassTokens: sumTokens(expandedUsages),
|
|
1734
|
+
compactClassBytes: sumBytes(compactUsages),
|
|
1735
|
+
expandedClassBytes: sumBytes(expandedUsages),
|
|
1736
|
+
compactFileBytes: Buffer.byteLength(content, "utf8"),
|
|
1737
|
+
expandedFileBytes: Buffer.byteLength(expanded, "utf8"),
|
|
1738
|
+
compactLlmTokens: countLlmTokens(content),
|
|
1739
|
+
expandedLlmTokens: countLlmTokens(expanded)
|
|
1740
|
+
};
|
|
1741
|
+
results.push(fileResult);
|
|
1742
|
+
totals.compactClassTokens += fileResult.compactClassTokens;
|
|
1743
|
+
totals.expandedClassTokens += fileResult.expandedClassTokens;
|
|
1744
|
+
totals.compactClassBytes += fileResult.compactClassBytes;
|
|
1745
|
+
totals.expandedClassBytes += fileResult.expandedClassBytes;
|
|
1746
|
+
totals.compactFileBytes += fileResult.compactFileBytes;
|
|
1747
|
+
totals.expandedFileBytes += fileResult.expandedFileBytes;
|
|
1748
|
+
totals.compactLlmTokens += fileResult.compactLlmTokens;
|
|
1749
|
+
totals.expandedLlmTokens += fileResult.expandedLlmTokens;
|
|
1750
|
+
}
|
|
1751
|
+
return {
|
|
1752
|
+
files: results,
|
|
1753
|
+
totals
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
function hasShortwindConfig(cwd) {
|
|
1757
|
+
return existsSync(path.join(cwd, "shortwind.config.json"));
|
|
1758
|
+
}
|
|
1759
|
+
function loadDefaultRegistry() {
|
|
1760
|
+
const allRecipes = [];
|
|
1761
|
+
for (const [filename, source] of Object.entries(DEFAULT_RECIPES_CSS)) {
|
|
1762
|
+
const parsed = parseRecipeFile(source, filename);
|
|
1763
|
+
if (parsed.ok) allRecipes.push(...parsed.value.recipes);
|
|
1764
|
+
}
|
|
1765
|
+
const resolved = buildRegistry(allRecipes);
|
|
1766
|
+
if (!resolved.ok) throw new Error(`Failed to build default registry: ${resolved.errors.map((e) => e.message).join("; ")}`);
|
|
1767
|
+
return resolved.value;
|
|
1768
|
+
}
|
|
1769
|
+
function sumTokens(usages) {
|
|
1770
|
+
let count = 0;
|
|
1771
|
+
for (const u of usages) count += u.tokens.length;
|
|
1772
|
+
return count;
|
|
1773
|
+
}
|
|
1774
|
+
function sumBytes(usages) {
|
|
1775
|
+
let count = 0;
|
|
1776
|
+
for (const u of usages) count += Buffer.byteLength(u.raw, "utf8");
|
|
1777
|
+
return count;
|
|
1778
|
+
}
|
|
1779
|
+
let _encoder = null;
|
|
1780
|
+
function getEncoder() {
|
|
1781
|
+
if (!_encoder) _encoder = new Tiktoken(cl100k_base);
|
|
1782
|
+
return _encoder;
|
|
1783
|
+
}
|
|
1784
|
+
function countLlmTokens(str) {
|
|
1785
|
+
return getEncoder().encode(str).length;
|
|
1786
|
+
}
|
|
1787
|
+
function formatBenchTable(result) {
|
|
1788
|
+
const lines = [];
|
|
1789
|
+
const colWidths = {
|
|
1790
|
+
file: 20,
|
|
1791
|
+
metric: 12,
|
|
1792
|
+
shortwind: 12,
|
|
1793
|
+
expanded: 12,
|
|
1794
|
+
saved: 10
|
|
1795
|
+
};
|
|
1796
|
+
for (const f of result.files) colWidths.file = Math.max(colWidths.file, f.filename.length);
|
|
1797
|
+
const padR = (str, width) => str.padEnd(width);
|
|
1798
|
+
const padL = (str, width) => str.padStart(width);
|
|
1799
|
+
const formatPct = (compact, exp) => {
|
|
1800
|
+
if (exp === 0) return "0.0%";
|
|
1801
|
+
return `${((1 - compact / exp) * 100).toFixed(1)}%`;
|
|
1802
|
+
};
|
|
1803
|
+
const row = (file, metric, compact, exp) => [
|
|
1804
|
+
padR(file, colWidths.file),
|
|
1805
|
+
padR(metric, colWidths.metric),
|
|
1806
|
+
padL(compact.toString(), colWidths.shortwind),
|
|
1807
|
+
padL(exp.toString(), colWidths.expanded),
|
|
1808
|
+
padL(formatPct(compact, exp), colWidths.saved)
|
|
1809
|
+
].join(" ");
|
|
1810
|
+
const header = [
|
|
1811
|
+
padR("File", colWidths.file),
|
|
1812
|
+
padR("Metric", colWidths.metric),
|
|
1813
|
+
padL("Shortwind", colWidths.shortwind),
|
|
1814
|
+
padL("Expanded", colWidths.expanded),
|
|
1815
|
+
padL("Saved", colWidths.saved)
|
|
1816
|
+
].join(" ");
|
|
1817
|
+
lines.push(header);
|
|
1818
|
+
lines.push("-".repeat(header.length));
|
|
1819
|
+
for (const f of result.files) {
|
|
1820
|
+
lines.push(row(f.filename, "Class Words", f.compactClassTokens, f.expandedClassTokens));
|
|
1821
|
+
lines.push(row("", "Class Bytes", f.compactClassBytes, f.expandedClassBytes));
|
|
1822
|
+
lines.push(row("", "File Bytes", f.compactFileBytes, f.expandedFileBytes));
|
|
1823
|
+
lines.push(row("", "File Tokens", f.compactLlmTokens, f.expandedLlmTokens));
|
|
1824
|
+
lines.push("-".repeat(header.length));
|
|
1825
|
+
}
|
|
1826
|
+
lines.push(row("TOTAL", "Class Words", result.totals.compactClassTokens, result.totals.expandedClassTokens));
|
|
1827
|
+
lines.push(row("", "Class Bytes", result.totals.compactClassBytes, result.totals.expandedClassBytes));
|
|
1828
|
+
lines.push(row("", "File Bytes", result.totals.compactFileBytes, result.totals.expandedFileBytes));
|
|
1829
|
+
lines.push(row("", "File Tokens", result.totals.compactLlmTokens, result.totals.expandedLlmTokens));
|
|
1830
|
+
lines.push("-".repeat(header.length));
|
|
1831
|
+
return lines.join("\n");
|
|
1832
|
+
}
|
|
1833
|
+
//#endregion
|
|
1834
|
+
export { rewriteHeaderSha as A, createRegistrySource as C, computeBodySha as D, buildHeaderLine as E, extractHeader as O, writeLockfile as S, detectProject as T, add as _, formatFindingsText as a, init as b, UpgradeError as c, BuildError as d, build as f, remove as g, preset as h, extractClassUsages as i, sealRecipeFile as j, normalizeBody 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, renameFamilyInSource as v, resolvePresetFamilies as w, readLockfile as x, DEFAULT_REGISTRY as y };
|
|
1835
|
+
|
|
1836
|
+
//# sourceMappingURL=bench-a_9WmuOE.js.map
|