Files

94 lines
3.9 KiB
TypeScript

/**
* Generate one static `learn/<slug>/index.html` shell per lesson, so each lesson is
* its own indexable URL (`/learn/<slug>/`) carrying lesson-specific `<title>` 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/learn/main.tsx"></script>
</body>
</html>
`;
}
/** 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 });
}
}
}