見出し画像

Lingui を使って App Router を利用している Next.js アプリを i18n したい

はじめに

Next.js を使った多言語対応(i18n/l10n)の仕方は多く存在するが、今回は Lingui をつかった方法を書こうと思う

ちなみに i18n は一応公式でもサポートされているが、こちらは middleware を用いた redirect や、JSON データのロードなどを自前で書く前提となっている。

パッとみた感じ使えそうでもあるのだが、以下の様なところが不足する

URL で現在の locale を表現したい場合の、 locale が省略されたパターン

たとえば

  • example.com/en-US → `en-US`

  • example.com/ja → `ja`

  • example.com → `en-US` (このパターンに対応したい)

というときの実装。

JSON データの中で文字列を埋め込んだり、 JSX に囲まれたものをまとめて i18n したいパターン

よくある

"titlle": "#{name} のときのタイトル"

みたいなことがしたいときや

<div>
¥ ${amount}
<span>税込<span>
</div>

みたいなデザインの都合上テキストの間に JSX が挟まるケースなどの処理ができない。

といったような理由で、そこまで積極的にデフォルトの i18n 実装を使おうというモチベーションにならなかった。

Lingui

そこで使ってみるかとなったのが Lingui 

i18n 用の JavaScript ライブラリで、以下のような特徴がある

  • デフォルト言語の文字列を辞書のキーにできる

  • コード上でi18nしたい文字列を抜き出して、辞書ファイルに自動で追記してくれる

  • 辞書ファイルで単数形・複数形の扱いや、JSXを使った埋め込む方法をサポートしている

  • AI を用いて辞書ファイルを翻訳する際に、コンテキストを渡せる

デフォルト言語の文字列を辞書のキーにできる

どういうことかというと、以下のような感じ

<button>
  {t`送信する`}
</button>

`t` は lingui の関数で、その後ろに指定した文字列をキーとして、コンテキストに合わせた言語に変換して表示してくれる。
他のライブラリだと、ここが明示的なキー(例えば `button.submit.text`のような) になるものもあるのだが、これだと開発者は i18n のファイルと実装のコードを頻繁に見比べる作業が発生するし、実際の UI の文字列から検索して実装部分を探すなんてこともできない。これが地味につらい

コード上でi18nしたい文字列を抜き出して、辞書ファイルに自動で追記してくれる

i18n は地道な作業になるので、できるだけまとめて実行したい。前述した `t` 関数などを使って実装上でデフォルト言語の文字列を記述しておけば、lingui の CLI で文字列を抜き出し、サポートしたい言語用の辞書ファイルすべてに記載してくれる。

例えば以下のように

// たとえばこういう記述をすると
<button>
  {t`送信する`}
</button>


// ja.po (日本語辞書ファイル)

#: routes/login/Login.tsx:67
msgid "送信する"
msgstr "送信する"

// en-US.po (英語辞書ファイル)

#: routes/login/Login.tsx:67
msgid "送信する"
msgstr "送信する"

辞書ファイルで単数形・複数形の扱いや、JSXを使った埋め込む方法をサポートしている

lingui の辞書ファイルは ICU という i18n 用のメッセージフォーマットをサポートしており、単数形と複数形の使い分けなどもできる。

また前述したような JSX が入ったパターンもサポートしてくれる。

AI を用いて辞書ファイルを翻訳する際に、コンテキストを渡せる

自分は実装時に気づいてなかったのだが、すこしおもしろいものとして、辞書ファイルに記載するための コンテキスト を実装側から渡せたりする。

<Trans context="direction">right</Trans>;
<Trans context="correctness">right</Trans>;

例えば上記の用に、 `right` の意味合いがコンテキストによって異なる場合に、それを辞書ファイル側にも記載しておくことができる。

そうすれば出来上がった辞書ファイルを丸ごと AI に投げて翻訳するときに「contextを考慮しつつ翻訳して」みたいに頼むこともできるというわけである。

自分が実装で利用したときは生成されたファイルを丸ごと Claude に渡して、「msgid に記載されている日本語を英語に翻訳したものを msgstr に記載して」と指示するだけで大方うまくいったのだが、コンテキストを利用すれば更にサービスに特化した単語もうまくやってくれたと思う。

実際にやってみる

Next.js のアプリをスクラッチで作ってみる。今回 App Router

> npx create-next-app@latest
Need to install the following packages:
create-next-app@14.2.13
Ok to proceed? (y) y

✔ What is your project named? … my-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
Creating a new Next.js app in /Users/takashi.nakagawa.a.ts/Desktop/test/test/my-app.

Using npm.

Initializing project with template: app-tw


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next

するとこんな感じの構成になる

├── README.md
├── app
│   ├── favicon.ico
│   ├── fonts
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── tailwind.config.ts
└── tsconfig.json

ここに Lingui の依存をいれる

npm i @lingui/macro @lingui/react
npm i -D @lingui/cli @lingui/loader @lingui/swc-plugin

そしたら Lingui の設定ファイルである `lingui.config.ts` を作成

/** @type {import("@lingui/conf").LinguiConfig} */

module.exports = {
  locales: ["ja", "en-US"],
  sourceLocale: "ja",
  fallbackLocales: {
    default: "ja",
  },
  catalogs: [
    {
      path: "locales/{locale}",
      include: ["app"],
    },
  ],
  format: "po",
  orderBy: "messageId",
};

export {};

今回は `ja` と `en-US` をサポートするということにする。

Next.js 側の config に Lingui のマクロ使うための設定を追記

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    swcPlugins: [
      [
        "@lingui/swc-plugin",
        {
          // the same options as in .swcrc
        },
      ],
    ],
    scrollRestoration: true,
  },
};

export default nextConfig;

