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
+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}">