Add sub-page render pipeline foundation

Groundwork for new /skills, /anti-patterns, /tutorials sections.
No user-visible changes yet — this is pure plumbing.

- Split main.css into tokens.css (design tokens + reset, ~100 lines) and
  main.css (everything else, imports tokens.css). Lets sub-pages import
  only tokens without pulling in the landing-page component CSS.
- Add marked as a dependency.
- Add scripts/lib/render-markdown.js: marked wrapper with a custom link
  resolver (skill slugs, reference/*.md anchors, external rel=noopener),
  stable heading slugger, and terminal-style code blocks.
- Add scripts/lib/render-page.js: page shell wrapper that injects the
  shared site header partial with aria-current marking.
- Add content/site/partials/header.html: shared site header with nav
  (Home / Skills / Anti-Patterns / Tutorials / Gallery / GitHub).
- Add public/css/sub-pages.css: shared styles for generated pages, with
  .site-header styling (sticky, backdrop blur, accent-underlined active
  nav item) and mobile collapse.

Build still produces the same 104 KB landing-page CSS chunk; tests pass.
This commit is contained in:
Paul Bakaus
2026-04-08 08:01:34 -07:00
parent 038d281247
commit 54b687d16f
8 changed files with 516 additions and 98 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* Markdown → HTML rendering for sub-pages.
*
* Wraps `marked` with a custom link renderer that resolves cross-references
* between skill bodies and their references, and emits stable heading slugs
* so anti-pattern → skill section anchors work.
*
* Skeleton in commit 1. Link resolution and heading slugger are wired up in
* commit 3 (skills generator) when the data model lands.
*/
import { marked } from 'marked';
/**
* Slugify a heading text into a stable anchor id.
* Matches the convention: lowercase, strip non-alphanum, spaces → dashes.
*
* @param {string} text
* @returns {string}
*/
export function slugify(text) {
return String(text)
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Build a marked renderer configured for impeccable's skill/tutorial bodies.
*
* @param {object} opts
* @param {Set<string>} [opts.knownSkillIds] - slugs of skills the site knows about; unknown /name mentions render as plain text
* @param {string} [opts.currentSkillId] - when rendering a skill body, resolve `reference/foo.md` to `#reference-foo` on the current page
* @returns {import('marked').Renderer}
*/
export function createRenderer({ knownSkillIds = new Set(), currentSkillId = null } = {}) {
const renderer = new marked.Renderer();
// Heading slugger — stable ids so we can anchor-link from elsewhere.
renderer.heading = ({ tokens, depth }) => {
const text = renderer.parser.parseInline(tokens);
const raw = tokens.map((t) => t.raw || '').join('');
const id = slugify(raw);
return `<h${depth} id="${id}">${text}</h${depth}>\n`;
};
// Link resolver.
renderer.link = ({ href, title, tokens }) => {
const text = renderer.parser.parseInline(tokens);
const resolved = resolveHref(href, { knownSkillIds, currentSkillId });
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
const relAttr = resolved.external ? ' target="_blank" rel="noopener"' : '';
return `<a href="${escapeAttr(resolved.href)}"${titleAttr}${relAttr}>${text}</a>`;
};
// Fenced code blocks — minimal glass-terminal styling, no syntax highlighter in v1.
renderer.code = ({ text, lang }) => {
const langClass = lang ? ` code-block--${escapeAttr(lang)}` : '';
return `<pre class="code-block${langClass}"><code>${escapeHtml(text)}</code></pre>\n`;
};
return renderer;
}
/**
* Resolve a markdown link href against the site's URL scheme.
*
* - `http(s)://…` → unchanged, external
* - `reference/foo.md` → `#reference-foo` on current skill page
* - `/skill-id` (known) → `/skills/skill-id`
* - `#anchor` → unchanged (in-page anchor)
* - anything else → unchanged (will be caught by build warnings later)
*
* @param {string} href
* @param {{ knownSkillIds: Set<string>, currentSkillId: string|null }} ctx
* @returns {{ href: string, external: boolean }}
*/
function resolveHref(href, { knownSkillIds, currentSkillId }) {
if (!href) return { href: '', external: false };
// External links
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
return { href, external: true };
}
// In-page anchor
if (href.startsWith('#')) {
return { href, external: false };
}
// reference/foo.md → #reference-foo on the current skill page
const refMatch = href.match(/^reference\/([a-z0-9-]+)\.md$/i);
if (refMatch && currentSkillId) {
return { href: `#reference-${refMatch[1].toLowerCase()}`, external: false };
}
// /skill-id mentioned in prose (e.g. "run /polish")
const slashMatch = href.match(/^\/([a-z0-9-]+)$/i);
if (slashMatch && knownSkillIds.has(slashMatch[1])) {
return { href: `/skills/${slashMatch[1]}`, external: false };
}
// [text](other-skill) → /skills/other-skill
if (/^[a-z0-9-]+$/i.test(href) && knownSkillIds.has(href)) {
return { href: `/skills/${href}`, external: false };
}
// Unknown — pass through. Generator can warn separately.
return { href, external: false };
}
/**
* Render a markdown string to HTML.
*
* @param {string} markdown
* @param {object} [opts]
* @param {Set<string>} [opts.knownSkillIds]
* @param {string} [opts.currentSkillId]
* @returns {string} HTML
*/
export function renderMarkdown(markdown, opts = {}) {
const renderer = createRenderer(opts);
return marked.parse(markdown, {
renderer,
gfm: true,
breaks: false,
});
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(str) {
return String(str).replace(/"/g, '&quot;');
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Page template wrapper for generated sub-pages.
*
* Reads the shared site header partial once and wraps content bodies with
* a minimal HTML scaffold that imports tokens.css + sub-pages.css.
*
* Used by scripts/build-sub-pages.js (wired up in commit 3).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '..', '..');
const HEADER_PARTIAL = path.join(ROOT_DIR, 'content', 'site', 'partials', 'header.html');
let cachedHeader = null;
/**
* Read the shared site header partial.
* Cached after first read.
*/
export function readHeaderPartial() {
if (cachedHeader === null) {
cachedHeader = fs.readFileSync(HEADER_PARTIAL, 'utf8').trim();
}
return cachedHeader;
}
/**
* Mark a nav item as current by adding aria-current="page" and removing
* the default nav href state. Matches on `data-nav="{activeNav}"`.
*
* @param {string} headerHtml
* @param {string} activeNav - one of: home, skills, anti-patterns, tutorials, gallery, github
* @returns {string}
*/
export function applyActiveNav(headerHtml, activeNav) {
if (!activeNav) return headerHtml;
return headerHtml.replace(
new RegExp(`data-nav="${activeNav}"`, 'g'),
`data-nav="${activeNav}" aria-current="page"`,
);
}
/**
* Wrap body HTML in a full page shell.
*
* @param {object} opts
* @param {string} opts.title - <title> text
* @param {string} opts.description - meta description
* @param {string} opts.bodyHtml - main content HTML (will be placed inside <main>)
* @param {string} [opts.activeNav] - which nav item to mark current
* @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>
* @returns {string} full HTML document
*/
export function renderPage({
title,
description,
bodyHtml,
activeNav,
canonicalPath,
extraHead = '',
bodyClass = 'sub-page',
}) {
const header = applyActiveNav(readHeaderPartial(), activeNav);
const safeTitle = escapeHtml(title);
const safeDesc = escapeAttr(description || '');
const canonical = canonicalPath
? `<link rel="canonical" href="https://impeccable.style${canonicalPath}">`
: '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${safeTitle}</title>
<meta name="description" content="${safeDesc}">
<meta name="theme-color" content="#fafafa">
${canonical}
<link rel="icon" type="image/svg+xml" href="/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">
${extraHead}
</head>
<body class="${bodyClass}">
<a href="#main" class="skip-link">Skip to content</a>
${header}
<main id="main">
${bodyHtml}
</main>
</body>
</html>
`;
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(str) {
return String(str || '').replace(/"/g, '&quot;');
}