国际化

启用语言在 config/i18n.ts,文案在 locales JSON。按产品需求增加语言并翻译后即可切换,无需另接 i18n 框架。

Saavo 模板已经内置多语言支持,默认提供英语、简体中文和繁体中文。不同语言通过 URL 前缀进行区分,页面文案则统一从对应的 Locale 文件中读取。

开发自己的产品时,通常不需要再引入额外的 i18n 库。只需要在配置中启用需要支持的语言,并补充对应的翻译内容;在页面和服务端代码中,则可以直接使用项目提供的 Localized、localized 和 serverLocalized 输出多语言文案。

已有功能

模板初始化后,以下国际化功能开箱即用:

功能默认值说明
启用语言en、zh-Hans、zh-Hantconfig/i18n.ts
默认语言en无前缀路由
其他语言前缀/{code}/...例如 /zh-Hans/docs
文案文件locales/en.json 等en.json 是键和类型的权威来源
语言切换页面顶部的语言切换器在已启用语言之间切换

页面语言根据 URL 路径中的语言前缀确定,而 API 则通过 Accept-Language 请求头识别语言。虽然适配器中保留了 Locale Cookie 的配置名称,但当前语言检测流程并不会读取 Cookie,换句话说,切换语言仍应以 URL 和请求头为准,Cookie 不会影响语言切换。

除了页面文案,站点名称、套餐名称等配置内容也可以参与多语言处理。需要翻译的配置项可以使用 { value, key } 形式,其中 value 作为默认内容,key 则指向 Locale 文件中的对应翻译。

除此之外,浏览器端需要使用的配置统一从 @/libs/config/client 读取。deploy.ts、权益配置以及各类密钥等配置属于服务端信息,不应直接导入前端代码,以免被打包到浏览器中。

先体验语言切换

打开首页,使用语言切换器在英语和中文之间切换。默认语言的地址没有前缀:

/                  英语首页
/zh-Hans           简体中文首页
/zh-Hant           繁体中文首页
/docs              英语文档
/zh-Hans/docs      简体中文文档

语言切换器

模板注释里还预留了其他语种,包括 RTL 的 ar。它们默认未启用,不会出现在语言列表中。

提示

修改品牌名称时,需要同步更新所有已启用语言中的对应文案。如果只修改英语版本,中文页面仍然可能继续显示默认的 “Saavo Starter”。

配置多语言

语言列表在 config/i18n.ts 中配置,文案则统一保存在 locales/ 目录下,每个语言对应一个独立的 JSON 文件。

添加新语言

  1. 在 languages 中取消注释或新增 { code, name, direction }。
  2. 增加 locales/{code}.json,键与 locales/en.json 对齐。
  3. 如有文档或博客,补齐 content/docs/{code}/ 和对应的博客目录。
  4. 翻译法律页、邮件以及产品配置中使用的所有 key。
  5. 对于 RTL 语言,设置 direction: 'rtl'。

defaultLanguage 决定无语言前缀路由使用的默认语言,因此不要轻易修改。更改后,原本默认指向英语的无前缀页面会切换为新的默认语言,现有英文访问路径也可能因此发生变化。

完整步骤见:

添加一种语言

修改品牌和界面文案

如果只需要修改某一种语言的文案,直接编辑对应的 Locale JSON 文件即可。如果需要同步调整品牌名称等通用内容,则要同时更新所有已启用语言对应的翻译文件。例如,如果需要将产品名称从 “Saavo Starter” 改为 “My Product”,不仅需要更新 locales/en.json,还要同步修改 locales/zh-Hans.json 和 locales/zh-Hant.json。

配置中的 LocalizedText 可以同时包含 value 和 key。当配置了 key 时,系统会优先读取 Locale 文件中的翻译;如果当前语言缺少对应内容,则回退到 value 中的默认英文文案。

组件中需要展示给用户的固定文案,也应该统一放到 Locale 文件中管理,不要直接在 JSX 中硬编写中英文长句。例如,如果需要展示 “购买失败,请重试” 的错误提示,应该在 Locale 文件中定义一个 components.billing.purchaseFailed 键,而不是直接在组件中写死 “Purchase failed, please try again.”。

在业务代码中使用多语言

业务代码只用项目提供的三个 API,都从 @/components/server/Localized 导入,不要直接读 Locale JSON。

路径里的 server 只表示 <Localized /> 输出纯字符串、不需要额外交互,页面两端都能用;不是只能在服务端调用。localized() 同样可以在客户端组件和点击、提交等回调里用。只有 serverLocalized() 必须已经拿到 Ctx 或 WorkerCtx。

按文案出现的位置选 API:

位置API
JSX 里的可见文本<Localized />
aria-label、placeholder、alt、Toast、函数返回值localized()
页面 SEO、API 响应、邮件等已有请求上下文的地方serverLocalized()

