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