kanna

kanna / i18n

kanna-i18n

v0.0.10 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release English

kanna-i18n は、ロケールファイルのディレクトリを Go のコードにコンパイルします。生成されるのは、すべてのメッセージの型付きコンストラクタと翻訳そのものなので、実行時に何かをパースしたり読み込んだりすることはありません。

kanna コードジェネレータファミリーの 1 つです。Go 1.26 以上が必要です。

インストール

go get -tool github.com/go-kanna/i18n/cmd/kanna-i18n

生成コードは、このモジュールのルートにあるランタイム github.com/go-kanna/i18n を呼び出します。上のコマンドで依存に追加されます。ランタイムが行うのは、実行時の値に左右される処理です。言語の選択、CLDR の複数形の選択、ロケールに合わせた数値の整形を担います。

使い方

言語ごとに YAML ファイルを 1 つ用意し、言語タグをファイル名にします:

# locales/en.yaml
greeting: "Hello!"
hello: "Hello, {name}!"
items_count:
  plural:
    one: "You have {count} item."
    other: "You have {count} items."
total_price: "Total: {price:number}"
user:
  not_found: "User not found."
# locales/ja.yaml
greeting: "こんにちは!"
hello: "こんにちは、{name}さん!"
items_count:
  plural:
    other: "アイテムが{count}個あります。"
total_price: "合計: {price}"
user:
  not_found: "ユーザーが見つかりません。"
//go:generate go tool kanna-i18n

デフォルトでは、この行は locales/ を読み込み、messages/i18n_gen.go を書き出します。内容は、メッセージごとのコンストラクタ、Localizer 関数、全言語の翻訳です。

// Code generated by kanna-i18n. DO NOT EDIT.

package messages

import (
	"github.com/go-kanna/i18n"
)

// Hello returns the message "hello":
//
//	Hello, {name}!
func Hello(name string) i18n.Message {
	return i18n.Message{Key: "hello", Args: []i18n.Arg{
		{Name: "name", Value: name},
	}}
}

// ItemsCount returns the message "items_count", in the form count selects:
//
//	one:   You have {count} item.
//	other: You have {count} items.
func ItemsCount(count int) i18n.Message {
	return i18n.Message{Key: "items_count", Args: []i18n.Arg{
		{Name: "count", Value: count},
	}}
}

// ...

// Localizer renders this package's messages in the language that best matches
// langs, each a language tag or an Accept-Language value, tried in order.
// A message missing from that language falls back to its parent languages and
// then to en.
func Localizer(langs ...string) i18n.Localizer {
	return bundle.Localizer(langs...)
}

// bundle holds every language this package was generated from, en, the
// default, first.
var bundle = i18n.NewBundle(
	i18n.Catalog{Lang: "en", Entries: map[string]i18n.Entry{
		"hello": {Single: i18n.Template{{Text: "Hello, "}, {Param: "name"}, {Text: "!"}}},
		"items_count": {Plural: &i18n.Plural{
			One:   i18n.Template{{Text: "You have "}, {Param: "count"}, {Text: " item."}},
			Other: i18n.Template{{Text: "You have "}, {Param: "count"}, {Text: " items."}},
		}},
		// ...
	}},
	i18n.Catalog{Lang: "ja", Entries: map[string]i18n.Entry{
		// ...
	}},
)

各コンストラクタのドキュメントにはデフォルト言語の文言が載るので、エディタは、メッセージを呼び出している箇所でその文言を表示します。

コンストラクタが返すのは文字列ではなく i18n.Message です。読み手の言語を知らないコードはこれを(たとえばエラーの中に入れて)返すことができ、言語を知っているコードがレンダリングします:

loc := messages.Localizer(r.Header.Get("Accept-Language"))
fmt.Fprintln(w, loc.Localize(messages.Hello("Alice")))      // Hello, Alice! or こんにちは、Aliceさん!
fmt.Fprintln(w, loc.Localize(messages.ItemsCount(1)))       // You have 1 item. or アイテムが1個あります。
fmt.Fprintln(w, loc.Localize(messages.TotalPrice(1234.56))) // Total: 1,234.56 or 合計: 1,234.56

デフォルト言語に合わない翻訳は、それに当たった最初のリクエストを止めるのではなく、生成を止めます。上の ja.yaml がプレースホルダとキーを書き間違え、price に独自の種別を指定していた場合は、次のようになります:

