next.js 14 i18n typescript
ここをベースに日本語版作ってみる
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
@tailwind base;
@tailwind components;
@tailwind utilities;
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
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
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;
次は言語選択の基本設定をしていく。
作成するディレクトリやファイル
mkdir -p app/[lng]/i18n/
touch app/[lng]/i18n/settings.ts
touch middleware.ts
app/[lng]/i18n/settings.ts
export const fallbackLng = "ja";
export const languages = [fallbackLng, "en"];
export const cookieName = "i18next";
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
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();
}
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
修正
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
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
{
"home.title": "ホーム",
"home.link.about": "このサイトについて",
"about.title": "このサイトについて",
"back.home": "ホームに戻る"
}
app/[lng]/i18n/locales/ja/special.json
{
"special.description": "個別ページ文言"
}
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
{
"special.description": "Special description"
}
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
修正
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;
とりあえず、URLの言語選択に対応した形で文言が切り替わったことは確認できた。
ただ、app/[lng]/i18n/index.ts
とかやりたいことはなんとなくわかるけど、細かいコードは何でこんなコードになるのか、よーわからん。。。
あとから詳細は追っかけるとして、今は全体を理解することに努める。
言語切り替えができるようにする。
ヘッダを作ってそこに言語切り替えのプルダウンなどを作ってみることにする。
思い付きで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
修正
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を追記)
module.exports = {
theme: {
extend: {
fontFamily: {
bizudp: ["var(--font-bizudp)"],
code: ["var(--font-code)"],
},
言語選択のコンボボックスを作成
"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関数は別途作成するので一旦ここまで。
クライアント版のuseTranslation関数を作る。
touch app/[lng]/i18n/client.ts
bun add react-cookie i18next-browser-languagedetector
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
"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;
やりたいことはできた。
できたんやけど、、、
やっぱり、 app/[lng]/i18n/client.ts
細かいところが。。。
全体的な感想としては、Javaやとこんなめんどっちぃ事せんでもi18nの仕組みがあってあんまり考えんでもこの辺ちゃっちゃとできてた。
改めて、Javaはよく考えられてると思った。
とりあえず、GitLabにあげとく。