Guide/guides

国際化(i18n)ガイド

Guren は翻訳管理、複数言語の複数形サポート、ファイルベースとメモリベースの両方のローダーを備えた包括的な国際化システムを提供します。

国際化(i18n)ガイド

Guren は翻訳管理、複数言語の複数形サポート、ファイルベースとメモリベースの両方のローダーを備えた包括的な国際化システムを提供します。

コアコンセプト

  • I18nManager – 翻訳とロケールを管理する中央ハブ。
  • Translator – コンテキスト付きで翻訳を実行するインスタンス。
  • TranslationLoader – 翻訳を読み込むインターフェース(JSONファイルまたはメモリ)。
  • 複数形処理 – 15以上の言語に対応した組み込みルールとカスタマイズ可能な形式。

基本的な使い方

クイックスタート

import { createI18n, setI18n, t, tc } from '@guren/core'

const i18n = createI18n({
  locale: 'en',
  fallbackLocale: 'en',
  messages: {
    en: {
      messages: {
        hello: 'Hello',
        welcome: 'Welcome, :name!',
        items: 'One item|:count items',
      },
    },
    ja: {
      messages: {
        hello: 'こんにちは',
        welcome: 'ようこそ、:nameさん!',
      },
    },
  },
})

setI18n(i18n)

// 翻訳
t('messages.hello')                    // 'Hello'
t('messages.welcome', { name: 'John' }) // 'Welcome, John!'

// 複数形
tc('messages.items', 1)  // 'One item'
tc('messages.items', 5)  // '5 items'

置換構文

変数置換には2つの形式がサポートされています。

// Laravel形式 :variable
const messages = { greeting: 'Hello, :name!' }
t('greeting', { name: 'World' }) // 'Hello, World!'

// ブレース形式 {variable}
const messages = { greeting: 'Hello, {name}!' }
t('greeting', { name: 'World' }) // 'Hello, World!'

ネストされたキー

ドット記法でネストされた翻訳キーにアクセスできます。

const messages = {
  en: {
    user: {
      profile: {
        title: 'ユーザープロフィール',
        settings: '設定',
      },
    },
  },
}

t('user.profile.title')    // 'ユーザープロフィール'
t('user.profile.settings') // '設定'

複数形処理

基本的な複数形処理

複数形を|で区切ります。

const messages = {
  en: {
    apple: 'apple|apples',
    item: 'One item|:count items',
  },
}

tc('apple', 1)  // 'apple'
tc('apple', 5)  // 'apples'

tc('item', 1)   // 'One item'
tc('item', 10)  // '10 items'

サポートされている言語

組み込みの複数形ルールは以下のとおりです。

言語 形式数 ルール
英語、ドイツ語、スペイン語、イタリア語、ポルトガル語、オランダ語 2 1 = 単数、それ以外 = 複数
フランス語、ブラジルポルトガル語 2 0-1 = 単数、それ以外 = 複数
日本語、中国語、韓国語、ベトナム語、タイ語 1 複数形なし
ロシア語、ウクライナ語 3 1、少数(2-4)、多数(5+)
ポーランド語、チェコ語、スロバキア語 3 複雑なルール
アラビア語 6 最も複雑な複数形処理

複雑な複数形処理

複数の複数形を持つ言語の場合は次のように記述します。

const messages = {
  ru: {
    // ロシア語: 1 | 少数 | 多数
    apple: 'яблоко|яблока|яблок',
  },
}

i18n.setLocale('ru')
tc('apple', 1)   // 'яблоко' (1)
tc('apple', 2)   // 'яблока' (少数)
tc('apple', 5)   // 'яблок' (多数)
tc('apple', 21)  // 'яблоко' (1 - 特殊ケース)

カスタム複数形ルール

import { createI18n } from '@guren/core'

const i18n = createI18n({
  locale: 'custom',
  messages: { /* ... */ },
})

// カスタムルールを設定
i18n.setPluralizationRule('custom', (count) => {
  if (count === 0) return 0
  if (count === 1) return 1
  return 2
})

翻訳の読み込み

JSONローダー

JSONファイルから翻訳を読み込めます。

lang/
├── en/
│   ├── messages.json
│   └── validation.json
└── ja/
    ├── messages.json
    └── validation.json
