mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Ships the first new sub-page section. Every user-invocable skill now has
its own page at /skills/{id}, with the canonical SKILL.md body rendered
via marked. The index at /skills lists all 21 skills grouped by category.
Editorial wrappers are opt-in: if content/site/skills/{id}.md exists, it
renders above the canonical body (with a "The skill itself" divider).
All 21 pages currently ship with the auto-rendered body only; hand-written
wrappers land in the next few commits.
- scripts/lib/sub-pages-data.js: builds the data model. Reuses
readSourceFiles() from lib/utils.js for skill content; parses the
ANTIPATTERNS array out of src/detect-antipatterns.mjs; reads optional
editorial wrappers from content/site/skills/*.md; validates that every
user-invocable skill has a category entry (build fails loudly if not).
- scripts/build-sub-pages.js: orchestrator. Writes generated HTML into
public/skills/*.html (gitignored). Called from both scripts/build.js
(before buildStaticSite) and server/index.js (at module load) so dev
and prod share the same generation code path.
- scripts/lib/render-page.js: new assetDepth parameter so generated
pages one level deep under public/ use relative paths (../favicon.svg,
../css/sub-pages.css) that Bun's HTML loader can resolve on disk.
- scripts/build.js: pass generated files into Bun.build entrypoints;
post-process to flatten build/public/* → build/* (Bun preserves the
public/ prefix when entrypoints span multiple depths).
- server/index.js: generateSubPages() runs at module load; new routes
/skills, /skills/:id, /anti-patterns, /tutorials, /tutorials/:slug
serve the pre-generated files via Bun.file().
- public/css/sub-pages.css: adds sub-page layout shell, skills index
grouped-list styling, skill detail header/meta chips/divider, collapsed
<details> reference sections, and a .prose block for rendered markdown
with editorial typography, code blocks, and inline code.
Verified: bun run build produces 26 HTML files (4 hand-authored + 22
generated), all flat under build/. Dev server returns 200 on /skills,
/skills/polish, /skills/impeccable, /skills/critique. Tests pass.
189 lines
6.0 KiB
JavaScript
189 lines
6.0 KiB
JavaScript
/**
|
|
* Build the data model used by the skill / anti-pattern / tutorial page
|
|
* generators.
|
|
*
|
|
* Single source of truth:
|
|
* - source/skills/{id}/SKILL.md → skill frontmatter + body
|
|
* - source/skills/{id}/reference/*.md → skill reference files
|
|
* - src/detect-antipatterns.mjs → ANTIPATTERNS array (parsed)
|
|
* - content/site/skills/{id}.md → optional editorial wrapper
|
|
* - content/site/tutorials/{slug}.md → full tutorial content
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { readSourceFiles, parseFrontmatter } from './utils.js';
|
|
|
|
/**
|
|
* Skills that should be excluded from the index and not get a detail page.
|
|
* These are deprecated shims or internal skills that users shouldn't browse.
|
|
*/
|
|
const EXCLUDED_SKILLS = new Set([
|
|
'frontend-design', // deprecated, renamed to impeccable
|
|
'teach-impeccable', // deprecated, folded into /impeccable teach
|
|
]);
|
|
|
|
/**
|
|
* Hand-curated category map for user-invocable skills.
|
|
* Mirrors public/js/data.js commandCategories. Validated below — the
|
|
* generator fails if any user-invocable skill is missing from this map.
|
|
*/
|
|
const SKILL_CATEGORIES = {
|
|
// CREATE - build something new
|
|
impeccable: 'create',
|
|
shape: 'create',
|
|
onboard: 'create',
|
|
overdrive: 'create',
|
|
// EVALUATE - review and assess
|
|
critique: 'evaluate',
|
|
audit: 'evaluate',
|
|
// REFINE - improve existing design
|
|
typeset: 'refine',
|
|
arrange: 'refine',
|
|
colorize: 'refine',
|
|
animate: 'refine',
|
|
delight: 'refine',
|
|
bolder: 'refine',
|
|
quieter: 'refine',
|
|
// SIMPLIFY - reduce and clarify
|
|
distill: 'simplify',
|
|
clarify: 'simplify',
|
|
adapt: 'simplify',
|
|
// HARDEN - production-ready
|
|
normalize: 'harden',
|
|
polish: 'harden',
|
|
optimize: 'harden',
|
|
harden: 'harden',
|
|
// SYSTEM - setup and tooling
|
|
extract: 'system',
|
|
};
|
|
|
|
export const CATEGORY_ORDER = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system'];
|
|
|
|
export const CATEGORY_LABELS = {
|
|
create: 'Create',
|
|
evaluate: 'Evaluate',
|
|
refine: 'Refine',
|
|
simplify: 'Simplify',
|
|
harden: 'Harden',
|
|
system: 'System',
|
|
};
|
|
|
|
export const CATEGORY_DESCRIPTIONS = {
|
|
create: 'Start something new — from a blank page to a working feature.',
|
|
evaluate: 'Review what you have. Score it, critique it, find what to fix.',
|
|
refine: 'Improve one dimension at a time — type, layout, color, motion.',
|
|
simplify: 'Strip complexity. Remove what does not earn its place.',
|
|
harden: 'Get it production-ready. Edge cases, performance, polish.',
|
|
system: 'Setup and tooling. Design system work, extraction, organization.',
|
|
};
|
|
|
|
/**
|
|
* Parse the ANTIPATTERNS array out of src/detect-antipatterns.mjs.
|
|
* Mirrors the trick in scripts/build.js validateAntipatternRules() so we
|
|
* don't have to run the browser-only module.
|
|
*/
|
|
export function readAntipatternRules(rootDir) {
|
|
const detectPath = path.join(rootDir, 'src/detect-antipatterns.mjs');
|
|
const src = fs.readFileSync(detectPath, 'utf-8');
|
|
const match = src.match(/const ANTIPATTERNS = \[([\s\S]*?)\n\];/);
|
|
if (!match) {
|
|
throw new Error(`Could not extract ANTIPATTERNS from ${detectPath}`);
|
|
}
|
|
// eslint-disable-next-line no-new-func
|
|
return new Function(`return [${match[1]}]`)();
|
|
}
|
|
|
|
/**
|
|
* Read an optional editorial wrapper file for a skill or tutorial.
|
|
* Returns { frontmatter, body } or null if the file doesn't exist.
|
|
*/
|
|
export function readEditorialWrapper(contentDir, kind, slug) {
|
|
const filePath = path.join(contentDir, kind, `${slug}.md`);
|
|
if (!fs.existsSync(filePath)) return null;
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
return parseFrontmatter(content);
|
|
}
|
|
|
|
/**
|
|
* Build the full sub-page data model.
|
|
*
|
|
* @param {string} rootDir - repo root
|
|
* @returns {{
|
|
* skills: Array,
|
|
* skillsByCategory: Record<string, Array>,
|
|
* knownSkillIds: Set<string>,
|
|
* rules: Array,
|
|
* tutorials: Array,
|
|
* }}
|
|
*/
|
|
export function buildSubPageData(rootDir) {
|
|
const { skills: rawSkills } = readSourceFiles(rootDir);
|
|
const contentDir = path.join(rootDir, 'content/site');
|
|
|
|
// Filter to user-invocable, non-deprecated skills.
|
|
const skills = rawSkills
|
|
.filter((s) => s.userInvocable && !EXCLUDED_SKILLS.has(s.name))
|
|
.map((s) => {
|
|
const category = SKILL_CATEGORIES[s.name];
|
|
const editorial = readEditorialWrapper(contentDir, 'skills', s.name);
|
|
return {
|
|
id: s.name,
|
|
name: s.name,
|
|
description: s.description,
|
|
argumentHint: s.argumentHint,
|
|
category,
|
|
body: s.body,
|
|
references: s.references,
|
|
editorial, // may be null
|
|
};
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
// Validate the category map covers every user-invocable skill.
|
|
const missing = skills.filter((s) => !s.category).map((s) => s.id);
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`SKILL_CATEGORIES in scripts/lib/sub-pages-data.js is missing entries for: ${missing.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
const knownSkillIds = new Set(skills.map((s) => s.id));
|
|
|
|
const skillsByCategory = {};
|
|
for (const cat of CATEGORY_ORDER) skillsByCategory[cat] = [];
|
|
for (const skill of skills) skillsByCategory[skill.category].push(skill);
|
|
|
|
// Anti-pattern rules, grouped for the index.
|
|
const rules = readAntipatternRules(rootDir);
|
|
|
|
// Tutorials: each required file in content/site/tutorials/.
|
|
const tutorialsDir = path.join(contentDir, 'tutorials');
|
|
const tutorials = [];
|
|
if (fs.existsSync(tutorialsDir)) {
|
|
const files = fs.readdirSync(tutorialsDir).filter((f) => f.endsWith('.md'));
|
|
for (const file of files) {
|
|
const slug = path.basename(file, '.md');
|
|
const raw = fs.readFileSync(path.join(tutorialsDir, file), 'utf-8');
|
|
const { frontmatter, body } = parseFrontmatter(raw);
|
|
tutorials.push({
|
|
slug,
|
|
title: frontmatter.title || slug,
|
|
description: frontmatter.description || '',
|
|
tagline: frontmatter.tagline || '',
|
|
order: frontmatter.order ? Number(frontmatter.order) : 99,
|
|
body,
|
|
});
|
|
}
|
|
tutorials.sort((a, b) => a.order - b.order);
|
|
}
|
|
|
|
return {
|
|
skills,
|
|
skillsByCategory,
|
|
knownSkillIds,
|
|
rules,
|
|
tutorials,
|
|
};
|
|
}
|