项目目标
本项目从零构建一个静态站点生成器,把 Markdown 目录转换成结构完整、内部链接可用的 HTML 站点。读者将亲手实现加载、渲染、装配与链接校验四个阶段,从而理解文件管道、模板与链接完整性的关键决策。
核心特性
- 递归读取并解析 front matter,将每个 Markdown 渲染为独立页面
- 用统一布局包裹正文,注入标题、导航与页脚
- 生成按时间倒序排列的首页索引与摘要
- 重写相对链接并在构建期报告断链
架构与分层
采用四阶段管道:加载器把内容文件解析为页面对象,渲染器把 Markdown 转成 HTML 并注入布局,装配器写入路由并生成索引,链接校验器在全量页面上核对内部目标。每个阶段都是对页面对象的纯函数,因此无需访问磁盘即可测试。
实施阶段
六个阶段依次为:内容加载与 front matter、Markdown 渲染、布局与页面装配、输出写入与索引、链接完整性校验、CLI 接线与测试。每一阶段结束时都产出一个可运行、可测试的成果。
测试与验收
用 vitest 运行。加载器覆盖嵌套目录与带冒号的引号值;渲染器覆盖标题、列表、代码块与内联链接;构建测试写入临时目录并断言文件齐备、索引倒序以及断链被报告而非静默生成。
延伸方向
语法高亮、RSS 生成与标签索引都是同一批页面对象上的新增阶段。由于每个阶段都是纯函数,新能力可以先测试再接入,无需改动既有管道。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
import { loadContent } from './loader.js';
import { renderPage } from './layout.js';
import { writeSite } from './build.js';
export async function buildSite({ contentDir, outDir, templatePath }) {
const pages = await loadContent(contentDir);
const rendered = pages.map((page) => renderPage(page, templatePath));
await writeSite(outDir, rendered, pages);
return { pageCount: pages.length, outDir };
}
mkdir markdown-ssg && cd markdown-ssg
npm init -y
npm install -D vitest@^2.1.0
{
"name": "markdown-ssg",
"type": "module",
"scripts": {
"build": "node src/cli.js",
"test": "vitest run"
}
}
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
export async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) files.push(...(await walk(full)));
else if (e.name.endsWith('.md')) files.push(full);
}
return files;
}
export function parseFrontMatter(raw, slug) {
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/);
const meta = m ? fromYaml(m[1]) : {};
const body = m ? raw.slice(m[0].length) : raw;
return { slug, title: meta.title ?? slug, date: meta.date ?? null, body, meta };
}
export async function loadContent(dir) {
const files = await walk(dir);
const pages = [];
for (const file of files) {
const slug = relative(dir, file).replace(/\\/g, '/').replace(/\.md$/, '');
pages.push(parseFrontMatter(await readFile(file, 'utf8'), slug));
}
return pages;
}
function fromYaml(text) {
const out = {};
for (const line of text.split('\n')) {
const [k, ...rest] = line.split(':');
if (k && rest.length) out[k.trim()] = rest.join(':').trim();
}
return out;
}
export function renderMarkdown(md) {
const lines = md.split('\n');
const html = [];
let inCode = false;
let inList = false;
for (const line of lines) {
if (line.startsWith('```')) {
inCode = !inCode;
html.push(inCode ? '<pre><code>' : '</code></pre>');
continue;
}
if (inCode) { html.push(escapeHtml(line)); continue; }
if (line.startsWith('# ')) { html.push(lineHtml('h1', line.slice(2))); continue; }
if (line.startsWith('## ')) { html.push(lineHtml('h2', line.slice(3))); continue; }
if (line.startsWith('- ')) {
if (!inList) { html.push('<ul>'); inList = true; }
html.push('<li>' + inline(line.slice(2)) + '</li>');
continue;
}
if (inList) { html.push('</ul>'); inList = false; }
if (line.trim()) html.push('<p>' + inline(line) + '</p>');
}
if (inList) html.push('</ul>');
return html.join('\n');
}
function inline(text) {
return escapeHtml(text).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, href) => {
return '<a href="' + href + '">' + t + '</a>';
});
}
import { readFileSync } from 'node:fs';
export function loadLayout(templatePath) {
return readFileSync(templatePath, 'utf8');
}
export function renderPage(page, templatePath) {
const layout = loadLayout(templatePath);
const body = renderMarkdown(page.body);
const html = layout
.replaceAll('{{title}}', escapeHtml(page.title))
.replaceAll('{{body}}', body)
.replaceAll('{{nav}}', navFor(page));
return { slug: page.slug, html, title: page.title, date: page.date };
}
function navFor(page) {
const home = page.slug.includes('/') ? '../../index.html' : 'index.html';
return '<nav><a href="' + home + '">Home</a></nav>';
}
import { mkdir, writeFile, rm, cp } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function writeSite(outDir, rendered, pages) {
await rm(outDir, { recursive: true, force: true });
for (const page of rendered) {
const target = join(outDir, page.slug + '.html');
await mkdir(dirname(target), { recursive: true });
await writeFile(target, page.html);
}
const sorted = [...pages].sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
await writeFile(join(outDir, 'index.html'), buildIndex(sorted, rendered));
}
function buildIndex(sorted, rendered) {
const items = sorted.map((p) => '<li><a href="' + p.slug + '.html">' + p.title + '</a></li>');
return '<ul class="index">' + items.join('') + '</ul>';
}
export function checkLinks(rendered) {
const known = new Set(rendered.map((p) => p.slug + '.html').concat(['index.html']));
const broken = [];
for (const page of rendered) {
const hrefs = [...page.html.matchAll(/href="([^"]+)"/g)].map((m) => m[1]);
for (const href of hrefs) {
if (/^https?:|^mailto:|^#/.test(href)) continue;
const normalized = normalize(href, page.slug);
if (!known.has(normalized)) broken.push({ from: page.slug, href });
}
}
return broken;
}
function normalize(href, fromSlug) {
const parts = fromSlug.split('/');
parts.pop();
for (const seg of href.split('/')) {
if (seg === '..') parts.pop();
else if (seg !== '.') parts.push(seg);
}
return parts.join('/');
}
import { buildSite } from './build.js';
import { loadContent } from './loader.js';
import { renderPage } from './layout.js';
const args = Object.fromEntries(
process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')),
);
const pages = await loadContent(args.content ?? 'content');
const rendered = pages.map((p) => renderPage(p, args.template ?? 'templates/layout.html'));
const broken = (await import('./links.js')).checkLinks(rendered);
if (broken.length) {
console.error('broken links:', broken.length);
process.exit(1);
}
const result = await buildSite({
contentDir: args.content ?? 'content',
outDir: args.out ?? 'dist',
templatePath: args.template ?? 'templates/layout.html',
});
console.log('wrote ' + result.pageCount + ' pages to ' + result.outDir);