import { I18nManager, JsonLoader } from '@guren/core'

const loader = new JsonLoader('./lang', {
  cache: true, // キャッシュを有効化(デフォルト: true)
})

const i18n = new I18nManager({
  locale: 'en',
  loader,
})

// オンデマンドでロケールを読み込み
await i18n.loadLocale('ja')

メモリローダー

テストや埋め込み翻訳に使用します。

import { MemoryLoader, I18nManager } from '@guren/core'

const loader = new MemoryLoader({
  en: {
    messages: { hello: 'Hello' },
  },
  ja: {
    messages: { hello: 'こんにちは' },
  },
})

// 動的にメッセージを追加
loader.addMessages('fr', { messages: { hello: 'Bonjour' } })

// ロケールを削除
loader.removeLocale('fr')

I18nManager

マネージャーの作成

import { I18nManager, JsonLoader } from '@guren/core'

const i18n = new I18nManager({
  locale: 'en',           // 現在のロケール
  fallbackLocale: 'en',   // 翻訳がない場合のフォールバック
  loader: new JsonLoader('./lang'),
  messages: {
    // オプションの初期メッセージ
    en: { /* ... */ },
  },
})

ロケール管理

// 現在のロケールを取得/設定
const locale = i18n.getLocale()
i18n.setLocale('ja')

// フォールバックロケールを取得/設定
const fallback = i18n.getFallbackLocale()
i18n.setFallbackLocale('en')

// 読み込まれたロケールを確認
i18n.isLocaleLoaded('en')      // true
i18n.getAvailableLocales()     // ['en', 'ja']

// ロケールを読み込み
await i18n.loadLocale('fr')

翻訳メソッド

// シンプルな翻訳
i18n.t('messages.hello')

// 置換付き
i18n.t('messages.welcome', { name: 'John' })

// 複数形
i18n.tc('messages.items', 5)
i18n.tc('messages.items', 5, { type: 'apple' })

// 翻訳が存在するか確認
i18n.has('messages.hello')       // true
i18n.has('messages.hello', 'ja') // 特定のロケールを確認

スコープ付きトランスレーター

// 特定のロケール用のトランスレーターを作成
const jaTranslator = i18n.forLocale('ja')
jaTranslator.t('messages.hello') // 'こんにちは'

// 元のマネージャーは変更されない
i18n.getLocale() // まだ 'en'

グローバル関数

グローバルマネージャーのセットアップ

import { createI18n, setI18n, getI18n, t, tc } from '@guren/core'

// ブートストラップファイルで
const i18n = createI18n({
  locale: 'en',
  messages: { /* ... */ },
})

setI18n(i18n)

// アプリケーションのどこでも
t('messages.hello')
tc('messages.items', 5)

// マネージャーにアクセス
const i18n = getI18n()

リクエストミドルウェア

リクエストごとのロケール

組み込みの detectLocaleMiddleware を使うと、?locale= クエリパラメータ・locale Cookie・Accept-Language ヘッダーの順でリクエストのロケールを解決できます(サポート対象のロケールに限定されます):

import { detectLocaleMiddleware } from '@guren/core'

app.use('*', detectLocaleMiddleware({ supported: ['en', 'ja'] }))

このミドルウェアは locale コンテキスト変数をセットし(Inertia レスポンスのルート <html lang> 属性に使われます)、i18n マネージャーが利用可能な場合(setI18n() のグローバル、または i18n オプションで渡したもの)はリクエストスコープの t/tc トランスレーターヘルパーもバインドします:

export class PostController extends Controller {
  async index() {
    const t = this.ctx.get('t')
    return this.json({ message: t('messages.hello') })
  }
}

オプション: fallback(デフォルトはサポート対象の先頭)、検出順を変える sources(例: ['header'])、queryParamcookieName、マネージャーを明示的に渡す i18n(false でトランスレーターのバインドを停止)。Accept-Language のマッチングはリージョンサブタグ(ja-JPja にマッチ)と q 値を理解します。supported は翻訳ファイルが実際に提供するロケールと同期させてください — リストに無いロケールは検出されません。

ミドルウェアを使わない場合、Inertia レスポンスはアプリ全体の i18n ロケール、それも無ければ en にフォールバックします。レスポンスごとの lang オプションが常に優先されます:

