EnglishSwitch to English
打开导航

国际化

按语言前缀组织内容文件,用构建期消息目录——这是应用约定,刻意不进框架核心。

适用于 v1.0.0-alpha.5 · 更新于

i18n 住在哪

国际化是应用的事,不是框架的事。Router 只负责路由、渲染上下文与文档所有权:它不自带消息目录、不做 locale 协商、不做复数与日期格式化。这条边界是刻意的(决策 D6,#1383):消息目录是一条带成熟生态选项的构建期数据管线,框架若自带一份,要么把某个格式冻结进契约,要么再长出第二套。

两套约定足以覆盖全部工作,且都能用框架已有能力搭出来:

  • 语言前缀文件:路由作为文档服务的那些内容。
  • 消息目录:组件插值用的那些字符串。

语言前缀文件约定

先声明构建要展开的语言,然后每种语言一个文件:

// openelement.config.ts
import { openElement } from '@openelement/router/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    openElement({
      i18n: {
        // 构建展开的前缀;第一项是默认语言。
        locales: ['en', 'zh'],
        defaultLocale: 'en',
      },
    }),
  ],
});

有了这行声明,/guide/api 就是默认语言的页面,/zh/guide/api 是中文副本。构建会为每个非默认语言把路由各渲染一份到带前缀的路径,PagePropsContext.locale 带着本次渲染解析出的语言,props 投影器据此选记录:

import { definePage, type PagePropsContext } from '@openelement/router';
import Page from './page.tsx';

const records = {
  en: { title: 'API routes', body: 'Applications own request-time handlers.' },
  zh: { title: 'API 路由', body: '请求时处理器由应用自己拥有。' },
} as const;

function projectProps(context: PagePropsContext) {
  // 路由从请求路径解析语言;选哪条记录是应用策略。
  const locale = context.locale === 'zh' ? 'zh' : 'en';
  return records[locale];
}

export default definePage(Page, { props: projectProps });

文件命名是这套约定的另一半,而它归你的 loader 管,不归 router 管:本站把 api.md 与 api.zh.md 并排放,由 collection loader 把后缀解析成 locale 字段(www/content-collections.ts)。译者与原文在同一目录里改,后缀方案更好;两棵树分开评审,目录方案(content/zh/api.md)更好。无论哪种,router 只要一件事——每种语言都有对应路由——上面那条 i18n 声明已经为静态渲染页面办到。

三条规矩让两半始终对齐:

  • 内部链接走同一个 helper。 每条站内链接都要带上当前语言前缀。zh 内容里一条裸 /guide/api 会把读者扔回英文树。前缀逻辑集中一处(stripLocalePrefix 或它的 localizePath 兄弟),别让任何页面手写。
  • 别用「两字母段」猜语言。 显式识别声明的语言集合;/ui/... 与 /go/... 是栏目,不是语言。
  • 默认语言不加前缀。 同一页面两个 URL 会分散搜索权重与统计口径;不带前缀的那条保持 canonical。

消息目录

组件插值用的字符串需要目录,而正确答案是那个最无聊的标准。构建期做:static-first 框架会预渲染页面,所以目录可以编译进各语言 bundle,不必运行时拉取。两种形态都合适:

  • ICU MessageFormat(npm:intl-messageformat)——多数翻译工具本来就能读写这套格式。消息自带复数、select 与占位符类型,解析器是个小依赖,且只有本地化 bundle 才拉它。
  • paraglide——一个编译器,把消息函数变成按语言的普通模块:未用到的消息从 bundle 里 tree-shake 掉,每个调用点都对目录做类型检查。它的 static-first 姿态与预渲染站点天然吻合,翻译量长大后这里是推荐默认。

两者都不该住进组件。由一个模块独占查找与格式化,组件调用它:

// app/i18n/messages.ts —— 应用代码,不是框架代码。
const catalogs = {
  en: {
    'cart.items': '{count, plural, one {# item} other {# items}}',
    'cart.total': 'Total: {amount}',
  },
  zh: {
    'cart.items': '{count, plural, other {# 件商品}}',
    'cart.total': '合计:{amount}',
  },
} as const;

export function messages(locale: string) {
  return locale === 'zh' ? catalogs.zh : catalogs.en;
}
// app/components/cart-summary.tsx(节选)
import { element, OpenElement, property } from '@openelement/element';
import { messages } from '../i18n/messages.ts';

@element('cart-summary')
export default class CartSummary extends OpenElement {
  @property({ reflect: false })
  locale = 'en';

  @property({ reflect: false, type: Number, attribute: false })
  count = 0;

  render() {
    const copy = messages(this.locale);
    return <p>{copy['cart.items'].replace('{count}', String(this.count))}</p>;
  }
}

上面那条 {count, plural, ...} 是交给格式化器的消息源文;节选只展示调用点的形状,真实实现会把原始 pattern 与参数一起交给编译后的格式化器(或 paraglide 生成的函数),而不是手工替换占位符。

框架真正贡献的是语言值的到达方式:服务端渲染与预渲染页面走 PagePropsContext.locale,island 则通过显式属性收到同一个值——island 自己从不读 URL,所以「语言从哪来」只有一个归属者。

什么不进框架

这条边界与 MDX 给内容管线画的边界是同一条。框架不会:

  • 自带消息格式、目录加载器或复数规则——挑合用生态里的那套;
  • 替你协商 Accept-Language——从路径解析语言属于路由;内容协商属于应用策略,请求时路由在 loader 里做即可;
  • 翻译你的内容文件——后缀还是目录,是 loader 的决定。

如果你开始想要「框架自带的目录状态」,答案通常和别的共享状态一样:自有模块上的 signal 或 context,经 props 投影器或 island 属性读入。

另见

  • 配置——i18n 声明与路由选项住在哪。
  • 路由与数据——语言前缀如何变成路由参数、loader 与 props。
  • MDX——本指南把内容管线先例套用到目录上。