重建博客时选了 AstroPaper 主题,功能列表里写着 i18n ready。等真要做中英双语才发现,这个 “ready” 指的是界面文案可以翻译,而不是站点有多语言路由。
主题里确实有 useTranslations(),组件也都用了 getRelativeLocaleUrl(),但 src/pages 下只有一套页面,构建出来永远是单语站。想要 / 出英文、/zh/ 出中文,路由这层得自己加。
目标
- 英文是默认语言,地址不带前缀:
/、/posts、/about - 中文带前缀:
/zh/、/zh/posts、/zh/about - 文章不强制成对翻译:写中文就只在中文站出现,不会在英文站留一个空壳
- RSS、sitemap、OG 图各语言一份,带 hreflang
第一步:配置语言
export default defineConfig({
i18n: {
locales: ["en", "zh"],
defaultLocale: "en",
routing: {
prefixDefaultLocale: false, // 默认语言不带前缀
},
},
});astro.config.ts
prefixDefaultLocale: false 决定了英文在根路径。但要注意:这个配置本身不会生成任何页面,它只负责让 Astro.currentLocale 和 getRelativeLocaleUrl() 正确工作。页面还得自己产出。
第二步:把页面挪进 [...locale]
这是整个改造的核心。目录从:
src/pages/
├── index.astro
├── about.astro
└── posts/[...page].astro
变成:
src/pages/
├── 404.astro ← 不参与多语言
└── [...locale]/
├── index.astro
├── about.astro
└── posts/[...page].astro
然后每个页面加一个 getStaticPaths,把语言当路由参数产出两次:
export function getStaticPaths() {
return LOCALES.map(locale => ({
params: { locale: localeParam(locale) },
}));
}
关键在 localeParam():默认语言返回 undefined。rest 参数拿到 undefined 时不会占位,页面就落在根路径。
export const LOCALES = ["en", "zh"] as const;
export const DEFAULT_LOCALE = "en";
export function localeParam(locale: Locale): string | undefined {
return locale === DEFAULT_LOCALE ? undefined : locale;
}src/utils/locale.ts
一条路由里可以有两个 rest 参数
改到分页路由时我卡了一下:[...locale]/posts/[...page].astro,一条路径里出现两个 rest 参数,这合法吗?
结论是可以。静态构建时路径完全由 getStaticPaths() 决定,rest 参数的匹配规则只在开发服务器做路径解析时才起作用。分页路由这样写:
export const getStaticPaths = (async ({ paginate }) => {
const allPosts = await getCollection("posts", ({ data }) => !data.draft);
return LOCALES.flatMap(locale =>
paginate(
getSortedPosts(allPosts.filter(({ id }) => getEntryLocale(id) === locale)),
{
params: { locale: localeParam(locale) },
pageSize: config.posts.perPage,
}
)
);
}) satisfies GetStaticPaths;
paginate() 的第二个参数支持 params,这样分页和语言两个维度就叠起来了。
第三步:内容按语言分目录
文章放进语言子目录:
src/content/posts/
├── en/rebuild-blog-with-astro.md
└── zh/rebuild-blog-with-astro.md
语言直接从内容 id 解析:
export function getEntryLocale(id: string): Locale {
const [first] = id.split("/");
return isLocale(first) ? first : DEFAULT_LOCALE;
}
各个列表页按当前语言过滤,标签也按语言分别统计——中文站的标签云里不会混进英文标签。
有个细节要处理:AstroPaper 默认会把内容的子目录带进 URL,中文文章会变成 /zh/posts/zh/xxx,多了一层。在生成 slug 的工具里把开头的语言段丢掉就行:
.filter((segment, index) => !(index === 0 && isLocale(segment)))
第四步:语言切换按钮
切换逻辑有个现实问题:同一篇文章不一定有另一种语言的版本。如果无脑把当前路径替换语言前缀,从中文文章切到英文就会 404。
我的处理是:文章详情页切换语言时,回到那个语言的文章列表;其他页面按原路径切换。
const switchLocaleUrl = (target: Locale) => {
const isPostDetail =
currentPath.startsWith("/posts/") && currentPath !== "/posts";
const path = isPostDetail ? "posts" : currentPath.replace(/^\//, "");
return getRelativeLocaleUrl(target, path);
};
第五步:SEO
每个页面输出各语言版本的 hreflang,外加一个 x-default 指向默认语言:
{
alternateLinks.map(link => (
<link rel="alternate" hreflang={link.hreflang} href={link.href} />
))
}
sitemap 也要告诉它有几种语言:
sitemap({
i18n: {
defaultLocale: "en",
locales: { en: "en", zh: "zh" },
},
}),
踩的三个坑
1. MissingLocaleError
一开始只改了主题配置里的 lang: "zh",没动 astro.config.ts 里的 i18n.locales。构建到生成 RSS 时报错:
MissingLocaleError: The locale/path `zh` does not exist in the configured `i18n.locales`.
getRelativeLocaleUrl() 只认 i18n.locales 里声明过的语言,主题层的 lang 字段跟它是两回事。
2. 示例文章删不干净
内容集合的 glob 规则是 **/[^_]*.{md,mdx}——下划线开头的文件会被排除,但下划线开头的目录里的普通文件照样会被收进来。主题的 _releases/ 里那几篇版本说明就这么混进了文章列表。
3. 日期格式和语言无关
日期组件里写死了 dayjs.format("D MMM, YYYY"),中文页面也显示 17 Sep, 2026。改成按语言走 Intl:
const date = new Intl.DateTimeFormat(LOCALE_TAGS[locale], {
year: "numeric",
month: "short",
day: "numeric",
}).format(datetime.toDate());
中文页变成 2026年9月17日,英文页保持 Sep 17, 2026。归档页的月份名同理。
小结
整套改造下来,需要动的是路由层和内容组织,组件层基本不用碰——前提是主题本身已经用 getRelativeLocaleUrl() 和 useTranslations() 写好了。
最后构建出 19 个页面,两种语言各一套,包含各自的 RSS 和 OG 图。如果你也在用 AstroPaper 或结构类似的 Astro 主题,[...locale] + getStaticPaths 返回 undefined 参数这个组合,是改动量最小的做法。