From 7847daffff4151e6a3df3635bec1a1e29345d99c Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 8 Apr 2026 09:16:19 -0700 Subject: [PATCH] Generate /skills index + 21 auto-rendered skill detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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. --- .gitignore | 5 + public/css/sub-pages.css | 464 ++++++++++++++++++++++++++++++++++ scripts/build-sub-pages.js | 207 +++++++++++++++ scripts/build.js | 32 ++- scripts/lib/render-page.js | 11 +- scripts/lib/sub-pages-data.js | 188 ++++++++++++++ server/index.js | 38 +++ 7 files changed, 938 insertions(+), 7 deletions(-) create mode 100644 scripts/build-sub-pages.js create mode 100644 scripts/lib/sub-pages-data.js diff --git a/.gitignore b/.gitignore index 66b81a81e..ea2f421cd 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ extension/detector/ # Evals (private, commercial) evals/ + +# Generated sub-pages (built from source/skills + content/site at build time) +public/skills/ +public/anti-patterns/ +public/tutorials/ diff --git a/public/css/sub-pages.css b/public/css/sub-pages.css index f9c3108e8..b8be102ad 100644 --- a/public/css/sub-pages.css +++ b/public/css/sub-pages.css @@ -224,3 +224,467 @@ a { display: none; } } + +/* ============================================ + SUB-PAGE LAYOUT SHELL + ============================================ */ + +main#main { + display: block; +} + +.sub-page-content, +.skill-detail { + max-width: 820px; + margin: 0 auto; + padding: clamp(2rem, 5vw, 4rem) clamp(1.25rem, 4vw, 2.5rem) 6rem; +} + +.sub-page-header { + margin-bottom: clamp(2.5rem, 6vw, 4rem); +} + +.sub-page-eyebrow { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-accent); + margin-bottom: var(--spacing-sm); +} + +.sub-page-title { + font-family: var(--font-display); + font-size: clamp(2.5rem, 6vw, 4.5rem); + font-weight: 400; + line-height: 1.05; + letter-spacing: -0.02em; + color: var(--color-ink); + margin-bottom: var(--spacing-md); +} + +.sub-page-lede { + font-size: clamp(1.0625rem, 1.6vw, 1.25rem); + line-height: 1.55; + color: var(--color-charcoal); + max-width: 62ch; +} + +/* ============================================ + SKILLS INDEX + ============================================ */ + +.skills-category { + margin-top: clamp(3rem, 6vw, 4.5rem); +} + +.skills-category-header { + margin-bottom: var(--spacing-lg); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-mist); +} + +.skills-category-title { + font-family: var(--font-display); + font-size: 1.75rem; + font-weight: 500; + font-style: italic; + color: var(--color-ink); + letter-spacing: -0.01em; + line-height: 1.1; + margin-bottom: 6px; +} + +.skills-category-desc { + font-size: 0.9375rem; + color: var(--color-ash); + max-width: 56ch; +} + +.skills-category-list { + list-style: none; + padding: 0; + margin: 0; +} + +.skills-category-item { + border-bottom: 1px solid var(--color-mist); +} + +.skills-category-item:last-child { + border-bottom: none; +} + +.skills-category-link { + display: grid; + grid-template-columns: 180px 1fr; + gap: var(--spacing-md); + align-items: baseline; + padding: 18px 0; + color: var(--color-ink); + transition: color var(--duration-fast) var(--ease-out); +} + +.skills-category-link:hover { + color: var(--color-accent); +} + +.skills-category-name { + font-family: var(--font-mono); + font-size: 1rem; + font-weight: 600; + color: var(--color-ink); +} + +.skills-category-link:hover .skills-category-name { + color: var(--color-accent); +} + +.skills-category-desc-text { + font-size: 0.9375rem; + line-height: 1.55; + color: var(--color-charcoal); +} + +@media (max-width: 640px) { + .skills-category-link { + grid-template-columns: 1fr; + gap: 4px; + padding: 14px 0; + } +} + +/* ============================================ + SKILL DETAIL + ============================================ */ + +.skill-detail-header { + margin-bottom: clamp(2.5rem, 5vw, 3.5rem); +} + +.skill-detail-eyebrow { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-ash); + margin-bottom: var(--spacing-sm); +} + +.skill-detail-eyebrow a { + color: inherit; + text-decoration: none; +} + +.skill-detail-eyebrow a:hover { + color: var(--color-accent); +} + +.skill-detail-title { + font-family: var(--font-mono); + font-size: clamp(2.25rem, 5vw, 3.5rem); + font-weight: 600; + letter-spacing: -0.02em; + color: var(--color-ink); + margin-bottom: var(--spacing-sm); +} + +.skill-detail-tagline { + font-family: var(--font-display); + font-size: clamp(1.125rem, 2vw, 1.5rem); + font-style: italic; + font-weight: 400; + color: var(--color-charcoal); + line-height: 1.4; + max-width: 58ch; + margin-bottom: var(--spacing-md); +} + +.skill-meta-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: var(--spacing-sm); +} + +.skill-meta-chip { + display: inline-flex; + align-items: center; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 4px 10px; + border-radius: 99px; + background: var(--color-cream); + border: 1px solid var(--color-mist); + color: var(--color-charcoal); +} + +.skill-meta-category[data-category="create"] { + background: var(--cat-create-bg); + border-color: var(--cat-create-border); + color: var(--cat-create-text); +} + +.skill-meta-category[data-category="evaluate"] { + background: var(--cat-evaluate-bg); + border-color: var(--cat-evaluate-border); + color: var(--cat-evaluate-text); +} + +.skill-meta-category[data-category="refine"] { + background: var(--cat-refine-bg); + border-color: var(--cat-refine-border); + color: var(--cat-refine-text); +} + +.skill-meta-category[data-category="simplify"] { + background: var(--cat-simplify-bg); + border-color: var(--cat-simplify-border); + color: var(--cat-simplify-text); +} + +.skill-meta-category[data-category="harden"] { + background: var(--cat-harden-bg); + border-color: var(--cat-harden-border); + color: var(--cat-harden-text); +} + +.skill-meta-category[data-category="system"] { + background: var(--cat-system-bg); + border-color: var(--cat-system-border); + color: var(--cat-system-text); +} + +.skill-meta-args { + font-family: var(--font-mono); + text-transform: none; + letter-spacing: 0; + font-weight: 500; +} + +.skill-detail-editorial { + margin-bottom: clamp(3rem, 6vw, 4.5rem); +} + +.skill-detail-divider { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin: clamp(3rem, 6vw, 4.5rem) 0 clamp(2rem, 4vw, 3rem); +} + +.skill-detail-divider::before, +.skill-detail-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--color-mist); +} + +.skill-detail-divider span { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-ash); + flex-shrink: 0; +} + +.skill-references { + margin-top: clamp(3rem, 6vw, 4.5rem); + padding-top: clamp(2rem, 4vw, 3rem); + border-top: 1px solid var(--color-mist); +} + +.skill-references-heading { + font-family: var(--font-display); + font-size: 1.5rem; + font-style: italic; + font-weight: 500; + color: var(--color-ink); + margin-bottom: var(--spacing-md); +} + +.skill-reference { + border-top: 1px solid var(--color-mist); +} + +.skill-reference:last-child { + border-bottom: 1px solid var(--color-mist); +} + +.skill-reference > summary { + list-style: none; + cursor: pointer; + padding: 16px 0; + display: flex; + align-items: center; + gap: var(--spacing-md); + transition: color var(--duration-fast) var(--ease-out); +} + +.skill-reference > summary::-webkit-details-marker { + display: none; +} + +.skill-reference > summary::before { + content: "+"; + flex-shrink: 0; + width: 18px; + font-family: var(--font-display); + font-size: 1.5rem; + color: var(--color-accent); + line-height: 1; + transition: transform var(--duration-base) var(--ease-out); +} + +.skill-reference[open] > summary::before { + transform: rotate(45deg); +} + +.skill-reference > summary:hover { + color: var(--color-accent); +} + +.skill-reference-label { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-ash); + flex-shrink: 0; +} + +.skill-reference-title { + font-family: var(--font-display); + font-size: 1.125rem; + font-style: italic; + color: var(--color-ink); +} + +.skill-reference-body { + padding: var(--spacing-sm) 0 var(--spacing-md) 34px; + max-width: 62ch; +} + +/* ============================================ + PROSE — rendered markdown bodies + ============================================ */ + +.prose { + font-size: 1rem; + line-height: 1.7; + color: var(--color-charcoal); + max-width: 65ch; +} + +.prose h1, +.prose h2, +.prose h3, +.prose h4 { + color: var(--color-ink); + font-weight: 600; + line-height: 1.25; + margin-top: 2em; + margin-bottom: 0.6em; +} + +.prose h1 { font-family: var(--font-display); font-size: 1.875rem; font-weight: 500; font-style: italic; } +.prose h2 { font-family: var(--font-display); font-size: 1.5rem; font-weight: 500; font-style: italic; margin-top: 2.2em; } +.prose h3 { font-size: 1.125rem; margin-top: 1.8em; } +.prose h4 { font-size: 1rem; } + +.prose h2:first-child, +.prose h3:first-child { + margin-top: 0; +} + +.prose p { + margin-top: 0; + margin-bottom: 1.1em; +} + +.prose ul, +.prose ol { + margin: 0 0 1.2em 0; + padding-left: 1.25rem; +} + +.prose li { + margin-bottom: 0.4em; +} + +.prose a { + color: var(--color-accent); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + text-decoration-color: var(--color-accent-dim); + transition: text-decoration-color var(--duration-fast) var(--ease-out); +} + +.prose a:hover { + text-decoration-color: var(--color-accent); +} + +.prose strong { + color: var(--color-ink); + font-weight: 600; +} + +.prose em { + font-style: italic; +} + +.prose code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--color-cream); + border: 1px solid var(--color-mist); + padding: 2px 6px; + border-radius: 4px; + color: var(--color-ink); +} + +.prose .code-block { + margin: 1.25em 0; + padding: var(--spacing-md); + background: oklch(12% 0.005 350); + color: oklch(92% 0.005 350); + border-radius: 10px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.55; + border: 1px solid oklch(20% 0.005 350); +} + +.prose .code-block code { + background: transparent; + border: none; + padding: 0; + color: inherit; + font-size: inherit; +} + +.prose blockquote { + margin: 1.5em 0; + padding: 0 0 0 var(--spacing-md); + border-left: 3px solid var(--color-mist); + color: var(--color-ash); + font-style: italic; +} + +.prose hr { + border: none; + height: 1px; + background: var(--color-mist); + margin: 2.5em 0; +} diff --git a/scripts/build-sub-pages.js b/scripts/build-sub-pages.js new file mode 100644 index 000000000..9485f1993 --- /dev/null +++ b/scripts/build-sub-pages.js @@ -0,0 +1,207 @@ +/** + * Generate static HTML files for /skills, /anti-patterns, /tutorials. + * + * Called from both scripts/build.js (before buildStaticSite) and + * server/index.js (at module load), so dev and prod share the same + * code path and output shape. + * + * Output lives under public/skills/, public/anti-patterns/, + * public/tutorials/, all gitignored. Bun's HTML loader picks them up + * the same way it picks up the hand-authored pages. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { + buildSubPageData, + CATEGORY_ORDER, + CATEGORY_LABELS, + CATEGORY_DESCRIPTIONS, +} from './lib/sub-pages-data.js'; +import { renderMarkdown, slugify } from './lib/render-markdown.js'; +import { renderPage } from './lib/render-page.js'; + +function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Render one skill detail page HTML body (without the site shell). + */ +function renderSkillDetail(skill, knownSkillIds) { + const bodyHtml = renderMarkdown(skill.body, { + knownSkillIds, + currentSkillId: skill.id, + }); + + const editorialHtml = skill.editorial + ? renderMarkdown(skill.editorial.body, { knownSkillIds, currentSkillId: skill.id }) + : ''; + + const tagline = skill.editorial?.frontmatter?.tagline || skill.description; + const categoryLabel = CATEGORY_LABELS[skill.category] || skill.category; + + // Reference files as collapsible
blocks + let referencesHtml = ''; + if (skill.references && skill.references.length > 0) { + const refs = skill.references + .map((ref) => { + const slug = slugify(ref.name); + const refBody = renderMarkdown(ref.content, { + knownSkillIds, + currentSkillId: skill.id, + }); + const title = ref.name + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + return ` +
+ Reference${escapeHtml(title)} +
+${refBody} +
+
`; + }) + .join('\n'); + referencesHtml = ` +
+

Deeper reference

+ ${refs} +
`; + } + + const metaStrip = ` +
+ ${escapeHtml(categoryLabel)} + User-invocable + ${skill.argumentHint ? `${escapeHtml(skill.argumentHint)}` : ''} +
`; + + return ` +
+
+

Skills / ${escapeHtml(categoryLabel)}

+

/${escapeHtml(skill.id)}

+

${escapeHtml(tagline)}

+ ${metaStrip} +
+ + ${editorialHtml ? `
\n${editorialHtml}\n
` : ''} + +
+ The skill itself +
+ +
+${bodyHtml} +
+ + ${referencesHtml} +
+`; +} + +/** + * Render the /skills index page body. + */ +function renderSkillsIndex(skillsByCategory) { + let html = ` +
+
+

22 commands

+

Skills

+

One skill (/impeccable) teaches your AI design. Twenty-one commands steer the result. Each one is a small, opinionated tool that knows how to fix one specific thing.

+
+`; + + for (const category of CATEGORY_ORDER) { + const list = skillsByCategory[category] || []; + if (list.length === 0) continue; + html += ` +
+
+

${escapeHtml(CATEGORY_LABELS[category])}

+

${escapeHtml(CATEGORY_DESCRIPTIONS[category])}

+
+ +
+`; + } + + html += `
`; + return html; +} + +/** + * Entry point. Generates all sub-page HTML files. + * + * @param {string} rootDir + * @returns {Promise<{ files: string[] }>} list of generated file paths (absolute) + */ +export async function generateSubPages(rootDir) { + const data = buildSubPageData(rootDir); + const outDirs = { + skills: path.join(rootDir, 'public/skills'), + antiPatterns: path.join(rootDir, 'public/anti-patterns'), + tutorials: path.join(rootDir, 'public/tutorials'), + }; + + // Fresh output dirs each time so stale files don't linger. + for (const dir of Object.values(outDirs)) { + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + } + + const generated = []; + + // Skills index + { + const html = renderPage({ + title: 'Skills — Impeccable', + description: + '22 commands that teach your AI harness how to design. Browse by category: create, evaluate, refine, simplify, harden, system.', + bodyHtml: renderSkillsIndex(data.skillsByCategory), + activeNav: 'skills', + canonicalPath: '/skills', + }); + const out = path.join(outDirs.skills, 'index.html'); + fs.writeFileSync(out, html, 'utf-8'); + generated.push(out); + } + + // Skills detail pages + for (const skill of data.skills) { + const bodyHtml = renderSkillDetail(skill, data.knownSkillIds); + const title = `/${skill.id} — Impeccable skill`; + const description = skill.editorial?.frontmatter?.tagline || skill.description; + const html = renderPage({ + title, + description, + bodyHtml, + activeNav: 'skills', + canonicalPath: `/skills/${skill.id}`, + }); + const out = path.join(outDirs.skills, `${skill.id}.html`); + fs.writeFileSync(out, html, 'utf-8'); + generated.push(out); + } + + return { files: generated }; +} diff --git a/scripts/build.js b/scripts/build.js index 8cc4756f8..61fb22724 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -20,6 +20,7 @@ import { fileURLToPath } from 'url'; import { readSourceFiles, readPatterns } from './lib/utils.js'; import { createTransformer, PROVIDERS } from './lib/transformers/index.js'; import { createAllZips } from './lib/zip.js'; +import { generateSubPages } from './build-sub-pages.js'; /** * Generate authoritative counts from source data and write to public/js/generated/counts.js. @@ -218,16 +219,17 @@ const DIST_DIR = path.join(ROOT_DIR, 'dist'); * Build static site using Bun's HTML bundler * Bun's HTML loader resolves and inlines CSS @imports. */ -async function buildStaticSite() { +async function buildStaticSite(extraEntrypoints = []) { 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'), + ...extraEntrypoints, ]; const outdir = path.join(ROOT_DIR, 'build'); - console.log('📦 Building static site with Bun...'); + console.log(`📦 Building static site with Bun (${entrypoints.length} HTML entries)...`); try { const result = await Bun.build({ @@ -250,11 +252,26 @@ async function buildStaticSite() { // 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/skills/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: 1 file`); + 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`); @@ -453,8 +470,13 @@ function generateCFConfig(buildDir) { async function build() { console.log('🔨 Building cross-provider design skills...\n'); - // Bundle HTML, JS, and CSS with Bun - await buildStaticSite(); + // Pre-generate sub-pages (skills, anti-patterns, tutorials) from source + console.log('📝 Generating sub-pages...'); + const { files: subPageFiles } = await generateSubPages(ROOT_DIR); + console.log(`✓ Generated ${subPageFiles.length} sub-page(s)\n`); + + // Bundle HTML, JS, and CSS with Bun (including generated sub-pages) + await buildStaticSite(subPageFiles); // Copy root-level static assets that need stable (unhashed) URLs const staticAssets = ['og-image.jpg', 'robots.txt', 'sitemap.xml', 'favicon.svg', 'apple-touch-icon.png']; diff --git a/scripts/lib/render-page.js b/scripts/lib/render-page.js index d46f35814..f87361d0c 100644 --- a/scripts/lib/render-page.js +++ b/scripts/lib/render-page.js @@ -55,6 +55,7 @@ export function applyActiveNav(headerHtml, activeNav) { * @param {string} [opts.canonicalPath] - relative URL path for * @param {string} [opts.extraHead] - raw HTML to inject into * @param {string} [opts.bodyClass] - optional class on + * @param {number} [opts.assetDepth] - how many `..` to prepend for Bun's HTML loader to resolve on-disk paths. 1 = page is one dir deep under public/ (e.g. public/skills/polish.html). Defaults to 1. * @returns {string} full HTML document */ export function renderPage({ @@ -65,6 +66,7 @@ export function renderPage({ canonicalPath, extraHead = '', bodyClass = 'sub-page', + assetDepth = 1, }) { const header = applyActiveNav(readHeaderPartial(), activeNav); const safeTitle = escapeHtml(title); @@ -73,6 +75,11 @@ export function renderPage({ ? `` : ''; + // Relative prefix for on-disk resolution by Bun's HTML loader. + // Bun rewrites these to hashed absolute URLs at build time, so runtime + // serving works regardless of the request path. + const rel = assetDepth > 0 ? '../'.repeat(assetDepth) : './'; + return ` @@ -82,11 +89,11 @@ export function renderPage({ ${canonical} - + - + ${extraHead} diff --git a/scripts/lib/sub-pages-data.js b/scripts/lib/sub-pages-data.js new file mode 100644 index 000000000..9009f7f5b --- /dev/null +++ b/scripts/lib/sub-pages-data.js @@ -0,0 +1,188 @@ +/** + * 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, + * knownSkillIds: Set, + * 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, + }; +} diff --git a/server/index.js b/server/index.js index 8c023434f..9306c43dc 100644 --- a/server/index.js +++ b/server/index.js @@ -1,4 +1,6 @@ import { serve, file } from "bun"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import homepage from "../public/index.html"; import cheatsheet from "../public/cheatsheet.html"; import gallery from "../public/gallery.html"; @@ -11,6 +13,29 @@ import { handleFileDownload, handleBundleDownload } from "./lib/api-handlers.js"; +import { generateSubPages } from "../scripts/build-sub-pages.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, ".."); + +// Pre-generate sub-pages so dev + prod share the same output shape. +console.log("📝 Generating sub-pages for dev server..."); +const { files: subPageFiles } = await generateSubPages(ROOT_DIR); +console.log(`✓ Generated ${subPageFiles.length} sub-page(s)`); + +// Helper: serve a generated HTML file by absolute path, 404 if missing. +async function serveGenerated(pagePath) { + const f = file(pagePath); + if (!(await f.exists())) return new Response("Not Found", { status: 404 }); + return new Response(f, { + headers: { + "Content-Type": "text/html;charset=utf-8", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + }); +} const server = serve({ port: process.env.PORT || 3000, @@ -21,6 +46,19 @@ const server = serve({ "/gallery": gallery, "/privacy": privacy, + // Generated sub-pages — served directly from the pre-generated files + "/skills": () => serveGenerated(path.join(ROOT_DIR, "public/skills/index.html")), + "/skills/:id": (req) => { + const id = req.params.id.replace(/[^a-z0-9-]/gi, ""); + return serveGenerated(path.join(ROOT_DIR, `public/skills/${id}.html`)); + }, + "/anti-patterns": () => serveGenerated(path.join(ROOT_DIR, "public/anti-patterns/index.html")), + "/tutorials": () => serveGenerated(path.join(ROOT_DIR, "public/tutorials/index.html")), + "/tutorials/:slug": (req) => { + const slug = req.params.slug.replace(/[^a-z0-9-]/gi, ""); + return serveGenerated(path.join(ROOT_DIR, `public/tutorials/${slug}.html`)); + }, + // Static assets - all public subdirectories "/assets/*": async (req) => { const url = new URL(req.url);