Skip to content

@agentaily/i18n

Agentaily 各产品共享的国际化机制 —— locale 状态、navigator 探测、跨子域持久化、 以及 <html lang> —— 与任何 UI 解耦。每个产品注入自己的类型化文案目录(message catalogs);本包负责底层管线。

createI18n({ catalogs, defaultLocale }) → { LocaleProvider, useLocale, useMessages }
  • 自包含。 除 React(peer dependency)外零运行时依赖。它所依托的偏好持久化原语 就地内嵌在包内(src/persistence/),所以消费 i18n 永远不会把组件库一起拉进来。
  • 类型安全的目录。 useMessages() 绑定到你传入的目录的精确形状,所以漏写 / 拼错的 key 是编译期错误(当两份目录都对着同一个 Messages interface 做检查时 —— 见下方 消费者模式)。
  • 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 依赖:

jsonc
// consumer package.json
"dependencies": {
  "@agentaily/i18n": "workspace:*"
}

本包消费为 TypeScript 源码(无构建步)—— exports 指向 ./src/index.ts,和 @agentaily/aml 一样。消费者里必须有 React >=18

用法

每个产品自己有一个小模块,注入它的类型化目录并 re-export 绑定好的 hooks:

tsx
// 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
});
tsx
// 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|localcookieDomainkeyPrefixcookieMaxAge)。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)