locales/<言語ID>.json を 1 枚置くだけで UI がその言語になる。同梱は
英語 → 日本語 → 简体中文 → 한국어 → Português (Brasil) → Español の 6 つで、
この並びがそのまま選択画面の並び順になる。
zaivern-code/
├── locales/
│ ├── en.json ← 基準言語 (訳が欠けたらここへ落ちる)
│ ├── ja.json ← 原文言語 (ID → 日本語。既存コードとの橋渡しに使う)
│ ├── zh-CN.json
│ ├── ko.json
│ ├── pt-BR.json
│ └── es.json
中身は安定 ID をキーにした平の JSON。
// locales/en.json
{
"app.new_session": "New Session",
"app.settings": "Settings",
"agent.send": "Send",
"agent.stop": "Stop",
"agent.approve": "Approve",
"agent.approve_all": "Approve All",
"agent.broadcast": "Broadcast to All Agents"
}Rust 側は ID を渡すだけ。
ui.button(tr("agent.approve"));| 入口 | 場所 |
|---|---|
| 🌐 メニュー | ツールバー右側 (🎨 の隣)。狭いときは ⋯ の中 |
| メニューバー | 表示 → 表示言語 |
| コマンドパレット | ⌘⇧P → 「表示言語」 |
| 設定画面 | 外観 → 表示言語 |
| プラグイン画面 | english-mode / japanese-mode / chinese-mode / korean-mode / portuguese-mode / spanish-mode の有効/無効 |
| 設定ファイル | ~/.zaivern/config.toml の ui_language |
| CLI | zai lang (一覧) / zai lang set <id> (切替) / zai lang install <id> (導入) |
再起動は要らない。 選んだ次のフレームから全部のラベルが入れ替わる。
言語パックは同時に 1 つだけ有効になる。 どの入口から変えても
ui_language とプラグインの有効/無効が必ず揃うので、「プラグイン画面では
english-mode が有効なのに画面は韓国語」という食い違いは起きない。
1 つも有効にしていない状態が「自動」。
ui_language = "auto" は OS の言語に従う。
| OS | 見るもの |
|---|---|
| Linux / macOS (端末から起動) | LC_ALL → LC_MESSAGES → LANG → LANGUAGE |
| macOS (Finder / Dock から起動) | 上が空なので defaults read -g AppleLocale を裏のスレッドで引く |
| Windows | GetUserDefaultLocaleName |
判定できた言語の辞書が無ければ英語、OS から何も取れなければ日本語
(このアプリの原文言語) にする。「入れただけで画面が別の言語になる」を
起こさないための保険で、macOS で defaults の結果が遅れて届いたときだけ、
そのフレームで 1 度だけ引き直す (毎フレームの費用は atomic 1 回)。
zh_TW は zh-CN へ、pt_PT は pt-BR へ寄る (同梱がそれしか無いあいだ)。
Zaivern Language Pack
↓ (GitHub リポジトリの locales/)
zai lang install
↓
~/.zaivern/locales/
zai lang list --remote # 配布元にある言語を見る (✓ = もう入っている)
zai lang install zh-CN # ~/.zaivern/locales/zh-CN.json へ入れる
zai lang install ko
zai lang set zh-CN # config.toml の ui_language を書き換える
zai lang list # いまの言語と、選べる言語
zai lang remove ko # 入れたファイルを消す (同梱は消せない)配布元は既定でこのビルドの配布元 (install.sh から読む)。
決め打ちしていないので、誰でも自分のリポジトリで配れる:
zai lang install fr --from someone/zaivern-lang-fr
ZAIVERN_LANG_REPO=my-org/lang-packs zai lang list --remoteinstall は置く前に検査する (fail-closed):
| 断る条件 | 理由 |
|---|---|
| JSON として読めない / 最上位がオブジェクトでない | 壊れた辞書を置かない |
| 空 | 入れても何も起きない |
{name} が基準と食い違う |
実行時に穴が開く ({path} が消えた案内など) |
既にある (かつ --force 無し) |
手で直した訳を黙って消さない |
書き込みは一時ファイル + rename。途中で切れても半端なファイルが残らない。 訳が足りないぶんは英語へ落ちるので、部分訳のパックでも入れて使える (何件足りないかは入れたときに出る)。
アプリを再ビルドしなくてよい。 置くだけで一覧に並ぶ。
zai lang export fr # ~/.zaivern/locales/fr.json に雛形を書き出す
$EDITOR ~/.zaivern/locales/fr.json
zai lang check fr # 過不足・プレースホルダを検査 (合わなければ終了コード 1)
zai lang set frGUI からも同じことができる (⌘⇧P → 「表示言語: 翻訳の雛形を書き出す」)。
置き場は 2 か所。先に来たほうが勝つ。
~/.zaivern/locales/<id>.json~/.config/zaivern/locales/<id>.json(OS が返す設定ディレクトリ。 Windows なら%APPDATA%\zaivern\locales)
同梱と同じ ID を書けば同梱の訳を上書きできる。1 行だけ直したいときは、 その 1 行だけの JSON を置けばよい (残りは同梱のまま)。
plugin.toml に [language] を書けば .zvplug 1 個で配れる。
[plugin]
name = "french-mode"
version = "1.0.0"
default_enabled = false # 入れただけで言語が変わるのは驚きなので false
[language]
id = "fr"
name = "Français"
locales = "locales" # このディレクトリ直下に fr.json を置くlocales を省略すると「同梱言語を選ぶだけ」のプラグインになる
(japanese-mode などがこの形。同じ訳を 2 か所に持たせない)。
# 画面に出るのに辞書へ載っていない tr("…") を探す (見つかったら終了コード 1)
zai lang missing
# 翻訳へ回せる形 (シャード JSON) で書き出す
zai lang missing src /tmp/delta.json
# 訳を 6 枚へ取り込む (プレースホルダが合わない訳は採らない)
zai lang apply /tmp/delta-translated.json
# 6 枚が噛み合っているか (番人テストと同じ判定)
zai lang check jalabel() -> &'static str の表のように tr(&x) 経由で届く文字列は静的には
辿れない。そちらは実行時に集める。
ZAIVERN_I18N_TRACE=1 zai .
# 画面をひととおり触ってから
# ⌘⇧P → 「表示言語: 訳が無い文字列を書き出す」集めるのは環境変数を付けたときだけ。既定では tr() は OnceLock を
1 回読むだけで、訳漏れの記録に費用を払わない (設計原則 3)。
このリポジトリの tr() 呼び出しは、もともと日本語の原文そのものを
渡していた (3300 箇所)。
ui.button(tr("承認")); // 既存
ui.button(tr("agent.approve")); // これから書くコードlocales/ja.json は ID → 日本語原文 なので、その逆引き
(日本語原文 → ID) を 1 枚作れば、呼び出し側を 1 行も書き換えずに
全部の文字列が 6 言語になる。tr() の引き先はこの順:
ID → 訳(新しいコード)日本語原文 → 訳(既存の 3300 箇所。原文言語のときは作らない)- 言語プラグインの旧形式辞書 (
[language] dictの TOML。後方互換) - 渡された文字列そのもの
4 があるので、訳が 1 つ欠けても UI は必ず何かを表示する。壊れない。
新しいコードは ID を使うこと。原文を直しても訳が生き残る。
| テスト | 守るもの |
|---|---|
locale::tests::全同梱言語のキー集合が一致する |
6 枚の鍵が完全に同じ |
locale::tests::訳は空でなくプレースホルダも一致する |
{name} が全言語で同じ集合 |
locale::tests::ソースのtrリテラルはすべて同梱辞書から引ける |
画面に日本語が残らない |
locale::tests::利用者が示した正準idは必ずある |
説明に出す ID の名前が変わらない |
locale::tests::同梱の全言語は基準と過不足なく噛み合う |
zai lang check と同じ判定 |
locale::tests::同じ原文を持つidは訳も一致する |
逆引きの勝者が変わっても表示が変わらない |
落ちたら直すのは locales/*.json であってテストではない。
tr("新しい文言") を足したら、zai lang missing で拾って 6 言語ぶん訳を入れる
(zai lang missing src /tmp/d.json → 訳す → zai lang apply /tmp/d.json)。
道具は Rust の zai に入っている (zai i18n … は zai lang … の別名)。走査もエスケープ解除も
src/locale.rs の scan_source_literals / unescape_rust 1 か所だけで、
番人テストと zai i18n が同じ実装を使う (2 つ持つとずれて、番人が
通っているのに画面に日本語が残る、という嘘が出る)。
PC で選んだ言語がそのままスマホの画面にも効く。
配信直前に i18n::export_prefix("remote.") で remote.* だけを取り出し、
window.ZVI18N = {…} としてページへ差し込む。JS 側は
T('remote.save', '💾 保存') と data-i18n 属性で差し替える。
第 2 引数は日本語の原文なので、辞書が届かなくても画面は壊れない。
<html lang> も選択中の言語 ID になる (音声入力の認識言語もこれに追従する)。
JSON は </script> と U+2028/2029 を退避してから埋めるので、訳文に何が
入っていてもページの構文は壊れない。
選択: ui_language ("auto" | 言語ID) ← 🌐 / 設定 / プラグイン / config.toml が全部ここへ集まる
辞書: 同梱 locales/<id>.json
← ~/.config/zaivern/locales/<id>.json で上書き
← ~/.zaivern/locales/<id>.json で上書き (いちばん強い)
← プラグインの [language] locales でも足せる
落ち先: <選択した言語> → (地域変種があれば同語の同梱変種) → en → 渡された文字列そのもの