iT邦幫忙

2022 iThome 鐵人賽

DAY 28
0
Modern Web

從零開始打造炫砲個人部落格,使用 Next.js、ContentLayer、i18next 等現代技術系列 第 28

使用 next-i18next 實作中英文多語系 - Modern Next.js Blog 系列 #28

  • 分享至 

  • twitterImage
  •  

讓我們來完成這個系列最後一塊重點功能:「i18next 中英文多語系支援」,讓部落格走向國際!

最終效果如下:

Chinese

English

Command Palette

這篇修改的程式碼如下:
https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/compare/day27-search-post...day28-i18next

Note:
如果你的網站沒有打算支援多語系,這篇文章可以忽略不看。
不影響剩餘 2 篇文章的閱讀與實作。

我的個人網站裡也有此系列的好讀版,程式碼更易讀、也支援深色模式和側邊目錄,歡迎前往閱讀!


Next.js 的 多語系支援

Next.js 內建就支援了多語系路由,可以做到根據 path 切分不同語系,效果如下:

或是根據 subdomain 切分語系,例如:

  • example.com 英文版網站
  • example.fr 法文版網站

詳細可參考官方文件:Advanced Features: Internationalized Routing | Next.js

而實際處理多語系字串,要搭配額外套件,像是 next-i18nextnext-intl 等。

這邊我們使用最熱門的 next-i18next 來實作。

使用 next-i18next 實作中英文多語系

安裝 next-i18next

輸入指令安裝套件:

pnpm add next-i18next

新增 next-i18next.config.js

module.exports = {
  i18n: {
    locales: ['en', 'zh-TW'],
    defaultLocale: 'zh-TW',
  },
};

修改 next.config.mjs,啟用 next-i18next:

// ...

import i18nConfig from './next-i18next.config.js';

const { i18n } = i18nConfig;

/** @type {import('next').NextConfig} */
const nextConfig = withContentlayer({
  // ...
  i18n,
});

export default nextConfig;

修改 src/pages/_app.tsx,用 appWithTranslation 包住整個 App:

// ...
import { appWithTranslation } from 'next-i18next';

import nextI18nConfig from '../../next-i18next.config';

// ...

// Explicitly pass nextI18nConfig to suppress i18next console warning
// `react-i18next:: You will need to pass in an i18next instance by using initReactI18next`
// Ref: https://github.com/i18next/next-i18next/issues/718#issuecomment-1190468800
export default appWithTranslation(MyApp, nextI18nConfig);

加入中英文語系檔案

想在 next-i18next 裡讓一段文字的中英文不同,需要在 public/locales/<locale>/<namespace>.json 路徑新增 JSON 檔,定義每個 i18n key 在不同語系呈現的文字。

我們這裡會新增 enzh-TW 這兩個語系,以及分成兩個 namespace:全站共用的 common 和首頁專屬的 indexPage

因此我們需要新增下面四個檔案:

  • public/locales/en/common.json
  • public/locales/en/indexPage.json
  • public/locales/zh-TW/common.json
  • public/locales/zh-TW/indexPage.json

內容的 key, value 可以依照你的網站內容調整。

新增 public/locales/en/common.json

{
  "copied": "Copied!",
  "table-of-contents": "Table of contents",
  "home": "Home",
  "posts": "Posts",
  "search": "Search",
  "search-posts": "Search Posts",
  "next-article": "Next Article",
  "previous-article": "Previous Article",
  "published-time": "Published time",
  "toggle-theme": "Toggle theme",
  "dark-mode": "Dark mode",
  "light-mode": "Light mode",
  "page": "Page",
  "operation": "Operation",
  "toggle-language": "Toggle language",
  "english": "English",
  "chinese": "中文"
}

新增 public/locales/en/indexPage.json

