国际化

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)为例:

  1. 在 project.inlang/settings.json 的 locales 里加上 "de"。
  2. 新建 messages/de.json,包含 messages/en.json 里的所有 key。
  3. 在 vite.config.ts 的 urlPatterns 里、en 那一项之前加上 ['de', '/de/:path(.*)?'],并在预渲染的 pages 里加上 { path: '/de/' }。
  4. 在 src/components/LocaleSwitcher.tsx 和 src/features/auth/components/account-section.tsx 的 localeNames 里加上这种语言的显示名。
  5. 修改写死了 zh 的地方:
    • src/routes/__root.tsx 里的跳转脚本和 og:locale
    • scripts/generate-sitemap.ts
    • src/config/private-paths.ts,其中的 /zh 版本让私有页面不被预渲染、也不进 robots.txt
    • src/core/email/templates/receipt.ts,数字和日期格式
    • src/core/payment/stripe.ts 里的 stripeLocale(),决定 Stripe 结账页的语言
    • src/features/billing/server/events.ts 里的 'en' | 'zh' 类型
  6. 为法律页面以及你要翻译的文档、文章添加 .de.mdx 文件。
  7. 扩展遍历 ['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 下的页面点一遍。