ElixirDesktopで作るスマホアプリ Part 9 Gettextによるi18n対応

# はじめに

この記事はElixirアドベントカレンダー2024のシリーズ２、12日目の記事です

https://qiita.com/advent-calendar/2024/elixir

i18n（多言語化対応)をGettextを使って行っていきます

作業ブランチは以下になります

```
git checkout -b feature/add_gettext
```

# i18nとは

Internationalization(国際化対応)の略称で、アプリをユーザーが設定した言語のUIで使用できるようにすることです

基本的には日本語（母国語）と英語が多く、更に広くサービスを展開するにあたって増えていく傾向があります

# Gettextとは

> gettextは国際化と地域化に対応するライブラリ構成要素の一つであり、様々な地域の言語に対応した地域化ソフトウェアを開発する際に用いられる。gettextライブラリを用いることで、ソフトウェアの対話的メッセージを翻訳された現地語にて容易に表示させることができる。　by https://ja.wikipedia.org/wiki/Gettext

つまりgettextはi18n対応をするのに使われるライブラリに該当します

Elixirでは以下のようにUIにはgetext関数の引数にキーワードを渡して、各言語に対応する辞書をもとにテキストを返します

```
gettext(edit) -> locale=ja -> msgid edit -> msgstr 編集
```
# 日本語辞書の追加

mix phx.newしたときにいくつかのエラーメッセージの英語の辞書は作成されているので日本語辞書を追加していきます

次のコマンドでデフォルトテンプレートを追加します

```
mix gettext.extract
```

英語のほうにはエラーメッセージの辞書しかないので次のコマンドでデフォルトテンプレートの辞書ファイルを作成します

```
mix gettext.merge priv/gettext
```

次のコマンドで日本語辞書のエラーメッセージとデフォルトテンプレートの辞書を作成します

```
mix gettext.merge priv/gettext --locale ja
```

# 既存の辞書に対応する日本語を入れる

ここまでですでにいくつか辞書ファイルに項目があるのでそちらを以下のように変更します
英語のままの方が自然、使わなそうなのはそのままにしておきます


```bash:priv/gettext/ja/LC_MESSAGES/default.po
#: lib/trarecord_web/components/core_components.ex:482
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""

#: lib/trarecord_web/components/core_components.ex:160
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr "再接続中"

#: lib/trarecord_web/components/core_components.ex:151
#, elixir-autogen, elixir-format
msgid "Error!"
msgstr ""

#: lib/trarecord_web/components/core_components.ex:172
#, elixir-autogen, elixir-format
msgid "Hang in there while we get back on track"
msgstr ""

#: lib/trarecord_web/components/core_components.ex:167
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr "問題が発生しました"

#: lib/trarecord_web/components/core_components.ex:150
#, elixir-autogen, elixir-format
msgid "Success!"
msgstr ""

#: lib/trarecord_web/components/core_components.ex:155
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr "インターネットに接続できません"

#: lib/trarecord_web/components/core_components.ex:76
#: lib/trarecord_web/components/core_components.ex:130
#, elixir-autogen, elixir-format
msgid "close"
msgstr "閉じる"
```

エラーメッセージも訳を追加します

```bash:priv/gettext/ja/LC_MESSAGES/errors.po
msgid "can't be blank"
msgstr "入力してください"

msgid "has already been taken"
msgstr "すでに使用されています"

msgid "is invalid"
msgstr "無効です"

msgid "must be accepted"
msgstr "同意してください"

msgid "has invalid format"
msgstr "無効なフォーマットです"

msgid "has an invalid entry"
msgstr "無効な値です"

msgid "is reserved"
msgstr "使用できません"

msgid "does not match confirmation"
msgstr "同じ値を入力してください"

msgid "is still associated with this entry"
msgstr "削除できませんでした"

msgid "are still associated with this entry"
msgstr "削除できませんでした"

msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] "%{count}項目入力してください"

msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] "%{count}文字入力してください"

msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] "バイト入力してください"

msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] "%{count}項目以上で入力してください"

msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] "%{count}文字以上で入力してください"

msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] "%{count}バイト以上で入力してください"

msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] "%{count}項目以内で入力してください"

msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] "%{count}文字以内で入力してください"

msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] "%{count}バイト以内で入力してください"

msgid "must be less than %{number}"
msgstr "%{number}未満の値を入力してください"

msgid "must be greater than %{number}"
msgstr "%{number}よりも大きい値を入力してください"

msgid "must be less than or equal to %{number}"
msgstr "%{number}以内の値入力してください"

msgid "must be greater than or equal to %{number}"
msgstr "%{number}以上の値を入力してください"

msgid "must be equal to %{number}"
msgstr "%{number}と同じ値にしてください"
```

