mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
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:
@@ -6,6 +6,7 @@
|
||||
"name": "vibe-design-plugins",
|
||||
"dependencies": {
|
||||
"jsdom": "^29.0.0",
|
||||
"marked": "^16.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
@@ -381,6 +382,8 @@
|
||||
|
||||
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
||||
|
||||
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"miniflare": ["miniflare@4.20260317.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260317.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-xuwk5Kjv+shi5iUBAdCrRl9IaWSGnTU8WuTQzsUS2GlSDIMCJuu8DiF/d9ExjMXYiQG5ml+k9SVKnMj8cRkq0w=="],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- site-header v1 -->
|
||||
<header class="site-header" data-site-header>
|
||||
<a href="/" class="site-header-brand" aria-label="Impeccable home">
|
||||
<span class="site-header-brand-name">Impeccable</span>
|
||||
</a>
|
||||
<nav class="site-header-nav" aria-label="Primary">
|
||||
<a href="/" data-nav="home">Home</a>
|
||||
<a href="/skills" data-nav="skills">Skills</a>
|
||||
<a href="/anti-patterns" data-nav="anti-patterns">Anti-Patterns</a>
|
||||
<a href="/tutorials" data-nav="tutorials">Tutorials</a>
|
||||
<a href="/gallery" data-nav="gallery">Gallery</a>
|
||||
<a href="https://github.com/pbakaus/impeccable" target="_blank" rel="noopener" data-nav="github">GitHub</a>
|
||||
</nav>
|
||||
</header>
|
||||
+2
-1
@@ -56,7 +56,8 @@
|
||||
"og-image": "bun run scripts/generate-og-image.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsdom": "^29.0.0"
|
||||
"jsdom": "^29.0.0",
|
||||
"marked": "^16.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"puppeteer": "^24.39.1"
|
||||
|
||||
+4
-97
@@ -5,108 +5,15 @@
|
||||
* Light, high-contrast, typographically bold.
|
||||
*/
|
||||
|
||||
/* Import other CSS partials */
|
||||
/* Shared design tokens + minimal reset */
|
||||
@import "./tokens.css";
|
||||
|
||||
/* Section partials (landing page only) */
|
||||
@import "./problem-section.css";
|
||||
@import "./workflow.css";
|
||||
@import "./gallery.css";
|
||||
@import "./skill-demos.css";
|
||||
|
||||
/* ============================================
|
||||
MINIMAL CSS RESET
|
||||
============================================ */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button, input, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DESIGN TOKENS
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Typography */
|
||||
--font-display: 'Cormorant Garamond', Georgia, serif;
|
||||
--font-body: 'Instrument Sans', system-ui, sans-serif;
|
||||
--font-mono: 'Space Grotesk', monospace;
|
||||
|
||||
/* Spacing Scale */
|
||||
--spacing-xs: 8px;
|
||||
--spacing-sm: 16px;
|
||||
--spacing-md: 24px;
|
||||
--spacing-lg: 32px;
|
||||
--spacing-xl: 48px;
|
||||
--spacing-2xl: 80px;
|
||||
--spacing-3xl: 120px;
|
||||
|
||||
/* Width */
|
||||
--width-max: 1400px;
|
||||
--width-content: 900px;
|
||||
|
||||
/* Animation */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--duration-fast: 0.15s;
|
||||
--duration-base: 0.3s;
|
||||
--duration-slow: 0.6s;
|
||||
--duration-slower: 0.8s;
|
||||
--duration-slowest: 1.2s;
|
||||
|
||||
/* Core Colors - Light Mode */
|
||||
--color-ink: oklch(10% 0 0);
|
||||
--color-text: oklch(10% 0 0);
|
||||
--color-paper: oklch(98% 0 0);
|
||||
--color-cream: oklch(96% 0.005 350);
|
||||
--color-charcoal: oklch(25% 0 0);
|
||||
--color-ash: oklch(55% 0 0);
|
||||
--color-mist: oklch(92% 0 0);
|
||||
--color-bg: oklch(96% 0.005 350);
|
||||
|
||||
/* Accent - Vibrant Magenta/Rose (original) */
|
||||
--color-accent: oklch(60% 0.25 350);
|
||||
--color-accent-hover: oklch(52% 0.25 350);
|
||||
--color-accent-dim: oklch(60% 0.25 350 / 0.15);
|
||||
--color-accent-soft: oklch(60% 0.25 350 / 0.25);
|
||||
|
||||
/* Framework category colors - Light mode */
|
||||
--cat-create-bg: #fdf2f8;
|
||||
--cat-create-border: #ec4899;
|
||||
--cat-create-text: #be185d;
|
||||
|
||||
--cat-evaluate-bg: #fdf4ff;
|
||||
--cat-evaluate-border: #d946ef;
|
||||
--cat-evaluate-text: #a21caf;
|
||||
|
||||
--cat-refine-bg: #eff6ff;
|
||||
--cat-refine-border: #3b82f6;
|
||||
--cat-refine-text: #1d4ed8;
|
||||
|
||||
--cat-simplify-bg: #fffbeb;
|
||||
--cat-simplify-border: #f59e0b;
|
||||
--cat-simplify-text: #b45309;
|
||||
|
||||
--cat-harden-bg: #f0fdf4;
|
||||
--cat-harden-border: #22c55e;
|
||||
--cat-harden-text: #15803d;
|
||||
|
||||
--cat-system-bg: #f5f5f4;
|
||||
--cat-system-border: #78716c;
|
||||
--cat-system-text: #44403c;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
SKIP LINK (Accessibility)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* impeccable.style — shared styles for sub-pages and the site-wide header
|
||||
*
|
||||
* Imported by all generated pages under /skills, /anti-patterns, /tutorials,
|
||||
* and by the hand-authored pages (index, cheatsheet, gallery, privacy) via
|
||||
* a direct <link> tag so they all share the same header + prose typography.
|
||||
*/
|
||||
|
||||
@import "./tokens.css";
|
||||
|
||||
/* ============================================
|
||||
BASE
|
||||
============================================ */
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-ink);
|
||||
background: var(--color-paper);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SHARED SITE HEADER
|
||||
============================================ */
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-lg);
|
||||
padding: 18px clamp(var(--spacing-md), 4vw, var(--spacing-2xl));
|
||||
background: color-mix(in oklch, var(--color-paper) 94%, transparent);
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(16px);
|
||||
backdrop-filter: saturate(1.4) blur(16px);
|
||||
border-bottom: 1px solid var(--color-mist);
|
||||
}
|
||||
|
||||
.site-header-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-ink);
|
||||
flex-shrink: 0;
|
||||
transition: color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.site-header-brand:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.site-header-brand-name {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.site-header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(var(--spacing-sm), 2vw, var(--spacing-lg));
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.site-header-nav a {
|
||||
position: relative;
|
||||
color: var(--color-charcoal);
|
||||
padding: 4px 0;
|
||||
transition: color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.site-header-nav a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.site-header-nav a[aria-current="page"] {
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-header-nav a[aria-current="page"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -6px;
|
||||
height: 2px;
|
||||
background: var(--color-accent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.site-header {
|
||||
padding: 14px var(--spacing-md);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.site-header-brand {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.site-header-nav {
|
||||
font-size: 0.8125rem;
|
||||
gap: var(--spacing-sm);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.site-header-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header-nav a[data-nav="github"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* impeccable.style — design tokens + minimal reset
|
||||
*
|
||||
* Shared by every page on the site (landing + generated sub-pages).
|
||||
* Keep this file small and dependency-free.
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
MINIMAL CSS RESET
|
||||
============================================ */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button, input, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DESIGN TOKENS
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Typography */
|
||||
--font-display: 'Cormorant Garamond', Georgia, serif;
|
||||
--font-body: 'Instrument Sans', system-ui, sans-serif;
|
||||
--font-mono: 'Space Grotesk', monospace;
|
||||
|
||||
/* Spacing Scale */
|
||||
--spacing-xs: 8px;
|
||||
--spacing-sm: 16px;
|
||||
--spacing-md: 24px;
|
||||
--spacing-lg: 32px;
|
||||
--spacing-xl: 48px;
|
||||
--spacing-2xl: 80px;
|
||||
--spacing-3xl: 120px;
|
||||
|
||||
/* Width */
|
||||
--width-max: 1400px;
|
||||
--width-content: 900px;
|
||||
|
||||
/* Animation */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--duration-fast: 0.15s;
|
||||
--duration-base: 0.3s;
|
||||
--duration-slow: 0.6s;
|
||||
--duration-slower: 0.8s;
|
||||
--duration-slowest: 1.2s;
|
||||
|
||||
/* Core Colors - Light Mode */
|
||||
--color-ink: oklch(10% 0 0);
|
||||
--color-text: oklch(10% 0 0);
|
||||
--color-paper: oklch(98% 0 0);
|
||||
--color-cream: oklch(96% 0.005 350);
|
||||
--color-charcoal: oklch(25% 0 0);
|
||||
--color-ash: oklch(55% 0 0);
|
||||
--color-mist: oklch(92% 0 0);
|
||||
--color-bg: oklch(96% 0.005 350);
|
||||
|
||||
/* Accent - Vibrant Magenta/Rose (original) */
|
||||
--color-accent: oklch(60% 0.25 350);
|
||||
--color-accent-hover: oklch(52% 0.25 350);
|
||||
--color-accent-dim: oklch(60% 0.25 350 / 0.15);
|
||||
--color-accent-soft: oklch(60% 0.25 350 / 0.25);
|
||||
|
||||
/* Framework category colors - Light mode */
|
||||
--cat-create-bg: #fdf2f8;
|
||||
--cat-create-border: #ec4899;
|
||||
--cat-create-text: #be185d;
|
||||
|
||||
--cat-evaluate-bg: #fdf4ff;
|
||||
--cat-evaluate-border: #d946ef;
|
||||
--cat-evaluate-text: #a21caf;
|
||||
|
||||
--cat-refine-bg: #eff6ff;
|
||||
--cat-refine-border: #3b82f6;
|
||||
--cat-refine-text: #1d4ed8;
|
||||
|
||||
--cat-simplify-bg: #fffbeb;
|
||||
--cat-simplify-border: #f59e0b;
|
||||
--cat-simplify-text: #b45309;
|
||||
|
||||
--cat-harden-bg: #f0fdf4;
|
||||
--cat-harden-border: #22c55e;
|
||||
--cat-harden-text: #15803d;
|
||||
|
||||
--cat-system-bg: #f5f5f4;
|
||||
--cat-system-border: #78716c;
|
||||
--cat-system-text: #44403c;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return String(str).replace(/"/g, '"');
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return String(str || '').replace(/"/g, '"');
|
||||
}
|
||||
Reference in New Issue
Block a user