{
  "latest-posts": "Latest Posts",
  "intro-title": "Hey,I am Iron Man ?",
  "intro-1": "I'm Tony Stark,not Stank!",
  "intro-2": "I'm rich and have saved the world lots of times.",
  "intro-3": "I have aliens, purple things, and purple aliens."
}

新增 public/locales/zh-TW/common.json

{
  "copied": "已複製!",
  "table-of-contents": "目錄",
  "home": "首頁",
  "posts": "文章",
  "search": "搜尋",
  "search-posts": "搜尋文章",
  "next-article": "下一篇文章",
  "previous-article": "上一篇文章",
  "published-time": "發佈時間",
  "toggle-theme": "切換主題",
  "dark-mode": "暗黑模式",
  "light-mode": "明亮模式",
  "page": "頁面",
  "operation": "操作",
  "toggle-language": "切換語言",
  "english": "English",
  "chinese": "中文"
}

新增 public/locales/zh-TW/indexPage.json

{
  "latest-posts": "最新文章",
  "intro-title": "Hey,I am Iron Man ?",
  "intro-1": "我是 Tony Stark,不是 Stank!",
  "intro-2": "老子很有錢,拯救過很多次世界。",
  "intro-3": "我討厭外星人、紫色的東西、和紫色外星人。"
}

在各頁面引用語系檔案

修改 src/pages/index.tsx

import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';

// ...

export const getStaticProps: GetStaticProps<Props> = async (context) => {
  const locale = context.locale!;

  // ...

  return {
    props: {
      ...(await serverSideTranslations(locale, ['indexPage', 'common'])),
      // ...
    },
  };
};

// ...

新增 src/configs/i18nConfigs.ts

export const LOCALES = ['en', 'zh-TW'];
export const DEFAULT_LOCALE = 'zh-TW';

修改 src/pages/posts/[slug].tsx

import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { ParsedUrlQuery } from 'querystring';
import { LOCALES } from '@/configs/i18nConfigs';

// ...

interface Params extends ParsedUrlQuery {
  slug: string;
}

export const getStaticPaths: GetStaticPaths = () => {
  const paths: string[] = [];
  LOCALES.forEach((locale) => {
    paths.push(...allPosts.map((post) => `/${locale}${post.path}`));
  });
  return {
    paths,
    fallback: false,
  };
};

export const getStaticProps: GetStaticProps<Props, Params> = async (
  context
) => {
  const { slug } = context.params!;
  const locale = context.locale!;

  // ...

  return {
    props: {
      ...(await serverSideTranslations(locale, ['common'])),
      // ...
    },
  };
};

// ...

這樣就能在各元件內用 next-i18nextuseTranslationt function 來根據語系顯示不同文字了,範例如下:

const { t } = useTranslation(['indexPage', 'common']);
<p>{t('home')}</p>

// en 顯示 'Home'
// zh-TW 顯示 '首頁'

在 Header 加入語系切換按鈕

接著來加入下圖這個語系切換按鈕:

Language toggle button

新增 src/components/LanguageSwitch.tsx

/* eslint-disable jsx-a11y/anchor-is-valid */
import Link from 'next/link';
import { useRouter } from 'next/router';

const LanguageSwitch = () => {
  const router = useRouter();
  const { pathname, query } = router;
  const nextLocale = router.locale === 'en' ? 'zh-TW' : 'en';

  return (
    <Link locale={nextLocale} href={{ pathname, query }}>
      <a
        aria-label="Toggle Language"
        className="rounded p-2 text-2xl leading-6 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 sm:p-3"
      >
        {router.locale === 'en' ? '??' : '??'}
      </a>
    </Link>
  );
};

export default LanguageSwitch;

修改 src/components/Header.tsx,顯示 LanguageSwitch:

import LanguageSwitch from '@/components/LanguageSwitch';
// ...

