mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Top nav was at six items and growing. The anti-patterns catalog and the visual-mode overlay demo were always two views of the same subject (the rule set and seeing it caught in place), so collapsing them into one page reduces nav weight and puts the catalog + live demo next to each other. Four sections in one scroll: See it (iframe demo), Try it live (specimen gallery), The catalog (full rule list), Run it yourself (invocation methods). Sidebar TOC nests the catalog sections under "The catalog" for deep linking. 301s from the old URLs preserve external links. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
685 lines
24 KiB
JavaScript
685 lines
24 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';
|
|
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 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 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
|
|
`;
|
|
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 (docs, slop, tutorials, live-mode, designing) 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.
|
|
// `.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(', ')}`);
|
|
|
|
|
|
// 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 em dashes
|
|
const emDashErrors = validateNoEmDashes(ROOT_DIR);
|
|
|
|
if (countErrors > 0 || headerErrors > 0 || emDashErrors > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\n✨ Build complete!');
|
|
}
|
|
|
|
// Run the build
|
|
build();
|