Files
pbakaus_impeccable/scripts/build.js
T
122a82f715 docs: strip AI prose, add STYLE.md and validateProse (#134)
Site copy was being called out as AI slop (specifically the word
"load-bearing"). Five-pass cleanup with a build validator to keep it
from creeping back.

Pass 1 — mechanical purge:
- Remove "load-bearing" from impeccable.md, brand.md, live.md,
  iterate-live.md
- Remove "highest-leverage" from critique.md, typeset.md, designing
- Remove em dashes from all 9 slop-page rule cards
- Replace "leverage" verb in personas.md

Pass 2 — rewrite the worst offenders:
- Changelog v2.0 "Data-driven skill rewrite" entry: drop "data-driven",
  "frontier models", "collapses into monoculture", "biggest unlock",
  "reflex defaults"; name the actual mechanism
- README opener: drop "deeper expertise and more control"; replace with
  three concrete differentiators (7 reference files, 23 commands, 27
  detection rules)
- Neo Mirai case study opener: action-first, name the image model used

Pass 3 — editorials:
- Fix negation pivot in distill.md ("simplicity is not about ... It is
  about ...")

Pass 4 — homepage why-panels:
- Foundation lead: name the 7 reference files specifically
- Language lead: show the discipline mapping with real command names
- Production-codebases panel: drop "Impeccable isn't a sketchpad"
  negation pivot
- Ships-code panel: replace "is native to that world" with "runs there"

Pass 5 — STYLE.md and validator:
- New STYLE.md at root: editorial brief with 12 principles and the
  enforced denylist (each rule with a rationale and a suggested
  replacement)
- scripts/build.js: validateNoEmDashes becomes validateProse. Adds 21
  phrase rules with rationales, catches the \`--\` em-dash substitute,
  expands target list to site/pages, site/content, README.md,
  README.npm.md
- CLAUDE.md: replace the em-dash section with a STYLE.md pointer and
  document the two-content-tree footgun (content/site/ vs site/content/
  must be edited in lockstep until they're unified)

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-02 23:40:32 -07:00

739 lines
29 KiB
JavaScript

#!/usr/bin/env node
/**
* Build System for Cross-Provider Design Skills
*
* Transforms source skills into provider-specific formats:
* - Cursor: .cursor/skills/
* - Claude Code: .claude/skills/
* - Gemini: .gemini/skills/
* - Codex: dist/codex/ only (OpenAI-metadata bundle; not synced to repo root)
* - Agents: .agents/skills/ (Codex repo/user installs)
* - GitHub: .github/skills/ (GitHub Copilot)
*
* Also assembles a universal ZIP containing all providers,
* and builds Tailwind CSS for production deployment.
*/
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js';
import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
import { createAllZips } from './lib/zip.js';
// Sub-page generation is now handled by Astro content collections.
// import { generateSubPages } from './build-sub-pages.js';
/**
* Generate authoritative counts from source data and write to public/js/generated/counts.js.
* Also validates that key HTML files reference the correct numbers.
*/
function generateCounts(rootDir, skills, buildDir) {
// Count active commands. After the v3.0 consolidation, commands are sub-commands
// of /impeccable. Count them from the command router table in SKILL.md.
const impeccableSkill = skills.find(s => s.name === 'impeccable');
let commandCount;
if (impeccableSkill) {
// Count lines in the command table that start with | `...` | — tolerant
// of argument hints inside the backticks (e.g. `craft [feature]`) and of
// multi-word commands (e.g. `pin <command>`).
const routerMatches = impeccableSkill.body.match(/^\| `[^`]+` \|/gm);
commandCount = routerMatches ? routerMatches.length : 0;
} else {
// Fallback: count user-invocable skills
const activeCommands = skills.filter(s => {
if (!s.userInvocable) return false;
const content = fs.readFileSync(s.filePath, 'utf-8');
return !content.includes('DEPRECATED');
});
commandCount = activeCommands.length;
}
// Count detection rules from impeccable package
const detectPkgPath = path.join(rootDir, 'src/detect-antipatterns.mjs');
const detectorSrc = fs.readFileSync(detectPkgPath, 'utf-8');
const ruleIds = new Set();
for (const match of detectorSrc.matchAll(/^\s+id: '([^']+)'/gm)) {
ruleIds.add(match[1]);
}
const detectionCount = ruleIds.size;
// Write generated counts module
const genDir = path.join(rootDir, 'public/js/generated');
fs.mkdirSync(genDir, { recursive: true });
fs.writeFileSync(path.join(genDir, 'counts.js'),
`// GENERATED by build.js — do not edit\n` +
`export const COMMAND_COUNT = ${commandCount};\n` +
`export const DETECTION_COUNT = ${detectionCount};\n`
);
// Validate counts in key files
const filesToCheck = [
'public/index.html',
'README.md',
'NOTICE.md',
'AGENTS.md',
'.claude-plugin/plugin.json',
'.claude-plugin/marketplace.json',
];
let errors = 0;
for (const relPath of filesToCheck) {
const absPath = path.join(rootDir, relPath);
if (!fs.existsSync(absPath)) continue;
const content = fs.readFileSync(absPath, 'utf-8');
// Check for stale command counts (look for "N commands" or "N skills" patterns)
// Strip changelog list content to avoid flagging historical counts
const strippedContent = content.replace(/<ul class="changelog-items">[\s\S]*?<\/ul>/g, '');
const countPattern = /\b(\d+)\s+(design\s+)?(commands|sub-commands|skills|steering commands)/gi;
for (const match of strippedContent.matchAll(countPattern)) {
const num = parseInt(match[1]);
// Allow 1 (for "1 skill") and the correct count
if (num !== commandCount && num !== 1) {
console.error(` ❌ ${relPath}: found "${match[0]}" but active command count is ${commandCount}`);
errors++;
}
}
// Check for stale detection counts
const detectPattern = /\b(\d+)\s+(deterministic\s+)?(checks|patterns|rules|detections)/gi;
for (const match of content.matchAll(detectPattern)) {
const num = parseInt(match[1]);
if (num !== detectionCount && num > 10) { // ignore small numbers like "3 patterns"
console.error(` ❌ ${relPath}: found "${match[0]}" but detection count is ${detectionCount}`);
errors++;
}
}
}
if (errors > 0) {
console.error(`\n❌ ${errors} stale count reference(s) found. Update them to match source of truth.`);
}
console.log(`✓ Generated counts: ${commandCount} commands, ${detectionCount} detection rules`);
return errors;
}
function validateSkillFrontmatter(skills) {
let errors = 0;
for (const skill of skills) {
if (skill.description && skill.description.length > 1024) {
console.error(`❌ ${skill.filePath}: invalid description: exceeds maximum length of 1024 characters (${skill.description.length})`);
errors++;
}
}
return errors;
}
/**
* Scan user-facing copy for AI-prose anti-patterns:
* - em dashes (— or &mdash;)
* - double-hyphen substitutes (` -- `)
* - denylisted phrases that read as AI tells in marketing copy
*
* The denylist is the editorial brief in STYLE.md, enforced. Each rule has a
* rationale that prints with the failure so the next author understands why.
*
* Scope: every surface a reader sees. Not source/skills/impeccable/, where
* LLM-facing reference instructions can use technical phrasings the marketing
* copy can't.
*
* Returns the number of occurrences found. Build fails if > 0.
*/
function validateProse(rootDir) {
const targets = [
'content/site',
'site/components',
'site/content',
'site/layouts',
'site/pages',
'README.md',
'README.npm.md',
];
const extensions = new Set(['.html', '.md', '.js', '.mjs', '.css', '.astro']);
const emDashPatterns = [/—/g, /&mdash;/gi, /&#8212;/gi, /&#x2014;/gi];
// Phrase rules: { re, rationale }. Add to STYLE.md when adding here.
const phraseRules = [
{ re: /\bload-bearing\b/i, rationale: 'AI tell. Stolen-engineer diction; almost always vague. Name what the thing actually does.' },
{ re: /\bhighest-leverage\b/i, rationale: 'AI tell. Vague claim of impact. Say what specifically pays off.' },
{ re: /\bbiggest unlock\b/i, rationale: 'AI tell. Marketing-speak. Describe the actual change.' },
{ re: /\breflex defaults?\b/i, rationale: 'Internal jargon leaking into user-facing copy. Say "instincts" or "first guesses".' },
{ re: /\bcollapses? into monoculture\b/i, rationale: 'Internal eval-speak. Describe what actually went wrong.' },
{ re: /\bdata-driven\b/i, rationale: 'Empty marketing adjective. Cite the data instead.' },
{ re: /\bseamless(?:ly)?\b/i, rationale: 'Hollow positive. Say what specifically works without friction.' },
{ re: /\brobust(?:ness)?\b/i, rationale: 'Hollow positive. Cite the failure mode it handles.' },
{ re: /\bdelves?\b|\bdelved\b|\bdelving\b/i, rationale: 'Top AI tell. Use "explore", "look at", or just delete.' },
{ re: /\belevate(?:s|d)?\b/i, rationale: 'Marketing verb. Use the specific verb (improve, raise, sharpen).' },
{ re: /\bempower(?:s|ed|ing)?\b/i, rationale: 'Marketing verb. Use "let you" or "make possible".' },
{ re: /\bunderscore(?:s|d)?\b/i, rationale: 'AI tell. Use "show" or "make clear".' },
{ re: /\bpivotal\b/i, rationale: 'Hollow positive. Use "central", "key", or describe the role.' },
{ re: /\bin today's\b/i, rationale: 'Throat-clearing opener. Cut the clause; start at the point.' },
{ re: /\bgone are the days\b/i, rationale: 'Throat-clearing. Make the point directly.' },
{ re: /\bwhether you're\b/i, rationale: 'Audience-pandering. Pick one reader; write to them.' },
{ re: /\blet's dive in\b/i, rationale: 'Throat-clearing. Just start.' },
{ re: /\bin summary\b|\bin conclusion\b/i, rationale: 'Summarizing closer. End on the strongest sentence; trust the reader.' },
{ re: /\bmoreover\b|\bfurthermore\b/i, rationale: 'Transition crutch on a metronome. Drop, or use "also".' },
{ re: /\btapestry\b/i, rationale: 'AI scenery noun. Cut.' },
];
let errors = 0;
const checkLine = (line, rel, lineNum) => {
for (const re of emDashPatterns) {
if (re.test(line)) {
console.error(` ❌ ${rel}:${lineNum}: em dash → ${line.trim().slice(0, 120)}`);
console.error(` Use commas, colons, semicolons, periods, or parentheses.`);
errors++;
re.lastIndex = 0;
break;
}
re.lastIndex = 0;
}
if (/ -- /.test(line)) {
console.error(` ❌ ${rel}:${lineNum}: \` -- \` em-dash substitute → ${line.trim().slice(0, 120)}`);
console.error(` Worse than the em dash. Pick real punctuation.`);
errors++;
}
for (const rule of phraseRules) {
if (rule.re.test(line)) {
const matched = line.match(rule.re)?.[0] ?? '';
console.error(` ❌ ${rel}:${lineNum}: "${matched}" → ${line.trim().slice(0, 120)}`);
console.error(` ${rule.rationale}`);
errors++;
}
}
};
const scan = (absPath, rel) => {
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
for (const entry of fs.readdirSync(absPath)) {
scan(path.join(absPath, entry), path.join(rel, entry));
}
return;
}
if (!extensions.has(path.extname(absPath))) return;
const src = fs.readFileSync(absPath, 'utf-8');
const lines = src.split('\n');
lines.forEach((line, i) => checkLine(line, rel, i + 1));
};
for (const target of targets) {
const full = path.join(rootDir, target);
if (fs.existsSync(full)) scan(full, target);
}
if (errors === 0) {
console.log(`✓ Prose validator: no AI tells in user-facing copy`);
} else {
console.error(`\n❌ ${errors} prose issue(s) in user-facing copy. See STYLE.md for the rules.`);
}
return errors;
}
/**
* Validate that every hand-authored HTML page carries the shared site header.
* The partial is stamped with `<!-- site-header v1 -->` so drift is loud.
*
* Returns the number of validation errors. Build fails if > 0.
*/
function validateSiteHeader(_rootDir) {
// With Astro, the shared header is a component (site/components/Header.astro).
// There's nothing to validate per-page — the component is imported by Base.astro
// and rendered identically everywhere. This function is kept as a no-op so the
// call site doesn't need to change.
console.log('✓ Site header is a shared Astro component (no per-page validation needed)');
return 0;
}
/**
* Copy directory recursively
*/
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '..');
const DIST_DIR = path.join(ROOT_DIR, 'dist');
// buildStaticSite (Bun HTML bundler) removed — now handled by Astro.
// Placeholder so the line-number-based edits below don't shift.
async function _REMOVED() {
const entrypoints = [
path.join(ROOT_DIR, 'public', 'index.html'),
path.join(ROOT_DIR, 'public', 'privacy.html'),
...extraEntrypoints,
];
const outdir = path.join(ROOT_DIR, 'build');
console.log(`📦 Building static site with Bun (${entrypoints.length} HTML entries)...`);
try {
const result = await Bun.build({
entrypoints: entrypoints,
outdir: outdir,
minify: true,
sourcemap: 'linked',
// Older Bun versions (e.g. the one Cloudflare Pages ships) don't dedupe
// shared CSS/JS chunks across HTML entrypoints — every entry tries to
// emit its own copy, and three different sub-pages all named index.html
// (under docs/, tutorials/, slop/) collide on the same
// chunk filename. Including [dir] in the chunk template scopes each
// chunk to its entry's directory so the names stay unique even when
// dedupe is off. Local Bun still emits a single shared chunk; CF Bun
// emits one per entry but each lands in its own directory.
naming: {
entry: '[dir]/[name].[ext]',
chunk: '[dir]/[name]-[hash].[ext]',
asset: '[dir]/[name]-[hash].[ext]',
},
});
if (!result.success) {
console.error('Build failed:');
for (const log of result.logs) {
console.error(log.message || log);
if (log.position) {
console.error(` at ${log.position.file}:${log.position.line}:${log.position.column}`);
}
}
process.exit(1);
}
// Calculate total size
const totalSize = result.outputs.reduce((sum, o) => sum + o.size, 0);
const htmlFiles = result.outputs.filter(o => o.path.endsWith('.html'));
const jsFiles = result.outputs.filter(o => o.path.endsWith('.js'));
const cssFiles = result.outputs.filter(o => o.path.endsWith('.css'));
// When entrypoints span multiple depths under public/ (e.g. public/index.html
// + public/docs/polish.html), Bun's HTML loader preserves the full public/
// prefix in the output tree. Flatten build/public/* up to build/*.
const nestedPublic = path.join(outdir, 'public');
if (fs.existsSync(nestedPublic)) {
for (const entry of fs.readdirSync(nestedPublic, { withFileTypes: true })) {
const from = path.join(nestedPublic, entry.name);
const to = path.join(outdir, entry.name);
if (fs.existsSync(to)) fs.rmSync(to, { recursive: true, force: true });
fs.renameSync(from, to);
}
fs.rmdirSync(nestedPublic);
}
console.log(`✓ Static site built to ./build/`);
console.log(` HTML: ${htmlFiles.length} file(s)`);
console.log(` JS: ${jsFiles.length} file(s) (${(jsFiles.reduce((s, f) => s + f.size, 0) / 1024).toFixed(1)} KB)`);
console.log(` CSS: ${cssFiles.length} file(s) (${(cssFiles.reduce((s, f) => s + f.size, 0) / 1024).toFixed(1)} KB)`);
console.log(` Total: ${(totalSize / 1024).toFixed(1)} KB\n`);
return result;
} catch (error) {
// Bun's build aggregator errors expose details on `error.errors` (an
// array of resolution / parse failures), not `error.stack`. Print
// both so CI logs surface the real cause instead of "undefined".
console.error('Failed to build static site:', error.message);
if (error.errors?.length) {
for (const e of error.errors) {
console.error(' -', e.message || e);
}
}
if (error.logs?.length) {
for (const log of error.logs) {
console.error(log.message || log);
}
}
if (error.stack) console.error(error.stack);
process.exit(1);
}
}
/**
* Assemble universal directory from all provider outputs
*/
function assembleUniversal(distDir) {
const universalDir = path.join(distDir, 'universal');
// Clean and recreate
if (fs.existsSync(universalDir)) {
fs.rmSync(universalDir, { recursive: true, force: true });
}
const providerConfigs = Object.values(PROVIDERS);
for (const { provider, configDir } of providerConfigs) {
const src = path.join(distDir, provider, configDir);
const dest = path.join(universalDir, configDir);
if (fs.existsSync(src)) {
copyDirSync(src, dest);
}
}
// Add a visible README so macOS users don't see an empty folder
// (all provider dirs are dotfiles, hidden by default in Finder)
fs.writeFileSync(path.join(universalDir, 'README.txt'),
`Impeccable. Design fluency for AI harnesses.
https://impeccable.style
This folder contains skills for all supported tools:
.cursor/ -> Cursor
.claude/ -> Claude Code
.gemini/ -> Gemini CLI
.codex/ -> Legacy bundle folder in this ZIP (Codex CLI uses .agents/)
.agents/ -> Codex CLI
.github/ -> GitHub Copilot
.kiro/ -> Kiro
.opencode/ -> OpenCode
.pi/ -> Pi
.trae-cn/ -> Trae China
.trae/ -> Trae International
To install, copy the relevant folder(s) into your project root.
For Codex, repo and user skill installs come from .agents/skills.
These are hidden folders (dotfiles). Press Cmd+Shift+. in Finder to see them.
`);
console.log(`✓ Assembled universal directory (${providerConfigs.length} providers)`);
}
/**
* Generate static API data for Cloudflare Pages deployment.
* Pre-builds all API responses as JSON files so they can be served
* as static assets via _redirects rewrites (no function invocations needed).
*/
function generateApiData(buildDir, skills, patterns) {
const apiDir = path.join(buildDir, '_data', 'api');
fs.mkdirSync(apiDir, { recursive: true });
// skills.json
const skillsData = skills.map(s => ({
id: path.basename(path.dirname(s.filePath)),
name: s.name,
description: s.description,
userInvocable: s.userInvocable,
}));
fs.writeFileSync(path.join(apiDir, 'skills.json'), JSON.stringify(skillsData));
// commands.json - after v3.0 consolidation, commands are sub-commands of
// /impeccable. Load them from command-metadata.json and include the root
// impeccable skill itself so UI surfaces like the cheatsheet can list them.
// Each entry also picks up a short `tagline` from its editorial file
// (content/site/skills/<id>.md) when one exists. Taglines are used by UI
// surfaces that need a human-friendly one-liner, while `description` stays
// optimized for auto-trigger keyword matching in the AI harness.
const readTagline = (id) => {
const editorialPath = path.join(ROOT_DIR, 'content/site/skills', `${id}.md`);
if (!fs.existsSync(editorialPath)) return null;
const raw = fs.readFileSync(editorialPath, 'utf-8');
const match = raw.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
const taglineMatch = match[1].match(/tagline:\s*"([^"]+)"/);
return taglineMatch ? taglineMatch[1] : null;
};
const metadataPath = path.join(ROOT_DIR, 'source/skills/impeccable/scripts/command-metadata.json');
if (!fs.existsSync(metadataPath)) {
throw new Error(`command-metadata.json is missing at ${metadataPath}. This file is required to generate the commands API.`);
}
const impeccable = skills.find(s => s.name === 'impeccable');
if (!impeccable) {
throw new Error('impeccable skill not found in source/skills/. The build system expects a single impeccable skill.');
}
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
const commandsData = [
{
id: 'impeccable',
name: 'impeccable',
description: impeccable.description,
tagline: readTagline('impeccable'),
userInvocable: true,
},
...Object.entries(metadata).map(([id, meta]) => ({
id,
name: id,
description: meta.description,
tagline: readTagline(id),
userInvocable: true,
})),
];
fs.writeFileSync(path.join(apiDir, 'commands.json'), JSON.stringify(commandsData));
// patterns.json
fs.writeFileSync(path.join(apiDir, 'patterns.json'), JSON.stringify(patterns));
// command-source/{id}.json (one per skill)
const cmdSourceDir = path.join(apiDir, 'command-source');
fs.mkdirSync(cmdSourceDir, { recursive: true });
for (const skill of skills) {
const id = path.basename(path.dirname(skill.filePath));
const content = fs.readFileSync(skill.filePath, 'utf-8');
fs.writeFileSync(
path.join(cmdSourceDir, `${id}.json`),
JSON.stringify({ content })
);
}
const skillWord = skillsData.length === 1 ? 'skill' : 'skills';
console.log(`✓ Generated static API data (${skillsData.length} ${skillWord}, ${commandsData.length} commands)`);
}
/**
* Copy dist files to build output for Cloudflare Pages Functions access.
* Download functions use env.ASSETS.fetch() to read these files.
*/
function copyDistToBuild(distDir, buildDir) {
const destDir = path.join(buildDir, '_data', 'dist');
copyDirSync(distDir, destDir);
console.log('✓ Copied dist files to build output');
}
/**
* Generate Cloudflare Pages config files (_headers, _redirects)
*/
function generateCFConfig(buildDir) {
// _headers: security + cache headers
const headers = `/*
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
# HTML pages: browser always revalidates, CDN caches 1h
/*.html
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=600
# Hashed JS/CSS bundles: immutable (filename changes on content change)
/assets/*.js
Cache-Control: public, max-age=31536000, immutable
/assets/*.css
Cache-Control: public, max-age=31536000, immutable
# Static images and logos: 1 week + 1 day stale
/assets/*.png
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
/assets/*.svg
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
/assets/*.webp
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
/antipattern-images/*
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
# Root static assets (favicon, og-image, etc.)
/favicon.svg
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
/og-image.jpg
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
/apple-touch-icon.png
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
# ZIP downloads: 1h cache
/dist/*.zip
Cache-Control: public, max-age=3600, stale-while-revalidate=600
# API routes: CDN caches 24h
/api/*
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
/_data/api/*
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
`;
fs.writeFileSync(path.join(buildDir, '_headers'), headers);
// _redirects: rewrite JSON API routes to static files (200 = rewrite, not redirect).
// Plus permanent redirects for legacy URLs.
const redirects = `/api/skills /_data/api/skills.json 200
/api/commands /_data/api/commands.json 200
/api/patterns /_data/api/patterns.json 200
/api/command-source/:id /_data/api/command-source/:id.json 200
/gallery /slop#try-it-live 301
/cheatsheet /docs 301
/skills /docs 301
/skills/:id /docs/:id 301
/anti-patterns /slop#catalog 301
/visual-mode /slop#see-it 301
/neon-mirai /neo-mirai/ 301
/neon-mirai/ /neo-mirai/ 301
/cases/neon-mirai /cases/neo-mirai 301
/cases/neon-mirai/ /cases/neo-mirai 301
`;
fs.writeFileSync(path.join(buildDir, '_redirects'), redirects);
// _routes.json: tell Cloudflare Pages which paths invoke Functions
// Without this, the SPA fallback serves index.html for function routes
const routes = {
version: 1,
include: ['/api/download/*'],
exclude: [],
};
fs.writeFileSync(path.join(buildDir, '_routes.json'), JSON.stringify(routes, null, 2));
console.log('✓ Generated Cloudflare Pages config (_headers, _redirects, _routes.json)');
}
/**
* Main build process
*/
async function build() {
console.log('🔨 Building cross-provider design skills...\n');
// Sub-page generation, HTML bundling, and static-asset copying are now
// handled by Astro (bun run build:site). This script focuses on skills,
// API data, and Cloudflare config.
// Copy browser detector to public/js/ so the antipattern examples can
// reference it (Astro serves public/ as-is).
const detectorSrc = path.join(ROOT_DIR, 'src', 'detect-antipatterns-browser.js');
if (fs.existsSync(detectorSrc)) {
const jsDir = path.join(ROOT_DIR, 'public', 'js');
fs.mkdirSync(jsDir, { recursive: true });
fs.copyFileSync(detectorSrc, path.join(jsDir, 'detect-antipatterns-browser.js'));
}
const buildDir = path.join(ROOT_DIR, 'build');
// Read source files (unified skills architecture)
const { skills } = readSourceFiles(ROOT_DIR);
const patterns = readPatterns(ROOT_DIR);
const userInvocableCount = skills.filter(s => s.userInvocable).length;
console.log(`📖 Read ${skills.length} skills (${userInvocableCount} user-invocable) and ${patterns.patterns.length + patterns.antipatterns.length} pattern categories\n`);
const frontmatterErrors = validateSkillFrontmatter(skills);
if (frontmatterErrors > 0) {
process.exit(1);
}
// Read skills version from plugin.json
const pluginJson = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
const skillsVersion = pluginJson.version;
// Transform for each provider
for (const config of Object.values(PROVIDERS)) {
const transform = createTransformer(config);
transform(skills, DIST_DIR, { skillsVersion });
}
// Assemble universal directory
assembleUniversal(DIST_DIR);
// Create ZIP bundles (individual + universal)
await createAllZips(DIST_DIR);
// Generate static API data and Cloudflare Pages config
// Write API data and CF config to public/ so Astro copies them to build/.
// Astro wipes build/ before writing, so anything written directly to build/
// during build:skills would be destroyed when build:site runs.
const publicDir = path.join(ROOT_DIR, 'public');
generateApiData(publicDir, skills, patterns);
generateCFConfig(publicDir);
// Copy all provider outputs to project root for local testing.
// `.codex/` is intentionally excluded: Codex no longer consumes that layout; keep
// generated bundles under dist/ only.
const syncConfigs = Object.values(PROVIDERS).filter(({ configDir }) => configDir !== '.codex');
for (const { provider, configDir } of syncConfigs) {
const skillsSrc = path.join(DIST_DIR, provider, configDir, 'skills');
const skillsDest = path.join(ROOT_DIR, configDir, 'skills');
if (fs.existsSync(skillsSrc)) {
// Preserve per-project script artifacts (e.g. live-mode config.json)
// across the rm + recopy. The build intentionally doesn't ship them,
// so without this the sync destroys local state on every rebuild.
const stashed = stashPerProjectArtifacts(skillsDest);
if (fs.existsSync(skillsDest)) fs.rmSync(skillsDest, { recursive: true });
copyDirSync(skillsSrc, skillsDest);
restorePerProjectArtifacts(skillsDest, stashed);
}
}
// Remove deprecated skill stubs from local harness dirs. They exist
// in dist/ so the cleanup script can redirect users, but they should
// not clutter the repo's own skill directories.
const deprecatedLocalSkills = [
'frontend-design', 'teach-impeccable',
'arrange', 'normalize', 'onboard', 'extract',
// v3.0 consolidation: standalone skills -> /impeccable sub-commands
'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize',
'critique', 'delight', 'distill', 'harden', 'layout', 'optimize',
'overdrive', 'polish', 'quieter', 'shape', 'typeset',
];
for (const { configDir } of syncConfigs) {
for (const name of deprecatedLocalSkills) {
const p = path.join(ROOT_DIR, configDir, 'skills', name);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
}
}
console.log(`📋 Synced skills to: ${syncConfigs.map(p => p.configDir).join(', ')}`);
// Build the Claude Code plugin subtree at ./plugin/.
// The Claude Code marketplace is configured with `source: "./plugin"`, so
// the plugin cache only copies this slim directory (~0.3 MB) instead of
// the entire monorepo (~291 MB on the previous "./" source). The harness
// dirs above stay where they are because `npx skills add pbakaus/impeccable`
// reads them directly from the GitHub repo at install time.
const pluginRoot = path.join(ROOT_DIR, 'plugin');
const pluginManifestDir = path.join(pluginRoot, '.claude-plugin');
const pluginSkillsDir = path.join(pluginRoot, 'skills');
if (fs.existsSync(pluginManifestDir)) fs.rmSync(pluginManifestDir, { recursive: true });
if (fs.existsSync(pluginSkillsDir)) fs.rmSync(pluginSkillsDir, { recursive: true });
const rootManifest = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
// Trailing slash on the skills path matches the documented schema in
// code.claude.com/docs/en/plugins-reference. Issue #86 has 3 reporters
// converging on "add trailing slash to fix slash commands not registering";
// the docs schema example consistently uses `"./custom/skills/"` form.
const pluginManifest = { ...rootManifest, skills: './skills/' };
fs.mkdirSync(pluginManifestDir, { recursive: true });
fs.writeFileSync(
path.join(pluginManifestDir, 'plugin.json'),
JSON.stringify(pluginManifest, null, 2) + '\n',
);
const claudeSkillsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'skills', 'impeccable');
if (fs.existsSync(claudeSkillsSrc)) {
fs.mkdirSync(pluginSkillsDir, { recursive: true });
copyDirSync(claudeSkillsSrc, path.join(pluginSkillsDir, 'impeccable'));
}
console.log('📦 Built Claude Code plugin subtree at ./plugin/');
// Generate authoritative counts and validate references
const countErrors = generateCounts(ROOT_DIR, skills, buildDir);
// Verify every hand-authored HTML page carries the shared site header
const headerErrors = validateSiteHeader(ROOT_DIR);
// Scan user-facing copy for AI tells (em dashes, marketing fluff, denylisted phrases)
const proseErrors = validateProse(ROOT_DIR);
if (countErrors > 0 || headerErrors > 0 || proseErrors > 0) {
process.exit(1);
}
console.log('\n✨ Build complete!');
}
// Run the build
build();