$ go generate ./...
locales/ja.yaml:2:8: error: parameter "namae" of key "hello" is not in en
  did you mean "name"?
locales/ja.yaml:6:14: error: parameter "price" of key "total_price" is int, but number in en
locales/ja.yaml:8:3: error: key "user.not_fund" is not in en
  did you mean "user.not_found"?
locales/ja.yaml: warning: missing keys: user.not_found

ロケールファイル

ロケールディレクトリはフラットで(言語ごとに 1 ファイル、再帰なし)、拡張子を除いたファイル名が言語を表します。en.yaml、pt-BR.yaml、ja.yml のような形です。言語のような名前のものが、黙ってスキップされることはありません。locales/en/ や locales/en.yaml/ のようなサブディレクトリ、en.json や en.toml のような別の形式のファイルは、生成を止めます。それ以外のファイルは警告を出してスキップするので、紛れ込んだ config.yaml で実行が失敗することはありません。ドットで始まる名前は警告なしでスキップします。

ネストしたマッピングはドットでつないだキーになり、キーはコンストラクタ名になります。user.not_found からは UserNotFound() が生成されます。キーの各部分は [a-z][a-z0-9_]* に一致します。2 つのキーが同じ名前を生成する場合や、出力先パッケージが既に宣言している名前をキーが生成する場合は、生成を止めます。キー localizer(その名前は生成ファイル自身が使います)と、出力先パッケージに手で宣言された Localizer も同様です。

値はすべて文字列かマッピングです。"true" や "123" のように、YAML が文字列以外として読む値はクォートしてください。メッセージには、改行とタブ以外の制御文字も、バイトオーダーマークも含められません。

複数形

メッセージの複数形は、明示的な plural マッピングの下に宣言します。キーは CLDR のカテゴリ(zero、one、two、few、many、other)で、other は必ず定義します。たまたま one や other という名前であるだけのキーは、普通のネストです。ロケールファイルで自由に使えない名前は、値がマッピングの plural だけで、その横に他のキーは置けません。ファイルのルートには、plural が形を与える対象のメッセージがないので、ここでの plural も普通のネストです。

コンストラクタは最初の引数として count int を取り、レンダリングする言語の複数形ルールに従って、その個数が形を選びます。英語では 1 は one ですが、日本語には one がなく、どの個数にも other を使います。複数形のメッセージに、その言語が使う形が欠けていると警告が出ます。該当する個数が other でレンダリングされてしまうからです。other だけのロシア語には one、few、many が欠けていますが、other だけの日本語に欠けているものはありません。

個数 0 は、メッセージに zero があれば必ず zero でレンダリングされます。Rails と同じです。そのため、英語に zero カテゴリはありませんが、en.yaml の zero: "You have no items." は機能します。ラトビア語のように、ルールに zero カテゴリがある言語では、そのカテゴリに属する他の個数も zero になります。たとえば 10 から 20 です。

プレースホルダ

形式 パラメータ レンダリング結果
{name} string そのまま
{name:int} int そのまま: 個数、ID
{name:number} float64 言語の書式で、小数は最大 3 桁: en では 1,234.56、de では 1.234,56
{{, }} リテラルの波括弧

{name:string} は {name} と同じで、複数形のメッセージの count は常に int です。number は、その言語の CLDR のデフォルトパターンどおりに書き出され、このパターンは小数部を最大 3 桁までしか残しません。1234.5678 は 1,234.568 とレンダリングされます。1 つのメッセージの中で、種別なしで書かれたプレースホルダは、別の箇所(たとえば別の複数形)に書かれた種別を引き継ぎます。異なる 2 つの種別を書くとエラーになります。各パラメータの型はデフォルト言語が決めます。翻訳では、上の ja.yaml の price のように種別を省略でき、異なる種別を書くと生成を止めます。

パラメータは最初に現れた順に並びます。count が先頭で、複数形のメッセージの形はカテゴリの順(zero から other まで)に読みます。名前は camelCase で、{user_name} は userName になります。Go が予約または事前宣言している名前には Arg が付きます。{type} は typeArg に、{error} は errorArg になります。

言語

