render-page.js

  1/**
  2 * Page template wrapper for generated sub-pages.
  3 *
  4 * Reads the shared site header partial once and wraps content bodies with
  5 * a minimal HTML scaffold that imports tokens.css + sub-pages.css.
  6 *
  7 * Used by scripts/build-sub-pages.js (wired up in commit 3).
  8 */
  9
 10import fs from 'node:fs';
 11import path from 'node:path';
 12import { fileURLToPath } from 'node:url';
 13
 14const __dirname = path.dirname(fileURLToPath(import.meta.url));
 15const ROOT_DIR = path.resolve(__dirname, '..', '..');
 16const HEADER_PARTIAL = path.join(ROOT_DIR, 'content', 'site', 'partials', 'header.html');
 17
 18let cachedHeader = null;
 19
 20/**
 21 * Read the shared site header partial.
 22 * Cached after first read.
 23 */
 24export function readHeaderPartial() {
 25  if (cachedHeader === null) {
 26    cachedHeader = fs.readFileSync(HEADER_PARTIAL, 'utf8').trim();
 27  }
 28  return cachedHeader;
 29}
 30
 31/**
 32 * Mark a nav item as current by adding aria-current="page" and removing
 33 * the default nav href state. Matches on `data-nav="{activeNav}"`.
 34 *
 35 * @param {string} headerHtml
 36 * @param {string} activeNav - one of: home, skills, anti-patterns, tutorials, gallery, github
 37 * @returns {string}
 38 */
 39export function applyActiveNav(headerHtml, activeNav) {
 40  if (!activeNav) return headerHtml;
 41  return headerHtml.replace(
 42    new RegExp(`data-nav="${activeNav}"`, 'g'),
 43    `data-nav="${activeNav}" aria-current="page"`,
 44  );
 45}
 46
 47/**
 48 * Wrap body HTML in a full page shell.
 49 *
 50 * @param {object} opts
 51 * @param {string}   opts.title         - <title> text
 52 * @param {string}   opts.description   - meta description
 53 * @param {string}   opts.bodyHtml      - main content HTML (will be placed inside <main>)
 54 * @param {string}   [opts.activeNav]   - which nav item to mark current
 55 * @param {string}   [opts.canonicalPath] - relative URL path for <link rel="canonical">
 56 * @param {string}   [opts.extraHead]   - raw HTML to inject into <head>
 57 * @param {string}   [opts.bodyClass]   - optional class on <body>
 58 * @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.
 59 * @returns {string} full HTML document
 60 */
 61export function renderPage({
 62  title,
 63  description,
 64  bodyHtml,
 65  activeNav,
 66  canonicalPath,
 67  extraHead = '',
 68  bodyClass = 'sub-page',
 69  assetDepth = 1,
 70}) {
 71  const header = applyActiveNav(readHeaderPartial(), activeNav);
 72  const safeTitle = escapeHtml(title);
 73  const safeDesc = escapeAttr(description || '');
 74  const canonical = canonicalPath
 75    ? `<link rel="canonical" href="https://impeccable.style${canonicalPath}">`
 76    : '';
 77
 78  // Relative prefix for on-disk resolution by Bun's HTML loader.
 79  // Bun rewrites these to hashed absolute URLs at build time, so runtime
 80  // serving works regardless of the request path.
 81  const rel = assetDepth > 0 ? '../'.repeat(assetDepth) : './';
 82
 83  return `<!DOCTYPE html>
 84<html lang="en">
 85<head>
 86  <meta charset="UTF-8">
 87  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 88  <title>${safeTitle}</title>
 89  <meta name="description" content="${safeDesc}">
 90  <meta name="theme-color" content="#fafafa">
 91  ${canonical}
 92  <link rel="icon" type="image/svg+xml" href="${rel}favicon.svg">
 93  <link rel="preconnect" href="https://fonts.googleapis.com">
 94  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 95  <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">
 96  <link rel="stylesheet" href="${rel}css/sub-pages.css">
 97  ${extraHead}
 98</head>
 99<body class="${bodyClass}">
100  <a href="#main" class="skip-link">Skip to content</a>
101  ${header}
102  <main id="main">
103${bodyHtml}
104  </main>
105  <script>
106    // Copy buttons on rendered code blocks
107    document.addEventListener('click', (e) => {
108      const btn = e.target.closest('[data-copy]');
109      if (!btn) return;
110      const text = btn.getAttribute('data-copy');
111      if (!text) return;
112      navigator.clipboard.writeText(text).then(() => {
113        btn.classList.add('is-copied');
114        setTimeout(() => btn.classList.remove('is-copied'), 1500);
115      }).catch(() => {});
116    });
117
118    // Mobile sidebar toggle (shown on narrow viewports, hidden on desktop).
119    document.addEventListener('click', (e) => {
120      const toggle = e.target.closest('.skills-sidebar-toggle');
121      if (!toggle) return;
122      const expanded = toggle.getAttribute('aria-expanded') === 'true';
123      toggle.setAttribute('aria-expanded', String(!expanded));
124    });
125
126    // Before/after split-compare: drag on touch, hover OR drag on mouse.
127    // Pointer events attach to the padded .split-comparison wrapper so
128    // there is a ~20px invisible buffer around the visible box. The
129    // divider only snaps back when the pointer leaves that outer buffer.
130    (function initSplitCompare() {
131      const wrappers = document.querySelectorAll('.split-comparison');
132      if (wrappers.length === 0) return;
133      const hasHover = matchMedia('(hover: hover)').matches;
134      const DEFAULT_POSITION = 50;
135
136      for (const wrapper of wrappers) {
137        const container = wrapper.querySelector('.split-container');
138        const splitAfter = wrapper.querySelector('.split-after');
139        const splitDivider = wrapper.querySelector('.split-divider');
140        if (!container || !splitAfter || !splitDivider) continue;
141
142        const tanAngle = Math.tan(10 * Math.PI / 180);
143        let skewOffset = 8;
144        const recalcSkew = () => {
145          const r = container.getBoundingClientRect();
146          if (r.width > 0 && r.height > 0) {
147            skewOffset = 50 * r.height * tanAngle / r.width;
148          }
149        };
150        recalcSkew();
151        window.addEventListener('resize', recalcSkew, { passive: true });
152
153        let targetX = DEFAULT_POSITION;
154        let currentX = DEFAULT_POSITION;
155        let rafId = null;
156
157        const paint = (pct) => {
158          const x = Math.max(-skewOffset, Math.min(100 + skewOffset, pct));
159          splitAfter.style.clipPath =
160            \`polygon(\${x + skewOffset}% 0%, 100% 0%, 100% 100%, \${x - skewOffset}% 100%)\`;
161          splitDivider.style.left = \`\${x}%\`;
162        };
163
164        const step = () => {
165          currentX += (targetX - currentX) * 0.2;
166          if (Math.abs(targetX - currentX) < 0.1) {
167            currentX = targetX;
168            rafId = null;
169          } else {
170            rafId = requestAnimationFrame(step);
171          }
172          paint(currentX);
173        };
174
175        const setTarget = (pct) => {
176          targetX = pct;
177          if (rafId === null) rafId = requestAnimationFrame(step);
178        };
179
180        paint(DEFAULT_POSITION);
181
182        // Percentage is always relative to the VISIBLE .split-container,
183        // not the padded .split-comparison wrapper. The pointer event
184        // target is the wrapper but the clip-path math uses the inner box.
185        const pctFromClientX = (clientX) => {
186          const rect = container.getBoundingClientRect();
187          return ((clientX - rect.left) / rect.width) * 100;
188        };
189
190        let hovering = false;
191        let dragging = false;
192
193        wrapper.addEventListener('pointerenter', (e) => {
194          if (hasHover && e.pointerType === 'mouse') {
195            hovering = true;
196          }
197        });
198
199        wrapper.addEventListener('pointerdown', (e) => {
200          dragging = true;
201          wrapper.setPointerCapture(e.pointerId);
202          setTarget(pctFromClientX(e.clientX));
203        });
204
205        wrapper.addEventListener('pointermove', (e) => {
206          if (dragging || hovering) {
207            setTarget(pctFromClientX(e.clientX));
208          }
209        });
210
211        const endDrag = (e) => {
212          if (dragging) {
213            dragging = false;
214            try { wrapper.releasePointerCapture(e.pointerId); } catch {}
215          }
216        };
217
218        wrapper.addEventListener('pointerup', endDrag);
219        wrapper.addEventListener('pointercancel', endDrag);
220
221        wrapper.addEventListener('pointerleave', (e) => {
222          endDrag(e);
223          if (hovering) {
224            hovering = false;
225            setTarget(DEFAULT_POSITION);
226          }
227        });
228      }
229    })();
230  </script>
231</body>
232</html>
233`;
234}
235
236function escapeHtml(str) {
237  return String(str || '')
238    .replace(/&/g, '&amp;')
239    .replace(/</g, '&lt;')
240    .replace(/>/g, '&gt;')
241    .replace(/"/g, '&quot;')
242    .replace(/'/g, '&#39;');
243}
244
245function escapeAttr(str) {
246  return String(str || '').replace(/"/g, '&quot;');
247}