JSX 文本:

import { Localized } from '@/components/server/Localized';

<h2>
    <Localized
        id="components.profileSettings.title"
        default="Profile Settings"
    />
</h2>

属性、Toast 等必须是字符串的位置:

import { localized } from '@/components/server/Localized';
import { toast } from 'react-toastify';

<img
    alt={localized({
        id: 'components.profileSettings.avatar.alt',
        default: 'User avatar',
    })}
/>

<input
    placeholder={localized({
        id: 'components.profileSettings.fullName.placeholder',
        default: 'Enter your full name',
    })}
/>

toast.error(localized({
    id: 'components.billing.purchaseFailed',
    default: 'Purchase failed, please refresh the page and try again.',
}));

服务端页面、API 和邮件已经持有 c 时:

import { serverLocalized } from '@/components/server/Localized';

const pageTitle = serverLocalized({
    c,
    id: 'pages.home.title',
    default: `${siteName} - A complete SaaS starter for Cloudflare`,
    values: { siteName },
});

id 必须是 locales/en.json 里已有的键,default 是缺翻译时的英文回退。邮件模板同样走 serverLocalized,例如 email.welcome.text。

需要插入姓名、数量等动态内容时,在 Locale 里保留完整句子,用 {name} 这类占位符,调用时通过 values 传入。不要把一句话拆成多段翻译再拼接。

localized({
    id: 'components.account.products.cadence.everyMonths',
    default: 'Every {count} months',
    values: { count },
});

日期、时间和金额不要写进翻译字符串里排版。先按当前语言格式化,再把结果放进 values。服务端用 c.var.getLocale();客户端组件一般由页面把 locale 传下来。金额用 formatMinorAmount。

import { formatMinorAmount } from '@/libs/utils/currency';

const date = new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
}).format(paidAt);

const amount = formatMinorAmount(totalMinor, currency, locale);

底层虽然基于 @intlify/core,但业务侧不要直接调它的 numberFormat / datetimeFormat,项目也没有为这两种格式单独配表。命名插值走上面的 values,数字和日期走 Intl。

翻译文案中的竖线要转义

目前 Saavo 的国际化功能内部使用 @intlify/core 处理翻译文本。这个库除了普通的变量插值,还支持复数等消息语法,其中 | 就有特殊含义。

在翻译文本中,未转义的 | 会被当作复数分支的分隔符,用来根据数量选择不同的文案。例如:

{
    "itemCount": "One item | {count} items"
}

传入不同数量时,会自动选择对应的内容:

// 输出 One item
localized({
    id: 'itemCount',
    values: { count: 1 },
});

// 输出 5 items
localized({
    id: 'itemCount',
    values: { count: 5 },
});

常见的写法有两种:

文案形式分支含义
单数文案 | 复数文案数量为 1 时使用前者,其他情况使用后者
零个文案 | 单数文案 | 复数文案分别处理 0、1 和其他数量

例如:

{
    "itemCount": "No items | One item | {count} items"
}

因此,如果你希望页面中真正显示 | 字符,就不能直接写在翻译文本中。

这个问题在普通文案里不太常见,但标题中很容易遇到,因为很多人习惯使用 | 分隔站点名称和页面标题。例如下面这种写法:

{
    "pages": {
        "home": {
            "title": "{siteName} | Product Overview"
        }
    }
}

这里的 | 会被翻译引擎识别为复数分支分隔符,最终可能只显示其中一部分内容。

如果需要显示真正的 |,应改为:

{
    "pages": {
        "home": {
            "title": "{siteName} {'|'} Product Overview"
        }
    }
}

这样 | 就会作为普通字符输出,不会再参与复数语法解析。

这个问题很容易在修改页面标题时遇到,所以使用 | 时需要特别注意。

上线检查

语言会直接出现在用户面前,上线前建议至少确认:

  • 已启用语言都能通过语言切换器访问,URL 前缀正确。
  • locales/en.json、zh-Hans.json、zh-Hant.json 的品牌名和关键页面文案已经换成自己的产品。
  • 新增语言的 JSON 键与英语对齐,没有缺 key。
  • 文档和博客在该语言下有内容,或你能接受缺页。
  • 法律页、邮件主题和正文已翻译。
  • 没有改动 defaultLanguage,除非已经确认无前缀路由的新语义。

建议每种启用语言都打开首页、登录、定价和一篇文档,而不是只看英语。

常见问题

接下来

根据接下来要开发的功能,可以继续阅读:

  • 想添加一种语言 → 添加一种语言
  • 想翻译组件里的硬编码文案 → 使用仓库的 i18n skill
  • 想写多语言文档 → 文档系统
  • 想写多语言博客 → 博客

大多数产品走完本章后,只需要维护 locale 文件和内容目录。

新页面从一开始就用 Localized 输出文案,避免事后再抽字符串。