# 認証周りのi18n対応

mix phx.gen.authで生成されたページの文言をi18n対応していきます

## ベース辞書の作成
ベースのdefault.potの末尾に以下を追加します

```bash:priv/gettext/default.pot
# Account

msgid "Account created successfully!"
msgstr ""

msgid "Password updated successfully!"
msgstr ""

msgid "welcome back!"
msgstr ""

msgid "Invalid email or password"
msgstr ""

msgid "Logged out successfully."
msgstr ""

msgid "Sending..."
msgstr ""

msgid "Email changed successfully."
msgstr ""

msgid "Creating account..."
msgstr ""

msgid "Signing in..."
msgstr ""

msgid "Sign up here"
msgstr ""

msgid "Sign in here"
msgstr ""

msgid "Forgot your password?"
msgstr ""

msgid "Setting"
msgstr ""

msgid "Current password"
msgstr ""

msgid "New password"
msgstr ""

msgid "Confirm new password"
msgstr ""

msgid "Change Email"
msgstr ""

msgid "Change Password"
msgstr ""
```

エラーメッセージの末尾に以下を追加います

```bash:priv/gettext/errors.pot
# User
msgid "must have the @ sign and no spaces"
msgstr ""

msgid "did not change"
msgstr ""

msgid "does not match password"
msgstr ""
```

## ベース辞書の変更を他の辞書に反映

追加したら次のコマンドを実行することで

gettext/en,getetext/ja配下のすべてのファイルに変更が適応されます

```
mix gettext.merge priv/gettext
```

## 日本語訳の追加

日本語訳を追加していきます
```bash:priv/gettext/ja/LC_MESSAGES/default.po
# Account

msgid "Account created successfully!"
msgstr "アカウントを作成しました"

msgid "Password updated successfully!"
msgstr "パスワードを変更しました"

msgid "Email changed successfully."
msgstr "メールアドレスを変更しました"

msgid "welcome back!"
msgstr "ログインしました"

msgid "Invalid email or password"
msgstr "Emailまたはパスワードに誤りがあります"

msgid "Logged out successfully."
msgstr "ログアウトしました"

msgid "Sending..."
msgstr "送信中"

msgid "Sign In"
msgstr "ログイン"

msgid "Sign Up"
msgstr "ユーザー登録"

msgid "Signing in..."
msgstr "ログイン中..."

msgid "Creating account..."
msgstr "作成中..."

msgid "Sign up here"
msgstr "ユーザー登録はこちら"

msgid "Sign in here"
msgstr "ログインはこちら"

msgid "Forgot your password?"
msgstr "パスワードを忘れた方はこちら"

msgid "Setting"
msgstr "設定"

msgid "Current password"
msgstr "現在のパスワード"

msgid "New password"
msgstr "新しいパスワード"

msgid "Confirm new password"
msgstr "新しいパスワード(確認)"

msgid "Change Email"
msgstr "メールアドレス変更"

msgid "Change Password"
msgstr "パスワード変更"
```

```bash:priv/gettext/ja/LC_MESSAGES/errors.po
msgid "must have the @ sign and no spaces"
msgstr "@を含み、スペースがない形式で入力してください"

msgid "did not change"
msgstr "変更がありません"

msgid "does not match password"
msgstr "パスワードが一致しません"
```

## 文言をgetextに置き換える

辞書ファイルが出来たので
`gettext([msgid])`となるように変更していきます


### User.ex

スキーマファイルにはgettextが読み込まれていないので追加します

```diff_elixir:lib/trarecord/accounts/user.ex
defmodule Trareco.Users.User do
  use Ecto.Schema
+ use Gettext, backend: TrarecordWeb.Gettext
  import Ecto.Changeset
```

```diff_elixir:lib/trarecord/accounts/user.ex:47
  defp validate_email(changeset, opts) do
    changeset
    |> validate_required([:email])
-   |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
+   |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: gettext("must have the @ sign and no spaces"))
    |> validate_length(:email, max: 160)
    |> maybe_validate_unique_email(opts)
  end
```

