插件国际化
国际化(i18n)让你的插件服务全球用户。本文介绍如何为 Obsidian 插件添加多语言支持。
为什么需要国际化?
| 指标 | 仅英文 | 多语言 |
|---|---|---|
| 潜在用户 | 英文用户 | 全球用户 |
| 用户满意度 | 中 | 高 |
| 社区贡献 | 少 | 多(翻译贡献) |
| 竞争力 | 低 | 高 |
架构设计
语言文件结构
text
src/
├── lang/
│ ├── en.ts # 英文(默认)
│ ├── zh-cn.ts # 简体中文
│ ├── zh-tw.ts # 繁体中文
│ ├── ja.ts # 日语
│ ├── ko.ts # 韩语
│ ├── de.ts # 德语
│ ├── fr.ts # 法语
│ └── index.ts # 语言管理
├── main.ts
└── ...定义翻译接口
typescript
// src/lang/index.ts
export interface LocaleStrings {
// 通用
"common.save": string;
"common.cancel": string;
"common.delete": string;
"common.edit": string;
"common.search": string;
"common.close": string;
"common.confirm": string;
"common.enable": string;
"common.disable": string;
// 设置
"settings.title": string;
"settings.apiKey": string;
"settings.apiKeyDesc": string;
"settings.maxResults": string;
"settings.maxResultsDesc": string;
"settings.language": string;
"settings.languageDesc": string;
// 命令
"commands.search": string;
"commands.insert": string;
"commands.refresh": string;
// 通知
"notice.saved": string;
"notice.deleted": string;
"notice.error": string;
"notice.noResults": string;
// 视图
"view.title": string;
"view.empty": string;
"view.loading": string;
}英文翻译(默认)
typescript
// src/lang/en.ts
import type { LocaleStrings } from "./index";
export const en: LocaleStrings = {
// 通用
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.search": "Search",
"common.close": "Close",
"common.confirm": "Confirm",
"common.enable": "Enable",
"common.disable": "Disable",
// 设置
"settings.title": "My Plugin Settings",
"settings.apiKey": "API Key",
"settings.apiKeyDesc": "Enter your API key for the service",
"settings.maxResults": "Max Results",
"settings.maxResultsDesc": "Maximum number of results to display",
"settings.language": "Language",
"settings.languageDesc": "Display language for the plugin",
// 命令
"commands.search": "Search notes",
"commands.insert": "Insert template",
"commands.refresh": "Refresh data",
// 通知
"notice.saved": "Settings saved",
"notice.deleted": "Item deleted",
"notice.error": "An error occurred",
"notice.noResults": "No results found",
// 视图
"view.title": "My Plugin",
"view.empty": "No items to display",
"view.loading": "Loading...",
};中文翻译
typescript
// src/lang/zh-cn.ts
import type { LocaleStrings } from "./index";
export const zhCn: LocaleStrings = {
// 通用
"common.save": "保存",
"common.cancel": "取消",
"common.delete": "删除",
"common.edit": "编辑",
"common.search": "搜索",
"common.close": "关闭",
"common.confirm": "确认",
"common.enable": "启用",
"common.disable": "禁用",
// 设置
"settings.title": "我的插件设置",
"settings.apiKey": "API 密钥",
"settings.apiKeyDesc": "输入服务的 API 密钥",
"settings.maxResults": "最大结果数",
"settings.maxResultsDesc": "显示的最大结果数量",
"settings.language": "语言",
"settings.languageDesc": "插件的显示语言",
// 命令
"commands.search": "搜索笔记",
"commands.insert": "插入模板",
"commands.refresh": "刷新数据",
// 通知
"notice.saved": "设置已保存",
"notice.deleted": "已删除",
"notice.error": "发生错误",
"notice.noResults": "未找到结果",
// 视图
"view.title": "我的插件",
"view.empty": "暂无内容",
"view.loading": "加载中...",
};日语翻译
typescript
// src/lang/ja.ts
import type { LocaleStrings } from "./index";
export const ja: LocaleStrings = {
// 通用
"common.save": "保存",
"common.cancel": "キャンセル",
"common.delete": "削除",
"common.edit": "編集",
"common.search": "検索",
"common.close": "閉じる",
"common.confirm": "確認",
"common.enable": "有効",
"common.disable": "無効",
// 设置
"settings.title": "プラグイン設定",
"settings.apiKey": "APIキー",
"settings.apiKeyDesc": "サービスのAPIキーを入力",
"settings.maxResults": "最大結果数",
"settings.maxResultsDesc": "表示する最大結果数",
"settings.language": "言語",
"settings.languageDesc": "プラグインの表示言語",
// 命令
"commands.search": "ノートを検索",
"commands.insert": "テンプレートを挿入",
"commands.refresh": "データを更新",
// 通知
"notice.saved": "設定を保存しました",
"notice.deleted": "削除しました",
"notice.error": "エラーが発生しました",
"notice.noResults": "結果が見つかりません",
// 视图
"view.title": "マイプラグイン",
"view.empty": "表示する項目がありません",
"view.loading": "読み込み中...",
};语言管理器
实现语言管理
typescript
// src/lang/index.ts
import { en } from "./en";
import { zhCn } from "./zh-cn";
import { ja } from "./ja";
import type { LocaleStrings } from "./index";
// 所有可用的语言
const locales: Record<string, LocaleStrings> = {
en,
"zh-cn": zhCn,
"zh-tw": zhCn, // 繁体中文可复用简体,后续替换
ja,
};
// 支持的语言列表
export const supportedLocales = [
{ code: "en", name: "English" },
{ code: "zh-cn", name: "简体中文" },
{ code: "zh-tw", name: "繁體中文" },
{ code: "ja", name: "日本語" },
{ code: "ko", name: "한국어" },
{ code: "de", name: "Deutsch" },
{ code: "fr", name: "Français" },
];
// 当前语言
let currentLocale = "en";
// 获取 Obsidian 系统语言
function getSystemLocale(): string {
// @ts-ignore
const lang = window.localStorage.getItem("language")
|| navigator.language
|| "en";
return lang.toLowerCase();
}
// 设置语言
export function setLocale(code: string): void {
if (locales[code]) {
currentLocale = code;
} else {
// 尝试匹配语言族
const prefix = code.split("-")[0];
const match = Object.keys(locales).find((k) => k.startsWith(prefix));
currentLocale = match || "en";
}
}
// 获取翻译文本
export function t(key: keyof LocaleStrings): string {
const locale = locales[currentLocale] || locales.en;
return locale[key] || locales.en[key] || key;
}
// 初始化(在插件加载时调用)
export function initI18n(userLocale?: string): void {
if (userLocale && locales[userLocale]) {
currentLocale = userLocale;
} else {
currentLocale = getSystemLocale();
}
}在插件中使用
初始化
typescript
// main.ts
import { Plugin } from "obsidian";
import { initI18n, t } from "./lang";
export default class MyPlugin extends Plugin {
async onload() {
// 初始化国际化
initI18n(this.settings.language);
// 注册命令
this.addCommand({
id: "search",
name: t("commands.search"),
callback: () => this.search(),
});
// 注册设置
this.addSettingTab(new MySettingTab(this.app, this));
}
}在设置面板中使用
typescript
// src/settings.ts
import { PluginSettingTab, App, Setting } from "obsidian";
import { t, supportedLocales, setLocale } from "./lang";
import type MyPlugin from "./main";
export class MySettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: t("settings.title") });
// 语言设置
new Setting(containerEl)
.setName(t("settings.language"))
.setDesc(t("settings.languageDesc"))
.addDropdown((dropdown) => {
for (const locale of supportedLocales) {
dropdown.addOption(locale.code, locale.name);
}
dropdown.setValue(this.plugin.settings.language || "en");
dropdown.onChange(async (value) => {
this.plugin.settings.language = value;
await this.plugin.saveSettings();
setLocale(value);
this.display(); // 重新渲染设置面板
});
});
// API Key 设置
new Setting(containerEl)
.setName(t("settings.apiKey"))
.setDesc(t("settings.apiKeyDesc"))
.addText((text) =>
text
.setPlaceholder("sk-...")
.setValue(this.plugin.settings.apiKey || "")
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
})
);
}
}在视图中使用
typescript
// src/views/MyView.ts
import { ItemView, WorkspaceLeaf } from "obsidian";
import { t } from "../lang";
import type MyPlugin from "../main";
export class MyView extends ItemView {
constructor(leaf: WorkspaceLeaf, private plugin: MyPlugin) {
super(leaf);
}
getViewType(): string {
return "my-plugin-view";
}
getDisplayText(): string {
return t("view.title");
}
async onOpen() {
const container = this.containerEl.children[1] as HTMLElement;
container.empty();
// 使用翻译文本
container.createEl("h2", { text: t("view.title") });
const searchInput = container.createEl("input", {
type: "text",
placeholder: t("common.search"),
});
}
}在通知中使用
typescript
import { Notice } from "obsidian";
import { t } from "./lang";
// 保存成功
new Notice(t("notice.saved"));
// 发生错误
new Notice(t("notice.error"));翻译管理
Crowdin 集成
使用 Crowdin 管理社区翻译:
- 创建
crowdin.yml配置:
yaml
project_id: "your-project-id"
api_token_env: CROWDIN_TOKEN
base_path: "."
base_url: "https://api.crowdin.com"
preserve_hierarchy: true
files:
- source: "/src/lang/en.ts"
translation: "/src/lang/%locale%.ts"
type: "ts"
languages_mapping:
locale:
"zh-CN": "zh-cn"
"zh-TW": "zh-tw"
"ja": "ja"
"ko": "ko"- 在 GitHub Actions 中自动同步:
yaml
# .github/workflows/crowdin.yml
name: Crowdin Sync
on:
schedule:
- cron: "0 0 * * 0" # 每周日同步
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Crowdin Download
uses: crowdin/github-action@v2
with:
upload_sources: true
download_translations: true
localization_branch_name: l10n
env:
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}翻译贡献指南
在 README 中添加翻译贡献说明:
markdown
## 翻译贡献
我们欢迎社区贡献翻译!
### 如何添加新语言
1. 复制 `src/lang/en.ts` 到 `src/lang/{语言代码}.ts`
2. 翻译所有文本
3. 在 `src/lang/index.ts` 中导入并注册
4. 提交 PR
### 翻译规范
- 保持简洁,避免过长文本
- 使用目标语言的自然表达,不要直译
- 注意标点符号规范(如中文使用全角标点)
- 术语保持一致最佳实践
1. 使用扁平键名
typescript
// ✅ 好的实践
"settings.apiKey": "API Key"
"settings.apiKeyDesc": "Enter your API key"
// ❌ 避免嵌套
"settings": {
"apiKey": "API Key"
}2. 支持插值
typescript
// 语言文件
"notice.fileCount": "{count} 个文件" // 中文
"notice.fileCount": "{count} files" // 英文
// 使用函数
function t(key: string, params?: Record<string, any>): string {
let text = locales[currentLocale]?.[key] || locales.en[key] || key;
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, String(v));
}
}
return text;
}
// 使用
t("notice.fileCount", { count: 42 });3. 处理复数
typescript
// 简单方案:为不同数量提供不同翻译
"items.one": "{count} 个项目"
"items.other": "{count} 个项目"
// 使用
const key = count === 1 ? "items.one" : "items.other";
t(key, { count });4. 避免拼接字符串
typescript
// ❌ 不要拼接
const msg = t("common.hello") + " " + name;
// ✅ 使用插值
"common.helloName": "你好,{name}!"
const msg = t("common.helloName", { name });常见问题
如何检测 Obsidian 的界面语言?
typescript
// 方法1:从 localStorage 获取
const lang = window.localStorage.getItem("language");
// 方法2:从 navigator 获取
const lang = navigator.language;翻译缺失时怎么办?
始终提供英文作为回退,确保翻译缺失时显示英文而非代码键:
typescript
function t(key: string): string {
return locales[currentLocale]?.[key] || locales.en[key] || key;
}如何处理 RTL 语言?
css
/* 阿拉伯语等 RTL 语言适配 */
[dir="rtl"] .my-plugin-container {
direction: rtl;
text-align: right;
}