Closed10

next.js 14 i18n typescript

Mt.SouthernMt.Southern

bunで環境構築してみる。

bun create next-app i18n-app

オプションは全部デフォルト。

できたら次はi18nのライブラリとかインストール

cd i18n-app
bun add i18next react-i18next i18next-resources-to-backend accept-language

デフォルトのpage.tsxだとかを削除。

rm app/page.tsx app/layout.tsx app/globals.css

続いて、ディレクトリとかファイルとかつくる。

mkdir -p app/[lng]
touch app/[lng]/page.tsx
touch app/[lng]/layout.tsx
touch app/[lng]/globals.css

mkdir -p app/[lng]/about
touch app/[lng]/about/page.tsx

app/[lng]/globals.css

app/[lng]/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

app/[lng]/page.tsx

app/[lng]/page.tsx
import Link from "next/link";

const HomePage = ({ params }: { params: { lng: string } }) => {
  return (
    <>
      <h1>ホーム</h1>
      <Link href={`/${params.lng}/about`}>このサイトについて</Link>
    </>
  );
};
export default HomePage;

app/[lng]/about/page.tsx

app/[lng]/about/page.tsx
import Link from "next/link";

const AboutPage = ({ params }: { params: { lng: string } }) => {
  return (
    <>
      <h1>このサイトについて</h1>
      <Link href={`/${params.lng}/`}>ホームに戻る</Link>
    </>
  );
};

export default AboutPage;

app/[lng]/layout.tsx

app/[lng]/layout.tsx
import { dir } from "i18next";
import "./globals.css";

// 言語の配列を定義する
const languages: string[] = ["ja", "en"];

// 静的なパラメータを生成する関数を定義する
export const generateStaticParams = async (): Promise<{ lng: string }[]> => {
  return languages.map((lng) => ({ lng }));
};

// ルートレイアウトのコンポーネントを定義する
export const RootLayout = ({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { lng: string };
}) => {
  return (
    <html lang={params.lng} dir={dir(params.lng)}>
      <head />
      <body>{children}</body>
    </html>
  );
};
export default RootLayout;
Mt.SouthernMt.Southern

次は言語選択の基本設定をしていく。

作成するディレクトリやファイル

mkdir -p app/[lng]/i18n/
touch app/[lng]/i18n/settings.ts

touch middleware.ts

app/[lng]/i18n/settings.ts

app/[lng]/i18n/settings.ts
export const fallbackLng = "ja";
export const languages = [fallbackLng, "en"];
export const cookieName = "i18next";

app/[lng]/layout.tsx 修正

app/[lng]/layout.tsx
import { dir } from "i18next";
import "./globals.css";
import { languages } from './i18n/settings';


// 静的なパラメータを生成する関数を定義する
export const generateStaticParams = async (): Promise<{ lng: string }[]> => {
  return languages.map((lng) => ({ lng }));
};

// ルートレイアウトのコンポーネントを定義する
export const RootLayout = ({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { lng: string };
}) => {
  return (
    <html lang={params.lng} dir={dir(params.lng)}>
      <head />
      <body>{children}</body>
    </html>
  );
};
export default RootLayout;

middleware.ts

middleware.ts
import { NextRequest, NextResponse } from "next/server";
import acceptLanguage from "accept-language";
import { fallbackLng, languages, cookieName } from "./app/[lng]/i18n/settings";

// accept-languageモジュールにサポート言語を登録する
acceptLanguage.languages(languages);

// ミドルウェアの設定を定義する
export const config = {
  // マッチャーを指定する。api, _next, assets, favicon, swなどのパスを除外する
  matcher: ["/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)"],
};

