mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
738 lines
26 KiB
JavaScript
738 lines
26 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: .codex/skills/ (Codex-specific compatibility bundle)
|
|
* - 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 } from './lib/utils.js';
|
|
import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
|
|
import { createAllZips } from './lib/zip.js';
|
|
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 router table that have a | `command` | pattern
|
|
const routerMatches = impeccableSkill.body.match(/^\| `\w+` \|/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;
|
|
}
|
|
|
|
/**
|
|
* Cross-validate that every detection rule with a `skillGuideline` has a
|
|
* matching DON'T line in the right section of source/skills/impeccable/SKILL.md.
|
|
*
|
|
* This is the linchpin of the single-source-of-truth design: it catches drift
|
|
* between the engine's ANTIPATTERNS and the human-written DO/DON'T prose.
|
|
*
|
|
* Returns the number of validation errors. Build fails if > 0.
|
|
*/
|
|
function validateAntipatternRules(rootDir) {
|
|
const detectPath = path.join(rootDir, 'src/detect-antipatterns.mjs');
|
|
const src = fs.readFileSync(detectPath, 'utf-8');
|
|
const apMatch = src.match(/const ANTIPATTERNS = \[([\s\S]*?)\n\];/);
|
|
if (!apMatch) {
|
|
console.error(' ❌ Could not extract ANTIPATTERNS from detect-antipatterns.mjs');
|
|
return 1;
|
|
}
|
|
const antipatterns = new Function(`return [${apMatch[1]}]`)();
|
|
const { antipatterns: skillSections } = readPatterns(rootDir);
|
|
|
|
// Build section -> joined-DON'T-text lookup for substring matching.
|
|
// Lowercased for case-insensitive matching: my XML refactor uses sentence-
|
|
// case "DO NOT nest cards" while the rules' skillGuideline strings are
|
|
// sentence-cased "Nest cards inside cards" (a fragment from the original
|
|
// markdown bullet "**DON'T**: Nest cards inside cards.").
|
|
const sectionText = {};
|
|
for (const section of skillSections) {
|
|
sectionText[section.name] = section.items.join('\n').toLowerCase();
|
|
}
|
|
|
|
let errors = 0;
|
|
let validated = 0;
|
|
for (const rule of antipatterns) {
|
|
if (!rule.skillGuideline) continue;
|
|
if (!rule.skillSection) {
|
|
console.error(` ❌ Rule '${rule.id}' declares skillGuideline but no skillSection`);
|
|
errors++;
|
|
continue;
|
|
}
|
|
const text = sectionText[rule.skillSection];
|
|
if (!text) {
|
|
console.error(` ❌ Rule '${rule.id}': skillSection '${rule.skillSection}' has no DON'T lines in source/skills/impeccable/SKILL.md`);
|
|
errors++;
|
|
continue;
|
|
}
|
|
if (!text.includes(rule.skillGuideline.toLowerCase())) {
|
|
console.error(` ❌ Rule '${rule.id}': skillGuideline '${rule.skillGuideline}' not found in any DON'T of section '${rule.skillSection}' in source/skills/impeccable/SKILL.md`);
|
|
errors++;
|
|
continue;
|
|
}
|
|
validated++;
|
|
}
|
|
|
|
if (errors > 0) {
|
|
console.error(`\n❌ ${errors} anti-pattern rule(s) drift between src/detect-antipatterns.mjs and source/skills/impeccable/SKILL.md`);
|
|
} else {
|
|
console.log(`✓ Validated ${validated}/${antipatterns.length} anti-pattern rules against impeccable SKILL.md`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Scan user-facing copy for em dashes (— or —).
|
|
* Em dashes in project copy are a known anti-pattern here; flag them loudly.
|
|
* Only scans files where we author copy, not vendored or generated output.
|
|
*
|
|
* Returns the number of occurrences found.
|
|
*/
|
|
function validateNoEmDashes(rootDir) {
|
|
const targets = [
|
|
'content/site',
|
|
'public/index.html',
|
|
'public/privacy.html',
|
|
'scripts/build-sub-pages.js',
|
|
'scripts/lib/sub-pages-data.js',
|
|
];
|
|
const extensions = new Set(['.html', '.md', '.js', '.mjs', '.css']);
|
|
const emDashPatterns = [/—/g, /—/gi, /—/gi, /—/gi];
|
|
let errors = 0;
|
|
|
|
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) => {
|
|
for (const re of emDashPatterns) {
|
|
if (re.test(line)) {
|
|
console.error(` ❌ ${rel}:${i + 1}: em dash in copy → ${line.trim().slice(0, 120)}`);
|
|
errors++;
|
|
break;
|
|
}
|
|
re.lastIndex = 0;
|
|
}
|
|
});
|
|
};
|
|
|
|
for (const target of targets) {
|
|
const full = path.join(rootDir, target);
|
|
if (fs.existsSync(full)) scan(full, target);
|
|
}
|
|
|
|
if (errors === 0) {
|
|
console.log(`✓ No em dashes in project copy`);
|
|
} else {
|
|
console.error(`\n❌ ${errors} em dash(es) in project copy. Use commas, colons, or parentheses.`);
|
|
}
|
|
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) {
|
|
const pages = [
|
|
'public/index.html',
|
|
'public/privacy.html',
|
|
];
|
|
const marker = '<!-- site-header v1 -->';
|
|
let errors = 0;
|
|
for (const rel of pages) {
|
|
const full = path.join(rootDir, rel);
|
|
if (!fs.existsSync(full)) {
|
|
console.error(` ❌ ${rel} is missing`);
|
|
errors++;
|
|
continue;
|
|
}
|
|
const src = fs.readFileSync(full, 'utf-8');
|
|
if (!src.includes(marker)) {
|
|
console.error(` ❌ ${rel} is missing the shared site header marker '${marker}'`);
|
|
errors++;
|
|
}
|
|
}
|
|
if (errors === 0) {
|
|
console.log(`✓ Validated site header on ${pages.length} hand-authored pages`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
|
|
/**
|
|
* Build static site using Bun's HTML bundler
|
|
* Bun's HTML loader resolves <link rel="stylesheet"> and inlines CSS @imports.
|
|
*/
|
|
async function buildStaticSite(extraEntrypoints = []) {
|
|
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 skills/, tutorials/, anti-patterns/) 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/ -> Codex compatibility bundle
|
|
.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).
|
|
// Also permanent redirects for legacy URLs: /skills -> /docs, /cheatsheet -> /docs.
|
|
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 /visual-mode#try-it-live 301
|
|
/cheatsheet /docs 301
|
|
/skills /docs 301
|
|
/skills/:id /docs/:id 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');
|
|
|
|
// Pre-generate sub-pages (skills, anti-patterns, tutorials) from source
|
|
console.log('📝 Generating sub-pages...');
|
|
const { files: subPageFiles } = await generateSubPages(ROOT_DIR);
|
|
console.log(`✓ Generated ${subPageFiles.length} sub-page(s)\n`);
|
|
|
|
// Bundle HTML, JS, and CSS with Bun (including generated sub-pages)
|
|
await buildStaticSite(subPageFiles);
|
|
|
|
// Copy root-level static assets that need stable (unhashed) URLs
|
|
const staticAssets = ['og-image.jpg', 'robots.txt', 'sitemap.xml', 'favicon.svg', 'apple-touch-icon.png'];
|
|
const buildDir = path.join(ROOT_DIR, 'build');
|
|
for (const asset of staticAssets) {
|
|
const src = path.join(ROOT_DIR, 'public', asset);
|
|
if (fs.existsSync(src)) {
|
|
fs.copyFileSync(src, path.join(buildDir, asset));
|
|
}
|
|
}
|
|
|
|
// Copy antipattern examples (self-contained HTML, not Bun entrypoints)
|
|
const examplesDir = path.join(ROOT_DIR, 'public', 'antipattern-examples');
|
|
if (fs.existsSync(examplesDir)) {
|
|
copyDirSync(examplesDir, path.join(buildDir, 'antipattern-examples'));
|
|
}
|
|
|
|
// Copy browser detector script (referenced by antipattern examples at /js/...)
|
|
const detectorSrc = path.join(ROOT_DIR, 'src', 'detect-antipatterns-browser.js');
|
|
if (fs.existsSync(detectorSrc)) {
|
|
const jsDir = path.join(buildDir, 'js');
|
|
fs.mkdirSync(jsDir, { recursive: true });
|
|
fs.copyFileSync(detectorSrc, path.join(jsDir, 'detect-antipatterns-browser.js'));
|
|
}
|
|
|
|
// 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
|
|
generateApiData(buildDir, skills, patterns);
|
|
copyDistToBuild(DIST_DIR, buildDir);
|
|
generateCFConfig(buildDir);
|
|
|
|
// Copy all provider outputs to project root for local testing
|
|
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)) {
|
|
if (fs.existsSync(skillsDest)) fs.rmSync(skillsDest, { recursive: true });
|
|
copyDirSync(skillsSrc, skillsDest);
|
|
}
|
|
}
|
|
|
|
// 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(', ')}`);
|
|
|
|
|
|
// Generate authoritative counts and validate references
|
|
const countErrors = generateCounts(ROOT_DIR, skills, buildDir);
|
|
|
|
// Cross-validate engine rules against impeccable SKILL.md DON'Ts
|
|
const validationErrors = validateAntipatternRules(ROOT_DIR);
|
|
|
|
// Verify every hand-authored HTML page carries the shared site header
|
|
const headerErrors = validateSiteHeader(ROOT_DIR);
|
|
|
|
// Scan user-facing copy for em dashes
|
|
const emDashErrors = validateNoEmDashes(ROOT_DIR);
|
|
|
|
if (countErrors > 0 || validationErrors > 0 || headerErrors > 0 || emDashErrors > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\n✨ Build complete!');
|
|
}
|
|
|
|
// Run the build
|
|
build();
|