Guide/guides

国際化(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個"
}

このオプションを1つ渡すだけで、次の仕組みがすべて組み込まれます。

  • 起動時に、サポート対象のすべてのロケールを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 10と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()はこのpropを読んで翻訳します。

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は翻訳カタログも検証します。CI向けに--i18nを付けると、この検査だけを実行し、失敗したときは0以外の終了コードで終わります。

bunx guren check --i18n

報告される項目は次のとおりです。

  • 不正なJSON: パースできないカタログファイルはローダーが読み飛ばし(コンソールに警告が出るだけです)、そのファイルのキーは実行時にフォールバックします。
  • キーの欠落: あるロケールにだけあって別のロケールに無いキーは、そのロケールの利用者にはフォールバック言語で表示されます(フォールバックにも無ければキーの文字列がそのまま表示されます)。
  • プレースホルダの不一致: 同じキーなのにロケールによって:name/{name}が違う場合は、翻訳するときに変数を落としている可能性が高いです。ロケールによっては意図してプレースホルダを省くこともあるので、警告として報告するだけで、終了コードには影響しません。
  • 解決できないキー: プロパティ名やファイル名にドットが入っているキーは、実行時に解決できません。

サーバーレスとバンドルカタログ

lang/は実行時にファイルシステムから読みますが、デプロイ先にはlang/が含まれません。そこでデプロイビルド(guren cloudflare:build、guren lambda:build、Vercelのビルド)では、ビルド時にlang/<locale>/*.jsonを読んでカタログを出力に埋め込みます。既定のディレクトリを使っているアプリなら、何も変えなくてもデプロイ後に翻訳が効きます。JSONとして読めないファイルは除外され、ビルドが警告を出します。翻訳を変えたときは、再ビルドと再デプロイが必要です。

ビルドが読むのはlang/だけです。pathを指定しているアプリや、Guren のデプロイビルドを使わない環境(サーバーレスガイド参照)では、MemoryLoaderを使ってカタログを自分でバンドルしてください。loaderを明示すると、ビルドが埋め込んだカタログよりそちらが優先されます。

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
})

t()は既定のアプリケーションからマネージャを取り出すので、簡単なスクリプトならこれで足ります。ただし、リクエストハンドラの中では使わないでください。共有マネージャのsetLocale()はプロセス全体で共有される状態なので、同時に処理しているリクエスト同士がお互いのロケールを上書きしてしまいます。

createApp({ i18n })で組み込んだアプリなら、リクエスト単位のヘルパー(this.t()、useTranslation())は安全に使えます。これらはリクエストとアプリのコンテナを通して解決され、共有の書き換え可能なロケール状態を経由しないからです。

ベストプラクティス

  1. ロケール間でキーをそろえる: CIでguren check --i18nを実行し、片方のロケールにだけ追加したキーが未翻訳のまま出荷されないようにします。
  2. リクエスト中にsetLocale()を呼ばない: 検出したロケールと、リクエスト単位のヘルパーに任せてください。
  3. 名前空間付きのキーを使う: 機能ごとにキーを整理し(user.profile.title)、名前空間ごとにファイルを1つ作ります。

次のステップ

  • テスト: TestAppで翻訳済みのレスポンスを検証する方法
  • サーバーレス: ファイルシステムの無いデプロイ先での使い方