export default function Header() {
  return (
    <header className="sticky top-0 z-10 border-b border-slate-900/10 bg-white/70 py-3 backdrop-blur transition-colors dark:border-slate-50/[0.06] dark:bg-gray-900/60">
      <SectionContainer>
        <div className="flex items-baseline justify-between">
          // ...

          <div className="flex items-center text-base leading-5 sm:gap-1">
            // ...
            
            // 加入 LanguageSwitch
            <LanguageSwitch />
            <ThemeSwitch />
            <CommandPaletteToggle />
            <MobileNav />
          </div>
        </div>
      </SectionContainer>
    </header>
  );
}

在 Command Palette 指令面板加入語系切換選單

第 26 篇我們加入了 Command Palette 指令面板,讓讀者能用鍵盤快速執行各種操作。

切換語系也是一個很重要的動作,讓我們把它加入 Command Palette 吧。

效果如下圖:

Command Palette

這邊和第 27 篇在 Command Palette 實作文章搜尋一樣有遇到技術問題。

如果把切換語系的 action 寫進 <CommandPalette/>actions array,changeLocale 函式只會在全站 mount 時初始化一次,會記憶住第一頁 useRouter 的網址。
進到第二頁後呼叫 changeLocale 時,雖然能切換語系,但會被強制導回第一頁。

因此這邊採用迂迴的方法,在每個 page 使用 useRegisterActions,動態加入每個頁面的 action,changeLocale 才會在每個頁面重新初始化。

修改 src/components/CommandPalette/CommandPalette.tsx,加入 language section:

import {
  // ...
  LanguageIcon,
} from '@heroicons/react/24/outline';
import { useTranslation } from 'next-i18next';

// ...

export default function CommandPalette({ children }: Props) {
  const { t } = useTranslation(['common']);
  // ...

  const actions = [
    // ...
    // - Language toggle
    {
      id: 'language',
      name: t('toggle-language'),
      keywords:
        'change toggle locale language translation 切換 更換 語言 語系 翻譯',
      icon: <LanguageIcon className="h-6 w-6" />,
      section: t('operation'),
    },
  ];
  
  // ...
}

// ...

新增 src/components/CommandPalette/useCommandPaletteLocaleActions.tsx

import { useRegisterActions } from 'kbar';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';

export const useCommandPaletteLocaleActions = () => {
  const router = useRouter();
  const { pathname, asPath, query } = router;
  const { t } = useTranslation(['common']);

  const changeLocale = (locale: string) => {
    router.push({ pathname, query }, asPath, { locale: locale });
  };

  useRegisterActions(
    [
      {
        id: 'language-english',
        name: 'English',
        keywords: 'locale language translation english 語言 語系 英文 英語',
        perform: () => changeLocale('en'),
        icon: <span className="p-1">??</span>,
        parent: 'language',
        section: t('operation'),
      },
      {
        id: 'language-chinese',
        name: '中文',
        keywords:
          'locale language translation traditional chinese taiwanese 語言 語系 翻譯 中文 台灣 繁體',
        perform: () => changeLocale('zh-TW'),
        icon: <span className="p-1">??</span>,
        parent: 'language',
        section: t('operation'),
      },
    ],
    [asPath]
  );
};

新增 src/components/LayoutPerPage.tsx,用來在每個頁面呼叫 useCommandPaletteLocaleActions

import { useCommandPaletteLocaleActions } from '@/components/CommandPalette/useCommandPaletteLocaleActions';

type Props = {
  children: React.ReactNode;
};

