国際化(i18n)ガイド
Gurenは国際化を一気通貫でサポートします: ファイルベースの翻訳カタログ、リクエスト単位のロケール検出、コントローラーとReactページの翻訳ヘルパー、15言語超の複数形化ルール、型付き翻訳キー、そしてカタログの整合性チェッカーです。
国際化(i18n)ガイド
Gurenは国際化を一気通貫でサポートします: ファイルベースの翻訳カタログ、リクエスト単位のロケール検出、コントローラーとReactページの翻訳ヘルパー、15言語超の複数形化ルール、型付き翻訳キー、そしてカタログの整合性チェッカーです。
クイックスタート
createAppにi18nオプションを渡し、カタログをlang/<locale>/に置くだけで有効になります:
// src/app.ts
import { createApp } from '@guren/core'
import { registerWebRoutes } from '../routes/web.js'
const app = createApp({
routes: registerWebRoutes,
i18n: {
supported: ['en', 'ja'], // 先頭がデフォルト(フォールバック)ロケール
},
})
// lang/en/messages.json
{
"hello": "Hello",
"welcome": "Welcome, :name!",
"items": "One item|:count items"
}
// lang/ja/messages.json
{
"hello": "こんにちは",
"welcome": "ようこそ、:nameさん!",
"items": ":count個"
}
このオプションひとつで次がすべて配線されます:
- 起動時に
lang/<locale>/*.jsonからサポート対象の全ロケールをロード - 各リクエストのロケールを
?locale=クエリパラメータ→localeクッキー→Accept-Languageヘッダの順で検出(supportedの範囲内のみ) - コントローラーでは
this.t()/this.tc()、InertiaページではuseTranslation()で翻訳 - Inertiaレスポンスは解決済みロケールを
<html lang>に反映し、アクティブなカタログをクライアントへ共有
コントローラーでの翻訳:
import { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'
export default class HomeController extends Controller {
async index() {
return this.inertia(pages.Home, {
message: this.t('messages.welcome', { name: 'Guren' }),
})
}
}
Reactページ・コンポーネントでの翻訳:
import { useTranslation } from '@guren/inertia-client'
export default function Nav() {
const { t, tc, locale } = useTranslation()
return (
<nav>
<span>{t('messages.hello')}</span>
<span>{tc('messages.items', 5)}</span> {/* '5 items' / '5個' */}
</nav>
)
}
ロケールがjaに解決された訪問者には、サーバーレンダリングのテキストもクライアント側のテキストも<html lang="ja">も、追加の配線なしで日本語カタログが適用されます。
翻訳ファイル
カタログはロケールごとのディレクトリに置きます。JSONファイル1つが1つの名前空間になり、ネストしたオブジェクトはドット記法のキーになります:
lang/
├── en/
│ ├── messages.json # キー: messages.*
│ ├── validation.json # キー: validation.*
│ └── errors.json # キー: errors.*
└── ja/
├── messages.json
├── validation.json
└── errors.json
// lang/en/messages.json
{
"hello": "Hello",
"user": {
"profile": "Profile",
"settings": "Settings"
}
}
this.t('messages.hello') // 'Hello'
this.t('messages.user.profile') // 'Profile'
ドットは常にパス区切りです。プロパティ名やファイル名にリテラルのドットが含まれる場合("a.b": "..."、nav.admin.json)は実行時に解決できず、guren check --i18nが報告します。代わりにオブジェクトをネストしてください。
アクティブロケールにないキーはフォールバックロケール(supportedの先頭、またはfallbackオプション)にフォールバックし、どこにもないキーはキー文字列そのものが返ります。
補間
プレースホルダは2つの記法をサポートし、どちらも値をリテラルとして挿入します(置換値がパターンとして解釈されることはありません):
{
"greeting": "Hello, :name!",
"braced": "Hello, {name}!"
}
this.t('messages.greeting', { name: 'World' }) // 'Hello, World!'
this.t('messages.braced', { name: 'World' }) // 'Hello, World!'
複数形化
複数形は|で区切り、tc()で翻訳します。フォームはアクティブロケールのルールで選択され、:countは自動的に置換に使えます:
{
"apple": "apple|apples",
"item": "One item|:count items"
}
this.tc('messages.apple', 1) // 'apple'
this.tc('messages.apple', 5) // 'apples'
this.tc('messages.item', 10) // '10 items'
サポート言語
| 言語 | フォーム数 | ルール |
|---|---|---|
| 英語・ドイツ語・スペイン語・イタリア語・ポルトガル語・オランダ語 | 2 | 1=単数、それ以外は複数 |
| フランス語・ブラジルポルトガル語 | 2 | 0-1=単数、それ以外は複数 |
| 日本語・中国語・韓国語・ベトナム語・タイ語 | 1 | 複数形なし |
| ロシア語・ウクライナ語 | 3 | mod-10/mod-100によるone/few/many(21は「one」、22-24は「few」) |
| ポーランド語・チェコ語・スロバキア語 | 3 | 複雑なルール |
| アラビア語 | 6 | 最も複雑な複数形化 |
3フォーム以上の言語はルール順に列挙します:
// lang/ru/messages.json — one | few | many
{
"apple": "яблоко|яблока|яблок"
}
this.tc('messages.apple', 1) // 'яблоко' (one)
this.tc('messages.apple', 2) // 'яблока' (few)
this.tc('messages.apple', 5) // 'яблок' (many)
this.tc('messages.apple', 21) // 'яблоко' (one — 特殊ケース)
ロケール検出
createApp({ i18n })はロケール検出を自動でマウントします。detectオプションで調整できます:
createApp({
i18n: {
supported: ['en', 'ja'],
fallback: 'en', // 省略時はsupportedの先頭
detect: {
sources: ['cookie', 'header'], // クエリパラメータ検出を外す
queryParam: 'locale', // 以下はデフォルト値
cookieName: 'locale',
},
},
})
Accept-Languageのマッチングは地域サブタグ(ja-JPはjaにマッチ)とq値を理解します。検出は必ずsupported内のロケールに解決されます。
detect: falseを渡すとdetectLocaleMiddlewareを自分でマウントできます。あるいはlocaleコンテキスト変数を設定する独自ミドルウェア(例: ログインユーザーの保存済み設定から)に置き換えることもできます。下流のすべて — this.t()、_i18n、<html lang> — はコンテキスト変数に追従し、検出後に他のミドルウェアが上書きした場合もそちらが優先されます。
Inertiaレスポンスは解決済みロケールをルートの<html lang>属性に使います。レスポンス単位のlangオプションが常に優先されます:
return this.inertia(pages.posts.Show, { post }, { lang: 'ja' })
コントローラー
すべてのコントローラーに、現在のリクエストのロケールにスコープされた翻訳ヘルパーがあります:
export default class PostController extends Controller {
async index() {
this.locale // 'ja'
this.t('messages.hello') // 'こんにちは'
this.t('messages.welcome', { name: 'ゲスト' }) // 補間
this.tc('messages.items', 3) // 複数形化
// ...
}
}
Reactページ
サーバーは解決済みロケールとそのカタログ(アクティブロケール+フォールバック)を_i18n propとしてInertiaページに共有し、useTranslation()がそれを読みます:
import { useTranslation } from '@guren/inertia-client'
const { t, tc, locale } = useTranslation()
クライアント側の翻訳はサーバーのデフォルトセマンティクスと一致します — 補間、複数形、フォールバック解決。同じキーはコントローラーで翻訳してもブラウザで翻訳しても同じ結果になります。(カスタム複数形化ルールやonMissingKeyハンドラなどサーバー専用のTranslatorカスタマイズは関数のためシリアライズされたpropを越えられず、サーバー側のみに適用されます。)サーバーサイドレンダリングも共有props経由なので追加配線は不要です。
Inertiaのpageオブジェクトを既に持っているコード(フックの外)では、propから直接translatorを構築できます:
import { createTranslator, type I18nPageProps } from '@guren/inertia-client'
import type { Page } from '@inertiajs/core'
export function translatorFor(page: Page) {
return createTranslator(page.props._i18n as I18nPageProps)
}
サーバー側だけで翻訳するアプリでは、i18nオプションにshare: falseを設定するとカタログをページpropsに含めません。
型付き翻訳キー
guren codegenはlang/を読んで.guren/translations.gen.tsを生成し、全キーをTypeScriptのユニオン型として登録します。以降、this.t()・this.tc()・useTranslation()のt/tcはキーを補完し、存在しないキーをコンパイル時に拒否します:
this.t('messages.welcome') // ✓ 補完される
this.t('messages.welcmoe') // ✗ コンパイルエラー
bunx guren codegen
開発サーバーはlang/配下の翻訳JSONファイルの変更で自動再生成します。lang/ディレクトリのないアプリではキーは通常のstringのままです。
カタログのチェック
guren checkは翻訳カタログを検証します(--i18nでこの検査だけを実行し、失敗時に非ゼロで終了します — CI向け):
bunx guren check --i18n
報告される項目:
- 不正なJSON — パースできないカタログファイルはローダーがスキップし(コンソール警告のみ)、そのキーは実行時にフォールバックします。
- キーの欠落 — あるロケールにあって別のロケールにないキーは、そのロケールの利用者にフォールバック言語で表示されます(フォールバックにもない場合はキー文字列がそのまま表示されます)。
- プレースホルダの不一致 — 同じキーでロケール間で
:name/{name}が異なる場合、翻訳時に変数が失われている可能性が高いです。警告として報告され(ロケールによっては意図的にプレースホルダを省くこともあるため)、exit codeには影響しません。 - 解決不能なキー — リテラルのドットを含むプロパティ名・ファイル名。実行時に解決できません。
サーバーレスとバンドルカタログ
lang/はファイルシステムから読まれますが、サーバーレス環境(サーバーレスガイド参照)にはファイルシステムがない場合があります。MemoryLoaderでカタログをバンドルしてください:
import { createApp, MemoryLoader } from '@guren/core'
import en from '../lang/en/messages.json'
import ja from '../lang/ja/messages.json'
const app = createApp({
i18n: {
supported: ['en', 'ja'],
loader: new MemoryLoader({
en: { messages: en },
ja: { messages: ja },
}),
},
})
loaderオプションはTranslationLoaderインターフェースを実装するものなら何でも受け付けるため、データベースやリモートサービスからカタログを供給することもできます。なお、codegenとcheck --i18nは規約のlang/ディレクトリを読みます — 上記のようにJSONファイルをlang/に置いたままimportでバンドルすれば、型付きキーとカタログチェックはそのまま機能します。
テスト
翻訳の挙動は実アプリを通してテストするのが最も簡単です — TestAppでラップします(テストガイド参照):
import { describe, test, beforeAll } from 'bun:test'
import { TestApp } from '@guren/testing'
import app from '../src/app.js'
describe('i18n', () => {
let http: TestApp
beforeAll(async () => {
http = await TestApp.fromApp(app)
})
test('?locale=ja で日本語を返す', async () => {
const response = await http.get('/?locale=ja')
response.assertOk()
await response.assertBodyContains('こんにちは')
})
})
カタログ自体のユニットテストにはマネージャを直接構築します:
import { createI18n } from '@guren/core'
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: { messages: { items: 'One item|:count items' } },
},
})
expect(i18n.tc('messages.items', 5)).toBe('5 items')
高度な使い方: マネージャの直接利用
createApp({ i18n })はI18nManagerを管理してくれます(コンテナからi18nで取得可能)。スクリプト、カスタムミドルウェア、HTTP以外の文脈では直接使えます:
import { JsonLoader, createI18n } from '@guren/core'
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
loader: new JsonLoader('./lang', { cache: true }),
})
// マネージャは構築時に何もロードしません — 使うロケールをすべてロードします。
await i18n.loadLocales(['en', 'ja'])
i18n.t('messages.hello')
i18n.tc('messages.items', 5)
i18n.has('messages.hello', 'ja')
i18n.getAvailableLocales()
// 特定ロケールに固定したtranslator — 共有マネージャのsetLocale()と
// 違って並行利用しても安全です。
const ja = i18n.forLocale('ja')
ja.t('messages.hello') // 'こんにちは'
カスタム複数形化ルール:
const translator = i18n.getTranslator()
translator.setPluralizationRule('custom', (count) => {
if (count === 0) return 0
if (count === 1) return 1
return 2
})
シンプルなスクリプト向けにsetI18n()/t()のグローバルレジストリもあります。ただしリクエストハンドラでは使わないでください: 共有マネージャのsetLocale()はプロセス全体の状態なので、並行リクエストが互いのロケールを上書きします。createApp({ i18n })で配線したアプリでは、リクエストスコープのヘルパー(this.t()、useTranslation())は安全です — リクエストとアプリのコンテナ経由で解決され、共有の可変ロケール状態を通りません。
ベストプラクティス
- ロケール間のパリティを保つ: CIで
guren check --i18nを回し、片方のロケールにだけ追加されたキーが未翻訳のまま出荷されるのを防ぎます。 - リクエスト中に
setLocale()を呼ばない: 検出済みロケールとリクエストスコープのヘルパーに任せます。 - 名前空間付きキーを使う: 機能ごとに整理し(
user.profile.title)、名前空間ごとに1ファイルにします。