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——全都由这一个决定自动推导出来,不用再单独操心。