// ミドルウェアの関数を定義する
export function middleware(req: NextRequest) {
  // 言語を格納する変数を宣言する
  let lng;
  // クッキーに言語が設定されていれば、それを取得する
  if (req.cookies.has(cookieName))
    lng = acceptLanguage.get(req.cookies.get(cookieName)?.value);
  // クッキーに言語が設定されていなければ、ヘッダーのAccept-Languageから取得する
  if (!lng) lng = acceptLanguage.get(req.headers.get("Accept-Language"));
  // ヘッダーにも言語が設定されていなければ、フォールバック言語を使用する
  if (!lng) lng = fallbackLng;

  // パスに含まれる言語がサポート言語でなければ、リダイレクトする
  if (
    !languages.some((loc) => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
    !req.nextUrl.pathname.startsWith("/_next")
  ) {
    return NextResponse.redirect(
      new URL(`/${lng}${req.nextUrl.pathname}`, req.url)
    );
  }

  // ヘッダーにrefererがあれば、それから言語を取得する
  if (req.headers.has("referer")) {
    const refererUrl = new URL(req.headers.get("referer") || "");
    const lngInReferer = languages.find((l) =>
      refererUrl.pathname.startsWith(`/${l}`)
    );
    // refererから言語が取得できれば、クッキーに設定する
    const response = NextResponse.next();
    if (lngInReferer) response.cookies.set(cookieName, lngInReferer);
    return response;
  }

  // それ以外の場合は、通常のレスポンスを返す
  return NextResponse.next();
}
Mt.SouthernMt.Southern

URLパスによる言語選択とURLパスに言語設定がない場合のデフォルト言語設定ができたので次は、いよいよ言語に対応した文言の実装を行っていく。

ディレクトリとかファイルとか作成。

touch app/[lng]/i18n/index.ts

mkdir -p  app/[lng]/i18n/locales/ja
mkdir -p  app/[lng]/i18n/locales/en

touch app/[lng]/i18n/locales/ja/translation.json
touch app/[lng]/i18n/locales/ja/special.json
touch app/[lng]/i18n/locales/en/translation.json
touch app/[lng]/i18n/locales/en/special.json

app/[lng]/i18n/settings.ts 修正

app/[lng]/i18n/settings.ts
export const fallbackLng = "ja";
export const languages = [fallbackLng, "en"];
export const cookieName = "i18next";
export const defaultNS = "translation";

export function getOptions(
  lng = fallbackLng,
  ns: string | string[] = defaultNS
) {
  return {
    // debug: true,
    supportedLngs: languages,
    fallbackLng,
    lng,
    fallbackNS: defaultNS,
    defaultNS,
    ns,
  };
}

app/[lng]/i18n/index.ts

app/[lng]/i18n/index.ts
import { createInstance, FlatNamespace, KeyPrefix } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";
import { FallbackNs } from "react-i18next";
import { getOptions } from "./settings";

const initI18next = async (lng: string, ns: string | string[]) => {
  // on server side we create a new instance for each render, because during compilation everything seems to be executed in parallel
  const i18nInstance = createInstance();
  await i18nInstance
    .use(initReactI18next)
    .use(
      resourcesToBackend(
        (language: string, namespace: string) =>
          import(`./locales/${language}/${namespace}.json`)
      )
    )
    .init(getOptions(lng, ns));
  return i18nInstance;
};

export async function useTranslation<
  Ns extends FlatNamespace,
  KPrefix extends KeyPrefix<FallbackNs<Ns>> = undefined
>(lng: string, ns?: Ns, options: { keyPrefix?: KPrefix } = {}) {
  const i18nextInstance = await initI18next(
    lng,
    Array.isArray(ns) ? (ns as string[]) : (ns as string)
  );
  return {
    t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
    i18n: i18nextInstance,
  };
}

app/[lng]/i18n/locales/ja/translation.json

app/[lng]/i18n/locales/ja/translation.json
{
  "home.title": "ホーム",
  "home.link.about": "このサイトについて",
  "about.title": "このサイトについて",
  "back.home": "ホームに戻る"
}

app/[lng]/i18n/locales/ja/special.json

app/[lng]/i18n/locales/ja/special.json
{
  "special.description": "個別ページ文言"
}

app/[lng]/i18n/locales/en/translation.json

app/[lng]/i18n/locales/en/translation.json
{
  "home.title": "Home",
  "home.link.about": "About this site",
  "about.title": "About this site",
  "back.home": "Back to home"
}

app/[lng]/i18n/locales/en/special.json

app/[lng]/i18n/locales/en/special.json
{
  "special.description": "Special description"
}

app/[lng]/page.tsx 修正

app/[lng]/page.tsx
import React from "react";
import Link from "next/link";
import { useTranslation } from "./i18n";

const HomePage = async ({ params }: { params: { lng: string } }) => {
  const { t } = await useTranslation(params.lng);
  return (
    <>
      <h1>{t("home.title")}</h1>
      <Link href={`/${params.lng}/about`}>{t("home.link.about")}</Link>
    </>
  );
};
export default HomePage;

app/[lng]/about/page.tsx 修正

app/[lng]/about/page.tsx
import Link from "next/link";
import { useTranslation } from "../i18n";
const AboutPage = async ({ params }: { params: { lng: string } }) => {
  const { t: tdef } = await useTranslation(params.lng);
  const { t: tsp } = await useTranslation(params.lng, "special");

  return (
    <>
      <h1>{tdef("about.title")}</h1>
      <Link href={`/${params.lng}/`}>{tdef("back.home")}</Link>
      <p>{tsp("special.description")}</p>
    </>
  );
};

export default AboutPage;
Mt.SouthernMt.Southern

とりあえず、URLの言語選択に対応した形で文言が切り替わったことは確認できた。

ただ、app/[lng]/i18n/index.tsとかやりたいことはなんとなくわかるけど、細かいコードは何でこんなコードになるのか、よーわからん。。。

あとから詳細は追っかけるとして、今は全体を理解することに努める。

Mt.SouthernMt.Southern

言語切り替えができるようにする。

ヘッダを作ってそこに言語切り替えのプルダウンなどを作ってみることにする。
思い付きでUIはshadcn/uiとか使ってみることにする。

bunx shadcn-ui@latest init

(--bunオプション付けると初期化スクリプトが途中で止まったので外した)

初期化時の質問は基本的にはデフォルトとするが、一部変更

? Where is your global CSS file? › app/[lng]/globals.css
? Where is your tailwind.config.js located? › tailwind.config.ts

コンボボックスに使うUIパーツを取得

bunx shadcn-ui@latest add command popover

作成するディレクトリやファイルなど

mkdir -p components/LanguageCombo
touch components/LanguageCombo/index.tsx

ついでにフォントの設定とかもやっておく。
GooglefontのBIZ UD Pゴシック、Mplus1Codeを使うことにする。

app/[lng]/layout.tsx 修正

app/[lng]/layout.tsx
import { dir } from "i18next";
import "./globals.css";
import { languages } from "./i18n/settings";
import { BIZ_UDPGothic, M_PLUS_1_Code } from "next/font/google";
import { cn } from "@/lib/utils";
import LanguageCombo from "@/components/LanguageCombo";

export const fontBizUdpGothic = BIZ_UDPGothic({
  weight: ["400", "700"],
  subsets: ["latin"],
  variable: "--font-bizudp",
});

export const fontCode = M_PLUS_1_Code({
  subsets: ["latin"],
  variable: "--font-code",
});

// 静的なパラメータを生成する関数を定義する
export const generateStaticParams = async (): Promise<{ lng: string }[]> => {
  return languages.map((lng) => ({ lng }));
};

// ルートレイアウトのコンポーネントを定義する
export default function RootLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { lng: string };
}) {
  return (
    <html lang={params.lng} dir={dir(params.lng)}>
      <head />
      <body
        className={cn(
          "min-h-screen bg-background font-bizudp antialiased",
          fontBizUdpGothic.variable,
          fontCode.variable
        )}
      >
        <nav>
          <LanguageCombo
            params={{
              lng: params.lng,
            }}
          />
        </nav>
        {children}
      </body>
    </html>
  );
}