```diff_elixir:lib/trarecord/accounts/user.ex:47:L101
  def email_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email])
    |> validate_email(opts)
    |> case do
      %{changes: %{email: _}} = changeset -> changeset
-     %{} = changeset -> add_error(changeset, :email, "did not change")
+     %{} = changeset -> add_error(changeset, :email, gettext("did not change"))     
    end
  end
```

```diff_elixir:lib/trarecord/accounts/user.ex:47:L123
  def password_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:password])
-   |> validate_confirmation(:password, message: "does not match password")
+   |> validate_confirmation(:password, message: gettext("does not match password"))
    |> validate_password(opts)
  end
```

```diff_elixir:lib/trarecord/accounts/user.ex:47:L157
  def validate_current_password(changeset, password) do
    changeset = cast(changeset, %{current_password: password}, [:current_password])

    if valid_password?(changeset.data, password) do
      changeset
    else
-     add_error(changeset, :current_password, "is not valid")
+     add_error(changeset, :current_password, gettext("is invalid"))
    end
  end
```

### home.html.heex

```diff_eex:lib/trarecord_web/controllers/page_html/home.html.heex
      <div class="fixed bottom-12 right-8 flex flex-col gap-y-8">
        <.link navigate={~p"/users/log_in"} class="btn font-bold normal-case w-32">
-         Sign In
+         <%= gettext("Sign In") %>
        </.link>
        <.link navigate={~p"/users/register"} class="btn font-bold normal-case w-32">
-         Sign Up
+         <%= gettext("Sign Up") %>
        </.link>
      </div>
```

### user_session_controller.ex

```diff_elixir:lib/trarecord_web/controllers/user_session_controller.ex
defmodule TrarecordWeb.UserSessionController do
  use TrarecordWeb, :controller

  alias Trarecord.Accounts
  alias TrarecordWeb.UserAuth

  def create(conn, %{"_action" => "registered"} = params) do
    conn
    |> put_session(:user_return_to, ~p"/onboarding")
-   |> create(params, "Account created successfully!")
+   |> create(params, gettext("Account created successfully!"))
  end

  def create(conn, %{"_action" => "password_updated"} = params) do
    conn
    |> put_session(:user_return_to, ~p"/users/settings")
-   |> create(params, "Password updated successfully!")
+   |> create(params, gettext("Password updated successfully!"))
  end

  def create(conn, params) do
-   create(conn, params, "Welcome back!")
+   create(conn, params, gettext("Welcome back!"))
  end

  defp create(conn, %{"user" => user_params}, info) do
    %{"email" => email, "password" => password} = user_params

    if user = Accounts.get_user_by_email_and_password(email, password) do
      conn
      |> put_flash(:info, info)
      |> UserAuth.log_in_user(user, user_params)
    else
      # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
      conn
-     |> put_flash(:error, "Invalid email or password")
+     |> put_flash(:error, gettext("Invalid email or password"))      
      |> put_flash(:email, String.slice(email, 0, 160))
      |> redirect(to: ~p"/users/log_in")
    end
  end

  def delete(conn, _params) do
    conn
-   |> put_flash(:info, "Logged out successfully.")
+   |> put_flash(:info, gettext("Logged out successfully."))
    |> UserAuth.log_out_user()
  end
end
```

### user_login_live.ex

```diff_elixir:lib/trarecord_web/live/user_login_live.ex:L4
  def render(assigns) do
    ~H"""
    <div id="login" class="m-auto pt-12 h-screen w-[80vw]">
      <.header class="text-center">
-       Sign In
+       <%= gettext("Sign In")
      </.header>

      <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
        <.input field={@form[:email]} type="email" label="Email" required />
        <.input field={@form[:password]} type="password" label="Password" required />

        <:actions>
          <ul>
            <li class="mb-2">
              <.link
                id="forgort-password"
                href={~p"/users/reset_password"}
                class="text-sm font-semibold underline"
              >
-               Forgot your password?
+               <%= gettext("Forgot your password?")
              </.link>
            </li>
            <li>
              <.link
                id="signup"
                navigate={~p"/users/register"}
                class="font-semibold text-sm underline"
              >
-               Sign up here
+               <%= gettext("Sign up here")
              </.link>
            </li>
          </ul>
        </:actions>
        <:actions>
-         <.button phx-disable-with="Signing in..." class="w-full">
+         <.button phx-disable-with={gettext("Signing in...")} class="w-full">
-           Sign In
+           <%= gettext("Sign In")
          </.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end
```

