Generate /skills index + 21 auto-rendered skill detail pages

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.
This commit is contained in:
Paul Bakaus
2026-04-08 09:16:19 -07:00
parent c4dc0feb23
commit 7847daffff
7 changed files with 938 additions and 7 deletions
+5
View File
@@ -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/
+464
View File
@@ -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;
}
+207
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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 <details> 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 `
<details class="skill-reference" id="reference-${slug}">
<summary><span class="skill-reference-label">Reference</span><span class="skill-reference-title">${escapeHtml(title)}</span></summary>
<div class="prose skill-reference-body">
${refBody}
</div>
</details>`;
})
.join('\n');
referencesHtml = `
<section class="skill-references" aria-label="Reference material">
<h2 class="skill-references-heading">Deeper reference</h2>
${refs}
</section>`;
}
const metaStrip = `
<div class="skill-meta-strip">
<span class="skill-meta-chip skill-meta-category" data-category="${skill.category}">${escapeHtml(categoryLabel)}</span>
<span class="skill-meta-chip">User-invocable</span>
${skill.argumentHint ? `<span class="skill-meta-chip skill-meta-args">${escapeHtml(skill.argumentHint)}</span>` : ''}
</div>`;
return `
<article class="skill-detail">
<header class="skill-detail-header">
<p class="skill-detail-eyebrow"><a href="/skills">Skills</a> / ${escapeHtml(categoryLabel)}</p>
<h1 class="skill-detail-title">/${escapeHtml(skill.id)}</h1>
<p class="skill-detail-tagline">${escapeHtml(tagline)}</p>
${metaStrip}
</header>
${editorialHtml ? `<section class="skill-detail-editorial prose">\n${editorialHtml}\n</section>` : ''}
<div class="skill-detail-divider">
<span>The skill itself</span>
</div>
<section class="skill-detail-body prose">
${bodyHtml}
</section>
${referencesHtml}
</article>
`;
}
/**
* Render the /skills index page body.
*/
function renderSkillsIndex(skillsByCategory) {
let html = `
<article class="sub-page-content">
<header class="sub-page-header">
<p class="sub-page-eyebrow">22 commands</p>
<h1 class="sub-page-title">Skills</h1>
<p class="sub-page-lede">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.</p>
</header>
`;
for (const category of CATEGORY_ORDER) {
const list = skillsByCategory[category] || [];
if (list.length === 0) continue;
html += `
<section class="skills-category" data-category="${category}">
<div class="skills-category-header">
<h2 class="skills-category-title">${escapeHtml(CATEGORY_LABELS[category])}</h2>
<p class="skills-category-desc">${escapeHtml(CATEGORY_DESCRIPTIONS[category])}</p>
</div>
<ul class="skills-category-list">
${list
.map(
(s) => ` <li class="skills-category-item">
<a href="/skills/${s.id}" class="skills-category-link">
<span class="skills-category-name">/${escapeHtml(s.id)}</span>
<span class="skills-category-desc-text">${escapeHtml(s.description)}</span>
</a>
</li>`,
)
.join('\n')}
</ul>
</section>
`;
}
html += `</article>`;
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 };
}
+27 -5
View File
@@ -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 <link rel="stylesheet"> 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'];
+9 -2
View File
@@ -55,6 +55,7 @@ export function applyActiveNav(headerHtml, activeNav) {
* @param {string} [opts.canonicalPath] - relative URL path for <link rel="canonical">
* @param {string} [opts.extraHead] - raw HTML to inject into <head>
* @param {string} [opts.bodyClass] - optional class on <body>
* @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({
? `<link rel="canonical" href="https://impeccable.style${canonicalPath}">`
: '';
// 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 `<!DOCTYPE html>
<html lang="en">
<head>
@@ -82,11 +89,11 @@ export function renderPage({
<meta name="description" content="${safeDesc}">
<meta name="theme-color" content="#fafafa">
${canonical}
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/svg+xml" href="${rel}favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Instrument+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/sub-pages.css">
<link rel="stylesheet" href="${rel}css/sub-pages.css">
${extraHead}
</head>
<body class="${bodyClass}">
+188
View File
@@ -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<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,
};
}
+38
View File
@@ -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);