@shortwind/cli 0.1.0-beta.0 → 0.1.0-beta.10

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