mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* Add a platform axis (web / ios / android / adaptive) to the skill Orthogonal to register: register decides whether design IS or SERVES the product; platform decides the delivery target and which native conventions apply. Set `## Platform` in PRODUCT.md; a missing field defaults to `web`, so legacy projects are unaffected. - extractPlatform() in skill/scripts/context.mjs (mirrors extractRegister); the CLI appends a NEXT STEP directive to read the native reference(s). `adaptive` (Flutter / RN / KMP shipping both iOS and Android) loads both ios.md and android.md. - New reference/ios.md (Apple HIG distilled) and reference/android.md (Material 3 distilled); reference/web.md is a thin pointer. The native refs frame register's role as narrow: platform conformance is the bar, brand lives in the expressive layer the platform gives you, never by breaking the rails. - Setup step 5 loads the native reference(s) when platform is native. Live mode and the detect CLI stay web-only, gated off ios/android/adaptive. - init asks platform right after register; adapt/audit/animate/layout carry short platform divergence notes; all secondary spots thread `adaptive`. - a11y stays in audit.md (loading it at design time makes output timid), so the native refs carry no Accessibility section; audit.md's Platform section owns native a11y. - Tests: extractPlatform unit coverage + skill-behavior scenario 10 (PRODUCT.md platform ios -> agent loads ios.md). Source-first: only skill/, scripts/, tests/, CLAUDE.md, NOTICE.md, the changelog and version are committed; the sync workflow regenerates the provider trees and ./plugin on merge. ios.md / android.md are distilled from the MIT-licensed ehmo/platform-design-skills; attribution in NOTICE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review: gate web tools on native platforms, drop version churn Maintainer-review fixes applied with AI assistance (Claude Code), on top of the rebased platform-axis commit: - Design hook (post-edit and Cursor pre-edit) now resolves the project platform via loadContext + extractPlatform and skips its web rule scan for ios / android / adaptive projects, so React Native / Flutter code never draws web-shaped findings (new hook-lib resolveProjectPlatform / isNativePlatform helpers, covered by unit and subprocess tests). - context.mjs CLI warns on an unrecognized ## Platform value (e.g. a toolchain name like `flutter`) instead of silently defaulting to web; extractRegister / extractPlatform now share extractSectionValue. - Removed reference/web.md: nothing loaded it; CLAUDE.md carries the "web has no extra rulebook" explanation. - init.md: skip live-mode config (Step 6) for native platforms; note the per-app PRODUCT.md pattern for repos shipping web + native. - android.md: Material-everywhere apps that also ship on iPhone still owe iOS OS guarantees (safe areas, Reduce Motion, edge-swipe back). - ios.md: reworded a design-time line that framed Dynamic Type as an accessibility check (a11y stays owned by audit.md). - Renumbered the new skill-behavior scenario to 14 after main's 10-13; updated CLAUDE.md scenario list; added android + unrecognized-value CLI test cases. - No version or changelog changes: versioning happens at release time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Tighten platform reference prose Editorial pass on the platform-axis text, applied with AI assistance (Claude Code) under maintainer direction: - ios.md / android.md rewritten to house style: single-line paragraphs (no hard wraps), one-sentence scope intro, deduplicated intro/slop-test, register-compression down to two sentences. In-file attribution paragraphs removed (NOTICE.md owns attribution); "read on top of the register reference" cruft removed (SKILL step 5 and the context.mjs directive already say it). Bans sections dropped: they restated the rules above them; the two additive items (tab-bar overload, hover-dependent affordances) folded into rules. ~40% smaller each. - Sub-command Platform sections (adapt, audit, animate, layout), SKILL step 5, init.md platform prose, and the context.mjs directive trimmed the same way. Build (prose validators, counts) and both test runners green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Treat an empty PRODUCT.md section as absent, not the next heading Copilot review catch: extractSectionValue read the next `## ...` heading as the section value when a field was left empty, which made the CLI warn "value `## Product Purpose` is not recognized". Stop at the next heading and return null instead. Regression tests for extractPlatform, extractRegister, and the CLI warning path. Applied with AI assistance (Claude Code) under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Only read a token list of both native targets as adaptive Bugbot catch: after the exact platform tokens failed, any Platform line containing the words ios and android was classified adaptive, so negated or explanatory prose ("web only, not ios or android") silently loaded both native refs and skipped the hook, with no warning. The combo parse now accepts only list separators and the two platform words; anything else falls through to the CLI's unrecognized-value WARNING. Regression tests added. Applied with AI assistance (Claude Code) under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
834 lines
34 KiB
JavaScript
834 lines
34 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 { generateApiData } from './lib/api-data.js';
|
|
import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
|
|
import { hooksJsonFor, buildClaudePluginHooksManifest } from './lib/transformers/hooks.js';
|
|
import { createAllZips } from './lib/zip.js';
|
|
import { collectPluginVersions } from './lib/validate-plugin-versions.js';
|
|
import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
|
|
// Sub-page generation is now handled by Astro content collections.
|
|
|
|
/**
|
|
* Generate authoritative counts from source data and write to site/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 the detector registry.
|
|
const detectionCount = new Set(ANTIPATTERNS.map(rule => rule.id)).size;
|
|
|
|
// Write generated counts module
|
|
const genDir = path.join(rootDir, 'site/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 = [
|
|
'site/pages/index.astro',
|
|
'README.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. Use the changelog-stripped content
|
|
// so historical counts in changelog entries (e.g. "28 rules" from an
|
|
// older release) don't flag against the current detector total.
|
|
const detectPattern = /\b(\d+)\s+(deterministic\s+)?(checks|patterns|rules|detections)/gi;
|
|
for (const match of strippedContent.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;
|
|
}
|
|
|
|
/**
|
|
* Guard against plugin/skill version drift (issue #274). The pure comparison
|
|
* lives in ./lib/validate-plugin-versions.js (so it's unit-tested directly);
|
|
* this wrapper owns the console output and the error count the build gates on.
|
|
*/
|
|
function validatePluginVersions(rootDir) {
|
|
const { source, mismatches, errors } = collectPluginVersions(rootDir);
|
|
// No root manifest at all → nothing to check (source null with no errors).
|
|
if (source == null && errors.length === 0) return 0;
|
|
|
|
for (const { relPath, reason } of errors) {
|
|
console.error(` ❌ ${relPath}: ${reason}`);
|
|
}
|
|
for (const { relPath, found, expected } of mismatches) {
|
|
console.error(
|
|
` ❌ ${relPath}: version "${found}" disagrees with .claude-plugin/plugin.json "${expected}"`,
|
|
);
|
|
}
|
|
|
|
const total = errors.length + mismatches.length;
|
|
if (total > 0) {
|
|
console.error(
|
|
`\n❌ ${total} plugin/skill version problem(s). Bump every version together and run ` +
|
|
`\`bun run build:release\` to regenerate the ./plugin subtree (issue #274).`,
|
|
);
|
|
} else {
|
|
console.log(`✓ Plugin/skill versions agree: ${source}`);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
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 —)
|
|
* - double-hyphen substitutes (` -- `)
|
|
* - denylisted phrases that read as AI tells in marketing copy
|
|
*
|
|
* The denylist is the editorial brief in docs/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 skill/, 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 = [
|
|
'site/components',
|
|
'site/content',
|
|
'site/layouts',
|
|
'site/pages',
|
|
'README.md',
|
|
'README.npm.md',
|
|
];
|
|
const extensions = new Set(['.html', '.md', '.js', '.mjs', '.css', '.astro']);
|
|
// The slop catalog documents every antipattern by example, so it must
|
|
// contain em dashes, buzzwords, and the rest as specimens. Exempt it from
|
|
// the prose gate: its job is to show the slop, not to avoid it.
|
|
const excludedPrefixes = ['site/pages/slop'];
|
|
const emDashPatterns = [/—/g, /—/gi, /—/gi, /—/gi];
|
|
// Phrase rules: { re, rationale }. Add to docs/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) => {
|
|
// Normalize to POSIX separators so the forward-slash excludedPrefixes match
|
|
// on Windows, where path.join() produces backslash-separated rel paths.
|
|
const relPosix = rel.split(path.sep).join('/');
|
|
if (excludedPrefixes.some(p => relPosix === p || relPosix.startsWith(p + '/'))) return;
|
|
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 docs/STYLE.md for the rules.`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Narrow prose check for the impeccable skill source.
|
|
*
|
|
* The full validateProse rules don't fit LLM-facing reference instructions:
|
|
* the hardening repetition and triadic checklists those files use exist on
|
|
* purpose, and the structural-prose rules in docs/STYLE.md require human judgment.
|
|
* This validator only enforces the mechanical wins: em dashes (which are
|
|
* pure punctuation laziness regardless of audience) and the small handful
|
|
* of denylisted phrases that have no technical reading. Em-dash creep is the
|
|
* only thing likely to come back at scale once humans stop watching.
|
|
*
|
|
* Returns the number of occurrences found. Build fails if > 0.
|
|
*/
|
|
function validateSkillProse(rootDir) {
|
|
const target = 'skill';
|
|
const extensions = new Set(['.md']);
|
|
const emDashPatterns = [/—/g, /—/gi, /—/gi, /—/gi];
|
|
// Tighter than validateProse: only the rules that have no technical reading.
|
|
// Skipping `data-driven` here would be a mistake (it slipped through twice
|
|
// in live.md before this pass); but `seamless`, `robust`, etc. have
|
|
// legitimate technical uses elsewhere we may want to allow.
|
|
const phraseRules = [
|
|
{ re: /\bload-bearing\b/i, rationale: 'AI tell. Name what the thing actually does.' },
|
|
{ re: /\bhighest-leverage\b/i, rationale: 'AI tell. Say what specifically pays off.' },
|
|
{ re: /\bbiggest unlock\b/i, rationale: 'Marketing-speak. Describe the actual change.' },
|
|
{ re: /\breflex defaults?\b/i, rationale: 'Internal jargon. Say "instincts" or "first guesses".' },
|
|
{ re: /\bcollapses? into monoculture\b/i, rationale: 'Eval-speak. Describe what actually went wrong.' },
|
|
{ re: /\bdata-driven\b/i, rationale: 'Empty marketing adjective. Cite the data instead.' },
|
|
{ re: /\bdelves?\b|\bdelved\b|\bdelving\b/i, rationale: 'Top AI tell. Use "explore" or "look at".' },
|
|
{ re: /\btapestry\b/i, rationale: 'AI scenery noun. Cut.' },
|
|
{ re: /\bin today's\b/i, rationale: 'Throat-clearing opener. Start at the point.' },
|
|
{ re: /\bgone are the days\b/i, rationale: 'Throat-clearing. Make the point directly.' },
|
|
{ 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.' },
|
|
];
|
|
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));
|
|
};
|
|
|
|
const full = path.join(rootDir, target);
|
|
if (fs.existsSync(full)) scan(full, target);
|
|
|
|
if (errors === 0) {
|
|
console.log(`✓ Skill prose validator: skill/ is clean`);
|
|
} else {
|
|
console.error(`\n❌ ${errors} prose issue(s) in skill/. See docs/STYLE.md.`);
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Guard the kinpaku default. Kinpaku is the site-wide default theme: the legacy
|
|
* --color-* names in tokens.css now carry dark-lacquer / gold-accent values, and
|
|
* the per-page kinpaku styling assumes that. If someone reintroduces the retired
|
|
* light-mode palette (white --color-paper, magenta --color-accent) the whole site
|
|
* silently regresses to light. Fail the build instead. Scoped to the token source
|
|
* — that's where the default lives; page CSS may still use light values locally
|
|
* for the deliberate AI-slop demonstrations.
|
|
*/
|
|
function validateTheme(rootDir) {
|
|
const tokensPath = path.join(rootDir, 'site', 'styles', 'tokens.css');
|
|
if (!fs.existsSync(tokensPath)) {
|
|
console.log('✓ Theme guard skipped (tokens.css not found)');
|
|
return 0;
|
|
}
|
|
const css = fs.readFileSync(tokensPath, 'utf8');
|
|
let errors = 0;
|
|
|
|
// Surfaces must be dark lacquer: either a --ks-* reference or a dark oklch
|
|
// (lightness < 35%). A high-lightness oklch means the light palette is back.
|
|
for (const name of ['color-paper', 'color-cream', 'color-bg']) {
|
|
const m = css.match(new RegExp(`--${name}:\\s*([^;]+);`));
|
|
if (!m) continue; // token removed entirely is fine
|
|
const val = m[1].trim();
|
|
if (val.includes('var(--ks-')) continue;
|
|
const light = val.match(/oklch\(\s*([\d.]+)%/);
|
|
if (light && Number(light[1]) >= 35) {
|
|
console.error(` ❌ tokens.css: --${name} is light (${val}). Kinpaku is the default; surfaces must be dark lacquer (var(--ks-lacquer*) or oklch < 35%).`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
// Accent must be kinpaku gold, not the retired magenta (hue ~350).
|
|
const accent = css.match(/--color-accent:\s*([^;]+);/);
|
|
if (accent && !accent[1].includes('var(--ks-')) {
|
|
const hue = accent[1].match(/oklch\(\s*[\d.]+%?\s+[\d.]+\s+([\d.]+)/);
|
|
if (hue && Number(hue[1]) >= 300 && Number(hue[1]) <= 360) {
|
|
console.error(` ❌ tokens.css: --color-accent is magenta (${accent[1].trim()}). The accent is kinpaku gold — use var(--ks-kinpaku).`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
if (errors > 0) {
|
|
console.error(`\n❌ ${errors} theme regression(s): light-mode defaults reintroduced in tokens.css.`);
|
|
} else {
|
|
console.log('✓ Theme defaults are kinpaku (dark surfaces, gold accent)');
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
function syncRootHookManifests(rootDir) {
|
|
const synced = [];
|
|
for (const config of Object.values(PROVIDERS)) {
|
|
if (!config.emitHooks) continue;
|
|
const manifest = hooksJsonFor(config.emitHooks);
|
|
if (!manifest) continue;
|
|
const rel = config.hooksManifestRel || path.join('hooks', 'hooks.json');
|
|
const dest = path.join(rootDir, config.configDir, rel);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.writeFileSync(dest, JSON.stringify(manifest, null, 2) + '\n');
|
|
synced.push(path.join(config.configDir, rel).split(path.sep).join('/'));
|
|
}
|
|
return synced;
|
|
}
|
|
|
|
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');
|
|
|
|
function parseBuildOptions(argv = process.argv.slice(2)) {
|
|
const skipRootSync = argv.includes('--skip-root-sync') || argv.includes('--no-root-sync');
|
|
return {
|
|
syncRootOutputs: !skipRootSync,
|
|
};
|
|
}
|
|
|
|
const BUILD_OPTIONS = parseBuildOptions();
|
|
|
|
// buildStaticSite (Bun HTML bundler) removed — now handled by Astro.
|
|
|
|
/**
|
|
* 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 custom agents (Codex skills use .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)`);
|
|
}
|
|
|
|
/**
|
|
* 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/version /_data/api/version.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/teach /docs/init 301
|
|
/skills/:id /docs/:id 301
|
|
/docs/teach /docs/init 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 site/public/js/ so the antipattern examples can
|
|
// reference it (Astro serves site/public/ as-is).
|
|
const detectorSrc = path.join(ROOT_DIR, 'cli', 'engine', 'detect-antipatterns-browser.js');
|
|
if (fs.existsSync(detectorSrc)) {
|
|
const jsDir = path.join(ROOT_DIR, 'site', '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 site/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, 'site', 'public');
|
|
generateApiData(publicDir, skills, patterns, ROOT_DIR);
|
|
generateCFConfig(publicDir);
|
|
|
|
if (BUILD_OPTIONS.syncRootOutputs) {
|
|
// Copy all provider outputs to project root for direct GitHub installs and
|
|
// submodule users. `.codex/` is intentionally excluded: Codex no longer
|
|
// consumes that layout; keep generated Codex 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 legacy 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);
|
|
}
|
|
}
|
|
|
|
for (const { provider, configDir, agentFormat } of Object.values(PROVIDERS)) {
|
|
if (!agentFormat) continue;
|
|
|
|
const agentsSrc = path.join(DIST_DIR, provider, configDir, 'agents');
|
|
const agentsDest = path.join(ROOT_DIR, configDir, 'agents');
|
|
|
|
if (fs.existsSync(agentsDest)) fs.rmSync(agentsDest, { recursive: true, force: true });
|
|
if (fs.existsSync(agentsSrc)) {
|
|
copyDirSync(agentsSrc, agentsDest);
|
|
}
|
|
}
|
|
|
|
const syncedHooks = syncRootHookManifests(ROOT_DIR);
|
|
if (syncedHooks.length > 0) {
|
|
console.log(`🪝 Synced hook manifests to: ${syncedHooks.join(', ')}`);
|
|
}
|
|
|
|
// 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');
|
|
const pluginAgentsDir = path.join(pluginRoot, 'agents');
|
|
const pluginHooksDir = path.join(pluginRoot, 'hooks');
|
|
if (fs.existsSync(pluginManifestDir)) fs.rmSync(pluginManifestDir, { recursive: true });
|
|
if (fs.existsSync(pluginSkillsDir)) fs.rmSync(pluginSkillsDir, { recursive: true });
|
|
if (fs.existsSync(pluginAgentsDir)) fs.rmSync(pluginAgentsDir, { recursive: true });
|
|
if (fs.existsSync(pluginHooksDir)) fs.rmSync(pluginHooksDir, { recursive: true });
|
|
|
|
const rootManifest = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
|
|
const claudeAgentsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'agents');
|
|
const pluginAgentEntries = fs.existsSync(claudeAgentsSrc)
|
|
? fs.readdirSync(claudeAgentsSrc)
|
|
.filter(file => file.endsWith('.md'))
|
|
.sort()
|
|
.map(file => `./agents/${file}`)
|
|
: [];
|
|
// 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/' };
|
|
if (pluginAgentEntries.length) {
|
|
pluginManifest.agents = pluginAgentEntries;
|
|
} else {
|
|
delete pluginManifest.agents;
|
|
}
|
|
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'));
|
|
}
|
|
|
|
if (fs.existsSync(claudeAgentsSrc)) {
|
|
copyDirSync(claudeAgentsSrc, pluginAgentsDir);
|
|
}
|
|
|
|
// Ship the design detector as a plugin-packaged hook. Claude Code
|
|
// auto-discovers `hooks/hooks.json` at the plugin root, so marketplace /
|
|
// `/plugin install` users get the PostToolUse hook without it being merged
|
|
// into their project `.claude/settings.json` (that path is the CLI's job).
|
|
fs.mkdirSync(pluginHooksDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginHooksDir, 'hooks.json'),
|
|
JSON.stringify(buildClaudePluginHooksManifest(), null, 2) + '\n',
|
|
);
|
|
|
|
console.log('📦 Built Claude Code plugin subtree at ./plugin/');
|
|
} else {
|
|
console.log('📋 Skipped root harness and plugin sync (--skip-root-sync)');
|
|
}
|
|
|
|
// Generate authoritative counts and validate references
|
|
const countErrors = generateCounts(ROOT_DIR, skills, buildDir);
|
|
|
|
// Guard plugin/skill version drift: marketplace + ./plugin subtree must
|
|
// match root plugin.json so marketplace installs never ship a stale version.
|
|
const versionErrors = validatePluginVersions(ROOT_DIR);
|
|
|
|
// Verify every hand-authored HTML page carries the shared site header
|
|
const headerErrors = validateSiteHeader(ROOT_DIR);
|
|
|
|
// Guard the kinpaku default: fail if light-mode token values are reintroduced
|
|
const themeErrors = validateTheme(ROOT_DIR);
|
|
|
|
// Scan user-facing copy for AI tells (em dashes, marketing fluff, denylisted phrases)
|
|
const proseErrors = validateProse(ROOT_DIR);
|
|
|
|
// Narrow scan of LLM-facing skill instructions: em dashes + a tighter denylist
|
|
// that has no technical reading. Hardening repetition is intentionally allowed.
|
|
const skillProseErrors = validateSkillProse(ROOT_DIR);
|
|
|
|
if (countErrors > 0 || versionErrors > 0 || headerErrors > 0 || themeErrors > 0 || proseErrors > 0 || skillProseErrors > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\n✨ Build complete!');
|
|
}
|
|
|
|
// Run the build. A rejection here (e.g. the release zip failing to build) must
|
|
// exit non-zero so a broken artifact never deploys silently.
|
|
build().catch((err) => {
|
|
console.error(`\n❌ Build failed: ${err.message}`);
|
|
process.exit(1);
|
|
});
|