这个博客是怎么搭起来的
发布于 2026-05-06
这个博客虽然看起来挺简单,但其实绝大多数的技术选型都被一个核心条件决定了:它部署在 Cloudflare Workers 上。由于 Workers 环境在运行时没法使用 node:fs 来读写文件系统,我们不得不在内容的加载和发现思路上做点变通。
1. 文章全靠 MDX
博客所有的文章都放在 src/posts/ 目录下,全是 .mdx 文件。MDX 体验真的很好,平时就当普通的 Markdown 写,需要搞点花活或者交互的时候,直接把 React 组件插进去就行。
按照常规思路,应用跑起来之后用 fs 扫一下目录就能加载文章。但咱们这儿没有 fs 呀。所以我们就写了个脚本(scripts/build-posts.mjs),在项目构建阶段提前把这些 MDX 扫出来,然后自动生成一个 posts-loaders.ts 文件。这里面包含了所有文章准确的 import() 代码:
// 脚本自动生成的,别手动改
import type { LoaderMap } from "@/lib/post-content";
export const postLoaders: Record<string, LoaderMap> = {
"blog-architecture": {
en: () => import("@/posts/blog-architecture.en.mdx"),
zh: () => import("@/posts/blog-architecture.zh.mdx"),
},
};
把 import 语句这样显式地写出来其实有个很大的好处:打包工具(Bundler)能清清楚楚地知道有哪些文件,从而完美地把每篇文章切成独立的代码块(chunk)。等用户访问页面的时候,运行时的 loadPostMDX 函数直接来这个映射表里查一下,就知道该加载哪个模块了。
而且既然是 MDX,你当然可以把平时写好的 UI 组件直接引进来用,比如:
每一篇 MDX 里都会抛出一个 export const metadata 对象,文章的标题、日期、简介、标签全在这里,不管是网页的 <title> 还是文章列表,读取的都是这一份数据,这就叫"单一事实来源"。
2. 原生支持中英双语
我想让博客同时支持中英双语,最轻量的做法就是直接在文件名上做文章,比如 <slug>.<locale>.mdx。
如果读者访问了一个还没来得及翻译的中文页面,加载器就会非常顺滑地自动切回英文版,不至于直接报错 404:
const exact = loaders[locale];
if (exact) return { module: await exact(), locale, usedFallback: false };
const fallback = loaders[DEFAULT_LOCALE];
return { module: await fallback(), locale: DEFAULT_LOCALE, usedFallback: true };
像 title 和 description 这些字段是跟着语言走的,而 date 和 tags 则是双语共享的。万一中英文两份文件里填的日期不一样怎么办?构建脚本会直接抛个警告出来,并且强制以英文版的为准,这样要修 bug 也很明确。
3. 把重活都丢给构建期
既然线上没法扫目录,那我们就把这事儿提前做好。在运行 Next.js 的流程之前,我们会先跑几个简单的 Node 脚本,它们会输出几个普通的 TypeScript 模块供应用去引入:
| 脚本 | 输出 | 主要是干嘛的 |
|---|---|---|
build-posts.mjs | src/lib/posts-discovered.ts | 提取每一篇 MDX 的 metadata 信息 |
build-posts.mjs | src/lib/posts-loaders.ts | 生成上文提到的 import() 映射表 |
build-gallery.mjs | src/lib/gallery-discovered.ts | 找出 MDX 里所有带画廊标记的 <img> |
这套流程已经被集成进了 manifest:build 命令里,不管你是本地开发(next dev)还是打包部署,它都会自动在前面先跑一遍:
"posts:build": "node scripts/build-posts.mjs",
"gallery:build": "node scripts/build-gallery.mjs",
"manifest:build": "pnpm posts:build && pnpm gallery:build",
"dev": "pnpm manifest:build && next dev",
"build": "pnpm manifest:build && next build"
4. 文章列表是怎么渲染的
build-posts.mjs 脚本不仅能发现文件,还能精准地把每个文件里的 export const metadata = { … } 这段代码抠出来执行掉。然后它会把同一个 slug 下的不同语言合并到一起,整理成一个干净的格式:
export type PostMeta = {
slug: string;
title: LocalizedString;
description: LocalizedString;
date: string;
tags: string[];
locales: Locale[];
};
有了这份清单,渲染文章列表页和实现搜索框就变得轻而易举了。目前博客的搜索功能只在标题、简介和标签这几个字段里找。之所以没做全文检索,是因为那样会导致我们要把所有文章的正文都塞进前端的包里,对现在来说实在是有些杀鸡用牛刀了。
5. 一个不需要 CMS 的照片墙
为了搞个照片墙专门搭一个后台管理系统?太折腾了。我的做法是,让 build-gallery.mjs 脚本去遍历所有的 MDX 文件,专门寻找那些带了 data-gallery 属性的 <img> 标签:
如果同一张图片既出现在了英文版文章里,又出现在了中文版文章里,脚本足够聪明,它会把两种语言的 title 和 alt 文本合并到一起。这样照片墙上只会展示一张图片,但却拥有双语的信息。
当然,如果你只是想在文章里随便插张图,不加 data-gallery 标记就行了,它会乖乖呆在文章里,绝不会跑到外面的照片墙上去凑热闹。
6. Next.js 路由
这块没啥稀奇的,就是老老实实用了 Next.js 推荐的 App Router(src/app/[locale]/)。在动态路由的配置里,我们会把所有支持的语言和文章 slug 组合起来,这样在 build 的时候就能生成出纯静态的 HTML:
export function generateStaticParams() {
return SUPPORTED_LOCALES.flatMap((locale) =>
getAllPosts().map((post) => ({ locale, slug: post.slug })),
);
}
export async function generateMetadata({ params }) {
const { locale, slug } = await params;
const loaded = await loadPostMDX(slug, locale);
return {
title: loaded.module.metadata?.title,
description: loaded.module.metadata?.description,
};
}
页面头部的 <title> 数据也是直接从刚才提取的 metadata 里拿,保证了列表展示和实际详情页信息永远一致。
7. 用 Tailwind 拯救排版
默认情况下,Tailwind 会把原生 HTML 标签(比如 <h1>、<p>)的默认样式全部扒干净。写 UI 组件的时候这很爽,但写博客长文的时候简直就是折磨。
解决办法就是引入 @tailwindcss/typography 插件。只要在最外层套一个 prose class,像行高、字间距、引用块甚至是深色模式(prose-invert)这些细节就全都有了:
<article
className="prose prose-neutral max-w-none dark:prose-invert
prose-headings:font-semibold
prose-a:text-blue-600 dark:prose-a:text-blue-400
prose-pre:bg-neutral-900 prose-pre:text-neutral-100"
>
{children}
</article>
"好的排版是隐形的,糟糕的排版却处处扎眼。"
要是在文章里嵌入了一些有自己样式的 React 组件,不想被 prose 干扰,直接在外面包一层 not-prose 就行了。前面的那两个按钮组件就是这么处理的。
8. 总结一下工作流
整个系统的运作流程其实非常线性:
- 在
src/posts/目录下用 Markdown 写好<slug>.en.mdx和<slug>.zh.mdx。补上metadata导出,想发到照片墙的图就加上data-gallery。 - 跑一把
pnpm manifest:build提取出 TypeScript 的静态清单文件。 - Next.js 拿着这份清单,欢快地打包出所有页面的静态资源。
- 最后交由
opennextjs-cloudflare转换格式,推送到 Cloudflare Workers。
不用复杂的后端和数据库,所有文章都作为代码的一部分来管理。写完内容,跑个脚本,搞定。