Skip to content

Commit 2298e72

Browse files
authored
Merge pull request #30 from objectstack-ai/copilot/add-language-auto-redirect
2 parents 1d6035b + 2725439 commit 2298e72

File tree

3 files changed

+135
-2
lines changed

3 files changed

+135
-2
lines changed

apps/docs/middleware.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,135 @@
1-
import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware';
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import Negotiator from 'negotiator';
23
import { i18n } from '@/lib/i18n';
34

5+
const LOCALE_COOKIE = 'FD_LOCALE';
6+
7+
/**
8+
* Supported languages extracted from i18n configuration
9+
*/
10+
const SUPPORTED_LANGUAGES = i18n.languages as readonly string[];
11+
12+
/**
13+
* Set locale cookie with consistent options
14+
*/
15+
function setLocaleCookie(response: NextResponse, locale: string): void {
16+
response.cookies.set(LOCALE_COOKIE, locale, {
17+
sameSite: 'lax',
18+
path: '/',
19+
});
20+
}
21+
22+
/**
23+
* Language code mapping
24+
* Maps browser language codes to our supported language codes
25+
*/
26+
const LANGUAGE_MAPPING: Record<string, string> = {
27+
'zh': 'cn', // Chinese -> cn
28+
'zh-CN': 'cn', // Chinese (China) -> cn
29+
'zh-TW': 'cn', // Chinese (Taiwan) -> cn
30+
'zh-HK': 'cn', // Chinese (Hong Kong) -> cn
31+
};
32+
33+
/**
34+
* Normalize language code to match our supported languages
35+
*/
36+
function normalizeLanguage(lang: string): string {
37+
// Check direct mapping first
38+
if (LANGUAGE_MAPPING[lang]) {
39+
return LANGUAGE_MAPPING[lang];
40+
}
41+
42+
// Check if the base language (without region) is mapped
43+
const baseLang = lang.split('-')[0];
44+
if (LANGUAGE_MAPPING[baseLang]) {
45+
return LANGUAGE_MAPPING[baseLang];
46+
}
47+
48+
return lang;
49+
}
50+
51+
/**
52+
* Get the preferred language from the request
53+
*/
54+
function getPreferredLanguage(request: NextRequest): string {
55+
// Check cookie first
56+
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
57+
if (cookieLocale && SUPPORTED_LANGUAGES.includes(cookieLocale)) {
58+
return cookieLocale;
59+
}
60+
61+
// Then check Accept-Language header
62+
const negotiatorHeaders = Object.fromEntries(request.headers.entries());
63+
const negotiator = new Negotiator({ headers: negotiatorHeaders });
64+
const browserLanguages = negotiator.languages();
65+
66+
// Normalize browser languages to match our supported languages
67+
const normalizedLanguages = browserLanguages.map(normalizeLanguage);
68+
69+
// Find the first match
70+
for (const lang of normalizedLanguages) {
71+
if (SUPPORTED_LANGUAGES.includes(lang)) {
72+
return lang;
73+
}
74+
}
75+
76+
return i18n.defaultLanguage;
77+
}
78+
479
/**
580
* Middleware for automatic language detection and redirection
681
*
782
* This middleware:
883
* - Detects the user's preferred language from browser settings or cookies
984
* - Redirects users to the appropriate localized version
85+
* - For default language (en): keeps URL as "/" (with internal rewrite)
86+
* - For other languages (cn): redirects to "/cn/"
1087
* - Stores language preference as a cookie
1188
*/
12-
export default createI18nMiddleware(i18n);
89+
export default function middleware(request: NextRequest) {
90+
const { pathname } = request.nextUrl;
91+
92+
// Check if the pathname already has a locale
93+
const pathnameHasLocale = i18n.languages.some(
94+
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
95+
);
96+
97+
if (pathnameHasLocale) {
98+
// Extract the locale from the pathname
99+
const locale = pathname.split('/')[1];
100+
101+
// If it's the default locale and hideLocale is 'default-locale', redirect to remove locale prefix
102+
if (locale === i18n.defaultLanguage && i18n.hideLocale === 'default-locale') {
103+
const url = new URL(request.url);
104+
// Remove locale prefix more precisely to avoid issues with partial matches
105+
url.pathname = pathname.replace(new RegExp(`^/${i18n.defaultLanguage}(/|$)`), '$1') || '/';
106+
const response = NextResponse.redirect(url);
107+
setLocaleCookie(response, locale);
108+
return response;
109+
}
110+
111+
return NextResponse.next();
112+
}
113+
114+
// Pathname doesn't have a locale, determine preferred language
115+
const preferredLanguage = getPreferredLanguage(request);
116+
117+
// If preferred language is the default, rewrite internally (keep URL clean)
118+
if (preferredLanguage === i18n.defaultLanguage && i18n.hideLocale === 'default-locale') {
119+
const url = new URL(request.url);
120+
// Handle root path specially to avoid double slashes
121+
url.pathname = pathname === '/' ? `/${i18n.defaultLanguage}` : `/${i18n.defaultLanguage}${pathname}`;
122+
return NextResponse.rewrite(url);
123+
}
124+
125+
// For non-default languages, redirect to the localized path
126+
const url = new URL(request.url);
127+
// Handle root path specially to avoid double slashes
128+
url.pathname = pathname === '/' ? `/${preferredLanguage}` : `/${preferredLanguage}${pathname}`;
129+
const response = NextResponse.redirect(url);
130+
setLocaleCookie(response, preferredLanguage);
131+
return response;
132+
}
13133

14134
export const config = {
15135
// Match all routes except:

apps/docs/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
"devDependencies": {
2424
"@tailwindcss/postcss": "^4.1.18",
2525
"@tailwindcss/typography": "^0.5.19",
26+
"@types/negotiator": "^0.6.4",
2627
"@types/node": "^20.10.0",
2728
"@types/react": "^19.2.8",
2829
"@types/react-dom": "^19.2.3",
2930
"autoprefixer": "^10.4.23",
31+
"negotiator": "^1.0.0",
3032
"postcss": "^8.5.6",
3133
"tailwindcss": "^4.0.0",
3234
"typescript": "^5.3.0",

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)