Repository Wiki
LyraVoid/Mizuki

多语言与 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

Loading diagram...

架构要点解读:

  1. 单一入口 i18n():所有消费方(工具函数与 Svelte 组件)都通过同一个同步函数取文案,没有上下文注入、没有 Provider、没有 hook——因为语言是构建期常量而非运行期状态。
  2. 语言注册表是唯一扩展点:新增语言只需新增语言包文件并在 map 中注册一行,getTranslation 的回退逻辑自动生效。
  3. 类型系统承担质量门禁:Translation = Record<I18nKey, string>(见 translation.ts)使任何语言包若缺少键即产生 TypeScript 编译错误,杜绝「某语言漏译导致运行时 undefined」。
  4. en 是默认翻译:const defaultTranslation = en(translation.ts)是所有未命中路径的最终回退目标。

源码中的运行时本体只有 30 行,位于 translation.ts:

typescript
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() 调用的完整解析链路

Loading diagram...

逐步解读(对照 translation.ts):

  1. 取语言:const lang = siteConfig.lang || "en" —— 语言来自站点配置;配置为空串或 undefined 时用 "en" 兜底。这是站点级设置,因此整个站点只有一种语言,渲染结果对同一路径完全确定。
  2. 归一化:map[lang.toLowerCase()] —— 配置写成 "zh_CN"、"ZH_CN"、"zh_cn" 都能命中注册表。
  3. 注册表查找:en、en_us、en_gb、en_au 全部折叠到同一份 en 包;zh_cn、zh_tw、ja、ja_jp 同理。这就是「别名折叠」:地域变体不引入独立翻译文件。
  4. 回退:|| defaultTranslation —— 未注册语言静默回退英文,不抛错、不告警(见下文「故障模式」)。
  5. 取值:getTranslation(lang)[key] —— 由于 Translation 的键即 I18nKey 全集,且是 Record 索引访问,若键存在必得字符串。

设计意图:整个链路是纯同步、无副作用、无缓存的两次对象属性访问。在 SSG 场景下这是最优解——每次构建只解析一种语言,零客户端运行时开销,也无需处理语言切换的响应式状态同步。

I18nKey:键的命名空间组织

I18nKey 是一个 TypeScript enum(值与名称相同),文件头部可见其按功能域分组:

typescript
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、backToHome404 页
音乐播放器musicPlayer、musicPlayerShow/Hide/Expand/Collapse播放器控件
通用uncategorized、noTags、wordCount、lightMode/darkMode/systemMode 等全站零散文案

为什么用 enum 而不是字符串字面量联合类型:enum 让消费方 i18n(I18nKey.xxx) 具备 IDE 自动补全与编译期拼写检查;同时 enum 的成员是值(运行时真实对象),使得语言包的键与类型检查解耦,仅靠 Translation 类型约束键完整性。

消费模式:工具函数中的实际用法

模式一:静态描述注入数据源(anime-data.ts)

typescript
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),供下游渲染层无感知地输出本地化文案。语言在模块求值时即被固化。

模式二:翻译结果作为聚合数据键

typescript
const ucKey = i18n(I18nKey.uncategorized); count[ucKey] = count[ucKey] ? count[ucKey] + 1 : 1;

Source: content-utils.ts

「未分类」计数以翻译后的字符串作为聚合键。这是一个重要的隐式契约:同一站点内 i18n() 是纯函数且语言恒定,因此该键是稳定可比较的。但注意它同时意味着该字符串进入了 URL/分类路径语义——见下一条。

模式三:翻译字符串参与 URL 判定

typescript
category.trim().toLowerCase() === i18n(I18nKey.uncategorized).toLowerCase()

Source: url-utils.ts

uncategorized 的译文被用于归一化比较,识别「未分类」分类条目。两侧都 trim().toLowerCase() 以对冲不同语言包的大小写与空格差异。

模式四:加密文章的占位文案

typescript
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 组件内联调用(构建期回归测试佐证)

javascript
/\{#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.langstring"en"(空值回退)站点唯一语言。经 toLowerCase() 归一化后查 map。已支持注册:en、en_us、en_gb、en_au、zh_cn、zh_tw、ja、ja_jp
map 注册表Record<string, Translation>见上文 8 个条目locale 别名 → 语言包的映射,新增语言在此注册
defaultTranslationTranslationen 包未命中 locale 的回退语言包

新增一种语言的完整步骤(源码推导的唯一扩展路径):

  1. 在 src/i18n/languages/ 下新建 xx.ts,导出形如 export const xx: Translation = { home: "...", ... } 的对象——类型注解会强制补齐 I18nKey 全部键,漏键即编译失败;
  2. 在 translation.ts 顶部 import { xx } from "./languages/xx";
  3. 在 map 中加入 xx: xx(以及可选的 xx_yy 地域别名);
  4. 将 siteConfig.lang 设为 "xx"。

无需改动任何消费方代码——这是键-注册表解耦的直接收益。

API Reference

i18n(key: I18nKey): string

参数:

  • key (I18nKey):I18nKey 枚举成员,如 I18nKey.animeStatusWatching

返回值: 当前站点语言下该键的译文(字符串)。

抛出: 无。空语言回退 "en";未知 locale 回退 defaultTranslation;键访问由 Record 索引完成,键集经类型约束为完备。

定义:

typescript
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 返回英文包。

typescript
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

  1. 新增语言:唯一官方扩展点,见「Configuration Options」的四步流程;类型系统自动强制键完备性。
  2. 新增键:在 I18nKey 枚举中加一行,四个语言包立即编译报错提示补译——这是「先加键、编译驱动补译」的工作流。
  3. 不可扩展项(有意的设计边界):无运行时语言切换、无插值/复数化、无 fallback 链(仅单级英文回退)、无 SSR/客户端语言协商。这些都是静态单语言模型的刻意取舍,而非遗漏。

Sources

(2 files)