跳到正文
Roland
返回

给 AstroPaper 加中英双语:[...locale] 路由实践

编辑此页

重建博客时选了 AstroPaper 主题,功能列表里写着 i18n ready。等真要做中英双语才发现,这个 “ready” 指的是界面文案可以翻译,而不是站点有多语言路由

主题里确实有 useTranslations(),组件也都用了 getRelativeLocaleUrl(),但 src/pages 下只有一套页面,构建出来永远是单语站。想要 / 出英文、/zh/ 出中文,路由这层得自己加。

目标

第一步:配置语言

export default defineConfig({
  i18n: {
    locales: ["en", "zh"],
    defaultLocale: "en",
    routing: {
      prefixDefaultLocale: false, // 默认语言不带前缀
    },
  },
});astro.config.ts

prefixDefaultLocale: false 决定了英文在根路径。但要注意:这个配置本身不会生成任何页面,它只负责让 Astro.currentLocalegetRelativeLocaleUrl() 正确工作。页面还得自己产出。

第二步:把页面挪进 [...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 参数这个组合,是改动量最小的做法。


编辑此页
分享这篇文章:

上一篇
博客重建:从 Gridsome 搬到 Astro