写博客两年,攒了 80 多篇文章,版本从 v0.1 迭代到 v1.8。每次改完配置或加了新功能都会更新 changelog,但从来没有一篇文章把整个架构串起来。

这篇文章来补这个缺口。

整体架构

本地 Markdown 文件
      ↓
Astro Content Collections(Zod schema 校验)
      ↓
构建管道(remark 插件 + rehype 插件 + MDX)
      ↓
静态 HTML + JSON 数据文件
      ↓
Cloudflare Pages(全球 CDN 部署)
      ↓
GitHub Actions 定时触发重建(自动更新已到期文章)

整条链路没有数据库、没有服务端渲染逻辑、没有动态后端。内容是纯 Markdown,部署是纯静态,发布靠 Git push。

内容层:Content Collections + Zod

博客文章全部是 Markdown 文件,放在 src/content/blog/ 目录下。每篇文章的 frontmatter 由 Zod schema 定义:

// src/content/config.ts
const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    category: z.string().optional(),
    tags: z.array(z.string()).optional(),
    series: z.string().optional(),
    seriesOrder: z.number().optional(),
    // 生长状态:seed=种子 / bud=发芽 / bloom=绽放
    growth: z.enum(['seed', 'bud', 'bloom']).optional(),
    // 手动指定别名,避免 URL 冲突
    slug: z.string().optional(),
  }),
});

这个 schema 有什么实际作用?Astro 在构建时会校验所有文章的 frontmatter。字段类型错了、日期格式不对,build 直接失败并告诉你哪篇文章有问题。

这比 WordPress 那种”发布时没问题,访问时报错”的做法好多了。

构建管道:四层插件链

Astro 默认把 Markdown 转成 HTML,但我的博客需要更多:脚注、维基链接语法、代码高亮、图片懒加载、结构化数据。

这些全部通过 remark 和 rehype 插件实现:

// astro.config.mjs
import remarkFootnote from 'remark-footnotes';
import remarkDirective from 'remark-directive';
import remarkEmoji from 'remark-emoji';
import { remarkHighlight } from './src/plugins/remark-highlight.js';
import { remarkWikiLinks } from './src/plugins/remark-wiki-links.js';
  • remark-footnotes:原生脚注语法 [^1] 自动变成脚注区块
  • remark-directive:支持 ::tip、:::warning 等自定义指令块
  • remark-wiki-links:[[维基链接]] 语法,生成内部链接
  • remark-highlight:代码块语法高亮

这套插件链的好处是:内容作者写文章时用 Markdown 惯用语法,不需要记任何自定义标签,但渲染出来的页面有丰富功能。

日期过滤:已发布文章的自动化管理

写技术博客有个痛点:预先写好一批文章,按节奏排期发布,但 build 时这些”未来文章”也会被构建进索引和 sitemap。

v1.7.2 做了 filterPublished 解决这个问题:

// src/utils.ts
export function filterPublished<T extends { data: { pubDate: Date } }>(
  posts: T[]
): T[] {
  const now = Date.now();
  return posts.filter(post => post.data.pubDate.getTime() <= now);
}

这个函数接入了博客的 13 个读取文章的位置:

  • 首页文章列表
  • 归档页(所有已发布文章)
  • 分类页(分类索引 + 分类详情)
  • 标签页(标签索引 + 标签详情)
  • 系列页(系列索引 + 系列详情)
  • 相关文章算法
  • 上下篇文章导航
  • RSS 全文输出
  • 搜索索引(Pagefind)

构建结果验证:84 篇文章中,80 篇已发布(pubDate ≤ 今天),4 篇未来文章(8/10 到 9/4)完全不生成页面、不进索引、不进 sitemap。

部署链路:GitHub Actions + Cloudflare Pages

作者本地:git push
      ↓
GitHub 仓库:触发 Cloudflare Pages 自动部署
      ↓
Cloudflare Pages:构建 Astro 项目,输出静态文件
      ↓
全球 CDN 边缘节点分发

但光靠这个还不够。文章按排期发布,到期了谁去触发重建?答案是 GitHub Actions 定时任务。