### user_registration_live.ex

```diff_elixir:lib/trarecord_web/live/user_registration_live.ex:L7
  def render(assigns) do
    ~H"""
    <div id="register" class="mx-auto pt-12 h-screen w-[80vw]">
      <.header class="text-center">
-       Sign Up
+       <%= gettext("Sign Up") %>
      </.header>

      <.simple_form
        for={@form}
        id="registration_form"
        phx-submit="save"
        phx-change="validate"
        phx-trigger-action={@trigger_submit}
        action={~p"/users/log_in?_action=registered"}
        method="post"
      >
        <.input field={@form[:email]} type="email" label="Email" required />
        <.input field={@form[:password]} type="password" label="Password" required />
        <:actions>
          <.link id="signin" navigate={~p"/users/log_in"} class="font-semibold text-sm underline">
-           Sign in here
+           <%= gettext("Sign in here") %>
          </.link>
        </:actions>

        <:actions>
          <.button
-            phx-disable-with="Creating account..."
+            phx-disable-with={gettext("Creating account...")}
            class="w-full h-12 disabled:bg-gray-400 disabled:border-gray-400"
          >
-           Sign Up
+           <%= gettext("Sign Up") %>
          </.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end
```

### user_settings_live.ex

```diff_elixir:lib/trarecord_web/live/user_settings_live.ex
  def render(assigns) do
    ~H"""
-   <.header_nav title="Setting" />
+   <.header_nav title={gettext("Setting")} />

    <div class="space-y-12 divide-y my-12 p-4">
      <div>
        <.simple_form
          for={@email_form}
          id="email_form"
          phx-submit="update_email"
          phx-change="validate_email"
        >
          <.input field={@email_form[:email]} type="email" label="Email" required />
          <.input
            field={@email_form[:current_password]}
            name="current_password"
            id="current_password_for_email"
            type="password"
-           label="Current password"
+           label={gettext("Current password")}
            value={@email_form_current_password}
            required
          />
          <:actions>
-           <.button phx-disable-with="Changing...">Change Email</.button>
+           <.button phx-disable-with="Changing..."><%= getttext("Change Email") %></.button>
          </:actions>
        </.simple_form>
      </div>
      <div>
        <.simple_form
          for={@password_form}
          id="password_form"
          action={~p"/users/log_in?_action=password_updated"}
          method="post"
          phx-change="validate_password"
          phx-submit="update_password"
          phx-trigger-action={@trigger_submit}
        >
          <input
            name={@password_form[:email].name}
            type="hidden"
            id="hidden_user_email"
            value={@current_email}
          />
-         <.input field={@password_form[:password]} type="password" label="New password" required />
+         <.input field={@password_form[:password]} type="password" label={gettext("New password")} required />

          <.input
            field={@password_form[:password_confirmation]}
            type="password"
-           label="Confirm new password"
+           label={gettext("Confirm new password")}
          />
          <.input
            field={@password_form[:current_password]}
            name="current_password"
            type="password"
-           label="Current password"
+           label={gettext("Current password")}
            id="current_password_for_password"
            value={@current_password}
            required
          />
          <:actions>
-           <.button phx-disable-with="Changing...">Change Password</.button>
+           <.button phx-disable-with="Changing..."><%= gettext("Change Password") %></.button>
          </:actions>
        </.simple_form>
        <.link href="/users/log_out" method="delete">
          <button class="btn btn-error text-white mt-4 w-full">Logout</button>
        </.link>
      </div>
    </div>

    <.bottom_tab current="Setting" />
    """
  end
```

# Onboarding

オンボーディングのi18n対応を行っていきます

## 辞書ファイル作成

末尾に以下を追加します

```bash:priv/gettext/default.pot
# Onboarding

msgid "Let's gather where we want to go."
msgstr ""

msgid "You can register <br />various spots using Google search."
msgstr ""

msgid "Let's organize them in folders."
msgstr ""

msgid "If you have more spots, divide them into <br />folders for easier management."
msgstr ""

msgid "Record your travel."
msgstr ""

msgid "You can record your travel of actually visiting the spot<br />with GPS logging and Checkin."
msgstr ""
```