return this.inertia(pages.posts.Show, { post }, { lang: 'ja' })

コントローラーでの使用

import { Controller } from '@guren/core'

export class UserController extends Controller {
  async index() {
    const t = this.ctx.get('t')

    return this.json({
      message: t('messages.hello'),
    })
  }
}

設定

完全な設定例

import { I18nManager, JsonLoader } from '@guren/core'

const i18n = new I18nManager({
  locale: 'en',
  fallbackLocale: 'en',
  loader: new JsonLoader('./lang', { cache: true }),
  messages: {
    en: {
      messages: {
        hello: 'Hello',
        goodbye: 'Goodbye',
      },
      validation: {
        required: 'The :field field is required',
        email: 'Please enter a valid email address',
        min: 'Must be at least :min characters',
      },
      errors: {
        not_found: 'Resource not found',
        unauthorized: 'Please log in to continue',
      },
    },
    ja: {
      messages: {
        hello: 'こんにちは',
        goodbye: 'さようなら',
      },
      validation: {
        required: ':fieldは必須です',
        email: '有効なメールアドレスを入力してください',
        min: ':min文字以上で入力してください',
      },
    },
  },
})

テスト

import { describe, test, expect, beforeEach } from 'bun:test'
import { createI18n, MemoryLoader } from '@guren/core'

describe('翻訳', () => {
  let i18n

  beforeEach(() => {
    i18n = createI18n({
      locale: 'en',
      fallbackLocale: 'en',
      messages: {
        en: {
          hello: 'Hello',
          welcome: 'Welcome, :name!',
          items: 'One item|:count items',
        },
        ja: {
          hello: 'こんにちは',
        },
      },
    })
  })

  test('シンプルなキーを翻訳する', () => {
    expect(i18n.t('hello')).toBe('Hello')
  })

  test('置換付きで翻訳する', () => {
    expect(i18n.t('welcome', { name: 'John' })).toBe('Welcome, John!')
  })

  test('複数形を処理する', () => {
    expect(i18n.tc('items', 1)).toBe('One item')
    expect(i18n.tc('items', 5)).toBe('5 items')
  })

  test('ロケールを切り替える', () => {
    i18n.setLocale('ja')
    expect(i18n.t('hello')).toBe('こんにちは')
  })

  test('存在しない翻訳にフォールバックする', () => {
    i18n.setLocale('ja')
    expect(i18n.t('welcome', { name: 'John' })).toBe('Welcome, John!')
  })

  test('存在しない翻訳はキーを返す', () => {
    expect(i18n.t('missing.key')).toBe('missing.key')
  })
})

翻訳ファイル構造

推奨される構成

lang/
├── en/
│   ├── messages.json     # 一般的なメッセージ
│   ├── validation.json   # バリデーションメッセージ
│   ├── errors.json       # エラーメッセージ
│   └── emails.json       # メールテンプレート
└── ja/
    ├── messages.json
    ├── validation.json
    ├── errors.json
    └── emails.json

JSONファイルの例

// lang/en/messages.json
{
  "hello": "Hello",
  "welcome": "Welcome, :name!",
  "items": "One item|:count items",
  "user": {
    "profile": "Profile",
    "settings": "Settings"
  }
}

// lang/en/validation.json
{
  "required": "The :field field is required",
  "email": "Please enter a valid email",
  "min": {
    "string": "Must be at least :min characters",
    "numeric": "Must be at least :min"
  }
}

ベストプラクティス

  1. 名前空間付きキーを使用: 機能ごとに翻訳を整理(user.profile.title)。

  2. フォールバックロケールを設定: 存在しない翻訳に備えて常にフォールバックを設定。

  3. 複数形に数を含める: 動的な数値には:countプレースホルダーを使用。

  4. 本番環境ではキャッシュを有効化: JSONローダーのキャッシュを本番環境で有効に。

  5. 翻訳を整理して保持: 異なる関心事には別々のファイルを使用。

  6. 存在しないキーを適切に処理: ロギング用にonMissingKeyコールバックを使用。

  7. 翻訳をテスト: すべてのキーがすべてのロケールに存在することを確認するテストを作成。

  8. ロケールをオンデマンドで読み込み: パフォーマンス向上のためloadLocale()を使用。