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 属性读入。