Next.js 静态导出实现 i18n

10 阅读1分钟

相关文章

实现

image.png

391249703-cead3236-876c-49e6-a495-d48a5d1718dc.png

安装依赖

pnpm add @lingui/core @lingui/react
pnpm add @lingui/cli @lingui/loader @lingui/swc-plugin

配置 nextjs swc 插件解析 .po 文件

const nextConfig: NextConfig = {
  output: "export",
+  webpack: (config) => {
+    config.module.rules.push({
+      test: /\.po$/,
+      use: {
+        loader: "@lingui/loader",
+      },
+    });
+    return config;
+  },
+  experimental: {
+    swcPlugins: [["@lingui/swc-plugin", {}]],
+  },
  transpilePackages: ["three"],
};

Lingui 配置

lingui.config.ts

import { defineConfig } from "@lingui/cli";

export default defineConfig({
  sourceLocale: "en",
  locales: ["zh", "en"],
  catalogs: [
    {
      path: "<rootDir>/src/locales/{locale}/messages",
      include: ["src"],
    },
  ],
});

国际化

解析 message.po 文件,提供全局的 context

i18n.ts

import { setupI18n } from "@lingui/core";
import linguiConfig from "../lingui.config";
import "server-only";
import type { I18n, Messages } from "@lingui/core";

export const { locales, sourceLocale } = linguiConfig;
type SupportedLocales = string;

async function loadCatalog(locale: SupportedLocales): Promise<{
  [k: string]: Messages;
}> {
  const { messages } = await import(`./locales/${locale}/messages.po`);
  return {
    [locale]: messages,
  };
}
const catalogs = await Promise.all(locales.map(loadCatalog));

export const allMessages = catalogs.reduce((acc, oneCatalog) => {
  return { ...acc, ...oneCatalog };
}, {});

type AllI18nInstances = { [K in SupportedLocales]: I18n };

export const allI18nInstances: AllI18nInstances = locales.reduce(
  (acc, locale) => {
    const messages = allMessages[locale] ?? {};
    const i18n = setupI18n({
      locale,
      messages: { [locale]: messages },
    });
    return { ...acc, [locale]: i18n };
  },
  {},
);

export const getI18nInstance = (locale: SupportedLocales): I18n => {
  if (!allI18nInstances[locale]) {
    console.warn(`No i18n instance found for locale "${locale}"`);
  }
  return allI18nInstances[locale]! || allI18nInstances.en!;
};

provider

LinguiClientProvider.tsx

"use client";
import { setupI18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { useState } from "react";
import type { Messages } from "@lingui/core";

export function LinguiClientProvider({
  children,
  initialLocale,
  initialMessages,
}: {
  children: React.ReactNode;
  initialLocale: string;
  initialMessages: Messages;
}) {
  const [i18n] = useState(() => {
    return setupI18n({
      locale: initialLocale,
      messages: { [initialLocale]: initialMessages },
    });
  });

  return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
}

全局配置

import { setI18n } from "@lingui/react/server";
import { Inter } from "next/font/google";
import { Toaster } from "react-hot-toast";
+import { getI18nInstance } from "~/i18n";
+import { LinguiClientProvider } from "~/providers/LinguiClientProvider";
import { Providers } from "../providers";
import "./globals.css";
import type { Metadata } from "next";
import type { ReactNode } from "react";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Magic tools",
  description: "a magic tools for developers",
  icons: ["/favicon.ico"],
};

export function getRootLayout(lang: string) {
  function RootLayout({ children }: { children: ReactNode }) {
+    const i18n = getI18nInstance(lang);
+    setI18n(i18n);

    return (
      <html lang={lang}>
        <body className={inter.className}>
          <Toaster />
+         <LinguiClientProvider
+            initialLocale={lang}
+            initialMessages={i18n.messages}
+          >
            <Providers>{children}</Providers>
+          </LinguiClientProvider>
        </body>
      </html>
    );
  }
  return RootLayout;
}

路由

需要实现的效果是有两个 enzh,并且 en 路径可以隐藏,具体思路是实现两个 RootLayout

391248585-217ec694-d905-4f1f-89f0-c2f2f2d128b7.png

然后在 lang 的路径里,导入对应的组件

layout.tsx

export { default, metadata } from "../../../(main)/base64/layout";

page.tsx

export { default } from "../../../(main)/base64/page";

主路由的默认设置为 en,lang 路由根据 generateStaticParams获取对应的 lang, 这里抽离的一个 getRootLayout 函数获取具体的 RootLayout

export function getRootLayout(lang: string) {
  function RootLayout({ children }: { children: ReactNode }) {
    const i18n = getI18nInstance(lang);
    setI18n(i18n);

    return (
      <html lang={lang}>
        <body className={inter.className}>
          <Toaster />
          <LinguiClientProvider
            initialLocale={lang}
            initialMessages={i18n.messages}
          >
            <Providers>{children}</Providers>
          </LinguiClientProvider>
        </body>
      </html>
    );
  }
  return RootLayout;
}

src/app/(main)/layout.tsx

import { getRootLayout } from "../_layout";

export { metadata } from "../_layout";

const RootLayout = getRootLayout("en");
export default RootLayout;

src/app/(lang)/[lang]/layout.tsx

import { locales } from "~/i18n";
import { getRootLayout } from "../../_layout";

export { metadata } from "../../_layout";

interface Props {
  params: Promise<{
    lang: string;
  }>;
  children: React.ReactNode;
}

export async function generateStaticParams() {
  return locales.map((locale) => ({ lang: locale }));
}

export default async function Layout({ params, children }: Props) {
  const { lang } = await params;
  const RootLayout = getRootLayout(lang);

  return <RootLayout>{children}</RootLayout>;
}

链接

添加 en zh 后,有些路由链接可能跳转的不是当前语言的,实现了 useI18nHelper 辅助

import { useLingui } from "@lingui/react/macro";
import { usePathname, useRouter } from "next/navigation";

export function useI18nHelper() {
  const router = useRouter();
  const pathname = usePathname();
  const { i18n } = useLingui();

  const sourceLocale = "en";
  const locales = ["en", "zh"];

  const switchLocale = () => {
    const newLocale =
      locales.find((locale) => locale !== i18n.locale) || sourceLocale;

    const realPathname = pathname.split("/").filter((i, index) => {
      if (index === 1 && i === i18n.locale) return false;
      return Boolean(i);
    });

    const newPathname =
      newLocale === sourceLocale
        ? `/${realPathname.join("/")}`
        : `/${newLocale}/${realPathname.join("/")}`;

    router.push(newPathname);
  };

  const getRealPathname = (path: string) => {
    const isLocalePath = locales.includes(pathname.split("/")[1]);

    return isLocalePath
      ? [i18n.locale, ...path.split("/").filter(Boolean)].join("/")
      : path;
  };

  return {
    // FIXME import from config file
    sourceLocale,
    locales,
    switchLocale,
    getRealPathname,
  };
}

Build

添加提取命令

{
  "scripts": {
    "extract": "lingui extract",
    "compile": "lingui compile",
  }
}

391249412-4ebb224b-9da6-45b7-a209-2f6b1d198348.png 手动翻译或借助工具翻译