第捌話
多言語対応
複数形に対応した翻訳メッセージ、ユーザーの地域の書式に合わせた数値と日付、右から左(RTL)のレイアウト、言語を切り替えての確認
Asuka のアプリは、使う人の言語でメッセージを表示し、その言語の読む向きに合わせてレイアウトされます。メッセージはカタログに置きます。カタログは言語ごとの JSON ファイルで、CLI はそこから、メッセージごとに型付きの関数を持つ Go のパッケージを生成します。
{
"unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}"
}ui.Text(locales.Live.Unread(count)) // "3 unread messages"; "3 ungelesene Nachrichten"表示される内容は count に従い、言語が切り替われば新しい言語にも従います。そのためのコードを書く必要はありません。examples/i18n に、ここで紹介するすべてを使った小さなアプリがあります。
はじめる
asuka i18n init # locales/en.json とその関数
asuka i18n add de ar # locales/de.json と ar.json(翻訳すべきメッセージ入り)カタログを入れた locales ディレクトリがあるアプリなら、設定は要りません。カタログが別の場所にある場合や、アプリを英語以外の言語で書いている場合は、asuka.json で指定します。
{ "i18n": { "dir": "translations", "source": "ja" } }source(デフォルトは en、カタログが一つしかなければその言語)は、アプリを書いている言語です。そのカタログにはすべてのメッセージがあり、ほかの言語のカタログはそれにフォールバックします。アプリが提供する言語は、このディレクトリにあるファイルで決まります。ファイル名は BCP 47 のタグにします(de.json、pt-BR.json、zh-Hans.json)。
asuka dev と asuka build は、起動時と、カタログが変わるたびに関数(locales/catalog_gen.go)を生成します。そのため、翻訳は保存するとすぐに反映されます。生成されたファイルはカタログと一緒にコミットしてください。そうすれば、CLI がなくても go build と go test が動きます。
このパッケージはアプリのコードからインポートしてください。インポートしていないアプリには警告が出ます。そのままではソースの言語しか表示されないためです。
カタログ
カタログは、メッセージを集めた ARB 形式のオブジェクトで、キーがメッセージの名前になります。ソースの言語のカタログでは、@ を付けたキーの下にメッセージの説明も書きます。これは翻訳者のためのもので、エディターのホバー表示にも使われます。
{
"@@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 のものです。英語は one と other、中国語と日本語は other だけ、ロシア語は one、few、many、other、アラビア語は六つあります。
アポストロフィーは、直後の波括弧か、plural のケースの中の # をエスケープします。'{' は { になり、アポストロフィーを二つ重ねると一つになります。それ以外のアポストロフィーはそのまま一つのアポストロフィーなので、don't はエスケープ不要です。
コンポーネントの中で
コンポーネントは一度しか実行されません。そのため、ほかのシグナルと同じく、言語に追従するのはアクセサ、エフェクト、メモの中で読んだものだけです。メッセージにはそれぞれ三つの形があります。
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 などです。
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}文字列の形も、エフェクトの中で読めば言語に追従します。シグナルの値を使って込み入ったテキストを組み立てるときは、こちらを使います。
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()}
})})コンポーネントの本体で、それらの外で取得したメッセージは、一度だけ評価されます。
ui.StaticText(locales.Title()) // 言語が変わっても元のまま
ui.Val(locales.Title()) // 同上CLI はこの二つについて、ファイル名と行番号を示して警告します。一度表示したら消えるもの、たとえばアラートや通知は文字列を受け取ります。
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("") を呼ぶと、再びユーザーの言語に従います。アプリの言語を、それぞれその言語自身の名前で並べたメニューは次のようになります。
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 と同じく、エフェクトの中で読めば変更に追従します。
ui.Text(signal.Func(func() string {
return i18n.FormatDate(ui.FormatLocale(), due.Get(), i18n.DateOptions{Date: i18n.MediumStyle})
}))パッケージ i18n は、ICU やブラウザーの Intl と同じように書式化します。データには、Unicode CLDR のうち modern カバレッジに含まれるロケールのものを使います。
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 の結果と照らし合わせて検証しています。
位置を手で指定するときは次のようにします。
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はアプリの言語になります。
電話番号、メディアプレイヤーのコントロール、コードエディターのように、どの言語でも同じ向きで読むものは、そう指定します。
ui.WithDirection(ui.LeftToRight, func() ui.Node { return playerControls() })ui.IsRTL() はアプリの向きを返します。描画のように、自動では向きに従わないもののために使います。フレーム、OnLayout、OnScroll、ScrollTo は物理的な座標で、左端が基準です。
言語を試す
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 が対応するすべてのプラットフォームで使えます。
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> のキーで、ビルドがそこに書き込む内容を翻訳できます。
{
"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 にあります。
カタログを使わない場合
パッケージ i18n は単独でも使えます。NewCatalog は任意の fs.FS からカタログを読み込み、ParseMessage と Message.Format はメッセージを書式化し、PluralCategory は数値の CLDR のカテゴリーを返し、Match は言語の中から一つを選びます。テキストを自分で翻訳するアプリは、ui.SetLanguages("en", "de") でアプリが対応している言語を Asuka に伝え、エフェクトの中で ui.Locale() を読んでください。