const LayoutPerPage = ({ children }: Props) => {
  useCommandPaletteLocaleActions();

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

export default LayoutPerPage;

接著將每個頁面用 <LayoutPerPage/> 包住。

修改 src/pages/index.tsx

// ...

const Home: NextPage<Props> = ({ posts, commandPalettePosts }) => {
  // ...

  return (
    <LayoutPerPage>
      // ...
    </LayoutPerPage>
  );
};

// ...

修改 src/pages/posts/[slug].tsx

// ...

const PostPage: NextPage<Props> = ({
  // ...
}) => {
  // ...

  return (
    <LayoutPerPage>
      // ...
    </LayoutPerPage>
  );
};

// ...

這樣就成功把切換語系 action 加入 Command Palette 了。

讓 giscus 留言區塊支援多語系

第 25 篇加入的 giscus 留言板也支援多語系,我們可以把 locale 傳給 <Giscus/>lang,來讓留言板語言與網站顯示的語言一致。

修改 src/components/Comment.tsx

import { useRouter } from 'next/router';
// ...

const Comment = () => {
  // ...
  const { locale } = useRouter();

  return (
    <div id="comment" className="mx-auto max-w-prose py-6">
      <Giscus
        // ...
        lang={locale}
      />
    </div>
  );
};

// ...

把所有頁面和元件文字換成 i18n key

最後把每個頁面和元件的文字換成 i18n key,就完成多語系處理了。

這邊改動檔案很多,寫法都一樣,這邊只列 2 個當參考,完整改動可以看這支 commit:
https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/commit/988eec15c02172c3de1b1de88630afc5bc5e5397

修改 src/components/Header.tsx,把 CustomLink 內的文字用 t function 轉換成翻譯後文字:

import { useTranslation } from 'next-i18next';
// ...

export default function Header() {
  const { t } = useTranslation(['common']);

  return (
    <header className="sticky top-0 z-10 border-b border-slate-900/10 bg-white/70 py-3 backdrop-blur transition-colors dark:border-slate-50/[0.06] dark:bg-gray-900/60">
      <SectionContainer>
        <div className="flex items-baseline justify-between">
          // ...
          <div className="flex items-center text-base leading-5 sm:gap-1">
            <div className="hidden gap-1 sm:flex">
              {headerConfigs.navLinks.map((link) => (
                <CustomLink
                  key={link.title}
                  href={link.href}
                  className="rounded p-3 font-medium text-gray-900 transition-colors hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800"
                >
                  {t(link.title)} // <-- 修改這一行
                </CustomLink>
              ))}
            </div>
            // ...
          </div>
        </div>
      </SectionContainer>
    </header>
  );
}

修改 src/components/CustomPre.tsx,一樣把文字改成 t function:

// ...

function CustomPre({ children, className, ...props }: Props) {
  // ...

  return (
    <div className="group relative">
      <pre
        {...props}
        ref={preRef}
        className={clsx(className, 'focus:outline-none')}
      >
        <div className="absolute top-0 right-0 m-2 flex items-center rounded-md bg-[#282a36] dark:bg-[#262626]">
          <span
            className={clsx('hidden px-2 text-xs text-green-400 ease-in', {
              'group-hover:flex': copied,
            })}
          >
            {t('copied')} // <-- 修改這一行
          </span>
        </div>

        {children}
      </pre>
    </div>
  );
}

// ...

成果

這樣就完成了!使用 pnpm dev,進去網站後按下 Header 的切換語系按鈕,或用 Command Palette 切換語系,就能在中英文語系切換,看到不同語系的網站了!

網址也會根據語系不同:

  • 中文:http://localhost:3000/
  • 英文:http://localhost:3000/en

最終效果如下:

Chinese

English

Command Palette

這篇修改的程式碼如下:
https://github.com/Kamigami55/nextjs-tailwind-contentlayer-blog-starter/compare/day27-search-post...day28-i18next

References

下一篇

恭喜你成功使用 next-i18next 加入多語系了!

下一篇是 30 天系列最後一篇實作,最後來加入一個微小但重要的功能:舊路徑轉址!


上一篇
在 kbar Command Palette 實作文章搜尋 - Modern Next.js Blog 系列 #27
下一篇
在 Next.js Contentlayer blog 實作舊路徑轉址 - Modern Next.js Blog 系列 #29
系列文
從零開始打造炫砲個人部落格,使用 Next.js、ContentLayer、i18next 等現代技術30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言