@agentaily/i18n
Agentaily 各产品共享的国际化机制 —— locale 状态、navigator 探测、跨子域持久化、 以及 <html lang> —— 与任何 UI 解耦。每个产品注入自己的类型化文案目录(message catalogs);本包负责底层管线。
createI18n({ catalogs, defaultLocale }) → { LocaleProvider, useLocale, useMessages }- 自包含。 除 React(peer dependency)外零运行时依赖。它所依托的偏好持久化原语 就地内嵌在包内(
src/persistence/),所以消费 i18n 永远不会把组件库一起拉进来。 - 类型安全的目录。
useMessages()绑定到你传入的目录的精确形状,所以漏写 / 拼错的 key 是编译期错误(当两份目录都对着同一个Messagesinterface 做检查时 —— 见下方 消费者模式)。 - SSR 安全。 每次访问
window/document/navigator都漏斗式地经过一个isBrowser()守卫;在服务端 import 或渲染永远不会碰到缺失的全局对象,持久化会静默 降级(cookie → localStorage → memory → no-op)。
这个包为什么存在
i18n 机制曾经活在 @agentaily/design-system 的运行时里(从已退役的 @agentaily/web-kit 移植过去)。那把一个横切的运行时关注点耦合进了 UI 库。本包是它的 独立归宿,这样任何消费者 —— 包括非 UI 代码 —— 都能用上 i18n 而不必依赖组件库。
迁移状态(2026-06-19): 这是独立抽取版(行为与 design-system 里那份完全 一致),已在 workspace 里注册。design-system 目前仍自带它自己的
createI18n;去重 (让 DS 从这里 re-export)以及把apps/website切过来都是后续。每个后续都各走 自己的 worktree + PR(见仓库CLAUDE.md→ "🌳 推进模型")。
安装(在 monorepo 内)
加一条 workspace 依赖:
// consumer package.json
"dependencies": {
"@agentaily/i18n": "workspace:*"
}本包消费为 TypeScript 源码(无构建步)—— exports 指向 ./src/index.ts,和 @agentaily/aml 一样。消费者里必须有 React >=18。
用法
每个产品自己有一个小模块,注入它的类型化目录并 re-export 绑定好的 hooks:
// src/i18n/index.ts
import { createI18n } from "@agentaily/i18n";
import en from "./en.json";
import zh from "./zh.json";
export type Locale = "en" | "zh";
// One interface both catalogs are checked against → drift is a compile error.
export interface Messages {
hero: { ctaPrimary: string; ctaSecondary: string };
// …
}
const catalogs: Record<Locale, Messages> = { en, zh };
export const { LocaleProvider, useLocale, useMessages } = createI18n({
catalogs,
defaultLocale: "zh", // fallback when nothing persisted & detection fails
});// app root
import { LocaleProvider } from "./i18n";
<LocaleProvider>
<App />
</LocaleProvider>;
// any component
import { useLocale, useMessages } from "./i18n";
const { locale, setLocale, locales } = useLocale();
const m = useMessages(); // typed to Messages
<button onClick={() => setLocale(locale === "zh" ? "en" : "zh")}>{m.hero.ctaPrimary}</button>;Locale 解析顺序:持久化的选择 → navigator 语言 → 兜底。切换 locale 会持久化该 选择(默认共享 .agentaily.com cookie → localStorage → memory)并设置 <html lang>; 它就地重渲染,不刷新页面。
API
| 导出 | 是什么 |
|---|---|
createI18n(config) | 工厂 → { LocaleProvider, useLocale, useMessages },绑定到你的目录类型。 |
detectLocale(stored, locales, fallback) | 纯解析器(持久化 → navigator → 兜底)。导出供直接 / SSR 使用。 |
isBrowser() | SSR 守卫。 |
createStorage, persistentState | 持久化原语(也在 @agentaily/i18n/persistence)。 |
createI18n 配置:{ catalogs, defaultLocale, storage?, setHtmlLang? }。 storage 是一个 StorageConfig(backend auto|cookie|local、cookieDomain、 keyPrefix、cookieMaxAge)。setHtmlLang 默认 true。
持久化为什么放在这里
createI18n 需要持久化 locale 选择,所以偏好存储就地内嵌在包内,以保持 i18n 自包含。它是一个通用的键 / 值偏好原语(design-system 的主题切换用一份等价 副本,平行的 @agentaily/theme 抽取也内嵌同一份 —— 刻意保持对称)。若 / 当我们去重 时,它可以毕业成自己的 @agentaily/persistence 包,让 i18n 和 theme 都依赖它 —— 在那之前它随本包发布、并导出以供复用。
测试
pnpm --filter @agentaily/i18n test(或在本目录npx vitest run)—— node 环境, 覆盖纯逻辑:持久化(createStorage/persistentState/ store 原语)与detectLocale。今天独立运行;无外部依赖。pnpm --filter @agentaily/i18n test:dom—— jsdom 环境(vitest.dom.config.ts), 覆盖 React 绑定(LocaleProvider/useLocale/useMessages、切换、<html lang>、 provider 外抛错)。需要react/react-dom/@testing-library/react/@vitejs/plugin-react/jsdom,这些只在本包注册进 workspace 后才安装。
apps/website 的 BDD switch-locale.feature 已经端到端地演练了同一套机制(经 design-system 副本);一旦本包取代那份副本,website 测试套也成为它的集成门禁。
目录结构
src/
index.ts 对外统一导出(barrel)
createI18n.tsx 工厂(React provider + hooks)
detectLocale.ts 纯 locale 解析器
env.ts isBrowser() SSR 守卫
persistence/ 就地内嵌的偏好存储(cookie → localStorage → memory → no-op)
index.ts · createStorage.ts · persistentState.ts · cookie.ts · stores.ts · types.ts
test/ persistence.test.ts · detectLocale.test.ts(node)· createI18n.test.tsx(jsdom)