多语言与 i18n 支持
Mizuki 的国际化(i18n)是一个极轻量的构建期翻译层:以 TypeScript 枚举 I18nKey 作为强类型键,以 src/i18n/languages/ 下的语言包(en、ja、zh_CN、zh_TW)作为值,通过 i18n() 与 getTranslation() 两个纯函数,在站点构建/渲染阶段把界面文案解析为单一目标语言的字符串,直接固化进生成的 HTML。
Purpose and Scope
本页完整覆盖 src/i18n 运行时的端到端机制:
- 键定义:
I18nKey枚举的组织方式、按功能域分组的键命名空间; - 翻译解析:
translation.ts中语言注册表map、别名折叠(en_us→en等)、大小写归一化与英文回退链; - 类型契约:
Translation = Record<I18nKey, string>如何在编译期强制语言包穷尽覆盖所有键; - 消费模式:工具函数(
anime-data.ts、content-utils.ts、post-card-content.ts、url-utils.ts)与 Svelte 组件中i18n(I18nKey.xxx)的实际调用方式; - 边界与故障模式:未知语言回退、连字符 locale(
zh-CN)不匹配、翻译字符串被用作数据键等边界情况。
以下相关主题有意留给兄弟页面,本页仅引用不展开:
- 站点整体配置(
siteConfig的其余字段)——见「站点配置」页; - 设置面板 / 主题切换的 UI 组件实现——见相关组件页;
- 内容集合(
astro:content)与文章数据流——见「内容集合」页。
Overview
这是一个 Astro 静态站点(SSG)主题的 i18n 方案,其核心设计取舍是:不做运行时多语言切换,而是在构建时读取站点配置中的一个全局语言值,把所有 UI 文案一次性翻译成该语言。
关键概念:
| 概念 | 载体 | 说明 |
|---|---|---|
| 翻译键 | I18nKey 枚举(src/i18n/i18nKey.ts) | 全部 UI 文案的键集合,按页面/功能分组(导航栏、公告栏、番剧页、短文页、404、音乐播放器等) |
| 语言包 | src/i18n/languages/{en,ja,zh_CN,zh_TW}.ts | 每个文件导出一个 Translation 对象,键为 I18nKey,值为该语言的字符串 |
| 语言注册表 | map: Record<string, Translation> | 把归一化后的 locale 字符串映射到语言包,含别名折叠 |
| 语言选择 | siteConfig.lang | 来自 src/config 的单一全局值;空值时回退 "en" |
| 回退策略 | defaultTranslation = en | 任何未注册的 locale 静默回退到英文 |
使用场景:站长在 siteConfig.lang 中配置一次语言(如 "zh_cn"),构建产物(导航、公告、番剧状态标签、空态提示、404 页、音乐播放器按钮、友链页等全部 UI 文案)即为该语言,客户端无需加载任何 i18n 运行时。
Architecture
架构要点解读:
- 单一入口
i18n():所有消费方(工具函数与 Svelte 组件)都通过同一个同步函数取文案,没有上下文注入、没有 Provider、没有 hook——因为语言是构建期常量而非运行期状态。 - 语言注册表是唯一扩展点:新增语言只需新增语言包文件并在
map中注册一行,getTranslation的回退逻辑自动生效。 - 类型系统承担质量门禁:
Translation = Record<I18nKey, string>(见 translation.ts)使任何语言包若缺少键即产生 TypeScript 编译错误,杜绝「某语言漏译导致运行时 undefined」。 en是默认翻译:const defaultTranslation = en(translation.ts)是所有未命中路径的最终回退目标。
源码中的运行时本体只有 30 行,位于 translation.ts:
1import { siteConfig } from "../config";
2import type I18nKey from "./i18nKey";
3import { en } from "./languages/en";
4import { ja } from "./languages/ja";
5import { zh_CN } from "./languages/zh_CN";
6import { zh_TW } from "./languages/zh_TW";
7
8export type Translation = Record<I18nKey, string>;
9
10const defaultTranslation = en;
11
12const map: Record<string, Translation> = {
13 en: en,
14 en_us: en,
15 en_gb: en,
16 en_au: en,
17 zh_cn: zh_CN,
18 zh_tw: zh_TW,
19 ja: ja,
20 ja_jp: ja,
21};Source: translation.ts
Core Flow:一次 i18n() 调用的完整解析链路
逐步解读(对照 translation.ts):
- 取语言:
const lang = siteConfig.lang || "en"—— 语言来自站点配置;配置为空串或undefined时用"en"兜底。这是站点级设置,因此整个站点只有一种语言,渲染结果对同一路径完全确定。 - 归一化:
map[lang.toLowerCase()]—— 配置写成"zh_CN"、"ZH_CN"、"zh_cn"都能命中注册表。 - 注册表查找:
en、en_us、en_gb、en_au全部折叠到同一份en包;zh_cn、zh_tw、ja、ja_jp同理。这就是「别名折叠」:地域变体不引入独立翻译文件。 - 回退:
|| defaultTranslation—— 未注册语言静默回退英文,不抛错、不告警(见下文「故障模式」)。 - 取值:
getTranslation(lang)[key]—— 由于Translation的键即I18nKey全集,且是Record索引访问,若键存在必得字符串。
设计意图:整个链路是纯同步、无副作用、无缓存的两次对象属性访问。在 SSG 场景下这是最优解——每次构建只解析一种语言,零客户端运行时开销,也无需处理语言切换的响应式状态同步。
I18nKey:键的命名空间组织
I18nKey 是一个 TypeScript enum(值与名称相同),文件头部可见其按功能域分组:
1enum I18nKey {
2 home = "home",
3 about = "about",
4 archive = "archive",
5 search = "search",
6 other = "other",
7
8 // 导航栏标题
9 navLinks = "navLinks",
10 navMy = "navMy",
11 navAbout = "navAbout",
12 navOthers = "navOthers",
13
14 tags = "tags",
15 categories = "categories",
16 recentPosts = "recentPosts",
17 postList = "postList",
18 tableOfContents = "tableOfContents",
19 tocEmpty = "tocEmpty",
20
21 // 公告栏
22 announcement = "announcement",
23 announcementClose = "announcementClose",
24 ...Source: i18nKey.ts
命名规律(从已读的 1–100 行归纳):
| 分组(源码注释) | 键前缀示例 | 覆盖的 UI 面 |
|---|---|---|
| 导航栏 | navLinks、navMy、navAbout、navOthers | 顶部导航折叠标题 |
| 公告栏 | announcement、announcementClose | 首页公告条 |
| 番剧页面 | animeTitle、animeStatus*、animeEmpty*、animeConfig* | 追番页标题、状态标签、空态、配置提示 |
| 短文页面 | diarySubtitle、diaryNoResults、diaryMinutesAgo 等 | 短文流 |
| 404 页面 | notFound、notFoundTitle、backToHome | 404 页 |
| 音乐播放器 | musicPlayer、musicPlayerShow/Hide/Expand/Collapse | 播放器控件 |
| 通用 | uncategorized、noTags、wordCount、lightMode/darkMode/systemMode 等 | 全站零散文案 |
为什么用 enum 而不是字符串字面量联合类型:enum 让消费方 i18n(I18nKey.xxx) 具备 IDE 自动补全与编译期拼写检查;同时 enum 的成员是值(运行时真实对象),使得语言包的键与类型检查解耦,仅靠 Translation 类型约束键完整性。
消费模式:工具函数中的实际用法
模式一:静态描述注入数据源(anime-data.ts)
1import I18nKey from "../i18n/i18nKey";
2import { i18n } from "../i18n/translation";
3...
4 emptyDescription: i18n(I18nKey.animeEmptyBilibili),
5...
6 watching: {
7 text: i18n(I18nKey.animeStatusWatching),
8 class: "..."Source: anime-data.ts
把翻译结果直接写进数据结构(emptyDescription、text),供下游渲染层无感知地输出本地化文案。语言在模块求值时即被固化。
模式二:翻译结果作为聚合数据键
const ucKey = i18n(I18nKey.uncategorized);
count[ucKey] = count[ucKey] ? count[ucKey] + 1 : 1;Source: content-utils.ts
「未分类」计数以翻译后的字符串作为聚合键。这是一个重要的隐式契约:同一站点内 i18n() 是纯函数且语言恒定,因此该键是稳定可比较的。但注意它同时意味着该字符串进入了 URL/分类路径语义——见下一条。
模式三:翻译字符串参与 URL 判定
category.trim().toLowerCase() === i18n(I18nKey.uncategorized).toLowerCase()Source: url-utils.ts
uncategorized 的译文被用于归一化比较,识别「未分类」分类条目。两侧都 trim().toLowerCase() 以对冲不同语言包的大小写与空格差异。
模式四:加密文章的占位文案
1import I18nKey from "@/i18n/i18nKey";
2import { i18n } from "@/i18n/translation";
3...
4 return i18n(I18nKey.postEncryptedMessage);Source: post-card-content.ts
加密文章在卡片列表中显示统一的本地化占位文案而非真实内容。此文件还展示了别名导入:@/i18n/i18nKey 与 ../i18n/i18nKey、@i18n/i18nKey 是同一文件的多种路径别名写法。
模式五:Svelte 组件内联调用(构建期回归测试佐证)
/\{#if isUltrawidePostLayoutSwitchable\}[\s\S]*?i18n\(I18nKey\.settingsFeatures\)[\s\S]*?i18n\(I18nKey\.ultrawidePostLayout\)/,
"the toggle must live in its own Features group",Source: layout-regressions.test.mjs
回归测试通过正则断言设置面板源码中存在 i18n(I18nKey.xxx) 调用及其分组结构。这说明 i18n 调用遍布 Svelte 组件源码,且其位置/分组本身是被测试锁定的契约——重构 UI 时若移动了这些调用,测试会失败。
Configuration Options
i18n 层只有一个真正意义上的配置输入,其余「配置」是代码内常量:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
siteConfig.lang | string | "en"(空值回退) | 站点唯一语言。经 toLowerCase() 归一化后查 map。已支持注册:en、en_us、en_gb、en_au、zh_cn、zh_tw、ja、ja_jp |
map 注册表 | Record<string, Translation> | 见上文 8 个条目 | locale 别名 → 语言包的映射,新增语言在此注册 |
defaultTranslation | Translation | en 包 | 未命中 locale 的回退语言包 |
新增一种语言的完整步骤(源码推导的唯一扩展路径):
- 在
src/i18n/languages/下新建xx.ts,导出形如export const xx: Translation = { home: "...", ... }的对象——类型注解会强制补齐I18nKey全部键,漏键即编译失败; - 在 translation.ts 顶部
import { xx } from "./languages/xx"; - 在
map中加入xx: xx(以及可选的xx_yy地域别名); - 将
siteConfig.lang设为"xx"。
无需改动任何消费方代码——这是键-注册表解耦的直接收益。
API Reference
i18n(key: I18nKey): string
参数:
key(I18nKey):I18nKey枚举成员,如I18nKey.animeStatusWatching
返回值: 当前站点语言下该键的译文(字符串)。
抛出: 无。空语言回退 "en";未知 locale 回退 defaultTranslation;键访问由 Record 索引完成,键集经类型约束为完备。
定义:
1export function i18n(key: I18nKey): string {
2 const lang = siteConfig.lang || "en";
3 return getTranslation(lang)[key];
4}Source: translation.ts
getTranslation(lang: string): Translation
参数:
lang(string):语言标识,内部执行lang.toLowerCase()归一化
返回值: 完整语言包对象(Record<I18nKey, string>);未注册 locale 返回英文包。
export function getTranslation(lang: string): Translation {
return map[lang.toLowerCase()] || defaultTranslation;
}Source: translation.ts
Failure Modes、边界情况与并发
| 场景 | 行为 | 源码依据 |
|---|---|---|
siteConfig.lang 为空/未定义 | 回退 "en" | siteConfig.lang || "en"(L28) |
locale 大小写不一致(ZH_CN) | 正常命中,toLowerCase() 归一化 | L24 |
locale 未注册(如 ko) | 静默回退英文,无警告无报错 | || defaultTranslation(L24) |
locale 使用连字符(zh-CN) | 不命中——注册表只收下划线形式,回退英文 | map 键均为 zh_cn 等(L12–21) |
| 语言包缺键 | 编译期拦截,不会到运行时 | Translation = Record<I18nKey, string>(L8) |
| 译文含前后空格/大小写差异 | URL 判定处双侧 trim().toLowerCase() 对冲 | url-utils.ts |
并发:map、语言包、defaultTranslation 均为模块级不可变常量,i18n() 为无副作用纯函数,天然线程安全(Astro 构建并发渲染下安全)。缓存:无显式缓存——两次属性访问本身就是最优路径,无需记忆化。
最值得警惕的边界:译文被当作数据键使用(count[ucKey]、uncategorized 的 URL 比较)。这隐式约定「同一构建内译文是稳定标识符」,但一旦未来引入运行时语言切换,该契约即被破坏——分类聚合与 URL 归一化会产生语言相关的键漂移。这是该设计最深的隐式耦合点。
Performance 与运维说明
- 零客户端成本:翻译在构建期完成,产物 HTML 已含目标语言文案,浏览器不加载任何 i18n 库、字典或切换逻辑。
- 构建期成本:每次
i18n()为两次对象属性访问 + 一次toLowerCase,可忽略;语言包在模块加载时全部import进内存(四种语言全量加载,即便只用一种)——对构建时间无实际影响,但意味着包体与语言数线性相关。 - 测试守护:layout-regressions.test.mjs 用正则断言
i18n(I18nKey.xxx)调用必须留在特定 Svelte 分支结构中,防止重构时静默丢失本地化文案。
Extension Points
- 新增语言:唯一官方扩展点,见「Configuration Options」的四步流程;类型系统自动强制键完备性。
- 新增键:在
I18nKey枚举中加一行,四个语言包立即编译报错提示补译——这是「先加键、编译驱动补译」的工作流。 - 不可扩展项(有意的设计边界):无运行时语言切换、无插值/复数化、无 fallback 链(仅单级英文回退)、无 SSR/客户端语言协商。这些都是静态单语言模型的刻意取舍,而非遗漏。