diff --git a/bun.lock b/bun.lock index 09b699889..c73679663 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/content/site/partials/header.html b/content/site/partials/header.html new file mode 100644 index 000000000..1d1e1b4bd --- /dev/null +++ b/content/site/partials/header.html @@ -0,0 +1,14 @@ + + diff --git a/package.json b/package.json index 080a0142e..c78c0ad45 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/css/main.css b/public/css/main.css index 77903781c..ea681605a 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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) diff --git a/public/css/sub-pages.css b/public/css/sub-pages.css new file mode 100644 index 000000000..02f027229 --- /dev/null +++ b/public/css/sub-pages.css @@ -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 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; + } +} diff --git a/public/css/tokens.css b/public/css/tokens.css new file mode 100644 index 000000000..e5517adac --- /dev/null +++ b/public/css/tokens.css @@ -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; +} diff --git a/scripts/lib/render-markdown.js b/scripts/lib/render-markdown.js new file mode 100644 index 000000000..647bd3f4a --- /dev/null +++ b/scripts/lib/render-markdown.js @@ -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} [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 `${text}\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 `${text}`; + }; + + // Fenced code blocks — minimal glass-terminal styling, no syntax highlighter in v1. + renderer.code = ({ text, lang }) => { + const langClass = lang ? ` code-block--${escapeAttr(lang)}` : ''; + return `
${escapeHtml(text)}
\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, 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} [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, '''); +} + +function escapeAttr(str) { + return String(str).replace(/"/g, '"'); +} diff --git a/scripts/lib/render-page.js b/scripts/lib/render-page.js new file mode 100644 index 000000000..d46f35814 --- /dev/null +++ b/scripts/lib/render-page.js @@ -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 - 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} + + + ${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, '"'); +}