/** * 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 - 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> * @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({ title, description, bodyHtml, activeNav, canonicalPath, extraHead = '', bodyClass = 'sub-page', assetDepth = 1, }) { const header = applyActiveNav(readHeaderPartial(), activeNav); const safeTitle = escapeHtml(title); const safeDesc = escapeAttr(description || ''); const canonical = canonicalPath ? `<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> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${safeTitle} ${canonical} ${extraHead} ${header}
${bodyHtml}
`; } function escapeHtml(str) { return String(str || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeAttr(str) { return String(str || '').replace(/"/g, '"'); }