用 Astro 搭这个博客
Content Collections、@sudoo i18n,外加一份就能跑的 Cloudflare Pages 部署。
~/posts/astro-best-practices $ cat post.md
上一篇讲了为什么不再用 Jekyll。这篇是无聊的 续集——新摊子具体长什么样。
不打算把这套技术栈说得有多新奇。静态站用 Astro,两套语言两棵 markdown 目录,GitHub Actions 把构建产物推到 Cloudflare Pages。有意思的地方都在 接缝处。
文章 = 一个带类型的集合
每篇文章都在 src/content/posts/<语言>/<slug>.md。schema 一个文件管完:
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const posts = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { posts };
这套写法白送两件事:
- glob loader 会自动递归到语言子目录,每条 entry 的 id 长这样
en-US/some-slug——语言直接从路径里出来,frontmatter 不用多写一行。 - Zod 在构建期跑。标题忘填、日期写错、tag 里混进个非字符串,全都会让
pnpm build直接报错,而不是糊上线。
按语言切文章列表的逻辑(splitPostId、postsForLocale、postsWithTag)
都集中在 src/i18n/utils.ts 里,[lang] 下的页面模板就可以写得很薄。
i18n:换成 @sudoo 的那套
最近一次值得一提的重构,是把零散的字符串 map 换成了用枚举做 key 的
@sudoo/internationalization。
形状是这样的:
// src/i18n/profile.ts
export enum BLOG_PROFILE {
SITE_TITLE = "SITE_TITLE",
NAV_ALL_POSTS = "NAV_ALL_POSTS",
POST_PUBLISHED = "POST_PUBLISHED",
// …每条要翻译的字符串占一项
}
// src/i18n/intl.ts
import { SudooInternationalization } from "@sudoo/internationalization";
import { IETF_LOCALE } from "@sudoo/locale";
import { enUSBlogProfile } from "./locale/en-US";
import { zhCNBlogProfile } from "./locale/zh-CN";
export const blogInternationalization =
SudooInternationalization.create<BLOG_PROFILE>(DEFAULT_LOCALE);
blogInternationalization.merge(IETF_LOCALE.ENGLISH_UNITED_STATES, enUSBlogProfile);
blogInternationalization.merge(IETF_LOCALE.CHINESE_SIMPLIFIED, zhCNBlogProfile);
各语言的字符串表都是 Record<BLOG_PROFILE, string>,意思是某个语言漏了
一条 key,TypeScript 就直接编译不过。新增一条字符串变成了一套有编译器
带着走的四步仪式:加 enum、加 en-US、加 zh-CN、在模板里用上。不会再
出现 t("nav.allPosts") 拼错时静默返回 key 字符串的那种场面。
路由就是 Astro 自带那套:
// astro.config.mjs
i18n: {
defaultLocale: "en-US",
locales: ["en-US", "zh-CN"],
routing: {
prefixDefaultLocale: true,
redirectToDefaultLocale: false,
},
},
双语站基本上只有 prefixDefaultLocale: true 这一个合理选项——每个 URL
都以 /en-US/ 或 /zh-CN/ 开头,根路径不再有歧义。语言切换按钮用
src/i18n/utils.ts 里的 switchLocalePath 把第一段替换掉,这样在 tag
页切语言会落到对应语言的同一个 tag 页,而不是被丢回首页。
部署:一个 workflow 文件
部署故意写得不浪漫:
# .github/workflows/deploy.yml
on:
push: { branches: [master] }
workflow_dispatch:
concurrency:
group: pages-deploy-blog
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm build
- env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: npx --yes wrangler@latest pages deploy dist --project-name=blog-mengw-io --branch=main
几个值得留意的点:
concurrency.cancel-in-progress: false——连续两次推送应该都发出去, 而不是后一次把前一次干掉。Cloudflare 那边会自己排队,不用担心。- 用
wrangler pages deploy dist,没走 Cloudflare 自己的 GitHub 集成。 集成更省事,但 wrangler 这种走 CLI 的方式更好排查问题——同样一行命令 可以直接在本地用同一份 env vars 复现失败。 - workflow 跑在
master上、却往--branch=main推,是故意的:这个 flag 告诉 Cloudflare 哪个环境算”生产”,跟 git 分支名解耦。容易踩坑, 留个记号。
两个仓库 secret——CLOUDFLARE_API_TOKEN(权限缩到只能 Pages: Edit
当前这个项目)和 CLOUDFLARE_ACCOUNT_ID——CD 部分就这么多。