i18n Skills
本说明主要描述当前项目里多语言消息文件的推荐用法
Overview
重点覆盖:
- 运行时
i18n.ts如何加载消息 dev-scripts如何检查翻译文件- 基础翻译与业务翻译如何拆分
- 页面层、copy builder、组件层的职责边界
职责边界
apps/*/src/i18n.ts- 负责线上运行时加载消息
- 决定哪些目录会真正参与页面渲染
dev-scripts check-translations- 负责开发期检查翻译 key 是否缺失、命名空间是否存在、不同语言是否一致
- 不决定线上实际加载行为
- 页面层 / copy builder
- 负责取翻译并组装结构化 copy
- 在翻译文件和 UI 组件之间建立缓冲层
- 共享组件
- 只消费结构化 copy
- 不直接依赖翻译框架 API
结论:
- 运行时配置和 CLI 检查配置可以相关,但不要强绑定为同一份配置。
- 应用自己决定线上要加载哪些消息目录。
- CLI 自己决定开发期要检查哪些消息文件。
- 页面和 builder 决定“如何把翻译组织成 UI 可消费的数据”。
- 组件层不应反向知道 namespace 细节。
推荐目录结构
基础翻译放根目录:
messages/
en.json
zh.json业务翻译按业务目录拆分:
messages/
en.json
zh.json
biz/
answerYou.en.json
answerYou.zh.json
biz2/
dcs/
foo.en.json
foo.zh.json推荐原则:
- 基础公共文案放
messages/{locale}.json - 业务域文案拆到独立目录
- 文件名统一以
.{locale}.json结尾
运行时用法
当前推荐做法是:
i18n.ts保留在应用侧- 通用消息读取逻辑下沉到
@windrun-huaiin/lib/i18n-server - 应用只负责声明运行时消息源白名单
当前 ddaas 的写法可参考 i18n.ts。
示例:
import { getRequestConfig } from 'next-intl/server';
import path from 'path';
import { loadMergedLocaleMessages, type RuntimeMessageSource } from '@windrun-huaiin/lib/i18n-server';
const messagesRoot = path.join(process.cwd(), 'messages');
const runtimeMessageSources: readonly RuntimeMessageSource[] = [
{ type: 'file' },
{ type: 'dir', path: 'biz' },
{ type: 'dir', path: 'biz2/dcs' },
];
export default getRequestConfig(async ({ requestLocale }) => {
const locale = requestLocale ?? 'en';
return {
locale,
messages: await loadMergedLocaleMessages({
locale,
messagesRoot,
sources: runtimeMessageSources,
}),
};
});说明:
{ type: 'file' }表示加载messages/{locale}.json{ type: 'dir', path: 'biz' }表示递归加载messages/biz/**/*.${locale}.json{ type: 'dir', path: 'biz2/dcs' }表示递归加载messages/biz2/dcs/**/*.${locale}.json
运行时建议使用白名单目录,不建议直接扫描整个 messages 目录。
Copy Builder 设计
核心理念
像 question-copy.ts 这样的文件,本质上是翻译系统里的缓冲层。
它的价值不在于“多包一层”,而在于把三件事拆开:
- 翻译文件负责存储消息
- builder 负责把消息映射成结构化 copy
- 组件只负责消费 copy 并渲染 UI
这样做的直接收益是:
- 翻译 key 不会在页面和深层组件之间横向扩散
- 组件不需要知道消息来自哪个 namespace
- 页面结构调整时,改动主要收敛在 builder
- 后续扩语言时,理想情况下只补消息文件,不回头改组件逻辑
推荐职责边界
推荐的数据流是:
- Page Server Component 或服务端路由层获取翻译
- copy builder / copy adapter 组装结构化文案对象
- Client Component 或共享组件只接收最小必要 copy
不推荐的数据流是:
- 页面层零散地把大量
t('...')一项项传到深层组件 - 深层共享组件自己再调用翻译 API
- 组件内部写语言分支,例如
isZh ? '中文' : 'English'
结构化 copy 的价值
推荐像 question-copy.ts 一样,定义清晰的 copy 类型,例如:
QuestionFormCopyQuestionEditorCopyQuestionImportCopyQuestionListItemCopyQuestionPreviewCopy
这类类型的意义是:
- 限制消息传播范围
- 让组件只依赖自己真正需要的字段
- 把“字段命名”和“页面 UI 结构”固定下来
- 让消息结构调整集中发生在 builder,而不是扩散到组件树
Builder 应做什么
builder 应该只做消息映射和结构适配:
- 从一个或多个 namespace 中读取消息
- 组装为清晰的 copy 对象
- 为页面层或共享组件提供稳定的数据结构
builder 不应该承担:
- 复杂业务逻辑
- 运行时状态分支
- 语言判断
- 与 UI 行为强耦合的控制逻辑
builder 是消息适配层,不是业务处理层。
消息组织建议
推荐把消息按页面或功能块分组,例如:
faqPage.questionsListfaqPage.questionsImportfaqPage.questionEditfaqPage.questionCreatefaqPage.questionFormfaqPage.questionPreviewfaqPage.questionDetail
这样做的好处是:
- 页面消息和共享消息边界清晰
- AI 和人工都更容易定位问题
- 消息文件不会碎得过度
同时建议:
- 页面专属消息和共享组件消息分开组织
- 像
questionForm这类会被多个页面复用的文案,单独作为共享消息块存在 - 不要把层级做得过深,保持“页面一级 + 局部子块”的结构即可
CLI 检查用法
dev-scripts 现在支持通过 messageGlobs 扩展检查范围。
配置文件参考 dev-scripts.config.json。
示例:
{
"i18n": {
"locales": ["en", "zh"],
"defaultLocale": "en",
"messageRoot": "messages",
"messageGlobs": [
"messages/{locale}.json",
"messages/biz/*.{locale}.json",
"messages/biz2/dcs/**/*.{locale}.json"
]
}
}说明:
{locale}是占位符,运行时会替换成en、zhcheck-translations会把同一 locale 命中的多个文件合并后再检查- 这样基础翻译和业务翻译会被视为一个完整消息集合
如果项目还依赖 @windrun-huaiin/* 底层包,并且这些包内部自己消费翻译,可以在 scan 中开启补扫:
{
"scan": {
"include": ["src/**/*.{tsx,ts,jsx,js}"],
"exclude": ["src/**/*.d.ts", "src/**/*.test.ts", "src/**/*.test.tsx", "node_modules/**"],
"includeWindrunPackages": true,
"namespaceWhitelist": [
"usage",
"faq",
"moneyPrice",
"features",
"tips",
"seoContent",
"cta",
"languageDetection",
"footer",
"clerk",
"fuma",
"pricePlan"
],
"whitelist": []
}
}说明:
includeWindrunPackages: true会把相关 windrun 包源码也纳入 AST 扫描- 当前实现的补扫粒度是“识别到相关包后,补扫该包整个
src/目录” - 因此一些底层包里的稳定 namespace 可能长期存在于报告中,更适合放进
namespaceWhitelist
CLI 能力边界
先看能力矩阵:
| 场景 | 例子 | 问题类型 | check-translations 是否支持 | 说明 |
|---|---|---|---|---|
| 单文件,代码里用了,翻译里没有 | messages/en.json 缺少 common.title,但代码调用了 t('title') | 翻译缺失 | 支持 | 会检查 namespace 和完整 key |
| 单文件,翻译里有,代码里没有 | messages/en.json 里有 legacy.banner,代码完全没用到 | 翻译冗余 | 支持 | 会报告 unused namespace / unused key |
| 多文件,代码里用了,合并后的翻译集合里没有 | messages/en.json + messages/biz/**/*.en.json 合并后仍缺少某 key | 翻译缺失 | 支持 | 先合并同一 locale 的所有命中文件,再检查 |
| 多文件,翻译里有,代码里没有 | 某个业务翻译文件里定义了废弃 key,但代码没有引用 | 翻译冗余 | 支持 | 也是基于合并后的完整翻译集合判断 |
| 多语言之间,某语言比其他语言少 key | zh 缺少 en 中已有的 key | 多语言翻译缺失 | 支持 | 会在 locale 一致性检查里体现 |
| 多语言之间,某语言比其他语言多 key | en 比 zh 多出一些 key | 多语言翻译冗余 | 支持 | 会报告某 locale 独有 key |
结论:
check-translations是统一的翻译检查命令- 它覆盖翻译缺失、翻译冗余、多语言不一致三类问题
- 不管是单文件还是多文件,差别只在“检查前如何读取并合并翻译数据”,不是检查模型本身不同
核心检查模型
check-translations 的工作流可以统一理解为三步:
- 读取翻译数据
- AST 扫描代码中的翻译使用情况
- 将“代码使用结果”和“翻译集合”以及“各 locale 之间的 key 集合”做比对
因此它最终回答的是三类问题:
- 翻译缺失:代码里用了,但翻译集合里没有
- 翻译冗余:翻译集合里有,但代码里没有
- 多语言不一致:某个 locale 相比其他 locale 多出或缺少 key
单文件与多文件
适用范围:
- 单文件模式,例如
messages/en.json - 多文件模式,例如
messages/{locale}.json加messages/biz/**/*.${locale}.json
对多文件模式的语义:
- 它不是逐文件独立检查
- 它会先把同一 locale 命中的多个文件深合并
- 然后把合并后的结果视为该 locale 的完整翻译集合再做统一检查
因此,多文件模式下它检查的是:
- 整体翻译是否缺失
- 整体翻译是否冗余
- 各语言的整体翻译集合是否一致
它具体检查什么
- 扫描代码里使用到的 namespace
- 扫描代码里使用到的完整翻译 key
- 检查某个 locale 是否缺少代码实际使用的 namespace
- 检查某个 locale 是否缺少代码实际使用的 key
- 检查某个 locale 是否存在未被代码使用的 namespace
- 检查某个 locale 是否存在未被代码使用的 key
- 检查不同 locale 之间是否存在某语言独有的 key
对于“未使用 key”的判断,还有一个重要规则:
- 如果代码已经使用了父级 key,例如
a.b - 那么翻译里的
a.b.c、a.b.0.x会被视为已覆盖 - 这类子叶子不会再被判定为 unused
这样可以兼容底层组件一次性读取整个对象或数组的用法,例如:
gallery.promptsmoneyPrice.subscription.planscredit.buckets.labels
风险边界
脚本基于 AST 静态分析,不是运行时追踪,因此以下场景只会做保守处理:
- 动态 namespace
- 动态 key
- 模板字符串但无法静态还原完整 key
- 经过复杂包装后无法静态解析的翻译调用
保守策略表现为:
check-translations在这类场景下会提示检查可能不完整
因此可以这样理解:
- 对常规、静态可分析的 next-intl 用法,这套脚本可以承担日常翻译检查职责
- 对拆分到自定义目录的多文件翻译源,这套脚本具备完整的检查能力
- 对 windrun 底层包这类稳定 namespace,建议结合
namespaceWhitelist控制报告噪音
当前实现下的业务约束
在当前不继续增强 AST 作用域解析的前提下,业务代码如果希望稳定通过 check-translations,建议遵守以下约束。
命名约束:
- 同一文件内,不要在多个函数里重复使用同名 translator 变量,例如都叫
t - 同一文件内如果存在多个 namespace translator,必须使用不同变量名,例如
formT、previewT、listT、importT、editT - 即使两个函数碰巧使用同一个 namespace,也不要为了省事统一命名成
t
文件组织约束:
- 同一业务域在翻译规模还不大时,可以先集中在一个大的 copy 文件中统一缓冲,不必为了形式上的拆分把文件切得很碎
- 这种集中式写法的前提是:同一文件内的 translator 变量名必须清晰区分,例如
formT、previewT、listT、importT、editT - 如果一个文件中的业务块、namespace、翻译规模持续增长,已经明显影响阅读、排查或扫描稳定性,再拆成多个小文件
- 是否拆分应以维护成本、可读性、排查效率为准,而不是机械追求“一个文件只能有一个主题”
这里需要补充一个平衡点:
question-copy.ts这种“围绕同一业务域、集中管理多个相关 copy 类型”的模式本身是合理的- 问题不在于“有 builder 文件”,而在于“同一文件里 translator 命名混乱、namespace 过多且缺乏明确边界”
- 也就是说,可以有聚合式 copy builder,并且在业务规模可控时这是推荐做法,但仍然应保持清晰的命名和职责边界
调用方式约束:
- 优先直接使用
const formT = await getTranslations({ locale, namespace: 'faqPage.questionForm' })这类显式绑定后就地调用 - 避免把 translator 变量跨多层函数传递后再调用
- 避免把 translator 再包一层复杂 helper 后间接调用
- 避免把多个不同 namespace 的 translator 混合塞进同一个对象后统一消费
key 使用约束:
- 优先使用静态 key,例如
t('answers.label') - 少用动态 key、复杂模板字符串 key、运行时拼接 key
- 如果一次性读取整个对象或数组,例如
gallery.prompts、moneyPrice.subscription.plans,应保持路径稳定且语义清晰
namespace 设计约束:
- 一个函数最好只围绕一个主 namespace 展开
- 如果必须同时使用多个 namespace,变量名应直接表达语义,而不是
t1、t2这类弱语义命名 - 对 windrun 底层稳定 namespace,优先通过
namespaceWhitelist管理,而不是在业务文件里做特殊兼容
配置使用约束:
- 精确误判使用
scan.whitelist - 整块稳定基础翻译使用
scan.namespaceWhitelist - 只有在底层包内部确实会消费翻译时,才开启
includeWindrunPackages
推荐风格可以概括为:
- 一个业务域可以先集中在一个 copy 文件
- 一个函数一个主 translator
- 一个 namespace 一个明确变量名
- 允许围绕同一业务域建立 copy builder 缓冲层
- 当同一文件已经明显失控时,再拆分为多个更小的 copy 文件
合并规则
无论是运行时还是 CLI 检查,当前都使用深合并:
- 同名对象节点会递归合并
- 非对象值以后加载的内容覆盖先加载的内容
因此建议:
- 公共 key 放基础文件
- 业务文件只补充自己负责的 namespace
- 尽量避免在多个文件里重复定义同一叶子 key
新增业务目录时怎么做
如果以后新增:
messages/biz2/dcs/*.en.json
messages/biz2/dcs/*.zh.json需要做两件事。
- 运行时
i18n.ts增加目录白名单
const runtimeMessageSources = [
{ type: 'file' },
{ type: 'dir', path: 'biz' },
{ type: 'dir', path: 'biz2/dcs' },
] as const;dev-scripts.config.json增加检查范围
"messageGlobs": [
"messages/{locale}.json",
"messages/biz/*.{locale}.json",
"messages/biz2/dcs/**/*.{locale}.json"
]注意事项
@windrun-huaiin/lib/i18n-server是服务端子入口,内部依赖fs/promises,不要在客户端组件中使用。apps/*/src/i18n.ts仍然应该保留在应用内,它是 next-intl 的应用入口,不建议整体下沉。check-translations对多文件翻译源是按“合并后的完整翻译集合”进行分析,不是逐文件独立检查。scan.whitelist是精确 key 级白名单,scan.namespaceWhitelist是 namespace 级白名单。- 开启
includeWindrunPackages后,当前实现会补扫相关 windrun 包的整个src/目录,而不是只补扫单个被 import 的文件。
推荐流程
- 公共文案放
messages/{locale}.json - 新业务文案放独立目录,例如
messages/biz、messages/biz2/dcs - 在
i18n.ts里把新目录加入运行时白名单 - 在
dev-scripts.config.json里把新目录加入messageGlobs - 如果项目依赖 windrun 底层组件并由它们内部消费翻译,按需开启
includeWindrunPackages - 将长期稳定、不希望反复检查的底层 namespace 放入
namespaceWhitelist - 执行
pnpm check-translations统一检查翻译缺失、翻译冗余和多语言一致性