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, '&')
239 .replace(/</g, '<')
240 .replace(/>/g, '>')
241 .replace(/"/g, '"')
242 .replace(/'/g, ''');
243}
244
245function escapeAttr(str) {
246 return String(str || '').replace(/"/g, '"');
247}