国际化
Paraglide 消息、英文在 / 与中文在 /zh、语言的识别与记忆、邮件和内容的本地化,以及如何新增一种语言。
ShipKit 自带英文和中文。翻译用的是 Paraglide JS:它把每条消息编译成一个带类型的小函数,所以缺了 key 就是类型错误,页面也只会下载它用到的文案。
消息
所有界面文案都放在两个扁平的 JSON 文件里:
messages/en.json
messages/zh.json
key 是扁平的,按区域加前缀,例如 auth_login_title、admin_emails_title、email_welcome_subject、setting_account_retention。参数用花括号:
{
"email_welcome_subject": "欢迎加入 {app}"
}
在组件里导入 m,然后调用对应的 key:
import { m } from '@/paraglide/messages'
<h1>{m.auth_login_title()}</h1>
<p>{m.email_welcome_subject({ app: appConfig.name })}</p>
src/paraglide/ 下的编译产物由 Paraglide 的 Vite 插件在开发服务器或构建运行时生成,使用的是 vite.config.ts 里的配置。不要手动运行 paraglide-js compile:它会忽略这些配置(cookie 名、URL 策略),语言路由会一直错乱,直到重启开发服务器。项目设置(基础语言和语言列表)在 project.inlang/settings.json。
为什么前缀很重要
前缀同时也决定了打包怎么拆分。vite.config.ts 按前缀把消息分组成不同的 chunk:admin_* 跟着管理后台一起加载,marketing_、blog_、docs_、home_、footer_、seo_ 和 legal_ 跟着公开站点加载。访问首页的人不会下载后台的文案。如果你新增的区域文案很多,给它的 key 起一个前缀,并考虑在那里加一个分组。
URL
英文不带前缀,中文在 /zh 下:
| 英文 | 中文 |
|---|---|
/pricing | /zh/pricing |
/dashboard | /zh/dashboard |
/ | /zh |
路由树里没有语言段。路由器(src/router.tsx)会把传入 URL 的前缀去掉、给传出的 URL 加回去,所以路由文件只写一份,读者用中文时 <Link to="/pricing"> 会自动指向 /zh/pricing。在路由器之外拼 href 时,用 @/paraglide/runtime 里的 localizeHref()。
语言如何确定
Paraglide 的策略依次是:url、cookie、浏览器首选语言、基础语言(en)。由于不带前缀的 URL 一律是英文,其余信号由 src/routes/__root.tsx 中 <head> 顶部的一段小脚本处理:在不带前缀的页面上,如果 APP_LOCALE cookie 是 zh,或者首次访问(没有 cookie)且浏览器语言是中文,就跳转到 /zh/...。这件事放在浏览器里做,是因为营销页是预渲染的静态文件,没有服务端代码能看到这个请求。
在服务端,src/server.ts 用 paraglideMiddleware 包住每个请求,因此服务端渲染时 getLocale() 能拿到正确的语言。
切换语言
营销页顶栏的语言菜单和设置里的语言一栏都会调用 setLocale():它写入 APP_LOCALE cookie,然后用新语言重新加载页面。设置里的那一栏还会把选择保存到用户的 locale 列,邮件就是按这个语言发送的。
SEO
每个页面都会输出指向自身的 canonical,外加每种语言的 hreflang 备选链接和一个 x-default(在 __root.tsx 里)。站点地图生成脚本(scripts/generate-sitemap.ts)会把每个英文页面和它的 /zh 版本一起列出。
Server function 里不做本地化
Server function 的调用地址是 /_serverFn/...,这个 URL 没有语言段。在里面用 Paraglide,语言会从 cookie 或 Accept-Language 推断,而不是调用方正在看的页面——通过链接进入 /zh 的访客会拿到英文文案。规则是:
- 服务端代码返回 key 和值(错误码、金额、状态)。
- 组件调用
m.*把它们变成文字。
删除账号就是一个现成的例子。服务端的功能守卫用错误码拒绝(blockAccountDeletion('ACTIVE_SUBSCRIPTION')),设置组件再把它映射成文案:
switch (code) {
case 'ACTIVE_SUBSCRIPTION':
toast.error(m.settings_danger_active_subscription())
break
default:
toast.error(m.auth_error_generic())
}
管理后台渲染的注册表(比如设置项和权限)出于同样的原因,把标签写成函数(label: () => m.setting_account_retention()),并在浏览器里读取。首页配置 src/config/landing.ts 也一样:每个字符串都是 thunk,因为这个模块在每个 Worker isolate 里只执行一次,而语言是按请求变化的。
例外是离开应用的文字:邮件和通知。它们按收件人的 user.locale 显式本地化,与请求无关:
m.email_welcome_subject({ app }, { locale })
user.locale 在注册时从语言 cookie 写入,之后可以在设置里修改,详见邮件与通知。站内通知只保存类型和参数,打开铃铛时再用查看者当前的语言渲染。
本地化内容
博客、文档和法律页面都是 MDX 文件,每种语言一个:privacy.en.mdx 和 privacy.zh.mdx,或者 getting-started.mdx 旁边放一个 getting-started.zh.mdx。缺少译文时先回退到不带语言后缀的文件,再回退到英文,所以页面不会因为没翻译就从列表里消失。详见内容。
新增一种语言
新增第三种语言不只是加一个 JSON 文件,因为有些地方写死了现有的两种语言。以德语(de)为例:
- 在
project.inlang/settings.json的locales里加上"de"。 - 新建
messages/de.json,包含messages/en.json里的所有 key。 - 在
vite.config.ts的urlPatterns里、en那一项之前加上['de', '/de/:path(.*)?'],并在预渲染的pages里加上{ path: '/de/' }。 - 在
src/components/LocaleSwitcher.tsx和src/features/auth/components/account-section.tsx的localeNames里加上这种语言的显示名。 - 修改写死了
zh的地方:src/routes/__root.tsx里的跳转脚本和og:localescripts/generate-sitemap.tssrc/config/private-paths.ts,其中的/zh版本让私有页面不被预渲染、也不进robots.txtsrc/core/email/templates/receipt.ts,数字和日期格式src/core/payment/stripe.ts里的stripeLocale(),决定 Stripe 结账页的语言src/features/billing/server/events.ts里的'en' | 'zh'类型
- 为法律页面以及你要翻译的文档、文章添加
.de.mdx文件。 - 扩展遍历
['en', 'zh']的测试(src/core/settings/i18n.test.ts、src/core/email/templates/templates.test.ts)。
然后运行 bun run typecheck 和 bun run test,再用 bun run dev 把 /de 下的页面点一遍。