主要是參考 [1] 提供的範例來在以 App Router 實作的 tailwind-nextjs-starter-blog 中加入多語系 (i18n)
要新增調整的地方主要包含以下幾點
- 資料夾結構
- 多語系相關設定
- 多語系文字內容
- 在 Component 套用多語系
- 多語系切換選單
contentlayer data 及 data type 調整- 標籤計數方法
相關套件安裝
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,
}
}
|
參考資料