Files
pbakaus_impeccable/scripts/build.js
T
Paul BakausandClaude Opus 4.6 e1032b7285 Add icon-tile-stack rule and cross-validate engine against skill
A new icon-tile-stack detection (the canonical AI feature-card with a
small rounded-square icon container above a heading), backed by a
two-column TDD fixture, plus a single-source-of-truth design that ties
the engine to the impeccable skill so they can no longer drift silently.

Detection
- New icon-tile-stack rule (slop): heading's previousElementSibling is
  a 32–128px rounded-square element with a non-transparent background
  or border, contains an svg/icon-i child, and sits above (not next to)
  the heading. Excludes round avatars, wide thumbnails, side-by-side
  layouts, tiny icons, and hero images.
- Two-column fixture convention: a single icon-tile-stack.html with a
  flag column (4 cases) and pass column (6 cases), with snippet-text
  matching used by the fixture test.

Single source of truth
- Each ANTIPATTERNS entry can now declare skillSection + skillGuideline.
  18 of 25 rules carry these fields; the build's new
  validateAntipatternRules() in scripts/build.js fails if any declared
  skillGuideline isn't found verbatim in the right SKILL.md section.
- scripts/build-extension.js now includes the description field in
  extension/detector/antipatterns.json (it was previously dropped).
- The existing count validator was promoted from warn to error so
  command count drift fails the build the same way detection drift does.

Impeccable skill DON'Ts
- Added 4 new top-level DON'Ts that target real default AI behavior:
  single-font, flat-type-hierarchy, all-caps-body, line-length.
- Cut 7 new DON'Ts I had drafted (tight-leading, tiny-text, wide-tracking,
  justified-text, low-contrast, cramped-padding, skipped-heading) because
  they teach things every model already knows from CSS/a11y basics. The
  detector still catches all of them.

Stale count cleanup
- 22 commands → 21 across 17 references in HTML, README, NOTICE, AGENTS,
  plugin.json, marketplace.json (left over from the validate skill removal).
- Dropped the hand-coded "212 design guidelines" marketing copy on the
  homepage, which never mapped to any real count.

Sub-agent
- New private .claude/agents/anti-patterns.md captures the full TDD
  recipe, schema, plug-in points, jsdom constraints, and pre-commit
  checklist so future sessions can add rules end-to-end without
  re-investigating the wiring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:58:13 -07:00

515 lines
18 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/
* - Agents: .agents/skills/ (VS Code Copilot + Antigravity)
*
* 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 { execSync } from 'child_process';
/**
* 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 (non-deprecated) user-invocable commands
const activeCommands = skills.filter(s => {
if (!s.userInvocable) return false;
const content = fs.readFileSync(s.filePath, 'utf-8');
return !content.includes('DEPRECATED');
});
const 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',
'public/cheatsheet.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|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;
}
/**
* 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
const sectionText = {};
for (const section of skillSections) {
sectionText[section.name] = section.items.join('\n');
}
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)) {
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;
}
/**
* 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 Tailwind CSS using the CLI
* Tailwind v4 uses @theme directive which Bun's CSS bundler doesn't understand
*/
function buildTailwindCSS() {
const inputFile = path.join(ROOT_DIR, 'public', 'css', 'main.css');
const outputFile = path.join(ROOT_DIR, 'public', 'css', 'styles.css');
console.log('🎨 Building Tailwind CSS...');
try {
execSync(`bunx @tailwindcss/cli -i "${inputFile}" -o "${outputFile}" --minify`, {
cwd: ROOT_DIR,
stdio: 'inherit'
});
console.log('✓ Tailwind CSS compiled\n');
} catch (error) {
console.error('Failed to build Tailwind CSS:', error.message);
process.exit(1);
}
}
/**
* Build static site using Bun's HTML bundler
* CSS is pre-compiled by Tailwind CLI, then bundled with HTML/JS
*/
async function buildStaticSite() {
const entrypoints = [
path.join(ROOT_DIR, 'public', 'index.html'),
path.join(ROOT_DIR, 'public', 'cheatsheet.html'),
path.join(ROOT_DIR, 'public', 'gallery.html'),
path.join(ROOT_DIR, 'public', 'privacy.html'),
];
const outdir = path.join(ROOT_DIR, 'build');
console.log('📦 Building static site with Bun...');
try {
const result = await Bun.build({
entrypoints: entrypoints,
outdir: outdir,
minify: true,
sourcemap: 'linked',
});
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 jsFiles = result.outputs.filter(o => o.path.endsWith('.js'));
const cssFiles = result.outputs.filter(o => o.path.endsWith('.css'));
console.log(`✓ Static site built to ./build/`);
console.log(` HTML: 1 file`);
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) {
console.error('Failed to build static site:', error.message);
console.error(error.stack);
if (error.logs) {
for (const log of error.logs) {
console.error(log.message || log);
}
}
process.exit(1);
}
}
/**
* Assemble universal directory from all provider outputs
*/
function assembleUniversal(distDir, suffix = '') {
const universalDir = path.join(distDir, `universal${suffix}`);
// 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}${suffix}`, 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)
const prefixNote = suffix ? '\nSkills in this bundle are prefixed with i- (e.g. /i-audit) to avoid conflicts.\n' : '';
fs.writeFileSync(path.join(universalDir, 'README.txt'),
`Impeccable — Design fluency for AI harnesses
https://impeccable.style
${prefixNote}
This folder contains skills for all supported tools:
.cursor/ → Cursor
.claude/ → Claude Code
.gemini/ → Gemini CLI
.codex/ → Codex CLI
.agents/ → VS Code Copilot, Antigravity
.kiro/ → Kiro
.opencode/ → OpenCode
.pi/ → Pi
.trae-cn/ → Trae China
.trae/ → Trae International
To install, copy the relevant folder(s) into your project root.
These are hidden folders (dotfiles) — press Cmd+Shift+. in Finder to see them.
`);
const label = suffix ? ' (prefixed)' : '';
console.log(`✓ Assembled universal${label} 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 (user-invocable skills only)
const commandsData = skillsData.filter(s => s.userInvocable);
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 })
);
}
console.log(`✓ Generated static API data (${skillsData.length} skills, ${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: DENY
# 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)
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
`;
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');
// Build CSS with Tailwind CLI (handles @theme directive)
buildTailwindCSS();
// Bundle HTML, JS, and compiled CSS with Bun
await buildStaticSite();
// 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));
}
}
// 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`);
// Transform for each provider (unprefixed + prefixed)
for (const config of Object.values(PROVIDERS)) {
const transform = createTransformer(config);
transform(skills, DIST_DIR);
transform(skills, DIST_DIR, { prefix: 'i-', outputSuffix: '-prefixed' });
}
// Assemble universal directory (unprefixed and prefixed)
assembleUniversal(DIST_DIR);
assembleUniversal(DIST_DIR, '-prefixed');
// 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);
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);
}
}
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);
if (countErrors > 0 || validationErrors > 0) {
process.exit(1);
}
console.log('\n✨ Build complete!');
}
// Run the build
build();