Skip to content

Commit 87fbffa

Browse files
authored
feat: scaffold docs internationalization (#619)
* feat: scaffold docs internationalization Add the i18n foundation so localized docs can be served as soon as translated MDX exists, without changing English content or URLs. - lib/i18n.ts: single source of truth for locales (en default, plus zh, es, fr, de, ja) with hideLocale 'default-locale', so English keeps its current /docs/... URLs and other locales are served at /<locale>/docs. - Make the docs loader i18n-aware and move the docs route under app/[lang]/docs; the layout renders the per-locale page tree, an I18nProvider, and a language switcher. - Integrate the Fumadocs locale middleware into proxy.ts, preserving the existing markdown content-negotiation and the raw .mdx route. - Pages without a translation fall back to English, so an automated translation workflow can fill in *.<locale>.mdx files incrementally. Note: localized pages still emit <html lang="en"> because only the docs subtree is nested under [lang]; full-site localization would move every route there. * fix: map docs search locales to supported tokenizers The i18n loader makes createFromSource build an Orama index per locale. Spanish/French/German tokenizers are built in, but Chinese and Japanese need @orama/tokenizers, so they threw LANGUAGE_NOT_SUPPORTED. Map each locale to a supported tokenizer (zh/ja fall back to english until CJK tokenizers are added) so indexing succeeds for every configured language.
1 parent a4cccff commit 87fbffa

8 files changed

Lines changed: 129 additions & 57 deletions

File tree

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import { ogImageUrl } from '@/lib/og'
1010
import type { Metadata } from 'next'
1111

1212
interface PageProps {
13-
params: Promise<{ slug?: string[] }>
13+
params: Promise<{ lang: string; slug?: string[] }>
1414
}
1515

1616
export default async function Page(props: PageProps) {
1717
const params = await props.params
18-
const page = docsSource.getPage(params.slug)
18+
const page = docsSource.getPage(params.slug, params.lang)
1919
if (!page) notFound()
2020

2121
const MDX = page.data.body
@@ -76,7 +76,7 @@ export async function generateStaticParams() {
7676

7777
export async function generateMetadata(props: PageProps): Promise<Metadata> {
7878
const params = await props.params
79-
const page = docsSource.getPage(params.slug)
79+
const page = docsSource.getPage(params.slug, params.lang)
8080
if (!page) notFound()
8181

8282
return {

‎app/[lang]/docs/layout.tsx‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
2+
import { I18nProvider } from 'fumadocs-ui/i18n'
3+
import { docsSource } from '@/lib/source'
4+
import { baseOptions } from '@/app/layout.config'
5+
import { i18n, LOCALE_NAMES } from '@/lib/i18n'
6+
import type { ReactNode } from 'react'
7+
8+
export default async function Layout({
9+
params,
10+
children,
11+
}: {
12+
params: Promise<{ lang: string }>
13+
children: ReactNode
14+
}) {
15+
const { lang } = await params
16+
const tree = docsSource.pageTree[lang] ?? docsSource.pageTree[i18n.defaultLanguage]
17+
18+
return (
19+
<I18nProvider
20+
locale={lang}
21+
locales={i18n.languages.map((locale) => ({ locale, name: LOCALE_NAMES[locale] ?? locale }))}
22+
>
23+
<DocsLayout
24+
tree={tree}
25+
i18n
26+
{...baseOptions}
27+
links={[
28+
...(baseOptions.links ?? []),
29+
{
30+
type: 'icon',
31+
url: 'https://discord.librechat.ai',
32+
icon: (
33+
<svg
34+
role="img"
35+
viewBox="0 0 24 24"
36+
fill="currentColor"
37+
className="size-5"
38+
aria-hidden="true"
39+
>
40+
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
41+
</svg>
42+
),
43+
text: 'Discord',
44+
label: 'Join Discord',
45+
external: true,
46+
},
47+
]}
48+
sidebar={{
49+
defaultOpenLevel: 0,
50+
}}
51+
>
52+
{/* Skip-link target at the start of the docs content region. */}
53+
<div id="main-content" tabIndex={-1} />
54+
{children}
55+
</DocsLayout>
56+
</I18nProvider>
57+
)
58+
}

‎app/api/search/route.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,18 @@ import { createFromSource } from 'fumadocs-core/search/server'
33

44
export const revalidate = false
55

6-
export const { GET } = createFromSource(docsSource)
6+
// Map each docs locale to an Orama-supported tokenizer language. Spanish,
7+
// French, and German are built in; Chinese and Japanese need @orama/tokenizers
8+
// (mandarin/japanese), so until those are wired up they fall back to english
9+
// tokenization. Without this the i18n loader makes Orama throw
10+
// LANGUAGE_NOT_SUPPORTED while building the zh/ja indexes.
11+
export const { GET } = createFromSource(docsSource, undefined, {
12+
localeMap: {
13+
en: 'english',
14+
es: 'spanish',
15+
fr: 'french',
16+
de: 'german',
17+
zh: 'english',
18+
ja: 'english',
19+
},
20+
})

‎app/docs/layout.tsx‎

Lines changed: 0 additions & 41 deletions
This file was deleted.

‎app/llms.mdx/docs/[[...slug]]/route.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getLLMText } from '@/lib/get-llm-text'
22
import { docsSource } from '@/lib/source'
3+
import { i18n } from '@/lib/i18n'
34
import { notFound } from 'next/navigation'
45

56
export const revalidate = false
@@ -17,5 +18,9 @@ export async function GET(_req: Request, { params }: { params: Promise<{ slug?:
1718
}
1819

1920
export function generateStaticParams() {
20-
return docsSource.generateParams()
21+
// This route serves the English raw markdown; keep one entry per slug.
22+
return docsSource
23+
.generateParams()
24+
.filter((p) => p.lang === i18n.defaultLanguage)
25+
.map(({ slug }) => ({ slug }))
2126
}

‎lib/i18n.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { I18nConfig } from 'fumadocs-core/i18n'
2+
3+
/**
4+
* Internationalization config for the docs.
5+
*
6+
* This is the single source of truth for which locales the site builds. To add
7+
* a language, append its code here and add a display name in LOCALE_NAMES; the
8+
* automated translation workflow then drops `*.<locale>.mdx` files next to the
9+
* English source in content/docs and Fumadocs serves them automatically.
10+
*
11+
* `hideLocale: 'default-locale'` keeps English at its existing URLs (/docs/...)
12+
* with no prefix; other locales are served under /<locale>/docs/...
13+
*/
14+
export const i18n: I18nConfig = {
15+
defaultLanguage: 'en',
16+
languages: ['en', 'zh', 'es', 'fr', 'de', 'ja'],
17+
hideLocale: 'default-locale',
18+
}
19+
20+
/** Human-readable names shown in the language switcher. */
21+
export const LOCALE_NAMES: Record<string, string> = {
22+
en: 'English',
23+
zh: '简体中文',
24+
es: 'Español',
25+
fr: 'Français',
26+
de: 'Deutsch',
27+
ja: '日本語',
28+
}

‎lib/source.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { docs, blog, changelog } from '@/.source'
22
import { loader } from 'fumadocs-core/source'
33
import { resolveIcon } from '@/lib/icons'
4+
import { i18n } from '@/lib/i18n'
45

56
export const docsSource = loader({
67
source: docs.toFumadocsSource(),
78
baseUrl: '/docs',
9+
i18n,
810
icon: resolveIcon,
911
})
1012

‎proxy.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
1-
import { NextRequest, NextResponse } from 'next/server'
1+
import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server'
2+
import { createI18nMiddleware } from 'fumadocs-core/i18n'
3+
import { i18n } from '@/lib/i18n'
4+
5+
const i18nMiddleware = createI18nMiddleware(i18n)
26

37
function isMarkdownPreferred(request: NextRequest): boolean {
48
const accept = request.headers.get('accept') ?? ''
59
return accept.includes('text/markdown')
610
}
711

8-
export default function proxy(request: NextRequest): NextResponse {
9-
if (isMarkdownPreferred(request)) {
10-
const { pathname } = request.nextUrl
12+
export default function proxy(request: NextRequest, event: NextFetchEvent) {
13+
const { pathname } = request.nextUrl
1114

12-
if (pathname.startsWith('/docs')) {
13-
const rest = pathname.slice('/docs'.length)
14-
const rewritten = `/llms.mdx/docs${rest}`
15-
return NextResponse.rewrite(new URL(rewritten, request.nextUrl))
16-
}
15+
// Serve raw markdown for content-negotiated requests (LLM/agent tooling).
16+
if (isMarkdownPreferred(request) && pathname.startsWith('/docs')) {
17+
const rest = pathname.slice('/docs'.length)
18+
return NextResponse.rewrite(new URL(`/llms.mdx/docs${rest}`, request.nextUrl))
1719
}
1820

19-
return NextResponse.next()
21+
// Leave the raw `.mdx` route to next.config rewrites; don't localize it.
22+
if (pathname.endsWith('.mdx')) return NextResponse.next()
23+
24+
// Locale detection + default-locale rewriting for the docs.
25+
return i18nMiddleware(request, event)
2026
}
2127

2228
export const config = {
23-
matcher: ['/docs/:path*'],
29+
matcher: ['/docs/:path*', '/(zh|es|fr|de|ja)/docs/:path*'],
2430
}

0 commit comments

Comments
 (0)