Skip to content

插件国际化

国际化(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 管理社区翻译:

  1. 创建 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"
  1. 在 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;
}

相关资源