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 —).
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, /—/gi, /—/gi, /—/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();