以下のコマンド実行後日本語訳を追加します

```bash
mix gettext.merge priv/gettext
```

```bash:priv/gettext/ja/LC_MESSAGES/default.po
# Onboarding

msgid "Let's gather where we want to go."
msgstr "行きたい場所を集めよう"

msgid "You can register <br />various spots using Google search."
msgstr "Google検索を使用して<br />様々なスポットを登録できます"

msgid "Let's organize them in folders."
msgstr "フォルダに分けて整理しよう"

msgid "If you have more spots, divide them into <br />folders for easier management."
msgstr "スポットが増えてきたら<br />フォルダに分けて管理しやすくします"

msgid "Record your travel."
msgstr "旅を記録しよう"

msgid "You can record your memories of actually visiting the spot<br />with GPS logging and Checkin."
msgstr "実際に行ったスポットを<br />GPSログや、チェックインで記録できます"
```

## onboarding_live/index.ex

```diff_elixir:lib/trarecord_web/live/onboarding_live/index.ex:L60
  def intro() do
    [
      %{
        page: 1,
-       title: "Let's gather where we want to go.",
+       title: gettext("Let's gather where we want to go."),
-       text: "You can register <br />various spots using Google search.",
+       text: gettext("You can register <br />various spots using Google search."),
        link:
          "https://www.freepik.com/free-vector/flying-around-world-with-airplane-concept-illustration_8426450.htm#fromView=author&page=1&position=12&uuid=dbcac267-0605-4076-ae43-111dfb07a118",
        image: "/images/flying-around-world.svg",
        bg: "#59b2ab"
      },
      %{
        page: 2,
-       title: "Let's organize them in folders.",
+       title: gettext("Let's organize them in folders."),
-       text: "If you have more spots, divide them into <br />folders for easier management.",
+       text: gettext("If you have more spots, divide them into <br />folders for easier management."),
        link:
          "https://www.freepik.com/free-vector/resume-folder-concept-illustration_5358953.htm#fromView=author&page=1&position=6&uuid=31a01ff6-d2cb-4798-bc51-89a0517d7ad9",
        image: "/images/folder.svg",
        bg: "#febe29"
      },
      %{
        page: 3,
-       title: "Record your travel.",
+       title: gettext("Record your travel."),
        text:
-         "You can record your memories of actually visiting the spot<br />with GPS logging and Checkin.",
+         gettext("You can record your memories of actually visiting the spot<br />with GPS logging and Checkin."),
        link:
          "https://www.freepik.com/free-vector/post-concept-illustration_5928515.htm#fromView=author&page=1&position=2&uuid=1450568b-5aac-4d60-a3ad-bef059625d94",
        image: "/images/post.svg",
        bg: "#22bcb5"
      }
    ]
  end
```

# Folder

フォルダ周りのi18n対応を行います

## 辞書ファイル作成

末尾に共通の文言とフォルダの文言を追加します

```bash:priv/gettext/default.pot
# General

msgid "Email"
msgstr ""

msgid "Password"
msgstr ""

msgid "Save"
msgstr ""

msgid "Saving..."
msgstr ""

msgid "Edit"
msgstr ""

msgid "Delete"
msgstr ""

msgid "Cancel"
msgstr ""

msgid "Back"
msgstr ""

msgid "Are you sure?"
msgstr ""

# Folder

msgid "Folder"
msgstr ""

msgid "Folder Name"
msgstr ""

msgid "Edit Folder"
msgstr ""

msgid "New Folder"
msgstr ""

msgid "Delete Folder"
msgstr ""

msgid "Folder updated successfully"
msgstr ""

msgid "Folder created successfully"
msgstr ""

msgid "Folder deleted successfully"
msgstr ""
```

以下のコマンドを実行して日本語訳を追加します

```
mix gettext.merge priv/gettext
```