さて次はどうやって現在の言語設定をロードするかだが、これは URL から持ってくるとする。設定次第では別ドメインなども可能。

example.com/ja なら日本語
example.com/en-US なら英語
そして example.com なら日本語、としてみたい。

lingui にはこのルーティングをハンドリングする機構はないので、今回は `next-i18n-router` を使う。

`middleware.ts` をこうする

import { i18nRouter } from "next-i18n-router";
import { NextRequest } from "next/server";
import { i18nConfig } from "./i18-config";

export function middleware(req: NextRequest) {
  return i18nRouter(req, i18nConfig);
}

// only applies this middleware to files in the app directory
export const config = {
  matcher: "/((?!api|static|.*\\..*|_next).*)",
};
 

`i18-config.ts` はこう

export const i18nConfig = {
  locales: ['ja', 'en-US'],
  defaultLocale: 'ja',
  localeDetector: false,
};

export type Locale = (typeof i18nConfig)["locales"][number];

localeDetector を true にするとブラウザの言語設定で勝手に言語を切り替えてくれたりするが、今回は使わない

layout.tsx をこんな感じにして、Linguiの辞書ファイルをロードするように

import { App } from "@/app/app";
import { Locale } from "@/i18-config";
import { Messages } from "@lingui/core";
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";

const geistSans = localFont({
  src: "./fonts/GeistVF.woff",
  variable: "--font-geist-sans",
  weight: "100 900",
});
const geistMono = localFont({
  src: "./fonts/GeistMonoVF.woff",
  variable: "--font-geist-mono",
  weight: "100 900",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default async function RootLayout({
  children,
  params,
}: Readonly<{
  children: React.ReactNode;
  params: { locale: Locale };
}>) {
  const translation = await loadCatalog(params.locale!);

  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <App translation={translation}>{children}</App>
      </body>
    </html>
  );
}

async function loadCatalog(locale: string) {
  try {
    const catalog = await import(`@lingui/loader!./locales/${locale}.po`);
    return catalog.messages as Messages;
  } catch (error) {
    console.error(`Failed to load catalog for locale ${locale}:`, error);
    return {};
  }
}  

app.tsx を作成する

"use client";

import { i18nConfig } from "@/i18-config";
import { Messages, i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { useCurrentLocale } from "next-i18n-router/client";
import { useEffect } from "react";

export const App = ({
  translation,
  children,
}: {
  translation: Messages;
  children: React.ReactNode;
}) => {
  useLinguiInit(translation);
  return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
};

export function useLinguiInit(messages: Messages) {
  const locale = useCurrentLocale(i18nConfig) || i18nConfig.defaultLocale;
  const isClient = typeof window !== "undefined";

  if (!isClient && locale !== i18n.locale) {
    // there is single instance of i18n on the server
    // note: on the server, we could have an instance of i18n per supported locale
    // to avoid calling loadAndActivate for (worst case) each request, but right now that's what we do
    i18n.loadAndActivate({ locale, messages });
  }
  if (isClient && !i18n.locale) {
    // first client render
    i18n.loadAndActivate({ locale, messages });
  }

  useEffect(() => {
    const localeDidChange = locale !== i18n.locale;
    if (localeDidChange) {
      i18n.loadAndActivate({ locale, messages });
    }
  }, [locale]);

  return i18n;
}

で page.tsx をこうする

"use client"

import { t } from "@lingui/macro";

export default function Home() {
  return <div>{t`こんにちは、世界`}</div>;
}


ここまでしたら Lingui の CLI で翻訳ファイルをつくる

npx lingui extract
✔
Catalog statistics for locales/{locale}:
┌─────────────┬─────────────┬─────────┐
│ LanguageTotal countMissing │
├─────────────┼─────────────┼─────────┤
│ ja (source) │      1      │    -    │
│ en-US11    │
└─────────────┴─────────────┴─────────┘

こんな感じにでてきて、 en-US 側に翻訳が足りてない (Missingが1) なことがわかる。

en-US.po はこんな感じ

msgid ""
msgstr ""
"POT-Creation-Date: 2024-09-25 02:01+0900\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en-US\n"

#: app/page.tsx:4
msgid "こんにちは、世界"
msgstr ""

msgstr に翻訳を追加

msgid ""
msgstr ""
"POT-Creation-Date: 2024-09-25 02:01+0900\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en-US\n"

#: app/page.tsx:4
msgid "こんにちは、世界"
msgstr "Hello, World"

そうすると `localhost:3000/en-US` でアクセスした際に、翻訳されていることが確認できるはず。

Next.js の Client Component のみならこれでいいのだが、Server Component ではこのままでは辞書ファイルがロードされていないので、例えば generateMetadata では別のやり方が必要になる

export async function generateMetadata({
  params,
}: {
  params: { locale: Locale };
}): Promise<Metadata> {
  const translation = await loadCatalog(params.locale);
  const i18n = setupI18n({
    locale: params.locale,
    messages: { [params.locale]: translation },
  });

  return {
    title: t(i18n)`ここのサイトのタイトルが入る`,
  }
}

params から locale をうけとり、それをもとに辞書ファイルをロードしてくる処理が必要になる。

最終的なフォルダ構成はこんな感じ

.
├── README.md
├── app
│   └── [locale]
│       ├── app.tsx
│       ├── favicon.ico
│       ├── fonts
│       ├── globals.css
│       ├── layout.tsx
│       └── page.tsx
├── i18-config.ts
├── lingui.config.ts
├── locales
│   ├── en-US.po
│   └── ja.po
├── middleware.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── tailwind.config.ts
└── tsconfig.json

複数形の話や、JSX との組み合わせについては割愛することにする。

こんな感じでサイトを簡単に? i18n 化できるので、ぜひ機会があれば試してみてはどうだろうか


いいなと思ったら応援しよう!