如何在 tailwind-nextjs-starter-blog 加入多語系 (i18n)

主要是參考 [1] 提供的範例來在以 App Router 實作的 tailwind-nextjs-starter-blog 中加入多語系 (i18n)

要新增調整的地方主要包含以下幾點

  1. 資料夾結構
  2. 多語系相關設定
  3. 多語系文字內容
  4. 在 Component 套用多語系
  5. 多語系切換選單
  6. contentlayer data 及 data type 調整
  7. 標籤計數方法

相關套件安裝

1
yarn add i18next react-i18next i18next-resources-to-backend accept-language

資料夾結構

加上語系相關的動態路徑, 之後多語系對應路徑會轉導到 /en/posts/zh-TW/posts

1
2
3
4
5
6
7
8
.
└── app
    └── [lng]
        ├── tags
        ├── posts
        |   └── page.tsx
        ├── layout.tsx
        └── page.tsx

多語系相關設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages, cookieName } from './app/i18n/settings'

acceptLanguage.languages(languages)

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js|site.webmanifest).*)'],
}

export function middleware(req) {
  let lng
  if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
  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))
  }

  if (req.headers.has('referer')) {
    const refererUrl = new URL(req.headers.get('referer'))
    const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`))
    const response = NextResponse.next()
    if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
    return response
  }

  return NextResponse.next()
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export default function RootLayout({
  children,
  params: { lng },
}: {
  children: React.ReactNode
  params: { lng: string }
}) {
  const basePath = process.env.BASE_PATH || ''

  return (
    <html
      lang={lng}
      dir={dir(lng)}
      className={`${space_grotesk.variable} scroll-smooth`}
      suppressHydrationWarning
    >
    </html>
  )
}

i18n 相關設定及 helper function

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.
└── app
    └── i18n
        └── locales
            ├── en
            |   └── translation.json
            ├── zh-TW
            |   └── translation.json
            ├── client.ts
            ├── index.ts
            └── settings.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export const fallbackLng = 'en'
export const languages = [fallbackLng, 'zh-TW']
export const defaultNS = 'translation'
export const cookieName = 'i18next'

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

在 server-side component 中使用的多語系功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'

const initI18next = async (lng, ns) => {
  const i18nInstance = createInstance()
  await i18nInstance
    .use(initReactI18next)
    .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
    .init(getOptions(lng, ns))
  return i18nInstance
}

export async function translate(lng, ns, options = {}) {
  const i18nextInstance = await initI18next(lng, ns)
  return {
    t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
    i18n: i18nextInstance
  }
}

在 client-side component 使用的多語系功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
'use client'

import { useEffect, useState } from 'react'
import i18next from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg } 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, namespace) => import(`./locales/${language}/${namespace}.json`))
  )
  .init({
    ...getOptions(),
    lng: undefined, // let detect the language on client side
    detection: {
      order: ['path', 'htmlTag', 'cookie', 'navigator'],
    },
    preload: runsOnServerSide ? languages : [],
  })

export function useTranslation(lng, ns, options) {
  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])
  }
  return ret
}

多語系文字內容

分別在對應語系新增需要在介面上使用的多語系文字內容

1
2
3
4
{
  "latest": "Latest",
  "all-posts": "All posts"
}

在 Component 套用多語系

在需要使用多語系的 component 使用 translate (server-side component) 及 useTranslation (client-side component)

Main.tsx 為例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { translate } from 'app/i18n'

export default async function Home({ posts, params }) {
  const { lng } = params
  const lngPosts = posts.filter((post) => post.language == lng)
  const { t } = await translate(lng, 'translation')
  return (
    <>
        {t('latest')}
    </>
  )
}

多語系切換選單

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
'use client'

import { Menu } from '@headlessui/react'
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from 'app/i18n/settings'
import { useTranslation } from 'app/i18n/client'
import { getOptions } from 'app/i18n/settings'
import { usePathname } from 'next/navigation'

export const LanguageSwitcher = ({ lng }) => {
  const { t } = useTranslation(lng, 'translation', getOptions())

  const pathname = usePathname()
  const currentPath = pathname.replace(`/${lng}`, '') || '/'

  const generatePath = (newLang) => {
    if (currentPath === '/') {
      return `/${newLang}`
    }
    return `/${newLang}${currentPath}`
  }

  return (
    <div className="mr-5 flex items-center">
      <Menu as="div" className="relative inline-block text-left">
        <Menu.Button className="x inline-flex items-center justify-center gap-x-1.5 rounded-md px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm">
          <Trans i18nKey="currentLanguage" t={t}>
            {lng.toUpperCase()}
          </Trans>
          <svg
            className="h-4 w-4"
            fill="none"
            viewBox="0 0 24 24"
            strokeWidth={1.5}
            stroke="currentColor"
          >
            <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
          </svg>
        </Menu.Button>

        <Menu.Items className="absolute right-0 z-10 mt-2 w-32 origin-top-right rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
          <div className="py-1">
            {languages
              .filter((l) => lng !== l)
              .map((l) => (
                <Menu.Item key={l}>
                  {({ active }) => (
                    <Link
                      href={generatePath(l)}
                      className={`
                      ${active ? 'bg-gray-100 text-gray-500' : 'text-gray-300'}
                      block px-4 py-2 text-sm
                    `}
                    >
                      {l.toUpperCase()}
                    </Link>
                  )}
                </Menu.Item>
              ))}
          </div>
        </Menu.Items>
      </Menu>
    </div>
  )
}

export default LanguageSwitcher

contentlayer data 及 data type 調整

computedField 加入 language 並調整 slug 的內容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const computedFields: ComputedFields = {
  readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
  slug: {
    type: 'string',
    resolve: (doc) => {
      const [lang, type, ...rest] = doc._raw.flattenedPath.split('/')
      return rest.join('/')
    },
  },
  language: {
    type: 'string',
    resolve: (doc) => doc._raw.flattenedPath.split('/')[0],
  },
}

調整 ./data 資料夾結構,在對應多語系資料夾中放入不同的文章

1
2
3
4
5
6
.
└── data
    ├── en
    |   └── posts
    └── zh-TW
        └── posts

在對應使用到 allPosts 的地方要 filter 不同語系的文章

ListLayoutWithTags.tsx 為例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export default function ListLayoutWithTags({
  posts,
  title,
  initialDisplayPosts = [],
  pagination,
  params,
}: ListLayoutProps) {
  const { lng } = params
  const pathname = usePathname()
  const tagCounts = (tagData as Record<string, Record<string, number>>)[lng] || {}
  const tagKeys = Object.keys(tagCounts)
  const sortedTags = tagKeys.sort((a, b) => tagCounts[b] - tagCounts[a])

  let displayPosts = initialDisplayPosts.length > 0 ? initialDisplayPosts : posts
  displayPosts = displayPosts.filter((post) => post.language === lng)

  return()
}

標籤計數方法

將不同語系的標籤分開計數,存在同一份 tag-data.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function createTagCount(allPosts) {
  const tagCount: CountData = {}

  siteMetadata.languages.forEach((lang) => {
    tagCount[lang] = {}
  })

  allPosts.forEach((file) => {
    if (
      file.tags &&
      (!process.env.NODE_ENV || process.env.NODE_ENV === 'development' || file.draft !== true)
    ) {
      const lang = file.language
      file.tags.forEach((tag) => {
        const formattedTag = slug(tag)
        if (formattedTag in tagCount[lang]) {
          tagCount[lang][formattedTag] += 1
        } else {
          tagCount[lang][formattedTag] = 1
        }
      })
    }
  })

  writeFileSync(path.join(process.cwd(), 'app/tag-data.json'), JSON.stringify(tagCount, null, 2))
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "en": {
    "e-reader": 1,
    "deep-reading": 1,
    "note-taking": 1,
  },
  "zh-TW": {
    "e-reader": 1,
    "deep-reading": 1,
    "note-taking": 1,
  }
}

參考資料

Built with Hugo
Theme Stack designed by Jimmy