```priv/gettext/ja/LC_MESSAGES/default.po
# General

msgid "Email"
msgstr "メールアドレス"

msgid "Password"
msgstr "パスワード"

msgid "Save"
msgstr "保存"

msgid "Saving..."
msgstr "保存中..."

msgid "Edit"
msgstr "編集"

msgid "Delete"
msgstr "削除"

msgid "Back"
msgstr "戻る"

msgid "Cancel"
msgstr "キャンセル"

msgid "Are you sure?"
msgstr "削除しますか？"

# Folder
msgid "Folder"
msgstr "フォルダ"

msgid "Folder Name"
msgstr "フォルダ名"

msgid "Edit Folder"
msgstr "フォルダ編集"

msgid "New Folder"
msgstr "フォルダ作成"

msgid "Delete Folder"
msgstr "フォルダ削除"

msgid "Folder updated successfully"
msgstr "フォルダを更新しました"

msgid "Folder created successfully"
msgstr "フォルダを作成しました"

msgid "Folder deleted successfully"
msgstr "フォルダを削除しました"
```

## 文言差し替え

文言をgettextに置き換えていきます

### folder_live/form_component.ex

```diff_elixir:lib/trarecord_web/live/folder_live/form_component.ex:L7
  def render(assigns) do
    ~H"""
    <div>
      <.header>
        <%= @title %>
      </.header>

      <.simple_form
        for={@form}
        id="folder-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
-       <.input field={@form[:name]} type="text" label="Name" />
+       <.input field={@form[:name]} type="text" label={gettext("Folder Name")} />
        <:actions>
-         <.button phx-disable-with="Saving...">Save</.button>
-         <.button phx-disable-with={gettext("Saving...")}><%= gettext("Save") %></.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end
```

```diff_elixir:lib/trarecord_web/live/folder_live/form_component.ex:L50
  defp save_folder(socket, :edit, folder_params) do
    case Folders.update_folder(socket.assigns.folder, folder_params) do
      {:ok, folder} ->
        notify_parent({:saved, folder})

        {:noreply,
         socket
-        |> put_flash(:info, "Folder updated successfully")
+        |> put_flash(:info, gettext("Folder updated successfully"))
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  defp save_folder(socket, :new, folder_params) do
    folder_params = Map.put(folder_params, "user_id", socket.assigns.user_id)

    case Folders.create_folder(folder_params) do
      {:ok, folder} ->
        notify_parent({:saved, folder})

        {:noreply,
         socket
-        |> put_flash(:info, "Folder created successfully")
+        |> put_flash(:info, gettext("Folder created successfully"))
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end
```

### folder_live/index.ex

```diff_elixir:lib/trarecord_web/live/folder_live/index.exL18
  defp apply_action(socket, :edit, %{"id" => id}) do
    socket
-   |> assign(:page_title, "Edit Folder")
+   |> assign(:page_title, gettext("Edit Folder"))
    |> assign(:folder, Folders.get_folder!(id))
  end

  defp apply_action(socket, :new, _params) do
    socket
-   |> assign(:page_title, "New Folder")
+   |> assign(:page_title, gettext("New Folder"))
    |> assign(:folder, %Folder{})
  end
  ...
  defp apply_action(socket, :delete, %{"id" => id}) do
    socket
-   |> assign(:page_title, "Delete Folder")
+   |> assign(:page_title, gettext("Delete Folder"))
    |> assign(:folder, Folders.get_folder!(id))
  end
  ...
  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    folder = Folders.get_folder!(id)
    {:ok, _} = Folders.delete_folder(folder)

    socket
-   |> put_flash(:info, "Folder deleted successfully")
+   |> put_flash(:info, gettext("Folder deleted successfully"))
    |> push_patch(to: ~p"/folders")
    |> stream_delete(:folders, folder)
    |> then(&{:noreply, &1})
  end
```

### folder_live/index.html.heex

```diff_elixir:lib/trarecord_web/live/folder_live/index.html.heex
- <.header_nav title="Folder">
+ <.header_nav title={gettext("Folder")}>
  <:actions>
    <.link id="add" patch={~p"/folders/new"}>
      <.icon name="hero-plus-solid" class="mr-4 h-8 w-8" />
    </.link>
  </:actions>
</.header_nav>

<div id="folders" class="mt-16" phx-update="stream">
  <%= for {id, folder} <- @streams.folders do %>
    <div id={id} class="card bg-base-200 m-4 shadow-xl">
      <.link navigate={~p"/folders/#{folder}"}>
        <div class="card-body p-4">
          <h2 class="card-title"><%= folder.name %></h2>
          <div class="card-actions justify-end">
-           <.link patch={~p"/folders/#{folder}/edit"}>Edit</.link>
+           <.link patch={~p"/folders/#{folder}/edit"}><%= getext("Edit") %></.link>
-           <.link patch={~p"/folders/#{folder}/delete"}>Delete</.link>
+           <.link patch={~p"/folders/#{folder}/delete"}><%= gettext("Delete") %></.link>
          </div>
        </div>
      </.link>
    </div>
  <% end %>
</div>
```