デフォルト言語(-default で指定し、指定がなければ en)がコンストラクタを定義します。コンストラクタの名前、パラメータ、型はそのファイルから決まり、他のファイルはすべて、これを基準にチェックされます。地域を含まないデフォルトに完全に一致するファイルがない場合は、地域バリアントのファイルが使われます。-default en は、en.yaml がなければ en-US.yaml を使い、en-GB.yaml のつもりだった可能性もある場合は警告します。-default en-US のように地域や文字体系を含むデフォルトには、それ自身のファイルしか使われません。en.yaml も en-GB.yaml も代わりにはなりません。

Localizer は、"ja" や "pt-BR" のような言語タグ、"fr, ja;q=0.8" のような Accept-Language の値、またはその両方を受け取ります。これらを値ごとに順に試し、パッケージの生成に使われた言語のどれかに近いものが見つかるまで続けます。近いかどうかは CLDR の基準で判定します。en-US のリクエストには en が使われ、nb のリクエストには、パッケージにデンマーク語があってノルウェー語がなければ da が使われます。そのため messages.Localizer(cookie.Value, r.Header.Get("Accept-Language")) は、ユーザーが選んだ言語を優先し、次にブラウザが求める言語を使います。パースできない値は、不正なエントリが 1 つあるだけでも全体がスキップされます。何も一致しなければ、デフォルト言語が使われます。

選ばれた言語にないメッセージは、親言語(en-GB なら en)をたどり、その後デフォルト言語へフォールバックして、見つかった言語の複数形ルールと数値の書式でレンダリングされます。Localize は決して失敗しません。ゼロ値の Localizer はすべてのメッセージをそのキーとしてレンダリングし、引数のないプレースホルダは {name} とレンダリングされます。

バリデーションエラー

kanna-validate と kanna-value は、破られたルールを count 付きの too_long のようにコードとパラメータで報告し、文言は呼び出し側に任せます。Rails の errors.details と同じです。i18n.FullMessages はこれらをフィールド名を含む文としてレンダリングし、i18n.ErrorMessages はフィールドごとにレンダリングします。Rails の errors.full_messages と errors.messages と同じです:

loc := messages.Localizer(lang)
if errs, ok := errors.AsType[validate.Errors](err); ok {
	for _, m := range i18n.FullMessages(loc, errs) {
		fmt.Println(m)
	}
}
// Title can't be blank
// Tags is too long (maximum is 20 characters)
// Confirm doesn't match Password
//
// タイトルを入力してください
// Tagsは20文字以内で入力してください
// 確認用パスワードとパスワードの入力が一致しません

kanna-validate と kanna-value が報告するすべてのコードの文言が組み込まれています。英語は Rails の文言、日本語は rails-i18n の文言です。ロケールファイルでは、フィールドに名前を付け、どの組み込みの文言も上書きし、独自のルールのコードを追加できます:

# locales/ja.yaml
errors:
  attributes:
    title: タイトル
    confirm: 確認用パスワード
    password: パスワード
    address:
      zip: 郵便番号
  messages:
    corp_domain: は会社のアドレスにしてください
  • errors.format は、フィールド名とメッセージをつなげます。英語では "{attribute} {message}"、日本語では "{attribute}{message}" です。
  • フィールド名は errors.attributes で決まります。まずインデックスを除いたパス(address.zip、tags[1] なら tags)で探し、次にその最後の部分(zip)で探します。どちらもなければ、最後の部分を書き下した形になります(completed_at なら Completed at)。confirmation の attribute(チェックの比較先のフィールド)にも同じように名前が付きます。
  • base に報告される、値全体についての問題は、メッセージだけになります。
  • メッセージはまず読み手の言語とその親言語で、アプリケーションの文言、組み込みの文言の順に探し、その後でデフォルト言語を探します。日本語の読み手には、アプリケーションの英語ではなく、組み込みの日本語が表示されます。デフォルト言語が変更していない組み込みの文言を翻訳が上書きしても、デフォルト言語が書き下しに任せているフィールドに翻訳が名前を付けても構いません。日本語は title に名前を付けますが、英語はそのままでも Title になります。

Path、Code、Params メソッドを持つものなら何でもレンダリングできます。このパッケージはバリデーションのパッケージをインポートしません。フィールド名なしのメッセージを 1 つ作るには、i18n.Coded("errors.messages", code, params) を使います。値オブジェクトの生成やデコードで得られる go-kanna/value の *rule.Error にも、Code と Params があります。

どの言語にもないコードは、errors.messages.taken のようにキーとしてレンダリングされるので、文言のないルールは消えずに現れます。

