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: .codex/skills/
 11 * - Agents: .agents/skills/ (VS Code Copilot + Antigravity)
 12 *
 13 * Also assembles a universal ZIP containing all providers,
 14 * and builds Tailwind CSS for production deployment.
 15 */
 16
 17import path from 'path';
 18import fs from 'fs';
 19import { fileURLToPath } from 'url';
 20import { readSourceFiles, readPatterns } from './lib/utils.js';
 21import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
 22import { createAllZips } from './lib/zip.js';
 23import { generateSubPages } from './build-sub-pages.js';
 24
 25/**
 26 * Generate authoritative counts from source data and write to public/js/generated/counts.js.
 27 * Also validates that key HTML files reference the correct numbers.
 28 */
 29function generateCounts(rootDir, skills, buildDir) {
 30  // Count active (non-deprecated) user-invocable commands
 31  const activeCommands = skills.filter(s => {
 32    if (!s.userInvocable) return false;
 33    const content = fs.readFileSync(s.filePath, 'utf-8');
 34    return !content.includes('DEPRECATED');
 35  });
 36  const commandCount = activeCommands.length;
 37
 38  // Count detection rules from impeccable package
 39  const detectPkgPath = path.join(rootDir, 'src/detect-antipatterns.mjs');
 40  const detectorSrc = fs.readFileSync(detectPkgPath, 'utf-8');
 41  const ruleIds = new Set();
 42  for (const match of detectorSrc.matchAll(/^\s+id: '([^']+)'/gm)) {
 43    ruleIds.add(match[1]);
 44  }
 45  const detectionCount = ruleIds.size;
 46
 47  // Write generated counts module
 48  const genDir = path.join(rootDir, 'public/js/generated');
 49  fs.mkdirSync(genDir, { recursive: true });
 50  fs.writeFileSync(path.join(genDir, 'counts.js'),
 51    `// GENERATED by build.js — do not edit\n` +
 52    `export const COMMAND_COUNT = ${commandCount};\n` +
 53    `export const DETECTION_COUNT = ${detectionCount};\n`
 54  );
 55
 56  // Validate counts in key files
 57  const filesToCheck = [
 58    'public/index.html',
 59    'public/cheatsheet.html',
 60    'README.md',
 61    'NOTICE.md',
 62    'AGENTS.md',
 63    '.claude-plugin/plugin.json',
 64    '.claude-plugin/marketplace.json',
 65  ];
 66
 67  let errors = 0;
 68  for (const relPath of filesToCheck) {
 69    const absPath = path.join(rootDir, relPath);
 70    if (!fs.existsSync(absPath)) continue;
 71    const content = fs.readFileSync(absPath, 'utf-8');
 72
 73    // Check for stale command counts (look for "N commands" or "N skills" patterns)
 74    // Strip changelog list content to avoid flagging historical counts
 75    const strippedContent = content.replace(/<ul class="changelog-items">[\s\S]*?<\/ul>/g, '');
 76    const countPattern = /\b(\d+)\s+(design\s+)?(commands|skills|steering commands)/gi;
 77    for (const match of strippedContent.matchAll(countPattern)) {
 78      const num = parseInt(match[1]);
 79      // Allow 1 (for "1 skill") and the correct count
 80      if (num !== commandCount && num !== 1) {
 81        console.error(`  āŒ ${relPath}: found "${match[0]}" but active command count is ${commandCount}`);
 82        errors++;
 83      }
 84    }
 85
 86    // Check for stale detection counts
 87    const detectPattern = /\b(\d+)\s+(deterministic\s+)?(checks|patterns|rules|detections)/gi;
 88    for (const match of content.matchAll(detectPattern)) {
 89      const num = parseInt(match[1]);
 90      if (num !== detectionCount && num > 10) { // ignore small numbers like "3 patterns"
 91        console.error(`  āŒ ${relPath}: found "${match[0]}" but detection count is ${detectionCount}`);
 92        errors++;
 93      }
 94    }
 95  }
 96
 97  if (errors > 0) {
 98    console.error(`\nāŒ ${errors} stale count reference(s) found. Update them to match source of truth.`);
 99  }
