Astro 的 i18n 表面上极其简单:配置里加 locales 和 defaultLocale,页面放进 src/pages/zh/,就完事了。
问题都出现在之后。下面这四个坑花掉了我最多时间。
一、官方示例的语言切换器只会跳首页
Astro 文档里的 i18n recipe 提供的 LanguagePicker,核心就是这一行:
<a href={`/${lang}/`}>...</a>
也就是说,读者正在看 /services,点一下「中文」,会被送回中文首页,当前页面丢了。五个页面的站点只是有点烦;四十篇文章的站点就是直接流失。
正确做法是:先剥掉当前的 locale 前缀,保留剩余路径,再重新加前缀。
export function switchLocalePath(targetLang: Locale, currentPathname: string): string {
return localizedPath(targetLang, stripLocale(currentPathname));
}
但这要求两种语言使用同一个 slug。所以这个规则必须一开始就定死:en/my-post.md 对应 zh/my-post.md。一旦你开始翻译 slug(zh/wo-de-wen-zhang.md),就必须额外维护一张对照表,而那张表迟早会漂。
二、Astro 5 之后 content entry 没有 slug 字段了
如果你学 content collections 是在 Astro 5 之前,会习惯性写 entry.slug。换成 content layer + glob() loader 之后,拿到的是 entry.id,它是相对 loader base 的路径,包含语言目录。
所以 src/content/blog/en/my-post.md 得到的 id 是 "en/my-post",语言和 slug 得自己拆:
const [lang, ...rest] = entry.id.split('/');
const slug = rest.join('/');
事情很小,但 2025 年之前的中文教程全都是旧 API。
三、Astro 7 把 compressHTML 的默认值改了
这个最阴。Astro 7 把 compressHTML 的默认值改成 'jsx',也就是按 JSX 规则裁掉空白:
<span>hello</span>
<em>world</em>
渲染出来是 helloworld,不是 hello world。
纯中文站点基本无感。但如果你的站点是英文正文(行内元素之间的空格是真实的词边界)混着中文,就会出现时有时无的缺空格,看起来像错别字。
想要旧的 HTML 语义行为,就显式设 compressHTML: true。
四、hreflang 是三行你一定会漏的标签
两种语言的每一个页面都要写全,而且必须包含指向自己的那条:
<link rel="alternate" hreflang="en" href="https://example.com/services" />
<link rel="alternate" hreflang="zh-Hans" href="https://example.com/zh/services" />
<link rel="alternate" hreflang="x-default" href="https://example.com/services" />
两个特别容易错的细节:
- 用
zh-Hans,不是zh。zh在简体/繁体之间是有歧义的,面向简体内容 Google 推荐zh-Hans。 - 不要按
Accept-Language或 IP 自动跳转。那会打断 hreflang 的互链关系,最坏情况下会被判定为 cloaking。要切换就在页头放一个手动链接。
如果重来一次
我会在写第一篇文章之前,先把「同文件名 = 同一篇内容」这个规则定死。剩下的东西——语言切换器、hreflang、sitemap、canonical——全都由这一个决定自动推导出来,不用再单独操心。