每天北京时间 00,Actions 执行 scan-posts.sh 脚本:

  1. 扫描 src/content/blog/ 下所有文章的 frontmatter 日期
  2. 找到当天到期(pubDate === 今天)的文章
  3. 生成构建日志写入 src/data/build-log.md
  4. commit + push 到 GitHub,触发 Cloudflare Pages 重建

这意味着:文章写好、设好 pubDate,到期那天自动上线,不需要手动操作。

搜索:Pagefind 的客户端全文搜索

博客没有服务器端搜索,用的是 Pagefind——一个在构建时生成搜索索引、运行时在浏览器里做全文搜索的工具。

# 安装
npx astro-add pagefind

# 使用(页面组件)
<PagefindUI />

构建时 Pagefind 扫描所有 HTML 文件,建立倒排索引,打包成 ~50KB 的 wasm 文件。读者搜索时,Pagefind 在浏览器里直接查索引,不需要请求服务器。

优势:零服务器成本、搜索速度极快、不需要任何后端 API。劣势:搜索索引在构建时生成,对已发布文章实时性无影响,但如果文章内容改了需要等下一次构建完成才能搜到新内容。

数据文件层:JSON 配置与 changelog

博客不只有文章。还有大量站点元数据:

src/data/
  changelog.json      ← 版本历史,每次发版更新
  build-log.md        ← 构建日志,Actions 自动生成
  books_music_movies.json  ← 书影音记录
  steam_games.json    ← 游戏记录
  github_projects.json  ← GitHub 项目列表
  gear.json           ← 装备清单
  daily-quotes.json   ← 每日一言

这些 JSON 文件在构建时被 getCollection 或 import 读入页面组件,渲染成页面内容。比如关于页的”游戏时长”模块,就是读取 steam_games.json 动态渲染的。

好处是数据与代码分离:要更新游戏时长,不需要改页面组件,改 JSON 就行。

样式架构:CSS 变量 + 作用域样式

博客用了 Astro 的 scoped CSS 特性,每个组件的样式只作用于该组件,不会泄露到全局。

全局 CSS 变量定义在 src/styles/global.css 里:

:root {
  --text-primary: #1a1a1a;
  --text-secondary: #4a4a4a;
  --bg-primary: #ffffff;
  --accent: #3b82f6;
  /* 更多变量... */
}

每个页面组件引用全局变量,组件自己的样式放在 .astro 文件里:

<style>
  .post-title {
    color: var(--text-primary);
    font-size: 1.5rem;
  }
</style>

这样改主题色只需要改 global.css 的变量值,全站生效,不需要逐个组件修改。

三层配置架构

博客配置分三层,每层职责不同:

层级文件职责修改频率
框架层astro.config.mjs集成插件、适配器、构建选项极低
站点层src/config/site.config.mjs站名、导航、社交链接、园丁守则低
内容层src/content/config.tsfrontmatter schema、文章字段定义极低

这个分层解决了”改一个导航链接要翻几百行配置文件”的问题。日常操作基本只动站点层,其他两层设好基本不动。

回头看这两年

v0.1 的时候,所有配置都在 astro.config.mjs 里,60 行。

现在:

  • astro.config.mjs:框架配置 + CSP 安全策略 + 插件链
  • site.config.mjs:站点元数据 + 导航 + 社交
  • src/content/config.ts:文章 schema
  • src/data/*.json:各种数据文件
  • src/plugins/:自定义 remark/rehype 插件
  • scripts/:自动化脚本(OG 图生成、别名映射、访问数据同步等)

架构没有”正确答案”。随着需求增长,某些当时合理的做法会变得笨拙,改就是了。重要的是每次迭代之后,博客能继续正常跑,读者感觉不到变化,开发者(也就是我)改起来更顺手。

两年迭代下来的最大感受:好的架构不是一开始设计出来的,是被需求一点点逼出来的。

继续阅读Continue Reading

关于本文About

评论Comments

留下你的想法

欢迎在下方评论区留下你的看法,一起把话题聊得更深。

COMMENTS