# 多言語対応

Asuka のアプリは、使う人の言語でメッセージを表示し、その言語の読む向きに合わせてレイアウトされます。メッセージはカタログに置きます。カタログは言語ごとの JSON ファイルで、CLI はそこから、メッセージごとに型付きの関数を持つ Go のパッケージを生成します。

```json
{
  "unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}"
}
```

```go
ui.Text(locales.Live.Unread(count)) // "3 unread messages"; "3 ungelesene Nachrichten"
```

表示される内容は `count` に従い、言語が切り替われば新しい言語にも従います。そのためのコードを書く必要はありません。`examples/i18n` に、ここで紹介するすべてを使った小さなアプリがあります。

## はじめる

```sh
asuka i18n init          # locales/en.json とその関数
asuka i18n add de ar     # locales/de.json と ar.json（翻訳すべきメッセージ入り）
```

カタログを入れた `locales` ディレクトリがあるアプリなら、設定は要りません。カタログが別の場所にある場合や、アプリを英語以外の言語で書いている場合は、`asuka.json` で指定します。

```json
{ "i18n": { "dir": "translations", "source": "ja" } }
```

`source`（デフォルトは `en`、カタログが一つしかなければその言語）は、アプリを書いている言語です。そのカタログにはすべてのメッセージがあり、ほかの言語のカタログはそれにフォールバックします。アプリが提供する言語は、このディレクトリにあるファイルで決まります。ファイル名は [BCP 47](https://www.rfc-editor.org/info/bcp47) のタグにします（`de.json`、`pt-BR.json`、`zh-Hans.json`）。

`asuka dev` と `asuka build` は、起動時と、カタログが変わるたびに関数（`locales/catalog_gen.go`）を生成します。そのため、翻訳は保存するとすぐに反映されます。生成されたファイルはカタログと一緒にコミットしてください。そうすれば、CLI がなくても `go build` と `go test` が動きます。

このパッケージはアプリのコードからインポートしてください。インポートしていないアプリには警告が出ます。そのままではソースの言語しか表示されないためです。

## カタログ

カタログは、メッセージを集めた [ARB](https://github.com/google/app-resource-bundle) 形式のオブジェクトで、キーがメッセージの名前になります。ソースの言語のカタログでは、`@` を付けたキーの下にメッセージの説明も書きます。これは翻訳者のためのもので、エディターのホバー表示にも使われます。

```json
{
  "@@locale": "en",
  "unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}",
  "@unread": {
    "description": "Above the list of messages.",
    "placeholders": { "count": { "type": "int", "example": "3" } }
  },
  "greeting": "Hello, {name}!"
}
```

キーは英字で始まり、そのあとに英字、数字、`_` が続きます。キーに階層はありません。関連するメッセージには `inboxTitle` と `inboxEmpty` のように共通の接頭辞を付けてください。エディターの補完でまとめて並びます。

プレースホルダーの `type` は `String`、`int`、`double`、`num`、`DateTime`、`Object` のいずれかで、それぞれ Go の型（`string`、`int`、`float64`、`float64`、`time.Time`、`any`）になります。`type` がない場合、`plural`、`selectordinal`、`number` で数える引数は `int`、`date` や `time` の引数は `DateTime`、それ以外は `String` になります。

## メッセージ

メッセージは ICU MessageFormat で書きます。ARB ファイル、Android、Format.JS で使われている形式です。

| メッセージ                                                                                                | 表示される内容                                                        |
| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `Hello, {name}!`                                                                                     | その位置に `name` の値                                                |
| `{count, plural, one {# file} other {# files}}`                                                      | メッセージの言語における `count` の複数形カテゴリーに対応するケース。`#` は数値                 |
| `{count, plural, =0 {No files} one {# file} other {# files}}`                                        | `=0` はちょうど 0 のときのケースで、カテゴリーより先に判定される                           |
| `{n, plural, offset:1 =0 {Nobody} =1 {{host}} one {{host} and # other} other {{host} and # others}}` | `offset:1` を付けると、カテゴリーと `#` は `n - 1` で数える                     |
| `{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}`                                  | 序数のカテゴリー：1st、22nd、103rd、11th                                   |
| `{gender, select, female {She} male {He} other {They}} replied.`                                     | 値と同じ名前のケース。なければ `other`                                        |
| `{price, number}`、`{ratio, number, percent}`、`{n, number, integer}`                                  | その言語の書き方の数値（1,234.5、1.234,5、12,34,567）                         |
| `{price, number, ::.00}`、`{n, number, ::group-off}`                                                  | 小数部をちょうど 2 桁にする、または桁区切りを付けない                                   |
| `{day, date, short}`、`{day, time, short}`                                                            | スタイル（`short`、`medium`、`long`、`full`）を指定した日付または時刻               |
| `{day, date, ::yMMMd}`                                                                               | CLDR のスケルトンが指定するフィールドを、その言語の順序で並べた日付（Jan 5, 2026、5. Jan. 2026） |

ケースは入れ子にできます。`plural`、`selectordinal`、`select` には必ず `other` のケースを書きます。複数形のカテゴリーは、メッセージの言語に対する [Unicode CLDR](https://cldr.unicode.org) のものです。英語は `one` と `other`、中国語と日本語は `other` だけ、ロシア語は `one`、`few`、`many`、`other`、アラビア語は六つあります。

アポストロフィーは、直後の波括弧か、plural のケースの中の `#` をエスケープします。`'{'` は `{` になり、アポストロフィーを二つ重ねると一つになります。それ以外のアポストロフィーはそのまま一つのアポストロフィーなので、`don't` はエスケープ不要です。

## コンポーネントの中で

コンポーネントは一度しか実行されません。そのため、ほかのシグナルと同じく、言語に追従するのはアクセサ、エフェクト、メモの中で読んだものだけです。メッセージにはそれぞれ三つの形があります。

```go
locales.Live.Title()            // アクセサ。プロパティに渡す
locales.Live.Unread(count)      // ……引数もアクセサ：シグナル、メモ、ui.Val(3)
locales.Unread(3)               // 文字列。いま表示している言語
locales.In("de").Unread(3)      // 文字列。別の言語
```

たいていのコードでは `Live` を使います。テキストを表示するプロパティはどれも `Live` を受け付けます。`ui.Text`、ボタンの `Title`、タブ、アクション、画面の `Title`、フィールドの `Placeholder`、ビューの `Label` などです。

```go
ui.Button(ui.ButtonProps{Title: locales.Live.Save(), Menu: []ui.Action{
	{Title: locales.Live.Duplicate(), OnPress: duplicate},
}})
ui.Tab{Title: locales.Live.Inbox(), Icon: ui.IconHome, Content: inbox}
```

文字列の形も、エフェクトの中で読めば言語に追従します。シグナルの値を使って込み入ったテキストを組み立てるときは、こちらを使います。

```go
ui.Text(signal.Func(func() string {
	if n := len(items.Get()); n > 0 {
		return locales.Unread(n)
	}
	return locales.AllRead()
}))
ui.Segmented(ui.SegmentedProps{Segments: signal.Func(func() []string {
	return []string{locales.Day(), locales.Week(), locales.Month()}
})})
```

コンポーネントの本体で、それらの外で取得したメッセージは、一度だけ評価されます。

```go
ui.StaticText(locales.Title())      // 言語が変わっても元のまま
ui.Val(locales.Title())             // 同上
```

CLI はこの二つについて、ファイル名と行番号を示して警告します。一度表示したら消えるもの、たとえばアラートや通知は文字列を受け取ります。

```go
ui.Alert(ui.AlertProps{Title: locales.DeleteTitle(), Actions: []ui.Action{
	{Title: locales.Live.Delete(), Role: ui.ActionDestructive, OnPress: remove},
	{Title: locales.Live.Cancel(), Role: ui.ActionCancel},
}})
```

メッセージはどのゴルーチンからでも取得できます。同じ関数で、どこから呼んでも安全です。

## アプリが表示する言語

アプリが表示する言語は一つで、`ui.Locale()` がそれを返します。アプリが対応している言語のうち、ユーザーがいちばんよく読める言語が選ばれます。判断には、ユーザーが優先する言語を、システムに並んでいる順に使います。たとえば `zh-TW` を読むユーザーには `zh-Hant` を選びます。該当するものがなければ、アプリのソースの言語になります。英語だけで書かれたアプリは、UIKit と同じく、中国語を読むユーザーにも英語で表示されます。

ユーザーが優先する言語は、システムの設定から取得します。

* **iOS と macOS**：「設定」の「言語と地域」にある優先する言語。
* **Android**：システムの言語。
* **Windows**：ユーザーの表示言語（「設定」の「時刻と言語」）。
* **Linux**：セッションの `LANGUAGE`。なければ `LC_ALL`、`LC_MESSAGES`、`LANG`。
* **Web**：ブラウザーの言語（`navigator.languages`）。

`ui.SetLocale("de")` を呼ぶと、何も作り直さずに、すぐに別の言語で表示されます。各テキストが新しい値を送り、アプリはレイアウトし直し、画面に出ているものの状態はそのまま保たれます。`ui.SetLocale("")` を呼ぶと、再びユーザーの言語に従います。アプリの言語を、それぞれその言語自身の名前で並べたメニューは次のようになります。

```go
ui.List(ui.ListProps{}, ui.Val(ui.Languages()), func(t string) string { return t },
	func(tag signal.Accessor[string], _ signal.Accessor[int]) ui.Node {
		return ui.View(ui.ViewProps{OnPress: func() { ui.SetLocale(tag.Get()) }},
			ui.Text(signal.Func(func() string { return i18n.Autonym(tag.Get()) })))
	})
```

選んだ言語は次回の起動以降も保持されます。システムがアプリの言語を保持している場所に保存されるので、システムの設定画面にもその言語が表示されます。

* **iOS と macOS**：アプリの `AppleLanguages`。「設定」でアプリごとの言語を選ぶと書き込まれるのもここです。UIKit と AppKit が表示する自分の文言（日付ピッカーの「キャンセル」、Mac の標準メニューなど）は、次回の起動からその言語になります。アプリ自身の文言はすぐに切り替わります。
* **Android**：Android 13 以降では `LocaleManager`。設定画面にも表示され、システムの文言もすぐに切り替わります。それより前のバージョンでは Asuka のプリファレンスに保存します。
* **Windows と Linux**：アプリごとの言語という仕組みがないため、アプリのデータディレクトリにあるファイル（Windows では `locale`、Linux では `asuka-locale`）に保存します。
* **Web**：ページの `localStorage`。

ユーザーがシステムの設定で自分の言語やアプリの言語を変えると、アプリもそれに従います。iOS ではシステムがアプリを再起動し、Android、macOS、Windows では実行中のまま切り替わります。Linux では言語はセッションのもので、アプリの起動時に読み込まれます。ブラウザーの言語はページの読み込み時に読み込まれます。

アプリが自分で選んだ言語で何かを表示するときは、追従できる場所でその言語を読んでください。`ui.Locale()` はどこから呼んでも安全で、これを呼ぶエフェクトは言語が変わると再実行されます。`ui.PreferredLanguages()` は、アプリが対応しているかどうかに関係なく、ユーザーが読める言語を返します。

## メッセージの選ばれ方

メッセージは、カタログの言語のうち、求められた言語にいちばん合うものから選ばれます。`zh-TW` なら `zh-Hant`、`pt-AO` なら `pt-PT`、`en-AU` なら `en-GB` です。その言語にメッセージがなければ、CLDR の親ロケール（`en-AU`、`en-001`、`en`。`zh-Hant-HK`、`zh-Hant`）をたどってフォールバックし、最後はソースの言語になります。翻訳が欠けていても何も表示されないことはなく、ソースのメッセージが表示されます。

## 数値と日付

日付、時刻、数値の書き方は、言語とは別の「地域のフォーマット」という設定です。`ui.CurrentRegion()` がそれを返します。返すのは、フォーマットのロケール（アプリが表示している言語とは別の言語のものでもかまいません）、週の始まりの曜日、時刻が 12 時間制か 24 時間制かで、ユーザーがシステムの設定で変えた内容も反映されます。システムが教えてくれない項目は、その地域の CLDR のデータで補います。

Mac では「言語と地域」の設定、Windows ではユーザーの地域のフォーマット、Linux では `LC_ALL`、`LC_TIME`、`LANG` と GNOME の時計の形式、ブラウザーでは `Intl` が返す最初の言語のものを使います。

メッセージは、数値や日付をこの方法で自動的に書式化します。自分で書式化するときは、パッケージ `i18n` と `ui.FormatLocale()` を使います。`ui.FormatLocale()` は、Apple の `Locale.current` と同じように言語と地域のフォーマットを一つにまとめたもので、たとえばイギリスのフォーマットで読む英語なら `en-GB-u-fw-mon-hc-h23` になります。`ui.Locale` と同じく、エフェクトの中で読めば変更に追従します。

```go
ui.Text(signal.Func(func() string {
	return i18n.FormatDate(ui.FormatLocale(), due.Get(), i18n.DateOptions{Date: i18n.MediumStyle})
}))
```

パッケージ `i18n` は、ICU やブラウザーの `Intl` と同じように書式化します。データには、Unicode CLDR のうち modern カバレッジに含まれるロケールのものを使います。

```go
i18n.FormatNumber("de", 1234567.891, i18n.NumberOptions{})                 // 1.234.567,891
i18n.FormatNumber("hi", 1234567, i18n.NumberOptions{})                     // 12,34,567
i18n.FormatNumber("fa", 0.25, i18n.NumberOptions{Percent: true})           // ۲۵٪
i18n.FormatNumber("en", 1.5, i18n.NumberOptions{MinFractionDigits: 2})     // 1.50
i18n.FormatDate("en", t, i18n.DateOptions{Date: i18n.FullStyle})           // Monday, January 5, 2026
i18n.FormatDate("ja", t, i18n.DateOptions{Date: i18n.LongStyle, Time: i18n.ShortStyle}) // 2026年1月5日 13:07
i18n.FormatDate("ru", t, i18n.DateOptions{Skeleton: "yMMMM"})              // январь 2026 г.
```

`Months`、`Weekdays`、`DayPeriods` は月、曜日、午前・午後などの名前を、`Autonym` は言語のその言語自身での名前（"Deutsch"、"简体中文"）を、`FirstWeekday` と `HourCycle` は地域の週の始まりと時計の形式を返します。日付はグレゴリオ暦です。UTC 以外のタイムゾーンは、ICU が名前で表すものでも、オフセット（`GMT+8`）で書きます。

## 右から左

右から左に書く言語では、コードを書かなくてもアプリが右から順にレイアウトされます。`Row` は右から並び、`Justify` と `Align` もそれに従い、テキストは右端から始まります。プラットフォームのビューも左右反転します（ナビゲーションバーとその戻るジェスチャー、タブバー、プッシュとポップ、スライダー、リストのスワイプアクション）。横方向の `Scroll` は右端から始まります。レイアウトは CSS の `direction` に従い、Chrome の結果と照らし合わせて検証しています。

位置を手で指定するときは次のようにします。

```go
ui.Style{Padding: ui.Edges{Start: 16}}          // テキストが始まる側に
ui.Style{Absolute: true, End: ui.Pt(8)}         // テキストが終わる側から
ui.Style{Margin: ui.Edges{Left: 16}}            // どの言語でも左に
```

CSS と同じく、`Left` と `Right` は文字どおりの位置から動きません。左右反転させたいもの、つまりほとんどすべてには `Start` と `End` を使ってください。`TextStart` はテキストが始まる側で、これがデフォルトです。

`forward` や `backward` で向きを表す名前のシンボルは左右反転します（`ui.IconForward`、`chevron.forward`、`arrow.backward`）。`chevron.left` は反転しません。メディアプレイヤーの巻き戻しのように、どの言語でも左を向くもののためのシンボルです。

テキストの一行は、その文字自体の向きで読まれます。アラビア語のアプリの中にある英語の文は右側に置かれますが、ピリオドは文末に残ります。どちらの側に置くかはアプリの向きで決まり、文字の並びは Unicode の双方向アルゴリズムで決まります。

各プラットフォーム純正のビューは、そのプラットフォームのアプリと同じように左右反転します。

* **macOS**：AppKit のビューは右から左になり、ツールバーとタブは右から並びます。AppKit はアプリの向きを起動時に一度だけ読みます。そのため、右から左に読む言語のアプリは最初のフレームから右から左になります。一方、実行中に向きが変わった場合、ウィンドウのボタンとタイトル、縦スクロールバーの位置、アラートのボタンの順序は、次に起動するまで元のままです。
* **Windows**：タイトルバーとメニューバーが左右反転し（ウィンドウのボタンが左に来ます）、ホストのヘッダーバー、タブ、コントロールは右から左になります。ただし、Windows が自分で描画する日付ピッカーは反転しません。
* **Linux**：GTK のウィジェットが向きを受け継ぎます。ヘッダーバー、ナビゲーション、ポップオーバーも含みます。
* **Web**：アプリの要素に `dir="rtl"` が付き、ページの `lang` はアプリの言語になります。

電話番号、メディアプレイヤーのコントロール、コードエディターのように、どの言語でも同じ向きで読むものは、そう指定します。

```go
ui.WithDirection(ui.LeftToRight, func() ui.Node { return playerControls() })
```

`ui.IsRTL()` はアプリの向きを返します。描画のように、自動では向きに従わないもののために使います。フレーム、`OnLayout`、`OnScroll`、`ScrollTo` は物理的な座標で、左端が基準です。

## 言語を試す

```sh
asuka dev --platform ios --lang de .       # この実行だけドイツ語で
asuka dev --platform ios --lang en-XA .    # [Šéļéçţ ƒîļéš one two]
asuka dev --platform ios --lang ar-XB .    # 各単語を右から左に、レイアウトも反転
asuka dev --platform ios --dir rtl .       # 翻訳なしで、どんなアプリも右から左に
```

`--lang` と `--dir` は、デスクトップと Web を含め、`asuka dev` が対応するすべてのプラットフォームで使えます。

```sh
asuka dev --lang ar .                      # このコンピューターで：macOS、Windows、Linux
asuka dev --platform web --dir rtl .       # ブラウザーで
```

この二つの疑似ロケールを使うと、Android の疑似ロケールと同じように、翻訳がまだなくてもレイアウトをテストできます。`en-XA` は、ソースのメッセージの文字にアクセント記号を付け、長さを 3 分の 1 ほど伸ばし、角括弧で囲みます。プレーンなまま表示されるテキストはカタログに入っていません。切れてしまうテキストは、表示する場所が狭すぎます。いくつもの角括弧でできた文は断片の組み合わせなので、翻訳で語順を入れ替えられません。`ar-XB` では、左右反転しないものがわかります。`go run` では、`ASUKA_LOCALE=de` と `ASUKA_DIRECTION=rtl` で同じことができます。

## カタログのチェック

`asuka dev`、`asuka build`、`asuka i18n check` はカタログをチェックします。次のものはエラーになり、処理が止まります。

* 正規のタグの名前が付いていないファイル、または `@@locale` がファイル名と異なるファイル
* パースできないメッセージ
* 翻訳にあってソースにないキー、または Asuka にメッセージがない `asuka.<key>` のキー
* 翻訳にあってソースのメッセージにない引数、または型に合わない使い方をしている引数（`plural` で数える `String` など）
* 生成される関数の名前が同じになってしまう二つのキー

次のものは警告で、処理は止まりません。翻訳に欠けているメッセージ（ソースの言語のメッセージが表示されます）、翻訳で省かれている引数、その言語にない複数形のケース（英語の `few`）や、その言語にあるのに欠けているケース（ロシア語の `few`。代わりに `other` が表示されます）、メッセージで使われていないプレースホルダーです。

## システムが表示するもの

`asuka build` は、カタログの言語を宣言します（iOS と macOS では `CFBundleLocalizations` と `.lproj`、Android では `locales_config`）。この宣言があるので、iOS と macOS は自分の文言をそれらの言語で表示し、「設定」にアプリの言語の選択肢が出ます。Windows と Linux のビルドは何も宣言しません。アプリごとの言語という仕組みがないためです。カタログの `asuka.<name>` のキーで、ビルドがそこに書き込む内容を翻訳できます。

```json
{
  "asuka.appName": "Notizen",
  "asuka.NSCameraUsageDescription": "Um Belege zu scannen.",
  "asuka.widget.steps.name": "Schritte",
  "asuka.widget.steps.description": "Deine Schritte heute.",
  "asuka.shortcut.newNote": "Neue Notiz"
}
```

## Asuka 自身の文言

プラットフォームが自分の文言を表示するので、Asuka 自身が表示する文言はわずかです。ボタンが一つだけのアラートのボタン、検索フィールドのプレースホルダー、デスクトップの「戻る」、Mac の「（アプリ名）について」「隠す」「終了」、ステッパーのラベルです。これらは主要な言語に翻訳済みで、アプリのカタログで `asuka.<key>` のキーを使えば、変更したりほかの言語を追加したりできます。たとえば `de.json` に `"asuka.ok": "Verstanden"` と書きます。キーの一覧は [internal/l10n/messages/en.json](https://github.com/egoist/asuka/blob/main/internal/l10n/messages/en.json) にあります。

## カタログを使わない場合

パッケージ `i18n` は単独でも使えます。`NewCatalog` は任意の `fs.FS` からカタログを読み込み、`ParseMessage` と `Message.Format` はメッセージを書式化し、`PluralCategory` は数値の CLDR のカテゴリーを返し、`Match` は言語の中から一つを選びます。テキストを自分で翻訳するアプリは、`ui.SetLanguages("en", "de")` でアプリが対応している言語を Asuka に伝え、エフェクトの中で `ui.Locale()` を読んでください。