100
101  console.log(`āœ“ Generated counts: ${commandCount} commands, ${detectionCount} detection rules`);
102  return errors;
103}
104
105/**
106 * Cross-validate that every detection rule with a `skillGuideline` has a
107 * matching DON'T line in the right section of source/skills/impeccable/SKILL.md.
108 *
109 * This is the linchpin of the single-source-of-truth design: it catches drift
110 * between the engine's ANTIPATTERNS and the human-written DO/DON'T prose.
111 *
112 * Returns the number of validation errors. Build fails if > 0.
113 */
114function validateAntipatternRules(rootDir) {
115  const detectPath = path.join(rootDir, 'src/detect-antipatterns.mjs');
116  const src = fs.readFileSync(detectPath, 'utf-8');
117  const apMatch = src.match(/const ANTIPATTERNS = \[([\s\S]*?)\n\];/);
118  if (!apMatch) {
119    console.error('  āŒ Could not extract ANTIPATTERNS from detect-antipatterns.mjs');
120    return 1;
121  }
122  const antipatterns = new Function(`return [${apMatch[1]}]`)();
123  const { antipatterns: skillSections } = readPatterns(rootDir);
124
125  // Build section -> joined-DON'T-text lookup for substring matching.
126  // Lowercased for case-insensitive matching: my XML refactor uses sentence-
127  // case "DO NOT nest cards" while the rules' skillGuideline strings are
128  // sentence-cased "Nest cards inside cards" (a fragment from the original
129  // markdown bullet "**DON'T**: Nest cards inside cards.").
130  const sectionText = {};
131  for (const section of skillSections) {
132    sectionText[section.name] = section.items.join('\n').toLowerCase();
133  }
134
135  let errors = 0;
136  let validated = 0;
137  for (const rule of antipatterns) {
138    if (!rule.skillGuideline) continue;
139    if (!rule.skillSection) {
140      console.error(`  āŒ Rule '${rule.id}' declares skillGuideline but no skillSection`);
141      errors++;
142      continue;
143    }
144    const text = sectionText[rule.skillSection];
145    if (!text) {
146      console.error(`  āŒ Rule '${rule.id}': skillSection '${rule.skillSection}' has no DON'T lines in source/skills/impeccable/SKILL.md`);
147      errors++;
148      continue;
149    }
150    if (!text.includes(rule.skillGuideline.toLowerCase())) {
151      console.error(`  āŒ Rule '${rule.id}': skillGuideline '${rule.skillGuideline}' not found in any DON'T of section '${rule.skillSection}' in source/skills/impeccable/SKILL.md`);
152      errors++;
153      continue;
154    }
155    validated++;
156  }
157
158  if (errors > 0) {
159    console.error(`\nāŒ ${errors} anti-pattern rule(s) drift between src/detect-antipatterns.mjs and source/skills/impeccable/SKILL.md`);
160  } else {
161    console.log(`āœ“ Validated ${validated}/${antipatterns.length} anti-pattern rules against impeccable SKILL.md`);
162  }
163  return errors;
164}
165
166/**
167 * Scan user-facing copy for em dashes (— or &mdash;).
168 * Em dashes in project copy are a known anti-pattern here; flag them loudly.
169 * Only scans files where we author copy, not vendored or generated output.
170 *
171 * Returns the number of occurrences found.
172 */
173function validateNoEmDashes(rootDir) {
174  const targets = [
175    'content/site',
176    'public/index.html',
177    'public/cheatsheet.html',
178    'public/privacy.html',
179    'scripts/build-sub-pages.js',
180    'scripts/lib/sub-pages-data.js',
181  ];
182  const extensions = new Set(['.html', '.md', '.js', '.mjs', '.css']);
183  const emDashPatterns = [/—/g, /&mdash;/gi, /&#8212;/gi, /&#x2014;/gi];
184  let errors = 0;
185
186  const scan = (absPath, rel) => {
187    const stat = fs.statSync(absPath);
188    if (stat.isDirectory()) {
189      for (const entry of fs.readdirSync(absPath)) {
190        scan(path.join(absPath, entry), path.join(rel, entry));
191      }
192      return;
193    }
194    if (!extensions.has(path.extname(absPath))) return;
195    const src = fs.readFileSync(absPath, 'utf-8');
196    const lines = src.split('\n');
197    lines.forEach((line, i) => {
198      for (const re of emDashPatterns) {
199        if (re.test(line)) {
200          console.error(`  āŒ ${rel}:${i + 1}: em dash in copy → ${line.trim().slice(0, 120)}`);
201          errors++;
202          break;
203        }
204        re.lastIndex = 0;
205      }
206    });
207  };
208
209  for (const target of targets) {
210    const full = path.join(rootDir, target);
211    if (fs.existsSync(full)) scan(full, target);
212  }
213
214  if (errors === 0) {
215    console.log(`āœ“ No em dashes in project copy`);
216  } else {
217    console.error(`\nāŒ ${errors} em dash(es) in project copy. Use commas, colons, or parentheses.`);
218  }
219  return errors;
220}
221
222/**
223 * Validate that every hand-authored HTML page carries the shared site header.
224 * The partial is stamped with `<!-- site-header v1 -->` so drift is loud.
225 *
226 * Returns the number of validation errors. Build fails if > 0.
227 */
228function validateSiteHeader(rootDir) {
229  const pages = [
230    'public/index.html',
231    'public/cheatsheet.html',
232    'public/privacy.html',
233  ];
234  const marker = '<!-- site-header v1 -->';
235  let errors = 0;
236  for (const rel of pages) {
237    const full = path.join(rootDir, rel);
238    if (!fs.existsSync(full)) {
239      console.error(`  āŒ ${rel} is missing`);
240      errors++;
241      continue;
242    }
243    const src = fs.readFileSync(full, 'utf-8');
244    if (!src.includes(marker)) {
245      console.error(`  āŒ ${rel} is missing the shared site header marker '${marker}'`);
246      errors++;
247    }
248  }
249  if (errors === 0) {
250    console.log(`āœ“ Validated site header on ${pages.length} hand-authored pages`);
251  }
252  return errors;
253}
254
255/**
256 * Copy directory recursively
257 */
258function copyDirSync(src, dest) {
259  fs.mkdirSync(dest, { recursive: true });
260  const entries = fs.readdirSync(src, { withFileTypes: true });
261  for (const entry of entries) {
262    const srcPath = path.join(src, entry.name);
263    const destPath = path.join(dest, entry.name);
264    if (entry.isDirectory()) {
265      copyDirSync(srcPath, destPath);
266    } else {
267      fs.copyFileSync(srcPath, destPath);
268    }
269  }
270}
271
272const __filename = fileURLToPath(import.meta.url);
273const __dirname = path.dirname(__filename);
274const ROOT_DIR = path.resolve(__dirname, '..');
275const DIST_DIR = path.join(ROOT_DIR, 'dist');
276
277/**
278 * Build static site using Bun's HTML bundler
279 * Bun's HTML loader resolves <link rel="stylesheet"> and inlines CSS @imports.
280 */
281async function buildStaticSite(extraEntrypoints = []) {
282  const entrypoints = [
283    path.join(ROOT_DIR, 'public', 'index.html'),
284    path.join(ROOT_DIR, 'public', 'cheatsheet.html'),
285    path.join(ROOT_DIR, 'public', 'privacy.html'),
286    ...extraEntrypoints,
287  ];
288  const outdir = path.join(ROOT_DIR, 'build');
289
290  console.log(`šŸ“¦ Building static site with Bun (${entrypoints.length} HTML entries)...`);
291
292  try {
293    const result = await Bun.build({
294      entrypoints: entrypoints,
295      outdir: outdir,
296      minify: true,
297      sourcemap: 'linked',
298      // Older Bun versions (e.g. the one Cloudflare Pages ships) don't dedupe
299      // shared CSS/JS chunks across HTML entrypoints — every entry tries to
300      // emit its own copy, and three different sub-pages all named index.html
301      // (under skills/, tutorials/, anti-patterns/) collide on the same
302      // chunk filename. Including [dir] in the chunk template scopes each
303      // chunk to its entry's directory so the names stay unique even when
304      // dedupe is off. Local Bun still emits a single shared chunk; CF Bun
305      // emits one per entry but each lands in its own directory.
306      naming: {
307        entry: '[dir]/[name].[ext]',
308        chunk: '[dir]/[name]-[hash].[ext]',
309        asset: '[dir]/[name]-[hash].[ext]',
310      },
311    });
312
313    if (!result.success) {
314      console.error('Build failed:');
315      for (const log of result.logs) {
316        console.error(log.message || log);
317        if (log.position) {
318          console.error(`  at ${log.position.file}:${log.position.line}:${log.position.column}`);
319        }
320      }
321      process.exit(1);
322    }
323
324    // Calculate total size
325    const totalSize = result.outputs.reduce((sum, o) => sum + o.size, 0);
326    const htmlFiles = result.outputs.filter(o => o.path.endsWith('.html'));
327    const jsFiles = result.outputs.filter(o => o.path.endsWith('.js'));
328    const cssFiles = result.outputs.filter(o => o.path.endsWith('.css'));
329
330    // When entrypoints span multiple depths under public/ (e.g. public/index.html
331    // + public/skills/polish.html), Bun's HTML loader preserves the full public/
332    // prefix in the output tree. Flatten build/public/* up to build/*.
333    const nestedPublic = path.join(outdir, 'public');
334    if (fs.existsSync(nestedPublic)) {
335      for (const entry of fs.readdirSync(nestedPublic, { withFileTypes: true })) {
336        const from = path.join(nestedPublic, entry.name);
337        const to = path.join(outdir, entry.name);
338        if (fs.existsSync(to)) fs.rmSync(to, { recursive: true, force: true });
339        fs.renameSync(from, to);
340      }
341      fs.rmdirSync(nestedPublic);
342    }
343
344    console.log(`āœ“ Static site built to ./build/`);
345    console.log(`  HTML: ${htmlFiles.length} file(s)`);
346    console.log(`  JS: ${jsFiles.length} file(s) (${(jsFiles.reduce((s, f) => s + f.size, 0) / 1024).toFixed(1)} KB)`);
347    console.log(`  CSS: ${cssFiles.length} file(s) (${(cssFiles.reduce((s, f) => s + f.size, 0) / 1024).toFixed(1)} KB)`);
348    console.log(`  Total: ${(totalSize / 1024).toFixed(1)} KB\n`);
349
350    return result;
351  } catch (error) {
352    // Bun's build aggregator errors expose details on `error.errors` (an
353    // array of resolution / parse failures), not `error.stack`. Print
354    // both so CI logs surface the real cause instead of "undefined".
355    console.error('Failed to build static site:', error.message);
356    if (error.errors?.length) {
357      for (const e of error.errors) {
358        console.error('  -', e.message || e);
359      }
360    }
361    if (error.logs?.length) {
362      for (const log of error.logs) {
363        console.error(log.message || log);
364      }
365    }
366    if (error.stack) console.error(error.stack);
367    process.exit(1);
368  }
369}
370
371/**
372 * Assemble universal directory from all provider outputs
373 */
374function assembleUniversal(distDir, suffix = '') {
375  const universalDir = path.join(distDir, `universal${suffix}`);
376
377  // Clean and recreate
378  if (fs.existsSync(universalDir)) {
379    fs.rmSync(universalDir, { recursive: true, force: true });
380  }
381
382  const providerConfigs = Object.values(PROVIDERS);
383
384  for (const { provider, configDir } of providerConfigs) {
385    const src = path.join(distDir, `${provider}${suffix}`, configDir);
386    const dest = path.join(universalDir, configDir);
387    if (fs.existsSync(src)) {
388      copyDirSync(src, dest);
389    }
390  }
391
392  // Add a visible README so macOS users don't see an empty folder
393  // (all provider dirs are dotfiles, hidden by default in Finder)
394  const prefixNote = suffix ? '\nSkills in this bundle are prefixed with i- (e.g. /i-audit) to avoid conflicts.\n' : '';
395  fs.writeFileSync(path.join(universalDir, 'README.txt'),
396`Impeccable — Design fluency for AI harnesses
397https://impeccable.style
398${prefixNote}
399This folder contains skills for all supported tools:
400
401  .cursor/    → Cursor
402  .claude/    → Claude Code
403  .gemini/    → Gemini CLI
404  .codex/     → Codex CLI
405  .agents/    → VS Code Copilot, Antigravity
406  .kiro/      → Kiro
407  .opencode/  → OpenCode
408  .pi/        → Pi
409  .trae-cn/   → Trae China
410  .trae/      → Trae International
411
412To install, copy the relevant folder(s) into your project root.
413These are hidden folders (dotfiles) — press Cmd+Shift+. in Finder to see them.
414`);
415
416  const label = suffix ? ' (prefixed)' : '';
417  console.log(`āœ“ Assembled universal${label} directory (${providerConfigs.length} providers)`);
418}
419
420/**
421 * Generate static API data for Cloudflare Pages deployment.
422 * Pre-builds all API responses as JSON files so they can be served
423 * as static assets via _redirects rewrites (no function invocations needed).
424 */
425function generateApiData(buildDir, skills, patterns) {
426  const apiDir = path.join(buildDir, '_data', 'api');
427  fs.mkdirSync(apiDir, { recursive: true });
428
429  // skills.json
430  const skillsData = skills.map(s => ({
431    id: path.basename(path.dirname(s.filePath)),
432    name: s.name,
433    description: s.description,
434    userInvocable: s.userInvocable,
435  }));
436  fs.writeFileSync(path.join(apiDir, 'skills.json'), JSON.stringify(skillsData));
437
438  // commands.json (user-invocable skills only)
439  const commandsData = skillsData.filter(s => s.userInvocable);
440  fs.writeFileSync(path.join(apiDir, 'commands.json'), JSON.stringify(commandsData));
441
442  // patterns.json
443  fs.writeFileSync(path.join(apiDir, 'patterns.json'), JSON.stringify(patterns));
444
445  // command-source/{id}.json (one per skill)
446  const cmdSourceDir = path.join(apiDir, 'command-source');
447  fs.mkdirSync(cmdSourceDir, { recursive: true });
448  for (const skill of skills) {
449    const id = path.basename(path.dirname(skill.filePath));
450    const content = fs.readFileSync(skill.filePath, 'utf-8');
451    fs.writeFileSync(
452      path.join(cmdSourceDir, `${id}.json`),
453      JSON.stringify({ content })
454    );
455  }
456
457  console.log(`āœ“ Generated static API data (${skillsData.length} skills, ${commandsData.length} commands)`);
458}
459
460/**
461 * Copy dist files to build output for Cloudflare Pages Functions access.
462 * Download functions use env.ASSETS.fetch() to read these files.
463 */
464function copyDistToBuild(distDir, buildDir) {
465  const destDir = path.join(buildDir, '_data', 'dist');
466  copyDirSync(distDir, destDir);
467  console.log('āœ“ Copied dist files to build output');
468}
469
470/**
471 * Generate Cloudflare Pages config files (_headers, _redirects)
472 */
473function generateCFConfig(buildDir) {
474  // _headers: security + cache headers
475  const headers = `/*
476  X-Content-Type-Options: nosniff
477  X-Frame-Options: SAMEORIGIN
478
479# HTML pages: browser always revalidates, CDN caches 1h
480/*.html
481  Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=600
482
483# Hashed JS/CSS bundles: immutable (filename changes on content change)
484/assets/*.js
485  Cache-Control: public, max-age=31536000, immutable
486
487/assets/*.css
488  Cache-Control: public, max-age=31536000, immutable
489
490# Static images and logos: 1 week + 1 day stale
491/assets/*.png
492  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
493
494/assets/*.svg
495  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
496
497/assets/*.webp
498  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
499
500/antipattern-images/*
501  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
502
503# Root static assets (favicon, og-image, etc.)
504/favicon.svg
505  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
506
507/og-image.jpg
508  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
509
510/apple-touch-icon.png
511  Cache-Control: public, max-age=604800, stale-while-revalidate=86400
512
513# ZIP downloads: 1h cache
514/dist/*.zip
515  Cache-Control: public, max-age=3600, stale-while-revalidate=600
516
517# API routes: CDN caches 24h
518/api/*
519  Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
520
521/_data/api/*
522  Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
523`;
524  fs.writeFileSync(path.join(buildDir, '_headers'), headers);
525
526  // _redirects: rewrite JSON API routes to static files (200 = rewrite, not redirect)
527  const redirects = `/api/skills /_data/api/skills.json 200
528/api/commands /_data/api/commands.json 200
529/api/patterns /_data/api/patterns.json 200
530/api/command-source/:id /_data/api/command-source/:id.json 200
531/gallery /visual-mode#try-it-live 301
532`;
533  fs.writeFileSync(path.join(buildDir, '_redirects'), redirects);
534
535  // _routes.json: tell Cloudflare Pages which paths invoke Functions
536  // Without this, the SPA fallback serves index.html for function routes
537  const routes = {
538    version: 1,
539    include: ['/api/download/*'],
540    exclude: [],
541  };
542  fs.writeFileSync(path.join(buildDir, '_routes.json'), JSON.stringify(routes, null, 2));
543
544  console.log('āœ“ Generated Cloudflare Pages config (_headers, _redirects, _routes.json)');
545}
546
547/**
548 * Main build process
549 */
550async function build() {
551  console.log('šŸ”Ø Building cross-provider design skills...\n');
552
553  // Pre-generate sub-pages (skills, anti-patterns, tutorials) from source
554  console.log('šŸ“ Generating sub-pages...');
555  const { files: subPageFiles } = await generateSubPages(ROOT_DIR);
556  console.log(`āœ“ Generated ${subPageFiles.length} sub-page(s)\n`);
557
558  // Bundle HTML, JS, and CSS with Bun (including generated sub-pages)
559  await buildStaticSite(subPageFiles);
560
561  // Copy root-level static assets that need stable (unhashed) URLs
562  const staticAssets = ['og-image.jpg', 'robots.txt', 'sitemap.xml', 'favicon.svg', 'apple-touch-icon.png'];
563  const buildDir = path.join(ROOT_DIR, 'build');
564  for (const asset of staticAssets) {
565    const src = path.join(ROOT_DIR, 'public', asset);
566    if (fs.existsSync(src)) {
567      fs.copyFileSync(src, path.join(buildDir, asset));
568    }
569  }
570
571  // Copy antipattern examples (self-contained HTML, not Bun entrypoints)
572  const examplesDir = path.join(ROOT_DIR, 'public', 'antipattern-examples');
573  if (fs.existsSync(examplesDir)) {
574    copyDirSync(examplesDir, path.join(buildDir, 'antipattern-examples'));
575  }
576
577  // Copy browser detector script (referenced by antipattern examples at /js/...)
578  const detectorSrc = path.join(ROOT_DIR, 'src', 'detect-antipatterns-browser.js');
579  if (fs.existsSync(detectorSrc)) {
580    const jsDir = path.join(buildDir, 'js');
581    fs.mkdirSync(jsDir, { recursive: true });
582    fs.copyFileSync(detectorSrc, path.join(jsDir, 'detect-antipatterns-browser.js'));
583  }
584
585  // Read source files (unified skills architecture)
586  const { skills } = readSourceFiles(ROOT_DIR);
587  const patterns = readPatterns(ROOT_DIR);
588  const userInvocableCount = skills.filter(s => s.userInvocable).length;
589  console.log(`šŸ“– Read ${skills.length} skills (${userInvocableCount} user-invocable) and ${patterns.patterns.length + patterns.antipatterns.length} pattern categories\n`);
590
591  // Read skills version from plugin.json
592  const pluginJson = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
593  const skillsVersion = pluginJson.version;
594
595  // Transform for each provider (unprefixed + prefixed)
596  for (const config of Object.values(PROVIDERS)) {
597    const transform = createTransformer(config);
598    transform(skills, DIST_DIR, { skillsVersion });
599    transform(skills, DIST_DIR, { prefix: 'i-', outputSuffix: '-prefixed', skillsVersion });
600  }
601
602  // Assemble universal directory (unprefixed and prefixed)
603  assembleUniversal(DIST_DIR);
604  assembleUniversal(DIST_DIR, '-prefixed');
605
606  // Create ZIP bundles (individual + universal)
607  await createAllZips(DIST_DIR);
608
609  // Generate static API data and Cloudflare Pages config
610  generateApiData(buildDir, skills, patterns);
611  copyDistToBuild(DIST_DIR, buildDir);
612  generateCFConfig(buildDir);
613
614  // Copy all provider outputs to project root for local testing
615  const syncConfigs = Object.values(PROVIDERS);
616
617  for (const { provider, configDir } of syncConfigs) {
618    const skillsSrc = path.join(DIST_DIR, provider, configDir, 'skills');
619    const skillsDest = path.join(ROOT_DIR, configDir, 'skills');
620
621    if (fs.existsSync(skillsSrc)) {
622      if (fs.existsSync(skillsDest)) fs.rmSync(skillsDest, { recursive: true });
623      copyDirSync(skillsSrc, skillsDest);
624    }
625  }
626
627  // Remove deprecated skill stubs from local harness dirs. They exist
628  // in dist/ so the cleanup script can redirect users, but they should
629  // not clutter the repo's own skill directories.
630  const deprecatedLocalSkills = [
631    'frontend-design', 'teach-impeccable',
632    'arrange', 'normalize', 'onboard', 'extract',
633  ];
634  for (const { configDir } of syncConfigs) {
635    for (const name of deprecatedLocalSkills) {
636      const p = path.join(ROOT_DIR, configDir, 'skills', name);
637      if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
638    }
639  }
640
641  console.log(`šŸ“‹ Synced skills to: ${syncConfigs.map(p => p.configDir).join(', ')}`);
642
643
644  // Generate authoritative counts and validate references
645  const countErrors = generateCounts(ROOT_DIR, skills, buildDir);
646
647  // Cross-validate engine rules against impeccable SKILL.md DON'Ts
648  const validationErrors = validateAntipatternRules(ROOT_DIR);
649
650  // Verify every hand-authored HTML page carries the shared site header
651  const headerErrors = validateSiteHeader(ROOT_DIR);
652
653  // Scan user-facing copy for em dashes
654  const emDashErrors = validateNoEmDashes(ROOT_DIR);
655
656  if (countErrors > 0 || validationErrors > 0 || headerErrors > 0 || emDashErrors > 0) {
657    process.exit(1);
658  }
659
660  console.log('\n✨ Build complete!');
661}
662
663// Run the build
664build();