🎉

Laravel Jetstream + Vue の多言語対応(i18n)

2022/03/08に公開

Laravel Jetstream (Inertia + Vue 構成) を多言語対応にする

最近 Laravel を使うときには Jetstream (Inertia + Vue) の構成でインストールすることが多い。
言語ファイルは取得できても Vue ファイル側が対応していないので、日本語で表示するために毎回対応が必要になっている。
修正範囲が広いので大体抜けが出る(悲しい)。

2022/3/31 追記

https://zenn.dev/blancpanda/articles/forked-jetstream-inertia-i18n

↑ 抜けが出過ぎて悲しいので、自分用に Jetstream をフォークしました。
この記事の内容と同等の変更を加えています。

1. 言語ファイルの取得

resources/lang 以下のファイルは、必要に応じて Laravel Lang などから取得する
Laravel Lang Publisher で管理すると楽なのでこちらを利用する。

composer require laravel-lang/publisher laravel-lang/lang --dev

英語と日本語のリソースを追加

php artisan lang:add en ja

※ 更新するときは reset を使うとプロジェクトで追加した項目を保持したまま翻訳データが更新される

php artisan lang:reset en ja
php artisan lang:reset

2. vue-i18n を導入

vue-i18n インストール

npm install vue-i18n@9

※ バージョンはVue I18n のドキュメントで確認する。

app.blade.php, app.js 修正

ルートテンプレート (app.blade.php) に locale を定義する。

resources/views/app.blade.php
<!-- Scripts -->
@routes
<script>
  var __locale = '{{ app()->getLocale() }}'
</script>
<script src="{{ mix('js/app.js') }}" defer></script>

app.js に多言語化(i18n)処理を追加する。
legacy: false、globalInjection: true を指定することでグローバルに、どの vue ファイルからでもメッセージ翻訳関数 $t() の使用が可能になる。

resources/js/app.js
require('./bootstrap')

import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/inertia-vue3'
import { InertiaProgress } from '@inertiajs/progress'
import { createI18n } from 'vue-i18n'

const appName =
  window.document.getElementsByTagName('title')[0]?.innerText || 'Laravel'

async function loadAndConvertMessages(locale) {
  const messages = await import(`../lang/${locale}.json`)
  var msgObj = {}
  Object.entries(messages).forEach(([key, value]) => {
    // :parameter -> {parameter}
    msgObj[key] = String(value).replace(/:([a-zA-Z_]+)/g, '{$1}')
  })
  return msgObj
}

async function loadLocaleMessages(i18n, locale) {
  const messages = await loadAndConvertMessages(locale)
  i18n.global.setLocaleMessage(locale, messages)
}

createInertiaApp({
  title: (title) => `${title} - ${appName}`,
  resolve: (name) => require(`./Pages/${name}.vue`),
  async setup({ el, app, props, plugin }) {
    const i18n = createI18n({
      legacy: false,
      globalInjection: true,
      locale: __locale,
    })

    await loadLocaleMessages(i18n, __locale)

    return createApp({ render: () => h(app, props) })
      .use(plugin)
      .use(i18n)
      .mixin({ methods: { route } })
      .mount(el)
  },
})

InertiaProgress.init({ color: '#4B5563' })

3. Vue ファイルを修正

多言語対応する文字列に翻訳関数 $t() を適用する。
ほとんどの文字列は resources/lang/*.json に定義されている。
resources/js 以下の Vue ファイルはほぼすべて修正が必要。

  • 文字列、タイトル、ボタン名、リンク名
  • ラベル
  • プレースホルダ

※ props に翻訳対象の文字列が入ってくる場合がある

例) resources/js/Jetstream/ConfirmsPassword.vue
<template>
  <span>
    <span @click="startConfirmingPassword">
      <slot />
    </span>

    <jet-dialog-modal :show="confirmingPassword" @close="closeModal">
      <template #title>
        {{ $t(title) }}
      </template>

      <template #content>
        {{ $t(content) }}

        <div class="mt-4">
          <jet-input
            type="password"
            class="mt-1 block w-3/4"
            :placeholder="$t('Password')"
            ref="password"
            v-model="form.password"
            @keyup.enter="confirmPassword"
          />

          <jet-input-error :message="form.error" class="mt-2" />
        </div>
      </template>

      <template #footer>
        <jet-secondary-button @click="closeModal">
          {{ $t('Cancel') }}
        </jet-secondary-button>

        <jet-button
          class="ml-3"
          @click="confirmPassword"
          :class="{ 'opacity-25': form.processing }"
          :disabled="form.processing"
        >
          {{ $t(button) }}
        </jet-button>
      </template>
    </jet-dialog-modal>
  </span>
</template>

<script>
import { defineComponent } from 'vue'
import JetButton from './Button.vue'
import JetDialogModal from './DialogModal.vue'
import JetInput from './Input.vue'
import JetInputError from './InputError.vue'
import JetSecondaryButton from './SecondaryButton.vue'

export default defineComponent({
  emits: ['confirmed'],

  props: {
    title: {
      default: 'Confirm Password',
    },
    content: {
      default: 'For your security, please confirm your password to continue.',
    },
    button: {
      default: 'Confirm',
    },
  },

  // ...
})
</script>

上記例では、title, content, button のデフォルト値が翻訳対象になる。

変数の値に翻訳をかけるため、クオテーションを入れないように注意する。
NG: {{ $t('title') }}
OK: {{ $t(title) }}

※ HTML タグを含むメッセージ

ユーザ登録画面の "サービス利用規約とプライバシーポリシーに同意する" の部分のように、パラメータを置き換えつつリンクを張る(a タグ)場合は以下のように記述することで翻訳できる。

resources/js/Pages/Auth/Register.vue
<div class="ml-2">
 <i18n-t keypath="I agree to the :terms_of_service and :privacy_policy">
  <template #terms_of_service>
   <a target="_blank" :href="route('terms.show')" class="underline text-sm text-gray-600 hover:text-gray-900">{{ $t('Terms of Service') }}</a>
  </template>
  <template #privacy_policy>
   <a target="_blank" :href="route('policy.show')" class="underline text-sm text-gray-600 hover:text-gray-900">{{ $t('Privacy Policy') }}</a>
  </template>
 </i18n-t>
</div>

参考: https://vue-i18n.intlify.dev/ja/guide/advanced/component.html

4. バリデーションの属性も修正

※ 忘れがちだけどエラーメッセージの項目名だけが英語だと悲しい。

app/Actions 以下のファイルのバリデーション部分で、Validator::make() の第 3 引数にバリデーション属性のカスタマイズを指定する。
ここでは __ ヘルパ関数が使える。

例) app/Actions/Fortify/CreateNewUser.php
    // ...

    public function create(array $input)
    {
        Validator::make($input, [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => $this->passwordRules(),
            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
        ], [], [
            'name' => __('Name'),
            'email' => __('Email'),
            'password' => __('Password'),
            'terms' => __('Terms of Service'),
        ])->validate();

    // ...

Special Thanks :

Discussion