mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
94 lines
3.9 KiB
TypeScript
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, '&').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</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 });
|
|
}
|
|
}
|
|
}
|