tailwind.config.ts 修正
(theme.extend以下にfontFamilyを追記)

tailwind.config.ts
module.exports = {
  theme: {
    extend: {
      fontFamily: {
        bizudp: ["var(--font-bizudp)"],
        code: ["var(--font-code)"],
      },

言語選択のコンボボックスを作成

components/LanguageCombo/index.tsx
"use client";
import React from "react";

import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

import { Command, CommandGroup, CommandItem } from "@/components/ui/command";

import { languages } from "@/app/[lng]/i18n/settings";
import Link from "next/link";

const langSet = new Set(languages);
const langs = [
  { label: "日本語", value: "ja" },
  { label: "English", value: "en" },
  { label: "Français", value: "fr" },
  { label: "Deutsch", value: "de" },
  { label: "Español", value: "es" },
  { label: "Português", value: "pt" },
  { label: "Русский", value: "ru" },
  { label: "한국어", value: "ko" },
  { label: "中文", value: "zh" },
] as const;

const LanguageCombo = ({ params }: { params: { lng: string } }) => {
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState("");
  return (
    <>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger>言語選択</PopoverTrigger>
        <PopoverContent>
          <Command>
            <CommandGroup>
              {langs.map((lang) => {
                const path = location.pathname.split("/").slice(2).join("/");
                return (
                  langSet.has(lang.value) && (
                    <CommandItem key={lang.value} value={lang.value}>
                      <Link href={`/${lang.value}/${path}`}>{lang.label}</Link>
                    </CommandItem>
                  )
                );
              })}
            </CommandGroup>
          </Command>
        </PopoverContent>
      </Popover>
    </>
  );
};

export default LanguageCombo;

できたけど、言語選択の部分が日本語固定。。。
ここも選択した言語によって変更したいが、クライアントコンポーネントなので非同期のuseTranslation関数は使えない。。。

クライアント版useTranslation関数は別途作成するので一旦ここまで。

Mt.SouthernMt.Southern

クライアント版のuseTranslation関数を作る。

touch app/[lng]/i18n/client.ts
bun add react-cookie i18next-browser-languagedetector

app/[lng]/i18n/client.ts

app/[lng]/i18n/client.ts
"use client";

import { useEffect, useState } from "react";
import i18next, { FlatNamespace, KeyPrefix } from "i18next";
import {
  initReactI18next,
  useTranslation as useTranslationOrg,
  UseTranslationOptions,
  UseTranslationResponse,
  FallbackNs,
} from "react-i18next";
import { useCookies } from "react-cookie";
import resourcesToBackend from "i18next-resources-to-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import { getOptions, languages, cookieName } from "./settings";

const runsOnServerSide = typeof window === "undefined";

i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(
    resourcesToBackend(
      (language: string, namespace: string) =>
        import(`./locales/${language}/${namespace}.json`)
    )
  )
  .init({
    ...getOptions(),
    lng: undefined,
    detection: {
      order: ["path", "htmlTag", "cookie", "navigator"],
    },
    preload: runsOnServerSide ? languages : [],
  });

export function useTranslation<
  Ns extends FlatNamespace,
  KPrefix extends KeyPrefix<FallbackNs<Ns>> = undefined
>(
  lng: string,
  ns?: Ns,
  options?: UseTranslationOptions<KPrefix>
): UseTranslationResponse<FallbackNs<Ns>, KPrefix> {
  const [cookies, setCookie] = useCookies([cookieName]);
  const ret = useTranslationOrg(ns, options);
  const { i18n } = ret;
  if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
    i18n.changeLanguage(lng);
  } else {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage);
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (activeLng === i18n.resolvedLanguage) return;
      setActiveLng(i18n.resolvedLanguage);
    }, [activeLng, i18n.resolvedLanguage]);
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (!lng || i18n.resolvedLanguage === lng) return;
      i18n.changeLanguage(lng);
    }, [lng, i18n]);
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (cookies.i18next === lng) return;
      setCookie(cookieName, lng, { path: "/" });
    }, [lng, cookies.i18next, setCookie]);
  }
  return ret;
}