プレースホルダの種別が決めるのは生成されるコンストラクタの型だけで、それ以外には影響しません。ランタイムは種別を一切見ないので、コードのパラメータは、ロケールファイルの記述にかかわらず Go の型に従ってレンダリングされ、種別が一致しなくても何も失敗しません。{count:number} と書いても int がその言語の数値の書式で書き出されることはなく、{count:int} と書いても float64 を締め出すことはできません。count の型が実際に決めるのは複数形です。int のときだけ形が選ばれ、それ以外の count は other になります。too_short、too_long、wrong_length の長さは常に int なので、{count:int} と書くか、複数形で書いてください。greater_than などの数値のコードの境界値は、チェックされたフィールドの型(int、float64、int64 のいずれか)を持つので、種別を省略し、メッセージは複数形にせず 1 つだけ書いてください。

string、int、float64 のいずれでもないパラメータは、i18n.Arg の説明どおり fmt を通してレンダリングされます。フックが count として渡した time.Time は 2000-01-01 00:00:00 +0000 UTC のようになります。別の形で書きたい場合は、メッセージを作る前にパラメータの中の値を置き換えてください。どちらのパッケージの Params もコピーを返します。

バリデーション

エラーは生成を止めます:

  • 翻訳にあってデフォルト言語にないキー
  • 一方の言語では複数形で、もう一方では複数形でないメッセージ
  • デフォルト言語のメッセージにないプレースホルダ、または異なる種別で書かれたプレースホルダ
  • 1 つのメッセージの中で、1 つのプレースホルダに書かれた異なる 2 つの種別
  • 不正な形式のファイル、キー、プレースホルダと、衝突する名前

警告は生成を止めません:

  • 翻訳に欠けているキー。ランタイムがフォールバックするので、翻訳が遅れても生成コードが壊れることは決してありません。翻訳が入るまでは、フォールバック先がレンダリングされます。
  • 言語が使う複数形のうち、メッセージが提供していないもの
  • 曖昧なデフォルト言語と、ロケールディレクトリでスキップされたファイル

キー、プレースホルダ、複数形のカテゴリ、種別のスペルミスには、意図していたと思われる名前が添えられます。

フラグ

kanna-i18n [flags]
フラグ 意味
-locales <dir> ロケールファイルを置くディレクトリ(デフォルトは locales)
-default <lang> コンストラクタを定義し、最後のフォールバック先になる言語(デフォルトは en)
-destination <dir> i18n_gen.go を書き出すディレクトリ(デフォルトは messages)
-package <name> Go ファイルのない出力先のパッケージ名(デフォルトはディレクトリの名前)
-check 書き出す代わりに、出力が最新であることを検証する
-v 書き出した、または変更しなかったファイルを一覧表示する
-version バージョンを表示する

パスは、go generate が実行されるディレクトリ、つまり //go:generate の行があるディレクトリからの相対パスです。生成ファイルのパッケージは、出力先に既にある Go ファイルが宣言しているパッケージです。Go ファイルがまだない場合は -package で指定し、既存のファイルと一致しない -package はエラーになります。成功した実行は、翻訳に欠けているキーなど、バリデーションに挙げた警告以外何も出力しません。i18n_gen.go という名前でも、生成コードのヘッダがないファイルには決して触れません。

メッセージを呼び出すコードは、実行がこれから書き出す内容を組み込んだ状態でチェックされます。対象は、出力先自身のコードと、同じモジュールの中で出力先をインポートしているパッケージのコードで、テストも含みます。実行はこれらを go コマンド経由で読み込みます。キーの削除や改名、プレースホルダの変更は、コードが以前のままのメッセージを呼び出している間は実行を止めます。追加されるメッセージの呼び出しの陰に隠れていた間違いも同様です。このとき、何も書き出されません。実行前からコードにあったエラーでは実行は止まりませんが、出力先(出力自身のパッケージ)のエラーでは止まります。パースできない出力先でも止まります。初回の実行でメッセージが書き出される前から、コードがそのメッセージを呼び出していても構いません。

サンプル

example は、同じメッセージを英語と日本語でレンダリングします。複数形、ロケールに応じた書式の数値、翻訳がないときのフォールバック、Accept-Language の値からの言語の選択を含み、実行時に設定するものはありません。CI はこれを再生成し、出力が変わる場合は失敗します。

開発

make test       # unit tests
make lint       # golangci-lint
make example    # regenerate the example, then build and run it

ライセンス

MIT