← 中文资源

简体中文版本

构建 Markdown 静态站点生成器

从零实现一个静态站点生成器:读取 Markdown 目录、套用统一布局、校验内部链接并输出可托管的 dist 目录。

阅读英文原文

项目目标

本项目从零构建一个静态站点生成器,把 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);