言語選択コンボボックスの修正

components/LanguageCombo/index.ts

components/LanguageCombo/index.ts
"use client";
import React from "react";

import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

import { Command, CommandGroup, CommandItem } from "@/components/ui/command";

import { languages } from "@/app/[lng]/i18n/settings";
import Link from "next/link";
import { useTranslation } from "@/app/[lng]/i18n/client";
import { useSearchParams, usePathname } from "next/navigation";

const langSet = new Set(languages);
const langs = [
  { label: "日本語", value: "ja" },
  { label: "English", value: "en" },
  { label: "Français", value: "fr" },
  { label: "Deutsch", value: "de" },
  { label: "Español", value: "es" },
  { label: "Português", value: "pt" },
  { label: "Русский", value: "ru" },
  { label: "한국어", value: "ko" },
  { label: "中文", value: "zh" },
] as const;

const getLabel = (value: string): string | undefined =>
  langs.find((lang) => lang.value === value)?.label;

const LanguageCombo = ({ params }: { params: { lng: string } }) => {
  const path = usePathname();
  const search = useSearchParams();
  const [open, setOpen] = React.useState(false);
  const { t } = useTranslation(params.lng);
  const exceptLangPath = path.split("/").slice(2).join("/");
  return (
    <>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger>{`${t("language.selected")} ${getLabel(
          params.lng
        )}`}</PopoverTrigger>
        <PopoverContent>
          <Command>
            <CommandGroup>
              {langs.map((lang) => {
                return (
                  langSet.has(lang.value) && (
                    <CommandItem key={lang.value} value={lang.value}>
                      <Link href={`/${lang.value}/${exceptLangPath}?${search}`}>
                        {lang.label}
                      </Link>
                    </CommandItem>
                  )
                );
              })}
            </CommandGroup>
          </Command>
        </PopoverContent>
      </Popover>
    </>
  );
};

export default LanguageCombo;
Mt.SouthernMt.Southern

やりたいことはできた。
できたんやけど、、、
やっぱり、 app/[lng]/i18n/client.ts 細かいところが。。。

Mt.SouthernMt.Southern

全体的な感想としては、Javaやとこんなめんどっちぃ事せんでもi18nの仕組みがあってあんまり考えんでもこの辺ちゃっちゃとできてた。
改めて、Javaはよく考えられてると思った。

このスクラップは2023/11/15にクローズされました