build.js

  1#!/usr/bin/env node
  2
  3/**
  4 * Build System for Cross-Provider Design Skills
  5 *
  6 * Transforms source skills into provider-specific formats:
  7 * - Cursor: .cursor/skills/
  8 * - Claude Code: .claude/skills/
  9 * - Gemini: .gemini/skills/
 10 * - Codex: dist/codex/ only (OpenAI-metadata bundle; not synced to repo root)
 11 * - Agents: .agents/skills/ (Codex repo/user installs)
 12 * - GitHub: .github/skills/ (GitHub Copilot)
 13 *
 14 * Also assembles a universal ZIP containing all providers,
 15 * and builds Tailwind CSS for production deployment.
 16 */
 17
 18import path from 'path';
 19import fs from 'fs';
 20import { fileURLToPath } from 'url';
 21import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js';
 22import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
 23import { createAllZips } from './lib/zip.js';
 24import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
 25// Sub-page generation is now handled by Astro content collections.
 26
 27/**
 28 * Generate authoritative counts from source data and write to site/public/js/generated/counts.js.
 29 * Also validates that key HTML files reference the correct numbers.
 30 */
 31function generateCounts(rootDir, skills, buildDir) {
 32  // Count active commands. After the v3.0 consolidation, commands are sub-commands
 33  // of /impeccable. Count them from the command router table in SKILL.md.
 34  const impeccableSkill = skills.find(s => s.name === 'impeccable');
 35  let commandCount;
 36  if (impeccableSkill) {
 37    // Count lines in the command table that start with | `...` | — tolerant
 38    // of argument hints inside the backticks (e.g. `craft [feature]`) and of
 39    // multi-word commands (e.g. `pin <command>`).
 40    const routerMatches = impeccableSkill.body.match(/^\| `[^`]+` \|/gm);
 41    commandCount = routerMatches ? routerMatches.length : 0;
 42  } else {
 43    // Fallback: count user-invocable skills
 44    const activeCommands = skills.filter(s => {
 45      if (!s.userInvocable) return false;
 46      const content = fs.readFileSync(s.filePath, 'utf-8');
 47      return !content.includes('DEPRECATED');
 48    });
 49    commandCount = activeCommands.length;
 50  }
 51
 52  // Count detection rules from the detector registry.
 53  const detectionCount = new Set(ANTIPATTERNS.map(rule => rule.id)).size;
 54
 55  // Write generated counts module
 56  const genDir = path.join(rootDir, 'site/public/js/generated');
 57  fs.mkdirSync(genDir, { recursive: true });
 58  fs.writeFileSync(path.join(genDir, 'counts.js'),
 59    `// GENERATED by build.js — do not edit\n` +
 60    `export const COMMAND_COUNT = ${commandCount};\n` +
 61    `export const DETECTION_COUNT = ${detectionCount};\n`
 62  );
 63
 64  // Validate counts in key files
 65  const filesToCheck = [
 66    'site/pages/index.astro',
 67    'README.md',
 68    'NOTICE.md',
 69    'AGENTS.md',
 70    '.claude-plugin/plugin.json',
 71    '.claude-plugin/marketplace.json',
 72  ];
 73
 74  let errors = 0;
 75  for (const relPath of filesToCheck) {
 76    const absPath = path.join(rootDir, relPath);
 77    if (!fs.existsSync(absPath)) continue;
 78    const content = fs.readFileSync(absPath, 'utf-8');
 79
 80    // Check for stale command counts (look for "N commands" or "N skills" patterns)
 81    // Strip changelog list content to avoid flagging historical counts
 82    const strippedContent = content.replace(/<ul class="changelog-items">[\s\S]*?<\/ul>/g, '');
 83    const countPattern = /\b(\d+)\s+(design\s+)?(commands|sub-commands|skills|steering commands)/gi;
 84    for (const match of strippedContent.matchAll(countPattern)) {
 85      const num = parseInt(match[1]);
 86      // Allow 1 (for "1 skill") and the correct count
 87      if (num !== commandCount && num !== 1) {
 88        console.error(`  āŒ ${relPath}: found "${match[0]}" but active command count is ${commandCount}`);
 89        errors++;
 90      }
 91    }
 92
 93    // Check for stale detection counts. Use the changelog-stripped content
 94    // so historical counts in changelog entries (e.g. "28 rules" from an
 95    // older release) don't flag against the current detector total.
 96    const detectPattern = /\b(\d+)\s+(deterministic\s+)?(checks|patterns|rules|detections)/gi;
 97    for (const match of strippedContent.matchAll(detectPattern)) {
 98      const num = parseInt(match[1]);
 99      if (num !== detectionCount && num > 10) { // ignore small numbers like "3 patterns"
100        console.error(`  āŒ ${relPath}: found "${match[0]}" but detection count is ${detectionCount}`);
101        errors++;
102      }
103    }
104  }
105
106  if (errors > 0) {
107    console.error(`\nāŒ ${errors} stale count reference(s) found. Update them to match source of truth.`);
108  }
109
110  console.log(`āœ“ Generated counts: ${commandCount} commands, ${detectionCount} detection rules`);
111  return errors;
112}
113
114function validateSkillFrontmatter(skills) {
115  let errors = 0;
116
117  for (const skill of skills) {
118    if (skill.description && skill.description.length > 1024) {
119      console.error(`āŒ ${skill.filePath}: invalid description: exceeds maximum length of 1024 characters (${skill.description.length})`);
120      errors++;
121    }
122  }
123
124  return errors;
125}
126
127/**
128 * Scan user-facing copy for AI-prose anti-patterns:
129 *   - em dashes (— or &mdash;)
130 *   - double-hyphen substitutes (` -- `)
131 *   - denylisted phrases that read as AI tells in marketing copy
132 *
133 * The denylist is the editorial brief in STYLE.md, enforced. Each rule has a
134 * rationale that prints with the failure so the next author understands why.
135 *
136 * Scope: every surface a reader sees. Not skill/, where
137 * LLM-facing reference instructions can use technical phrasings the marketing
138 * copy can't.
139 *
140 * Returns the number of occurrences found. Build fails if > 0.
141 */
142function validateProse(rootDir) {
143  const targets = [
144    'site/components',
145    'site/content',
146    'site/layouts',
147    'site/pages',
148    'README.md',
149    'README.npm.md',
150  ];
151  const extensions = new Set(['.html', '.md', '.js', '.mjs', '.css', '.astro']);
152  const emDashPatterns = [/—/g, /&mdash;/gi, /&#8212;/gi, /&#x2014;/gi];
153  // Phrase rules: { re, rationale }. Add to STYLE.md when adding here.
154  const phraseRules = [
155    { re: /\bload-bearing\b/i, rationale: 'AI tell. Stolen-engineer diction; almost always vague. Name what the thing actually does.' },
156    { re: /\bhighest-leverage\b/i, rationale: 'AI tell. Vague claim of impact. Say what specifically pays off.' },
157    { re: /\bbiggest unlock\b/i, rationale: 'AI tell. Marketing-speak. Describe the actual change.' },
158    { re: /\breflex defaults?\b/i, rationale: 'Internal jargon leaking into user-facing copy. Say "instincts" or "first guesses".' },
159    { re: /\bcollapses? into monoculture\b/i, rationale: 'Internal eval-speak. Describe what actually went wrong.' },
160    { re: /\bdata-driven\b/i, rationale: 'Empty marketing adjective. Cite the data instead.' },
161    { re: /\bseamless(?:ly)?\b/i, rationale: 'Hollow positive. Say what specifically works without friction.' },
162    { re: /\brobust(?:ness)?\b/i, rationale: 'Hollow positive. Cite the failure mode it handles.' },
163    { re: /\bdelves?\b|\bdelved\b|\bdelving\b/i, rationale: 'Top AI tell. Use "explore", "look at", or just delete.' },
164    { re: /\belevate(?:s|d)?\b/i, rationale: 'Marketing verb. Use the specific verb (improve, raise, sharpen).' },
165    { re: /\bempower(?:s|ed|ing)?\b/i, rationale: 'Marketing verb. Use "let you" or "make possible".' },
166    { re: /\bunderscore(?:s|d)?\b/i, rationale: 'AI tell. Use "show" or "make clear".' },
167    { re: /\bpivotal\b/i, rationale: 'Hollow positive. Use "central", "key", or describe the role.' },
168    { re: /\bin today's\b/i, rationale: 'Throat-clearing opener. Cut the clause; start at the point.' },
169    { re: /\bgone are the days\b/i, rationale: 'Throat-clearing. Make the point directly.' },
170    { re: /\bwhether you're\b/i, rationale: 'Audience-pandering. Pick one reader; write to them.' },
171    { re: /\blet's dive in\b/i, rationale: 'Throat-clearing. Just start.' },
172    { re: /\bin summary\b|\bin conclusion\b/i, rationale: 'Summarizing closer. End on the strongest sentence; trust the reader.' },
173    { re: /\bmoreover\b|\bfurthermore\b/i, rationale: 'Transition crutch on a metronome. Drop, or use "also".' },
174    { re: /\btapestry\b/i, rationale: 'AI scenery noun. Cut.' },
175  ];
176  let errors = 0;
177
178  const checkLine = (line, rel, lineNum) => {
179    for (const re of emDashPatterns) {
180      if (re.test(line)) {
181        console.error(`  āŒ ${rel}:${lineNum}: em dash → ${line.trim().slice(0, 120)}`);
182        console.error(`        Use commas, colons, semicolons, periods, or parentheses.`);
183        errors++;
184        re.lastIndex = 0;
185        break;
186      }
187      re.lastIndex = 0;
188    }
189    if (/ -- /.test(line)) {
190      console.error(`  āŒ ${rel}:${lineNum}: \` -- \` em-dash substitute → ${line.trim().slice(0, 120)}`);
191      console.error(`        Worse than the em dash. Pick real punctuation.`);
192      errors++;
193    }
194    for (const rule of phraseRules) {
195      if (rule.re.test(line)) {
196        const matched = line.match(rule.re)?.[0] ?? '';
197        console.error(`  āŒ ${rel}:${lineNum}: "${matched}" → ${line.trim().slice(0, 120)}`);
198        console.error(`        ${rule.rationale}`);
199        errors++;
200      }
201    }
202  };
203
204  const scan = (absPath, rel) => {
205    const stat = fs.statSync(absPath);
206    if (stat.isDirectory()) {
207      for (const entry of fs.readdirSync(absPath)) {
208        scan(path.join(absPath, entry), path.join(rel, entry));
209      }
210      return;
211    }
212    if (!extensions.has(path.extname(absPath))) return;
213    const src = fs.readFileSync(absPath, 'utf-8');
214    const lines = src.split('\n');
215    lines.forEach((line, i) => checkLine(line, rel, i + 1));
216  };
217
218  for (const target of targets) {
219    const full = path.join(rootDir, target);
220    if (fs.existsSync(full)) scan(full, target);
221  }
222
223  if (errors === 0) {
224    console.log(`āœ“ Prose validator: no AI tells in user-facing copy`);
225  } else {
226    console.error(`\nāŒ ${errors} prose issue(s) in user-facing copy. See STYLE.md for the rules.`);
227  }
228  return errors;
229}
230
231/**
232 * Narrow prose check for the impeccable skill source.
233 *
234 * The full validateProse rules don't fit LLM-facing reference instructions:
235 * the hardening repetition and triadic checklists those files use exist on
236 * purpose, and the structural-prose rules in STYLE.md require human judgment.
237 * This validator only enforces the mechanical wins: em dashes (which are
238 * pure punctuation laziness regardless of audience) and the small handful
239 * of denylisted phrases that have no technical reading. Em-dash creep is the
240 * only thing likely to come back at scale once humans stop watching.
241 *
242 * Returns the number of occurrences found. Build fails if > 0.
243 */
244function validateSkillProse(rootDir) {
245  const target = 'skill';
246  const extensions = new Set(['.md']);
247  const emDashPatterns = [/—/g, /&mdash;/gi, /&#8212;/gi, /&#x2014;/gi];
248  // Tighter than validateProse: only the rules that have no technical reading.
249  // Skipping `data-driven` here would be a mistake (it slipped through twice
250  // in live.md before this pass); but `seamless`, `robust`, etc. have
251  // legitimate technical uses elsewhere we may want to allow.
252  const phraseRules = [
253    { re: /\bload-bearing\b/i, rationale: 'AI tell. Name what the thing actually does.' },
254    { re: /\bhighest-leverage\b/i, rationale: 'AI tell. Say what specifically pays off.' },
255    { re: /\bbiggest unlock\b/i, rationale: 'Marketing-speak. Describe the actual change.' },
256    { re: /\breflex defaults?\b/i, rationale: 'Internal jargon. Say "instincts" or "first guesses".' },
257    { re: /\bcollapses? into monoculture\b/i, rationale: 'Eval-speak. Describe what actually went wrong.' },
258    { re: /\bdata-driven\b/i, rationale: 'Empty marketing adjective. Cite the data instead.' },
259    { re: /\bdelves?\b|\bdelved\b|\bdelving\b/i, rationale: 'Top AI tell. Use "explore" or "look at".' },
260    { re: /\btapestry\b/i, rationale: 'AI scenery noun. Cut.' },
261    { re: /\bin today's\b/i, rationale: 'Throat-clearing opener. Start at the point.' },
262    { re: /\bgone are the days\b/i, rationale: 'Throat-clearing. Make the point directly.' },
263    { re: /\blet's dive in\b/i, rationale: 'Throat-clearing. Just start.' },
264    { re: /\bin summary\b|\bin conclusion\b/i, rationale: 'Summarizing closer. End on the strongest sentence.' },
265  ];
266  let errors = 0;
267
268  const checkLine = (line, rel, lineNum) => {
269    for (const re of emDashPatterns) {
270      if (re.test(line)) {
271        console.error(`  āŒ ${rel}:${lineNum}: em dash → ${line.trim().slice(0, 120)}`);
272        console.error(`        Use commas, colons, semicolons, periods, or parentheses.`);
273        errors++;
274        re.lastIndex = 0;
275        break;
276      }
277      re.lastIndex = 0;
278    }
279    if (/ -- /.test(line)) {
280      console.error(`  āŒ ${rel}:${lineNum}: \` -- \` em-dash substitute → ${line.trim().slice(0, 120)}`);
281      console.error(`        Worse than the em dash. Pick real punctuation.`);
282      errors++;
283    }
284    for (const rule of phraseRules) {
285      if (rule.re.test(line)) {
286        const matched = line.match(rule.re)?.[0] ?? '';
287        console.error(`  āŒ ${rel}:${lineNum}: "${matched}" → ${line.trim().slice(0, 120)}`);
288        console.error(`        ${rule.rationale}`);
289        errors++;
290      }
291    }
292  };
293
294  const scan = (absPath, rel) => {
295    const stat = fs.statSync(absPath);
296    if (stat.isDirectory()) {
297      for (const entry of fs.readdirSync(absPath)) {
298        scan(path.join(absPath, entry), path.join(rel, entry));
299      }
300      return;
301    }
302    if (!extensions.has(path.extname(absPath))) return;
303    const src = fs.readFileSync(absPath, 'utf-8');
304    const lines = src.split('\n');
305    lines.forEach((line, i) => checkLine(line, rel, i + 1));
306  };
307
308  const full = path.join(rootDir, target);
309  if (fs.existsSync(full)) scan(full, target);
310
311  if (errors === 0) {
312    console.log(`āœ“ Skill prose validator: skill/ is clean`);
313  } else {
314    console.error(`\nāŒ ${errors} prose issue(s) in skill/. See STYLE.md.`);
315  }
316  return errors;
317}
318
319/**
320 * Validate that every hand-authored HTML page carries the shared site header.
321 * The partial is stamped with `<!-- site-header v1 -->` so drift is loud.
322 *
323 * Returns the number of validation errors. Build fails if > 0.
324 */
325function validateSiteHeader(_rootDir) {
326  // With Astro, the shared header is a component (site/components/Header.astro).
327  // There's nothing to validate per-page — the component is imported by Base.astro
328  // and rendered identically everywhere. This function is kept as a no-op so the
329  // call site doesn't need to change.
330  console.log('āœ“ Site header is a shared Astro component (no per-page validation needed)');
331  return 0;
332}
333
334/**
335 * Copy directory recursively
336 */
337function copyDirSync(src, dest) {
338  fs.mkdirSync(dest, { recursive: true });
339  const entries = fs.readdirSync(src, { withFileTypes: true });
340  for (const entry of entries) {
341    const srcPath = path.join(src, entry.name);
342    const destPath = path.join(dest, entry.name);
343    if (entry.isDirectory()) {
344      copyDirSync(srcPath, destPath);
345    } else {
346      fs.copyFileSync(srcPath, destPath);
347    }
348  }
349}
350
351const __filename = fileURLToPath(import.meta.url);
352const __dirname = path.dirname(__filename);
353const ROOT_DIR = path.resolve(__dirname, '..');
354const DIST_DIR = path.join(ROOT_DIR, 'dist');
355
356// buildStaticSite (Bun HTML bundler) removed — now handled by Astro.
357
358/**
359 * Assemble universal directory from all provider outputs
360 */
361function assembleUniversal(distDir) {
362  const universalDir = path.join(distDir, 'universal');
363
364  // Clean and recreate
365  if (fs.existsSync(universalDir)) {
366    fs.rmSync(universalDir, { recursive: true, force: true });
367  }
368
369  const providerConfigs = Object.values(PROVIDERS);
370
371  for (const { provider, configDir } of providerConfigs) {
372    const src = path.join(distDir, provider, configDir);
373    const dest = path.join(universalDir, configDir);
374    if (fs.existsSync(src)) {
375      copyDirSync(src, dest);
376    }
377  }
378
379  // Add a visible README so macOS users don't see an empty folder
380  // (all provider dirs are dotfiles, hidden by default in Finder)
381  fs.writeFileSync(path.join(universalDir, 'README.txt'),
382`Impeccable. Design fluency for AI harnesses.
383https://impeccable.style
384
385This folder contains skills for all supported tools:
386
387  .cursor/    -> Cursor
388  .claude/    -> Claude Code
389  .gemini/    -> Gemini CLI
390  .codex/     -> Codex custom agents (Codex skills use .agents/)
391  .agents/    -> Codex CLI
392  .github/    -> GitHub Copilot
393  .kiro/      -> Kiro
394  .opencode/  -> OpenCode
395  .pi/        -> Pi
396  .trae-cn/   -> Trae China
397  .trae/      -> Trae International
398
399To install, copy the relevant folder(s) into your project root.
400For Codex, repo and user skill installs come from .agents/skills.
401These are hidden folders (dotfiles). Press Cmd+Shift+. in Finder to see them.
402`);
403
404  console.log(`āœ“ Assembled universal directory (${providerConfigs.length} providers)`);
405}
406
407/**
408 * Generate static API data for Cloudflare Pages deployment.
409 * Pre-builds all API responses as JSON files so they can be served
410 * as static assets via _redirects rewrites (no function invocations needed).
411 */
412function generateApiData(buildDir, skills, patterns) {
413  const apiDir = path.join(buildDir, '_data', 'api');
414  fs.mkdirSync(apiDir, { recursive: true });
415
416  // skills.json
417  const skillsData = skills.map(s => ({
418    id: path.basename(path.dirname(s.filePath)),
419    name: s.name,
420    description: s.description,
421    userInvocable: s.userInvocable,
422  }));
423  fs.writeFileSync(path.join(apiDir, 'skills.json'), JSON.stringify(skillsData));
424
425  // commands.json - after v3.0 consolidation, commands are sub-commands of
426  // /impeccable. Load them from command-metadata.json and include the root
427  // impeccable skill itself so UI surfaces like the cheatsheet can list them.
428  // Each entry also picks up a short `tagline` from its editorial file
429  // (site/content/skills/<id>.md) when one exists. Taglines are used by UI
430  // surfaces that need a human-friendly one-liner, while `description` stays
431  // optimized for auto-trigger keyword matching in the AI harness.
432  const readTagline = (id) => {
433    const editorialPath = path.join(ROOT_DIR, 'site/content/skills', `${id}.md`);
434    if (!fs.existsSync(editorialPath)) return null;
435    const raw = fs.readFileSync(editorialPath, 'utf-8');
436    const match = raw.match(/^---\n([\s\S]*?)\n---/);
437    if (!match) return null;
438    const taglineMatch = match[1].match(/tagline:\s*"([^"]+)"/);
439    return taglineMatch ? taglineMatch[1] : null;
440  };
441
442  const metadataPath = path.join(ROOT_DIR, 'skill/scripts/command-metadata.json');
443  if (!fs.existsSync(metadataPath)) {
444    throw new Error(`command-metadata.json is missing at ${metadataPath}. This file is required to generate the commands API.`);
445  }
446  const impeccable = skills.find(s => s.name === 'impeccable');
447  if (!impeccable) {
448    throw new Error('impeccable skill not found at skill/SKILL.md. The build system expects exactly one skill at that path.');
449  }
450
451  const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
452  const commandsData = [
453    {
454      id: 'impeccable',
455      name: 'impeccable',
456      description: impeccable.description,
457      tagline: readTagline('impeccable'),
458      userInvocable: true,
459    },
460    ...Object.entries(metadata).map(([id, meta]) => ({
461      id,
462      name: id,
463      description: meta.description,
464      tagline: readTagline(id),
465      userInvocable: true,
466    })),
467  ];
468  fs.writeFileSync(path.join(apiDir, 'commands.json'), JSON.stringify(commandsData));
469
470  // patterns.json
471  fs.writeFileSync(path.join(apiDir, 'patterns.json'), JSON.stringify(patterns));
472
473  // command-source/{id}.json (one per skill)
474  const cmdSourceDir = path.join(apiDir, 'command-source');
475  fs.mkdirSync(cmdSourceDir, { recursive: true });
476  for (const skill of skills) {
477    const id = path.basename(path.dirname(skill.filePath));
478    const content = fs.readFileSync(skill.filePath, 'utf-8');
479    fs.writeFileSync(
480      path.join(cmdSourceDir, `${id}.json`),
481      JSON.stringify({ content })
482    );
483  }
484
485  const skillWord = skillsData.length === 1 ? 'skill' : 'skills';
486  console.log(`āœ“ Generated static API data (${skillsData.length} ${skillWord}, ${commandsData.length} commands)`);
487}
488
489/**
490 * Copy dist files to build output for Cloudflare Pages Functions access.
491 * Download functions use env.ASSETS.fetch() to read these files.
492 */
493function copyDistToBuild(distDir, buildDir) {
494  const destDir = path.join(buildDir, '_data', 'dist');
495  copyDirSync(distDir, destDir);
496  console.log('āœ“ Copied dist files to build output');
497}
498
499/**
500 * Generate Cloudflare Pages config files (_headers, _redirects)
501 */
502function generateCFConfig(buildDir) {
503  // _headers: security + cache headers
504  const headers = `/*
505  X-Content-Type-Options: nosniff
506  X-Frame-Options: SAMEORIGIN
507
508# HTML pages: browser always revalidates, CDN caches 1h
509/*.html
510  Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=600
511
512# Hashed JS/CSS bundles: immutable (filename changes on content change)
513/assets/*.js
514  Cache-Control: public, max-age=31536000, immutable
515
516/assets/*.css
517  Cache-Control: public, max-age=31536000, immutable
518
519# Static images and logos: 1 week + 1 day stale
520/assets/*.png
521  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
522
523/assets/*.svg
524  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
525
526/assets/*.webp
527  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
528
529/antipattern-images/*
530  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
531
532# Root static assets (favicon, og-image, etc.)
533/favicon.svg
534  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
535
536/og-image.jpg
537  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
538
539/apple-touch-icon.png
540  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
541
542# ZIP downloads: 1h cache
543/dist/*.zip
544  Cache-Control: public, max-age=3600, stale-while-revalidate=600
545
546# API routes: CDN caches 24h
547/api/*
548  Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
549
550/_data/api/*
551  Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
552`;
553  fs.writeFileSync(path.join(buildDir, '_headers'), headers);
554
555  // _redirects: rewrite JSON API routes to static files (200 = rewrite, not redirect).
556  // Plus permanent redirects for legacy URLs.
557  const redirects = `/api/skills /_data/api/skills.json 200
558/api/commands /_data/api/commands.json 200
559/api/patterns /_data/api/patterns.json 200
560/api/command-source/:id /_data/api/command-source/:id.json 200
561/gallery /slop#try-it-live 301
562/cheatsheet /docs 301
563/skills /docs 301
564/skills/:id /docs/:id 301
565/anti-patterns /slop#catalog 301
566/visual-mode /slop#see-it 301
567/neon-mirai /neo-mirai/ 301
568/neon-mirai/ /neo-mirai/ 301
569/cases/neon-mirai /cases/neo-mirai 301
570/cases/neon-mirai/ /cases/neo-mirai 301
571`;
572  fs.writeFileSync(path.join(buildDir, '_redirects'), redirects);
573
574  // _routes.json: tell Cloudflare Pages which paths invoke Functions
575  // Without this, the SPA fallback serves index.html for function routes
576  const routes = {
577    version: 1,
578    include: ['/api/download/*'],
579    exclude: [],
580  };
581  fs.writeFileSync(path.join(buildDir, '_routes.json'), JSON.stringify(routes, null, 2));
582
583  console.log('āœ“ Generated Cloudflare Pages config (_headers, _redirects, _routes.json)');
584}
585
586/**
587 * Main build process
588 */
589async function build() {
590  console.log('šŸ”Ø Building cross-provider design skills...\n');
591
592  // Sub-page generation, HTML bundling, and static-asset copying are now
593  // handled by Astro (bun run build:site). This script focuses on skills,
594  // API data, and Cloudflare config.
595
596  // Copy browser detector to site/public/js/ so the antipattern examples can
597  // reference it (Astro serves site/public/ as-is).
598  const detectorSrc = path.join(ROOT_DIR, 'cli', 'engine', 'detect-antipatterns-browser.js');
599  if (fs.existsSync(detectorSrc)) {
600    const jsDir = path.join(ROOT_DIR, 'site', 'public', 'js');
601    fs.mkdirSync(jsDir, { recursive: true });
602    fs.copyFileSync(detectorSrc, path.join(jsDir, 'detect-antipatterns-browser.js'));
603  }
604
605  const buildDir = path.join(ROOT_DIR, 'build');
606
607  // Read source files (unified skills architecture)
608  const { skills } = readSourceFiles(ROOT_DIR);
609  const patterns = readPatterns(ROOT_DIR);
610  const userInvocableCount = skills.filter(s => s.userInvocable).length;
611  console.log(`šŸ“– Read ${skills.length} skills (${userInvocableCount} user-invocable) and ${patterns.patterns.length + patterns.antipatterns.length} pattern categories\n`);
612
613  const frontmatterErrors = validateSkillFrontmatter(skills);
614  if (frontmatterErrors > 0) {
615    process.exit(1);
616  }
617
618  // Read skills version from plugin.json
619  const pluginJson = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
620  const skillsVersion = pluginJson.version;
621
622  // Transform for each provider
623  for (const config of Object.values(PROVIDERS)) {
624    const transform = createTransformer(config);
625    transform(skills, DIST_DIR, { skillsVersion });
626  }
627
628  // Assemble universal directory
629  assembleUniversal(DIST_DIR);
630
631  // Create ZIP bundles (individual + universal)
632  await createAllZips(DIST_DIR);
633
634  // Generate static API data and Cloudflare Pages config
635  // Write API data and CF config to site/public/ so Astro copies them to build/.
636  // Astro wipes build/ before writing, so anything written directly to build/
637  // during build:skills would be destroyed when build:site runs.
638  const publicDir = path.join(ROOT_DIR, 'site', 'public');
639  generateApiData(publicDir, skills, patterns);
640  generateCFConfig(publicDir);
641
642  // Copy all provider outputs to project root for local testing.
643  // `.codex/` is intentionally excluded: Codex no longer consumes that layout; keep
644  // generated bundles under dist/ only.
645  const syncConfigs = Object.values(PROVIDERS).filter(({ configDir }) => configDir !== '.codex');
646
647  for (const { provider, configDir } of syncConfigs) {
648    const skillsSrc = path.join(DIST_DIR, provider, configDir, 'skills');
649    const skillsDest = path.join(ROOT_DIR, configDir, 'skills');
650
651    if (fs.existsSync(skillsSrc)) {
652      // Preserve legacy per-project script artifacts (e.g. live-mode config.json)
653      // across the rm + recopy. The build intentionally doesn't ship them,
654      // so without this the sync destroys local state on every rebuild.
655      const stashed = stashPerProjectArtifacts(skillsDest);
656      if (fs.existsSync(skillsDest)) fs.rmSync(skillsDest, { recursive: true });
657      copyDirSync(skillsSrc, skillsDest);
658      restorePerProjectArtifacts(skillsDest, stashed);
659    }
660  }
661
662  for (const { provider, configDir, agentFormat } of Object.values(PROVIDERS)) {
663    if (!agentFormat) continue;
664
665    const agentsSrc = path.join(DIST_DIR, provider, configDir, 'agents');
666    const agentsDest = path.join(ROOT_DIR, configDir, 'agents');
667
668    if (fs.existsSync(agentsDest)) fs.rmSync(agentsDest, { recursive: true, force: true });
669    if (fs.existsSync(agentsSrc)) {
670      copyDirSync(agentsSrc, agentsDest);
671    }
672  }
673
674  // Remove deprecated skill stubs from local harness dirs. They exist
675  // in dist/ so the cleanup script can redirect users, but they should
676  // not clutter the repo's own skill directories.
677  const deprecatedLocalSkills = [
678    'frontend-design', 'teach-impeccable',
679    'arrange', 'normalize', 'onboard', 'extract',
680    // v3.0 consolidation: standalone skills -> /impeccable sub-commands
681    'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize',
682    'critique', 'delight', 'distill', 'harden', 'layout', 'optimize',
683    'overdrive', 'polish', 'quieter', 'shape', 'typeset',
684  ];
685  for (const { configDir } of syncConfigs) {
686    for (const name of deprecatedLocalSkills) {
687      const p = path.join(ROOT_DIR, configDir, 'skills', name);
688      if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
689    }
690  }
691
692  console.log(`šŸ“‹ Synced skills to: ${syncConfigs.map(p => p.configDir).join(', ')}`);
693
694  // Build the Claude Code plugin subtree at ./plugin/.
695  // The Claude Code marketplace is configured with `source: "./plugin"`, so
696  // the plugin cache only copies this slim directory (~0.3 MB) instead of
697  // the entire monorepo (~291 MB on the previous "./" source). The harness
698  // dirs above stay where they are because `npx skills add pbakaus/impeccable`
699  // reads them directly from the GitHub repo at install time.
700  const pluginRoot = path.join(ROOT_DIR, 'plugin');
701  const pluginManifestDir = path.join(pluginRoot, '.claude-plugin');
702  const pluginSkillsDir = path.join(pluginRoot, 'skills');
703  const pluginAgentsDir = path.join(pluginRoot, 'agents');
704  if (fs.existsSync(pluginManifestDir)) fs.rmSync(pluginManifestDir, { recursive: true });
705  if (fs.existsSync(pluginSkillsDir)) fs.rmSync(pluginSkillsDir, { recursive: true });
706  if (fs.existsSync(pluginAgentsDir)) fs.rmSync(pluginAgentsDir, { recursive: true });
707
708  const rootManifest = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
709  const claudeAgentsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'agents');
710  const pluginAgentEntries = fs.existsSync(claudeAgentsSrc)
711    ? fs.readdirSync(claudeAgentsSrc)
712        .filter(file => file.endsWith('.md'))
713        .sort()
714        .map(file => `./agents/${file}`)
715    : [];
716  // Trailing slash on the skills path matches the documented schema in
717  // code.claude.com/docs/en/plugins-reference. Issue #86 has 3 reporters
718  // converging on "add trailing slash to fix slash commands not registering";
719  // the docs schema example consistently uses `"./custom/skills/"` form.
720  const pluginManifest = { ...rootManifest, skills: './skills/' };
721  if (pluginAgentEntries.length) {
722    pluginManifest.agents = pluginAgentEntries;
723  } else {
724    delete pluginManifest.agents;
725  }
726  fs.mkdirSync(pluginManifestDir, { recursive: true });
727  fs.writeFileSync(
728    path.join(pluginManifestDir, 'plugin.json'),
729    JSON.stringify(pluginManifest, null, 2) + '\n',
730  );
731
732  const claudeSkillsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'skills', 'impeccable');
733  if (fs.existsSync(claudeSkillsSrc)) {
734    fs.mkdirSync(pluginSkillsDir, { recursive: true });
735    copyDirSync(claudeSkillsSrc, path.join(pluginSkillsDir, 'impeccable'));
736  }
737
738  if (fs.existsSync(claudeAgentsSrc)) {
739    copyDirSync(claudeAgentsSrc, pluginAgentsDir);
740  }
741
742  console.log('šŸ“¦ Built Claude Code plugin subtree at ./plugin/');
743
744  // Generate authoritative counts and validate references
745  const countErrors = generateCounts(ROOT_DIR, skills, buildDir);
746
747  // Verify every hand-authored HTML page carries the shared site header
748  const headerErrors = validateSiteHeader(ROOT_DIR);
749
750  // Scan user-facing copy for AI tells (em dashes, marketing fluff, denylisted phrases)
751  const proseErrors = validateProse(ROOT_DIR);
752
753  // Narrow scan of LLM-facing skill instructions: em dashes + a tighter denylist
754  // that has no technical reading. Hardening repetition is intentionally allowed.
755  const skillProseErrors = validateSkillProse(ROOT_DIR);
756
757  if (countErrors > 0 || headerErrors > 0 || proseErrors > 0 || skillProseErrors > 0) {
758    process.exit(1);
759  }
760
761  console.log('\n✨ Build complete!');
762}
763
764// Run the build
765build();