EN中文

这个博客是怎么搭起来的

发布于 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 };

titledescription 这些字段是跟着语言走的,而 datetags 则是双语共享的。万一中英文两份文件里填的日期不一样怎么办?构建脚本会直接抛个警告出来,并且强制以英文版的为准,这样要修 bug 也很明确。

3. 把重活都丢给构建期

既然线上没法扫目录,那我们就把这事儿提前做好。在运行 Next.js 的流程之前,我们会先跑几个简单的 Node 脚本,它们会输出几个普通的 TypeScript 模块供应用去引入:

脚本输出主要是干嘛的
build-posts.mjssrc/lib/posts-discovered.ts提取每一篇 MDX 的 metadata 信息
build-posts.mjssrc/lib/posts-loaders.ts生成上文提到的 import() 映射表
build-gallery.mjssrc/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> 标签:

如果同一张图片既出现在了英文版文章里,又出现在了中文版文章里,脚本足够聪明,它会把两种语言的 titlealt 文本合并到一起。这样照片墙上只会展示一张图片,但却拥有双语的信息。

当然,如果你只是想在文章里随便插张图,不加 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. 总结一下工作流

整个系统的运作流程其实非常线性:

  1. src/posts/ 目录下用 Markdown 写好 <slug>.en.mdx<slug>.zh.mdx。补上 metadata 导出,想发到照片墙的图就加上 data-gallery
  2. 跑一把 pnpm manifest:build 提取出 TypeScript 的静态清单文件。
  3. Next.js 拿着这份清单,欢快地打包出所有页面的静态资源。
  4. 最后交由 opennextjs-cloudflare 转换格式,推送到 Cloudflare Workers。

不用复杂的后端和数据库,所有文章都作为代码的一部分来管理。写完内容,跑个脚本,搞定。