```diff_elixir:lib/trarecord_web/live/folder_live/index.html.heex:L44
<.modal
  :if={@live_action in [:delete]}
  id="folder-delete-modal"
  show
  on_cancel={JS.navigate(~p"/folders")}
>
- <p class="m-4">Are you sure?</p>
+ <p class="m-4"><%= gettext("Are you sure?") %></p>
  <div class="flex justify-between mx-4 gap-x-4">
    <button class="btn w-28" phx-click={JS.navigate(~p"/folders")}>
-     Cancel
+     <%= gettext("Cancel") %>
    </button>
    <button
      class="btn btn-error text-white w-28"
      phx-click={JS.push("delete", value: %{id: @folder.id})}
    >
-     Delete
+     <%= gettext("Delete") %>
    </button>
  </div>
</.modal>
```

# デフォルト辞書の設定
使用するデフォルトの辞書(デフォルトローケール)を以下のように設定します

```diff_elixir:config/config.exs:L34
config :trarecord, Trarecord.Mailer, adapter: Swoosh.Adapters.Local

+ config :trarecord, TrarecordWeb.Gettext, default_locale: "ja"
```

テストの文言を全部日本語にするのが面倒なのでテスト時は英語にします

```diff_elixir:config/test.exs
import Config

# Only in tests, remove the complexity from the password hashing algorithm
config :pbkdf2_elixir, :log_rounds, 1
+ config :trarecord, TrarecordWeb.Gettext, default_locale: "en"
```

# 動作確認

UIが日本語化されているのが確認できました

![スクリーンショット 2024-12-25 13.25.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/e0e85117-b393-e08c-1704-ecb8a61581e3.png)

# テスト修正

認証周りの文言を微妙に変えたのでそこを修正します

## accounts_test.exs

```diff_elixir:test/trarecord/accounts_test.exs:L161
    test "validates current password", %{user: user} do
      {:error, changeset} =
        Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()})

-     assert %{current_password: ["is not valid"]} = errors_on(changeset)
+     assert %{current_password: ["is invalid"]} = errors_on(changeset)
    end
```

```diff_elixir:test/trarecord/accounts_test.exs:L283
    test "validates current password", %{user: user} do
      {:error, changeset} =
        Accounts.update_user_password(user, "invalid", %{password: valid_user_password()})

-     assert %{current_password: ["is not valid"]} = errors_on(changeset)
-     assert %{current_password: ["is invalid"]} = errors_on(changeset)
    end
```

## user_settings_live_test.exs:L67

```diff_elixir:test/trarecord_web/live/user_settings_live_test.exs:L67
    test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do
      {:ok, lv, _html} = live(conn, ~p"/users/settings")

      result =
        lv
        |> form("#email_form", %{
          "current_password" => "invalid",
          "user" => %{"email" => user.email}
        })
        |> render_submit()

      assert result =~ "Change Email"
      assert result =~ "did not change"
-     assert result =~ "is not valid"
+     assert result =~ "is invalid"
    end
```

```diff_elixir:test/trarecord_web/live/user_settings_live_test.exs:L139
    test "renders errors with invalid data (phx-submit)", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/settings")

      result =
        lv
        |> form("#password_form", %{
          "current_password" => "invalid",
          "user" => %{
            "password" => "too short",
            "password_confirmation" => "does not match"
          }
        })
        |> render_submit()

      assert result =~ "Change Password"
      assert result =~ "should be at least 12 character(s)"
      assert result =~ "does not match password"
-     assert result =~ "is not valid"
+     assert result =~ "is invalid"
    end
```

# 最後に

本記事ではgettextの日本語辞書の作成とUIの文言をgettextに置き換えるi18n対応を行いました

ある程度作ったあとからやるととっても大変なので、最初からやるようにしましょう
次は端末の言語設定を取得して辞書を切り替える機能を実装していきます

本記事で以上になりますありがとうございました

# 参考サイト
https://qiita.com/shufo/items/8ba11c065edc4ad1229e
https://hexdocs.pm/gettext/Mix.Tasks.Gettext.Merge.html#module-usage