/** * Generate one static `learn//index.html` shell per lesson, so each lesson is * its own indexable URL (`/learn//`) carrying lesson-specific `` and * `<meta description>`. The shared `src/learn` entry renders the right view from the * path. Driven by the lesson `.md` frontmatter — adding a lesson stays "drop a * file" — and run from `vite.config.ts` at load (dev and build) so the shells stay * in sync; the generated dirs are git-ignored. * * Plain content-shells only (per-URL metadata, client-rendered body). Prerendering * the prose into the HTML would help non-JS crawlers but is deliberately deferred. */ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const ROOT = new URL('../', import.meta.url); const LESSONS_DIR = fileURLToPath(new URL('src/learn/lessons/', ROOT)); const LEARN_DIR = fileURLToPath(new URL('learn/', ROOT)); interface LessonMeta { slug: string; title: string; tagline: string; } // A minimal frontmatter reader, separate from `core/lesson-parse`'s `parseLesson`: // this runs at vite-config load time, before the `@core` alias is registered, and // only needs the three header fields (not the parsed body). The two must agree on // the frontmatter shape — both read `slug`/`title`/`tagline` from `--- … ---`. /** Pull `slug`/`title`/`tagline` from a lesson's `--- … ---` frontmatter. */ function frontmatter(md: string): LessonMeta | null { const block = md.match(/^---\n([\s\S]*?)\n---/); if (!block) return null; const field = (key: string): string | undefined => block[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1].trim(); const slug = field('slug'); const title = field('title'); const tagline = field('tagline'); return slug && title && tagline ? { slug, title, tagline } : null; } /** Lesson metadata for every `src/learn/lessons/*.md`, sorted by slug. */ function readLessons(): LessonMeta[] { return readdirSync(LESSONS_DIR) .filter((f) => f.endsWith('.md')) .map((f) => frontmatter(readFileSync(LESSONS_DIR + f, 'utf8'))) .filter((l): l is LessonMeta => l !== null) .sort((a, b) => a.slug.localeCompare(b.slug)); } /** Rollup inputs (one per lesson) → the generated `learn/<slug>/index.html` shells. */ export function lessonInputs(): Record<string, string> { const input: Record<string, string> = {}; for (const { slug } of readLessons()) input[`learn-${slug}`] = `${LEARN_DIR}${slug}/index.html`; return input; } const escapeHtml = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); function shell({ title, tagline }: LessonMeta): string { return `<!doctype html> <html lang="en" data-theme="light"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="description" content="${escapeHtml(tagline)}" /> <title>${escapeHtml(title)} — Astrolabe
`; } /** Write a shell per lesson and prune dirs for lessons that no longer exist. */ export function generateLearnPages(): void { const lessons = readLessons(); const wanted = new Set(lessons.map((l) => l.slug)); for (const lesson of lessons) { mkdirSync(`${LEARN_DIR}${lesson.slug}/`, { recursive: true }); writeFileSync(`${LEARN_DIR}${lesson.slug}/index.html`, shell(lesson)); } for (const entry of readdirSync(LEARN_DIR, { withFileTypes: true })) { if (entry.isDirectory() && !wanted.has(entry.name)) { rmSync(`${LEARN_DIR}${entry.name}`, { recursive: true, force: true }); } } }