7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ElixirAdvent Calendar 2024

Day 13

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

Posted at

はじめに

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

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 mix gettext.merge priv/gettext

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

mix gettext.merge priv/gettext --locale ja

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

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

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 "閉じる"

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

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の末尾に以下を追加します

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 ""

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

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

日本語訳の追加

日本語訳を追加していきます

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 "パスワード変更"
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が読み込まれていないので追加します

lib/trarecord/accounts/user.ex
defmodule Trareco.Users.User do
  use Ecto.Schema
+ use Gettext, backend: TrarecordWeb.Gettext
  import Ecto.Changeset
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
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
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
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

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 Un") %>
        </.link>
      </div>

user_session_controller.ex

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

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

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

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対応を行っていきます

辞書ファイル作成

末尾に以下を追加します

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 ""

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

mix gettext.merge priv/gettext
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

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対応を行います

辞書ファイル作成

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

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

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

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

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

デフォルト辞書の設定

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

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

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

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

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

テスト修正

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

accounts_test.exs

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

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

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?