【免费下载链接】thinkrail
Vibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need
本文以apps/web/src/i18n子模块设计文档为主体,结合 ThinkRail 仓库中的协议定义、传输层、状态库与共享@thinkrail/locales实现,完整拆解“用户选择的语言 + 浏览器语言 + 内置词表 + 宿主本地语言文件”如何收敛成整个 Web 客户端唯一生效的一门语言。读完后你能掌握该模块的公开 API 边界、生效效应的触发条件、本地语言文件的校验规则,以及新增/调试一种语言时需要检查的每一个环节。
模块定位:一个“只推导、不渲染”的语言解析器
在 ThinkRail 的架构决策中,界面文案是具名键词表,而不是硬编码文本;用户可以通过向全局数据目录放入语言文件来新增或覆盖语言,而不是修改代码(见 architecture.md Decision 23)。这条决策把工作拆成了三段:宿主读取并校验文件、Web 端合并并选出生效语言、组件通过useT()渲染。
apps/web/src/i18n负责中间那一段。它的职责描述非常克制:
Turns the synced
AppConfig.localepreference, the browser's languages, the bundled catalogs, and the host's local files into the one active language of the shared i18next instance. Components never do this themselves: they calluseT().
也就是说,该模块不拥有i18next 实例(实例属于packages/locales的./react入口),不拥有语言设置界面(属于panels),也不读文件(属于宿主)。它只做一件事:从已有的状态推导出“现在该用哪个 tag、该装哪些覆盖词表”,然后把结果应用出去。
在 Web 模块清单里,它的定位被明确标注为“有状态、跨模块”的一格(apps/web/SPEC.md):
| 归属 | 内容 |
|---|---|
| 拥有 | languageOptions推导、生效 tag 推导、唯一一个应用效应 |
| 公开面(barrel) | useLocaleSync()、useSystemLanguages()、localeSignature,以及纯函数languageOptions、effectiveLocale、availableLocales、localOverlays |
| 允许依赖 | store、@thinkrail/locales、@thinkrail/contracts(仅类型) |
| 禁止 | 渲染 UI、读取文件、第二个 i18next 实例 |
公开面在 apps/web/src/i18n/index.ts 中一次性导出,纯函数与 hook 分文件组织:推导逻辑在languages.ts,副作用与订阅在useLocaleSync.ts,桌面菜单标签推送在nativeMenu.ts。
数据流全景:三个输入源,一个效应
整个链路可以概括为“两个状态、一个环境、一次应用”:
AppConfig.locale ─┐ locales.list 快照 ─┼─▶ appStore(locale, localLocales) ─┐ navigator.languages ───────────────────────────────────┼─▶ useLocaleSync │ │ effectiveLocale / localOverlays ┘ ▼ applyLocale(tag, overlays) document.documentElement.lang = tag pushNativeMenuLabels() │ ▼ useT() 的 Translator 被替换 → 全部消费者重渲染其中:
- 偏好:
AppConfig.locale,默认值为SYSTEM_LOCALE(即"system"),见 packages/contracts/src/domain.ts。它在 welcome 快照里随配置下发,落入 store 的locale字段(apps/web/src/store/appStore.ts)。 - 本地快照:
localLocales,类型为LocalLocalesSnapshot | null,由传输层调用installLocalLocales写入(apps/web/src/store/appStore.ts、L2295-L2297)。 - 浏览器语言:
navigator.languages,由useSystemLanguages()通过useSyncExternalStore订阅languagechange事件获得。 - 应用效应:在
main.tsx的App中挂载一次(apps/web/src/main.tsx)。
输入源一:协议协商后才有本地语言
本地语言不是“连上就有”,而是受协议版本门控。传输层在收到 welcome 后调用refreshLocalLocales(apps/web/src/transport/wireTransport.ts),实现如下:
function refreshLocalLocales(connectionGeneration: number): void { const store = useAppStore.getState(); if (store.protocolVersion === null || store.protocolVersion < LOCALES_PROTOCOL_VERSION) { store.installLocalLocales(null); return; } void getTransport() .request("locales.list", {}) .then((snapshot) => { const current = useAppStore.getState(); if (isConnectedGeneration(current, connectionGeneration)) current.installLocalLocales(snapshot); }) .catch(() => {}); }(apps/web/src/transport/wireTransport.ts)
这里有三个值得注意的设计点:
LOCALES_PROTOCOL_VERSION = 89是水位线(packages/contracts/src/wsProtocol.ts)。旧宿主直接installLocalLocales(null),于是客户端只剩内置语言 —— 这正是文档中“an older host installsnull, so only bundled languages exist”的含义。locales.changed只是失效通知,不是数据。宿主发布的是空载荷,客户端收到后重新拉一次locales.list(apps/web/src/transport/wireTransport.ts);服务端侧发布逻辑见 packages/server/src/host/server.ts。- 代际校验:请求返回后要用
isConnectedGeneration确认连接未换代,避免旧连接的快照覆盖新连接的状态。
快照的结构由契约包定义(packages/contracts/src/domain.ts):
export interface LocalLocaleFileOk { fileName: string; tag: string; ok: true; strings: Record<string, string>; } export type LocalLocaleFileErrorCode = | "invalid-name" | "duplicate" | "too-large" | "too-many" | "unreadable" | "invalid-json" | "not-object"; export interface LocalLocaleFileError { fileName: string; tag: string | null; ok: false; error: LocalLocaleFileErrorCode; /** Parser or OS detail for `invalid-json` / `unreadable`, untranslated. */ detail?: string; } export interface LocalLocalesSnapshot { dir: string; files: LocalLocaleFile[]; }注意error是错误码而非错误文案:客户端负责把码翻译成人话(settings.language.error.*),detail保留未翻译的解析器/系统信息。这样同一个文件问题在不同界面语言下都能正确表达。
宿主侧的读取实现保证了“一个坏文件不会拖垮其他文件”(packages/server/src/locales/locales.ts):大小超限(MAX_LOCALE_FILE_BYTES = 2 MB)先判,再读文件、再JSON.parse、再validateCatalog,任何一步失败都变成ok: false条目;MAX_LOCALE_FILES = 64之后的文件统一报too-many,同 tag 的重复文件报duplicate(L85-L100)。
输入源二:浏览器语言与languagechange
系统语言不是快照,而是实时读取:
function subscribeLanguages(onChange: () => void): () => void { window.addEventListener("languagechange", onChange); return () => window.removeEventListener("languagechange", onChange); } function readLanguages(): string { return typeof navigator === "undefined" ? "" : (navigator.languages ?? []).join(","); } export function useSystemLanguages(): string { return useSyncExternalStore(subscribeLanguages, readLanguages, readLanguages); }(apps/web/src/i18n/useLocaleSync.ts)
它返回的是逗号拼接的字符串而不是数组:useSyncExternalStore依赖引用相等来判定是否变化,数组每次都会是新引用,字符串才能稳定比较。下游在效应里用systemLanguages.split(",")还原成列表(L35)。typeof navigator === "undefined"的分支让这段代码在 SSR/测试环境下也不会抛错。
纯函数层:languages.ts的四个推导
availableLocales与localOverlays
function localTags(snapshot: LocalLocalesSnapshot | null): string[] { return (snapshot?.files ?? []).flatMap((file) => (file.ok ? [file.tag] : [])); } export function availableLocales(snapshot: LocalLocalesSnapshot | null): string[] { return [...new Set([...BUNDLED_LOCALES, ...localTags(snapshot)])]; } export function localOverlays(snapshot: LocalLocalesSnapshot | null): Record<string, Catalog> { return Object.fromEntries( (snapshot?.files ?? []).flatMap((file) => (file.ok ? [[file.tag, file.strings]] : [])), ); }(apps/web/src/i18n/languages.ts)
- 可用语言 = 内置 tag ∪ 有效本地 tag,用
Set去重,内置项排在前面。 - 覆盖词表只收集
ok: true的文件,键是规范化后的 tag。损坏文件既不参与覆盖,也不参与“可用”。 - 两者都不缓存:
snapshot变了就整体重算,逻辑简单且必然一致。
effectiveLocale:两种解析语义
export function effectiveLocale( preference: string, snapshot: LocalLocalesSnapshot | null, systemLanguages: readonly string[], ): string { return resolveLocale(preference, availableLocales(snapshot), systemLanguages); }真正的规则在resolveLocale(packages/locales/src/index.ts):
export function resolveLocale(preference, available, systemLanguages): string { if (preference !== SYSTEM_LOCALE) { const lower = preference.toLowerCase(); return available.find((candidate) => candidate.toLowerCase() === lower) ?? "en"; } for (const language of systemLanguages) { const match = matchAvailable(language, available); if (match) return match; } return "en"; }两条路径的严格程度不同,这是实现层面的关键差异:
| 偏好值 | 匹配方式 | 匹配失败 |
|---|---|---|
显式 tag(如de-AT) | 仅大小写不敏感的精确匹配 | 直接回落en |
"system" | 逐个遍历系统语言,先精确匹配,再退到基础语言(de-AT→de) | 遍历完仍无命中则en |
也就是说,显式选择de-AT而本地只有de.json时,不会自动降级到de;而系统模式下浏览器报告de-AT时可以命中de。单元测试把这两种语义都钉住了(apps/web/src/i18n/languages.test.ts):
expect(effectiveLocale("uk", snapshot, ["de"])).toBe("en"); expect(effectiveLocale("system", snapshot, ["de-AT", "en"])).toBe("de"); expect(effectiveLocale("de", snapshot, ["en"])).toBe("de");languageOptions:三类条目与四种来源
语言设置页需要的不是 tag 列表,而是带语义的选项数组。LanguageOption是一个判别联合(apps/web/src/i18n/languages.ts):
export type LanguageOption = | { kind: "language"; tag: string; name: string; source: LanguageSource } | { kind: "broken"; fileName: string; tag: string | null; error: LocalLocaleFileErrorCode; detail?: string } | { kind: "missing"; tag: string };source的四种取值由“是否内置 × 是否存在本地文件”组合而成:
source | 含义 | 界面提示键 |
|---|---|---|
builtin | 仅内置词表(当前只有en) | settings.language.source.builtin |
local | 仅本地文件提供 | settings.language.source.local |
builtin+local | 内置 + 本地覆盖(即“内置被本地改过”) | settings.language.source.customized |
无(broken) | 文件存在但不可用,随附错误码 | settings.language.source.broken |
整理逻辑还包含三个容易忽略的细节(L45-L84):
- 排序按
Intl.DisplayNames得到的语言自称排序(languageName,见 packages/locales/src/index.ts,会把首字母大写)。注意sort的比较函数在非language条目上返回0,且broken/missing是排序后再追加的,因此语言列表始终在前。 missing只在必要时出现:当偏好既不是"system"、又不在已知集合(可用 tag 加上损坏文件里能解析出的 tag,全部小写比较)中时,才补一条{ kind: "missing", tag: preference }。这就是“用户选过uk,但文件被删掉”的状态。broken与missing不会重复:损坏文件若能解析出 tag,其 tag 会进入known,因此不会再额外报缺失。
生效效应:localeSignature与“只在真变化时应用”
这是整个模块最需要精确理解的一段(apps/web/src/i18n/useLocaleSync.ts):
export function localeSignature(tag: string, snapshot: LocalLocalesSnapshot | null): string { const overlays = localOverlays(snapshot); return JSON.stringify([tag, fallbackChain(tag).map((language) => overlays[language] ?? null)]); } const INITIAL_SIGNATURE = localeSignature("en", null); export function useLocaleSync(): void { const preference = useAppStore((s) => s.locale); const snapshot = useAppStore((s) => s.localLocales); const systemLanguages = useSystemLanguages(); const applied = useRef(INITIAL_SIGNATURE); useEffect(() => { const tag = effectiveLocale(preference, snapshot, systemLanguages.split(",")); const signature = localeSignature(tag, snapshot); if (signature === applied.current) return; applied.current = signature; applyLocale(tag, localOverlays(snapshot)); document.documentElement.lang = tag; pushNativeMenuLabels(); }, [preference, snapshot, systemLanguages]); }签名为什么是“tag + 回退链上的词表”
fallbackChain(tag)的定义是[...new Set([tag, baseLanguage(tag), "en"])](packages/locales/src/index.ts)。因此:
- 选中
de:链是["de", "en"],签名里包含de与en的本地覆盖。 - 选中
de-AT:链是["de-AT", "de", "en"]。 - 任何一条链上没有本地文件时,对应位置为
null,不参与差异。
由此得到文档里那句结论的直接后果:编辑一个与当前回退链无关的语言文件,签名不变,效应虽然因snapshot引用变化而重新执行,但会在第一行提前返回。测试用一个极小的断言固定了这一点(apps/web/src/i18n/useLocaleSync.test.ts):
const base = localeSignature("de", snapshot([de])); expect(localeSignature("de", snapshot([de, fr]))).toBe(base); expect(localeSignature("de", snapshot([{ ...de, strings: { a: "B" } }]))).not.toBe(base); expect(localeSignature("de-AT", snapshot([de]))).not.toBe(base); expect(localeSignature("en", null)).toBe(localeSignature("en", snapshot([fr])));最后一行还说明:en的回退链只有自己,所以一个仅含fr的快照对英文界面的签名毫无影响。
INITIAL_SIGNATURE的作用
useRef的初值是localeSignature("en", null)。这带来一个具体行为:如果应用启动时生效语言就是en、且没有任何本地文件,首次效应会判定“无需应用”并直接返回 —— 因为英文词表在模块导入时就已经同步装好了(见下一节)。这避免了首帧一次多余的语言切换。
效应做的四件事
| 顺序 | 动作 | 失败/缺失时的表现 |
|---|---|---|
| 1 | applyLocale(tag, localOverlays(snapshot)) | 无覆盖时只是重置内置词表并切换语言 |
| 2 | document.documentElement.lang = tag | 总是写入,供 CSS/无障碍/浏览器使用 |
| 3 | pushNativeMenuLabels() | 无桌面桥时是 no-op |
| 4 | 隐式发布新 translator | 所有useT()消费者重渲染 |
注意第 4 步不是显式调用,而是applyLocale内部的副作用(见 packages/locales/src/react.tsx):它先对touched ∪ 链 ∪ 覆盖键的每个语言removeResourceBundle再addResourceBundle({ ...bundled, ...overlay }),然后changeLanguage(tag)并publishLocaleChange()。先删后加是必要设计:本地文件被删除后,其字符串必须真的消失,而不能残留在 i18next 的资源里。
桌面原生菜单:可选桥接
function menuBridge(): NativeMenuBridge | undefined { const bridge: unknown = Reflect.get(globalThis, "__THINKRAIL_NATIVE_MENU__"); return typeof bridge === "object" && bridge !== null && typeof Reflect.get(bridge, "setLabels") === "function" ? (bridge as NativeMenuBridge) : undefined; } export function pushNativeMenuLabels(): void { const keys = [...DESKTOP_MENU_KEYS, ...DESKTOP_DIALOG_KEYS]; menuBridge()?.setLabels(Object.fromEntries(keys.map((key) => [key, t(key)]))); }(apps/web/src/i18n/nativeMenu.ts)
- 桥通过
globalThis.__THINKRAIL_NATIVE_MENU__注入,桌面端 preload 用Object.defineProperty定义且不可写、不可配置(apps/desktop/src/preload.ts),因此 Web 侧必须做运行时形状校验,而不能假设它存在。 - 推送的键是两份白名单:
DESKTOP_MENU_KEYS(Edit/Settings/Quit/Window/Close)与DESKTOP_DIALOG_KEYS(退出确认、启动失败、更新重启失败及标题、启动与更新收尾的 splash 文案),定义在 packages/locales/src/index.ts,并用satisfies readonly (keyof EnglishCatalog)[]保证键一定存在于英文词表。 - 这里用的是全局
t(import { t } from "@thinkrail/locales/react")而非useT()的 translator,因为它在效应中调用、不属于渲染期计算 —— 与“渲染期禁止读全局”的规则并不冲突。
为什么英文必须先同步装好
@thinkrail/locales/react在模块求值时立即初始化实例(packages/locales/src/react.tsx):
export const i18n = i18next.createInstance(); void i18n.init({ lng: "en", fallbackLng: (code: string) => fallbackChain(code).slice(1), load: "currentOnly", resources: { en: { [NAMESPACE]: EN } }, initAsync: false, keySeparator: false, nsSeparator: false, returnEmptyString: false, interpolation: { escapeValue: false }, });initAsync: false加上内置en资源,意味着首帧之前英文就可用。这正是文档中“English is installed synchronously before first paint, so nothing renders keys while the host connects”的实现依据:即使宿主还没连上、locales.list还没回来,界面也只会显示英文,而不会闪出一片settings.language.title之类的原始键名。
同一文件还规定了渲染期契约:useT()是一个useSyncExternalStore订阅,返回的Translator在语言或其字符串变化时被整体替换(L59-L70);currentLocale()/localeOf(t)/currentTranslator()则用于事件期代码。这一区分很重要,原因见后文“日期与数字”。
边界:为什么这些事不能在本模块做
不渲染 UI
语言设置页在panels(apps/web/src/panels/LanguageSettings.tsx),它只消费本模块的纯推导:
const options = useMemo(() => languageOptions(preference, snapshot), [preference, snapshot]); const systemTag = effectiveLocale(SYSTEM_LOCALE, snapshot, systemLanguages); const folder = snapshot?.dir;选择语言时它并不直接改 store,而是发 RPC:request("settings.update", { config: { locale: next } }),失败才用toast.error提示(L95-L100)。“打开文件夹”同理走locales.reveal,服务端会先mkdirSync再调用文件管理器(packages/server/src/host/handlers.ts)。
这种拆分保证了展示逻辑(禁用态、来源提示、错误文案)与语言解析逻辑(谁是可用语言、谁是生效语言)互不污染,也让纯函数可以被单元测试直接覆盖,无需渲染整棵组件树。
不读文件
文件读取、大小上限、轮询检测都在宿主(packages/server/src/locales/SPEC.md 与 locales.ts)。宿主用每秒一次的stat签名比较(name、inode、size、mtime、ctime)而不是fs.watch,并在变化时只发一个“失效”通知。Web 侧因此永远是“请求—替换快照”的单向流,没有本地文件句柄、没有路径知识。
不允许第二个 i18next 实例
packages/locales/react只createInstance()一次并导出单例。如果 Web 端另起实例,applyLocale的替换与useT()的订阅就不再指向同一个对象,切换语言会出现“部分界面变了、部分界面没变”的割裂状态。契约包与 locales 包之间的分工也说明了这点:contracts只放数据类型,翻译规则放在locales根入口。
四条“必须做对”的规则逐条拆解
1. 只在真实变化时应用
文档原文是:效应在localeSignature(生效 tag 加上恰好其回退链上的字符串)变化时才应用;无关文件的编辑或重复的locales.list什么都不做。
实现上还有一层隐含成本考量:applyLocale会重建 translator 并通知所有订阅者,等于一次全量重渲染。用签名把“数据变化”收窄为“语义变化”,避免了每次轮询/每次推送导致的全应用重渲染。副作用是:useLocaleSync自身每次效应都会JSON.stringify一次签名,这是有意的取舍 —— 相比全量重渲染,签名计算的代价可以忽略。
2. 英文先于首帧、本地字符串后来就位
英文同步安装(上一节),本地字符串在locales.list返回后通过同一次效应“就地替换”。因此不存在“整页重挂载”的方案 ——packages/locales的设计说明里也明确否决了 remount:重挂载会丢草稿附件、计划输入,并重跑终端附着等生命周期副作用。
3. 选中的本地 tag 消失:显示英文,偏好不动
这条规则把“用户意图”和“当前可用资源”分开:
languageOptions会产出一条{ kind: "missing", tag },设置页把它标为激活且禁用(choiceId生成missing:<tag>,见 apps/web/src/panels/LanguageSettings.tsx、L191-L193)。effectiveLocale因为精确匹配失败返回"en",界面回落英文。- store 里的
preference从未被改写,所以文件回来后(下一次locales.changed→ 新快照)签名变化,语言自动恢复。
端到端测试完整验证了这条闭环(e2e/language-settings.spec.ts):
removeLocale("de.json"); await expect(page.getByTestId("language-missing-de")).toHaveAttribute("data-active", "true"); await expect(page.locator("html")).toHaveAttribute("lang", "en"); await expect(page.getByTestId("settings-nav-language")).toContainText("Language"); writeLocale("de.json", JSON.stringify({ "settings.nav.language": "Sprache" })); await expect(page.getByTestId("language-de")).toHaveAttribute("data-active", "true"); await expect(page.locator("html")).toHaveAttribute("lang", "de");注意第二条断言里,删掉文件后html lang变回en—— 这正是效应把document.documentElement.lang与生效 tag 同步的结果(页面初始值为en,见 apps/web/index.html)。
4. 日期与数字用currentLocale()
文档要求:日期和数字必须用currentLocale(),不能硬编码"en-US",也不能直接用浏览器默认值。原因是格式化必须与当前生效语言一致,而不是与浏览器语言一致 —— 用户可能把界面切成de,但系统语言仍是zh-CN。
配套的渲染期规则来自packages/locales:渲染期(组件体、hook 体、useMemo/useState初始化器、.map回调、store selector 及其可达辅助函数)必须把useT()返回的Translator作为参数传入,而不是读全局t。因为 React Compiler 会按输入缓存渲染表达式,读全局的 helper 在语言切换后仍会返回旧语言的字符串。localeOf(t)专门用于“在某次渲染里按该 translator 的语言做Intl格式化”。
词表规范与本地文件实战
虽然apps/web/src/i18n只做推导,但要让它“有东西可推导”,本地文件必须通过共享的校验规则。规则实现在@thinkrail/locales根入口(浏览器与服务器共用,见 packages/locales/src/index.ts),由宿主在读取时执行。
文件放在哪里、叫什么
- 位置:全局数据目录下的
locales/,即localesDir()返回的<dataDir>/locales(packages/server/src/locales/locales.ts);仓库贡献文档写作~/.thinkrail/locales/(CONTRIBUTING.md)。它永远不是工作区目录,避免克隆下来的仓库改写用户界面。 - 命名:
localeTagFromFileName只接受.json、非点号开头、且主干匹配^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$的文件名,再经Intl.getCanonicalLocales规范化;de.json→"de",pt-BR.json合法,backup.json.bak、.de.json、my-lang.json均返回null(进而报invalid-name)。
值的取舍:一个“保守但安静”的校验器
validateCatalog的返回值只有两种:{ ok: true, strings }或{ ok: false, error: "not-object" }。除了根不是对象,其余问题都是静默丢弃单条,而不是整份文件报错:
| 情形 | 处理 |
|---|---|
| 值不是字符串,或为空字符串 | 丢弃该键,界面显示英文 |
值包含$t(嵌套 | 丢弃该键 |
{{placeholder}}集合与英文不一致 | 丢弃该键 |
| 标签集合与英文不一致,或用了带属性/具名标签 | 丢弃该键 |
<紧跟{{ | 丢弃该键(防止插值拼出标签) |
| 未知键 | 保留为数据(文件可能比应用新或旧) |
键名是constructor/__proto__ | 作为普通键处理(只用自有属性查找) |
其中“标签集合”的检查非常严格:markup()只允许<0>、</0>、<1/>这类裸索引标记,且排序后比较集合。设计动机在模块说明里写得很直接:词表不得覆盖代码所拥有组件的 props(例如链接的href)—— 否则一个语言文件就能改变链接指向。占位符检查对复数键做了特殊处理:key_<category>与英文key_other比较,且{{count}}允许出现或不出现,这样译者可以选择显示或省略数字(L100-L120)。
复数:用 CLDR 类别,缺的用_other补齐
completePlurals(tag, strings)会找出词表中所有“英文有对应复数家族”的键,按Intl.PluralRules(tag)解析该语言需要的类别,把缺失类别用本家族的_other(否则任一已存在形式)填充(L130-L150)。applyLocale对每个覆盖词表都调用它(packages/locales/src/react.tsx)。
效果是:俄语这类需要_one/_few/_many/_other的语言,译者只写三四种形式,计数器也不会突然掉回英文。贡献文档给出的形式是(CONTRIBUTING.md):
{ "tools.read.firstLines_one": "первая {{count}} строка", "tools.read.firstLines_few": "первые {{count}} строки", "tools.read.firstLines_many": "первые {{count}} строк" }键的类型层也做了配合:MessageKey会把_zero/_one/… 折叠回基键,因此代码里t("tools.read.firstLines")只需引用基键,复数后缀由 i18next 运行时解析。
最小可运行示例
在语言设置页“Add a language”区块的描述里(settings.language.add.steps,见 packages/locales/en.json)已经把步骤写清楚:下载en.json、按语言码命名、翻译值、保存即生效。一个覆盖少量键的de.json:
{ "settings.language.title": "Sprache", "settings.nav.language": "Sprache", "settings.language.add.steps": "Lade <0>en.json</0> herunter und übersetze die Werte.", "workbench.tool.files": "Dateien" }注意第三条保留<0>…</0>且位置对应,否则按“标签集合不一致”被丢弃。这个片段与 e2e 用例中使用的数据一致(e2e/language-settings.spec.ts)。
质量闸门:为什么“新文案不可能漏翻译”
bun run check:i18n串起三道检查(package.json):
"check:i18n": "bun test scripts/checkI18n.test.ts scripts/i18nRenderReach.test.ts && bun scripts/check-i18n.ts"- 硬编码文案检测:扫描
apps/web/src、packages/ui、packages/resource-renderers/src、thinkrail-extensions,对 JSX 文本、aria-label/title/placeholder/alt等文本属性、toast.error(...)与errorText(...)的文案参数报错(scripts/check-i18n.ts、L130-L180)。白名单只有ThinkRail。 - 词表自身健康:
en.json键必须有序、值必须是非空字符串、每个键(去掉复数后缀)必须在消费方源码中被引用(L220-L239)。 - 渲染期全局 translator 检测:
i18nRenderReach从t/i18n/currentLocale/currentTranslator这些“全局读取器”出发,追踪其在渲染期同步回调(useAppStore、useMemo、map、filter、getter 等)中的可达性(scripts/i18nRenderReach.ts)。
第 3 项是把“语言切换后部分文案不更新”这类只有运行时才暴露的 bug 前移到静态检查。它的必要性来自applyLocale的机制:切换语言并不重挂载组件树,只替换 translator 值 —— 因此任何绕过useT()的渲染期读取都会拿到旧值。
测试与可验证行为
该模块的测试分布在三层,覆盖点与文档条目一一对应:
| 测试 | 覆盖的行为 |
|---|---|
| apps/web/src/i18n/languages.test.ts | 可用语言 = 内置 ∪ 有效本地;来源标记(含builtin+local);损坏文件出现在末尾;missing仅在偏好不可用时出现;系统模式的基础语言降级 |
| apps/web/src/i18n/useLocaleSync.test.ts | 签名只随 tag 与其回退链字符串变化;en不受仅含fr的快照影响 |
| apps/web/src/i18n/nativeMenu.test.ts | 有桥时推送当前语言的菜单与对话框标签;链尾未翻译的键回落英文;无桥时不抛错 |
| e2e/language-settings.spec.ts | 本地文件列出/可选/热更新/删除后回落/恢复;html lang同步;缓存标签、<T>富文本、已挂载编辑器的控件都随语言切换重渲染 |
其中 e2e 的第一个用例还验证了一个容易被忽略的行为:写入非法 JSON 的fr.json后,设置页出现language-broken-fr.json条目且输入框禁用(L64-L67)—— 坏文件既不能选,也不会影响de的使用。
已知限制
packages/locales与apps/web/src/i18n的设计文档都把以下条目列为明确的延后项,在规划自建语言方案时应当把它们当作当前事实边界:
- 内置非英文语言:
BUNDLED映射目前只有en(packages/locales/src/index.ts),新增内置语言需要改这张表并通过构建进入产物。 - 首帧本地语言:在宿主返回
locales.list之前,即使偏好是de,界面也会短暂显示英文。这是“英文同步安装、本地稍后到达”的必然结果。 - RTL 布局:
dir属性未处理,只设置了lang。 - 服务端错误文案:错误文本不翻译,仍按原样展示;若要做多语言,需先迁移为错误码。
- 语言感知的数字格式:token 计数、成本、上下文百分比仍使用英文分隔符。
- 复合短语:嵌入已翻译计数片段的句子(例如“1 skill”列表)尚未组件化。
这些限制都不影响本地语言文件的完整能力:增删改文件、热更新、坏文件隔离、删除后回落英文并保留偏好,均已由上述源码与测试确认。
小结
apps/web/src/i18n的价值在于把“多源输入”压缩成“一个可比较的签名 + 一次幂等的应用”:
- 输入侧,偏好来自
AppConfig.locale,本地词表来自受协议版本门控的locales.list快照,系统语言来自navigator.languages的实时订阅; - 推导侧,四个纯函数把快照整理成可用 tag、覆盖词表、生效 tag 与选项列表,其中显式偏好只做精确匹配、
"system"才允许基础语言降级; - 应用侧,
localeSignature用“tag + 恰好其回退链上的字符串”判定真实变化,然后用applyLocale+<html lang>+ 原生菜单标签完成一次原子更新; - 约束侧,模块不渲染、不读文件、不新建 i18next 实例,把 UI 交给
panels、文件交给宿主、实例交给@thinkrail/locales/react,并由check:i18n的三道闸门守住“没有硬编码文案、词表健康、渲染期不读全局 translator”。
把这四条串起来,就能解释为什么在 ThinkRail 里改一个de.json并保存后,界面会就地换语言而不丢草稿,也能解释为什么删掉它之后界面会退回英文、而设置里仍然记得你选过de。
【免费下载链接】thinkrail
Vibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need
相关推荐
Conductor多语言客户端与SDK深度解析
Conductor多语言客户端与SDK深度解析 本文深入解析了Conductor多语言客户端与SDK的核心架构和实现细节。首先详细介绍了Java SDK的核心接
工作流自动化任务调度后端LibreChat 多语言本地化:新增语言全流程与 i18n 底层机制深度解析
LibreChat 多语言本地化:新增语言全流程与 i18n 底层机制深度解析 本篇以 LibreChat 仓库中的 本地化指南 https://link.gi
人工智能AI 应用大模型后端前端AI AgentRAGTinode 多语言本地化(i18n)完整指南:服务端邮件/SMS 模板与各端客户端翻译实践
Tinode 多语言本地化(i18n)完整指南:服务端邮件/SMS 模板与各端客户端翻译实践 本指南围绕 docs/translations.md https:
即时通讯后端WebSocket认证鉴权可观测性
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考