From c74ab5ad104416dadbe6f8f471c1d13e682d06c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:29:23 +0000 Subject: [PATCH 1/5] Initial plan From 04d3893fa9a593964eb923a72e8fb1733491969e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:01:36 +0000 Subject: [PATCH 2/5] feat: implement language-based homepage redirection - Add custom middleware for automatic language detection - Map Chinese language codes (zh, zh-CN, zh-TW, zh-HK) to 'cn' - Redirect non-default language users to localized paths (/cn/) - Keep default language (en) users on clean URLs (/) - Store language preference in cookies for consistency - Add negotiator and type definitions as dependencies Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- apps/docs/middleware.ts | 110 +++++++++++++++++++++++++++++++++++++++- apps/docs/package.json | 3 ++ pnpm-lock.yaml | 29 +++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 6c9d3d9d6..39d83e091 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -1,15 +1,121 @@ -import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware'; +import { NextRequest, NextResponse } from 'next/server'; +import Negotiator from 'negotiator'; import { i18n } from '@/lib/i18n'; +const LOCALE_COOKIE = 'FD_LOCALE'; + +/** + * Language code mapping + * Maps browser language codes to our supported language codes + */ +const LANGUAGE_MAPPING: Record = { + 'zh': 'cn', // Chinese -> cn + 'zh-CN': 'cn', // Chinese (China) -> cn + 'zh-TW': 'cn', // Chinese (Taiwan) -> cn + 'zh-HK': 'cn', // Chinese (Hong Kong) -> cn +}; + +/** + * Normalize language code to match our supported languages + */ +function normalizeLanguage(lang: string): string { + // Check direct mapping first + if (LANGUAGE_MAPPING[lang]) { + return LANGUAGE_MAPPING[lang]; + } + + // Check if the base language (without region) is mapped + const baseLang = lang.split('-')[0]; + if (LANGUAGE_MAPPING[baseLang]) { + return LANGUAGE_MAPPING[baseLang]; + } + + return lang; +} + +/** + * Get the preferred language from the request + */ +function getPreferredLanguage(request: NextRequest): string { + // Check cookie first + const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value; + if (cookieLocale && (i18n.languages as readonly string[]).includes(cookieLocale)) { + return cookieLocale; + } + + // Then check Accept-Language header + const negotiatorHeaders: Record = {}; + request.headers.forEach((value, key) => { + negotiatorHeaders[key] = value; + }); + + const negotiator = new Negotiator({ headers: negotiatorHeaders }); + const browserLanguages = negotiator.languages(); + + // Normalize browser languages to match our supported languages + const normalizedLanguages = browserLanguages.map(normalizeLanguage); + + // Find the first match + for (const lang of normalizedLanguages) { + if ((i18n.languages as readonly string[]).includes(lang)) { + return lang; + } + } + + return i18n.defaultLanguage; +} + /** * Middleware for automatic language detection and redirection * * This middleware: * - Detects the user's preferred language from browser settings or cookies * - Redirects users to the appropriate localized version + * - For default language (en): keeps URL as "/" (with internal rewrite) + * - For other languages (cn): redirects to "/cn/" * - Stores language preference as a cookie */ -export default createI18nMiddleware(i18n); +export default function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Check if the pathname already has a locale + const pathnameHasLocale = i18n.languages.some( + (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` + ); + + if (pathnameHasLocale) { + // Extract the locale from the pathname + const locale = pathname.split('/')[1]; + + // If it's the default locale and hideLocale is 'default-locale', redirect to remove locale prefix + if (locale === i18n.defaultLanguage && i18n.hideLocale === 'default-locale') { + const url = new URL(request.url); + url.pathname = pathname.replace(`/${i18n.defaultLanguage}`, '') || '/'; + const response = NextResponse.redirect(url); + response.cookies.set(LOCALE_COOKIE, locale); + return response; + } + + return NextResponse.next(); + } + + // Pathname doesn't have a locale, determine preferred language + const preferredLanguage = getPreferredLanguage(request); + + // If preferred language is the default, rewrite internally (keep URL clean) + if (preferredLanguage === i18n.defaultLanguage && i18n.hideLocale === 'default-locale') { + const url = new URL(request.url); + url.pathname = `/${i18n.defaultLanguage}${pathname}`; + return NextResponse.rewrite(url); + } + + // For non-default languages, redirect to the localized path + const url = new URL(request.url); + url.pathname = `/${preferredLanguage}${pathname}`; + const response = NextResponse.redirect(url); + response.cookies.set(LOCALE_COOKIE, preferredLanguage); + return response; +} export const config = { // Match all routes except: diff --git a/apps/docs/package.json b/apps/docs/package.json index fd2d14460..2e7953bb7 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -21,12 +21,15 @@ "server-only": "^0.0.1" }, "devDependencies": { + "@formatjs/intl-localematcher": "^0.8.0", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", + "@types/negotiator": "^0.6.4", "@types/node": "^20.10.0", "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.23", + "negotiator": "^1.0.0", "postcss": "^8.5.6", "tailwindcss": "^4.0.0", "typescript": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 915efbd3d..357df209b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,12 +52,18 @@ importers: specifier: ^0.0.1 version: 0.0.1 devDependencies: + '@formatjs/intl-localematcher': + specifier: ^0.8.0 + version: 0.8.0 '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.18) + '@types/negotiator': + specifier: ^0.6.4 + version: 0.6.4 '@types/node': specifier: ^20.10.0 version: 20.19.30 @@ -70,6 +76,9 @@ importers: autoprefixer: specifier: ^10.4.23 version: 10.4.23(postcss@8.5.6) + negotiator: + specifier: ^1.0.0 + version: 1.0.0 postcss: specifier: ^8.5.6 version: 8.5.6 @@ -532,9 +541,15 @@ packages: '@formatjs/fast-memoize@3.0.3': resolution: {integrity: sha512-CArYtQKGLAOruCMeq5/RxCg6vUXFx3OuKBdTm30Wn/+gCefehmZ8Y2xSMxMrO2iel7hRyE3HKfV56t3vAU6D4Q==} + '@formatjs/fast-memoize@3.1.0': + resolution: {integrity: sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==} + '@formatjs/intl-localematcher@0.7.5': resolution: {integrity: sha512-7/nd90cn5CT7SVF71/ybUKAcnvBlr9nZlJJp8O8xIZHXFgYOC4SXExZlSdgHv2l6utjw1byidL06QzChvQMHwA==} + '@formatjs/intl-localematcher@0.8.0': + resolution: {integrity: sha512-zgMYWdUlmEZpX2Io+v3LHrfq9xZ6khpQVf9UAw2xYWhGerGgI9XgH1HvL/A34jWiruUJpYlP5pk4g8nIcaDrXQ==} + '@fumadocs/ui@16.4.7': resolution: {integrity: sha512-NnkMIN5BzBRh2OzA9rp2SgbGEkEwfCfq0sE4vq2n+GkIDIggicGYUNgSl2gtIBQsKYKP/a4/0wrkQKdq4eUJlw==} peerDependencies: @@ -1439,6 +1454,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/negotiator@0.6.4': + resolution: {integrity: sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -3298,11 +3316,20 @@ snapshots: dependencies: tslib: 2.8.1 + '@formatjs/fast-memoize@3.1.0': + dependencies: + tslib: 2.8.1 + '@formatjs/intl-localematcher@0.7.5': dependencies: '@formatjs/fast-memoize': 3.0.3 tslib: 2.8.1 + '@formatjs/intl-localematcher@0.8.0': + dependencies: + '@formatjs/fast-memoize': 3.1.0 + tslib: 2.8.1 + '@fumadocs/ui@16.4.7(@types/react@19.2.8)(fumadocs-core@16.4.7(@types/react@19.2.8)(lucide-react@0.562.0(react@19.2.3))(next@16.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5))(next@16.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)': dependencies: fumadocs-core: 16.4.7(@types/react@19.2.8)(lucide-react@0.562.0(react@19.2.3))(next@16.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) @@ -4119,6 +4146,8 @@ snapshots: '@types/ms@2.1.0': {} + '@types/negotiator@0.6.4': {} + '@types/node@12.20.55': {} '@types/node@20.19.30': From 427d33e021a51e41d98a6e71daff27a51187c997 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:06:51 +0000 Subject: [PATCH 3/5] refactor: address code review feedback - Remove unused @formatjs/intl-localematcher dependency - Extract SUPPORTED_LANGUAGES constant to reduce type assertion duplication - Use Object.fromEntries for more concise header conversion - Improve code readability and efficiency Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- apps/docs/middleware.ts | 15 ++++++++------- apps/docs/package.json | 1 - pnpm-lock.yaml | 18 ------------------ 3 files changed, 8 insertions(+), 26 deletions(-) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 39d83e091..32a9d6d5d 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -4,6 +4,11 @@ import { i18n } from '@/lib/i18n'; const LOCALE_COOKIE = 'FD_LOCALE'; +/** + * Helper to check if a language is supported + */ +const SUPPORTED_LANGUAGES = i18n.languages as readonly string[]; + /** * Language code mapping * Maps browser language codes to our supported language codes @@ -39,16 +44,12 @@ function normalizeLanguage(lang: string): string { function getPreferredLanguage(request: NextRequest): string { // Check cookie first const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value; - if (cookieLocale && (i18n.languages as readonly string[]).includes(cookieLocale)) { + if (cookieLocale && SUPPORTED_LANGUAGES.includes(cookieLocale)) { return cookieLocale; } // Then check Accept-Language header - const negotiatorHeaders: Record = {}; - request.headers.forEach((value, key) => { - negotiatorHeaders[key] = value; - }); - + const negotiatorHeaders = Object.fromEntries(request.headers.entries()); const negotiator = new Negotiator({ headers: negotiatorHeaders }); const browserLanguages = negotiator.languages(); @@ -57,7 +58,7 @@ function getPreferredLanguage(request: NextRequest): string { // Find the first match for (const lang of normalizedLanguages) { - if ((i18n.languages as readonly string[]).includes(lang)) { + if (SUPPORTED_LANGUAGES.includes(lang)) { return lang; } } diff --git a/apps/docs/package.json b/apps/docs/package.json index 2e7953bb7..8baa38cad 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -21,7 +21,6 @@ "server-only": "^0.0.1" }, "devDependencies": { - "@formatjs/intl-localematcher": "^0.8.0", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", "@types/negotiator": "^0.6.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 357df209b..e6993c153 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,9 +52,6 @@ importers: specifier: ^0.0.1 version: 0.0.1 devDependencies: - '@formatjs/intl-localematcher': - specifier: ^0.8.0 - version: 0.8.0 '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -541,15 +538,9 @@ packages: '@formatjs/fast-memoize@3.0.3': resolution: {integrity: sha512-CArYtQKGLAOruCMeq5/RxCg6vUXFx3OuKBdTm30Wn/+gCefehmZ8Y2xSMxMrO2iel7hRyE3HKfV56t3vAU6D4Q==} - '@formatjs/fast-memoize@3.1.0': - resolution: {integrity: sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==} - '@formatjs/intl-localematcher@0.7.5': resolution: {integrity: sha512-7/nd90cn5CT7SVF71/ybUKAcnvBlr9nZlJJp8O8xIZHXFgYOC4SXExZlSdgHv2l6utjw1byidL06QzChvQMHwA==} - '@formatjs/intl-localematcher@0.8.0': - resolution: {integrity: sha512-zgMYWdUlmEZpX2Io+v3LHrfq9xZ6khpQVf9UAw2xYWhGerGgI9XgH1HvL/A34jWiruUJpYlP5pk4g8nIcaDrXQ==} - '@fumadocs/ui@16.4.7': resolution: {integrity: sha512-NnkMIN5BzBRh2OzA9rp2SgbGEkEwfCfq0sE4vq2n+GkIDIggicGYUNgSl2gtIBQsKYKP/a4/0wrkQKdq4eUJlw==} peerDependencies: @@ -3316,20 +3307,11 @@ snapshots: dependencies: tslib: 2.8.1 - '@formatjs/fast-memoize@3.1.0': - dependencies: - tslib: 2.8.1 - '@formatjs/intl-localematcher@0.7.5': dependencies: '@formatjs/fast-memoize': 3.0.3 tslib: 2.8.1 - '@formatjs/intl-localematcher@0.8.0': - dependencies: - '@formatjs/fast-memoize': 3.1.0 - tslib: 2.8.1 - '@fumadocs/ui@16.4.7(@types/react@19.2.8)(fumadocs-core@16.4.7(@types/react@19.2.8)(lucide-react@0.562.0(react@19.2.3))(next@16.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5))(next@16.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)': dependencies: fumadocs-core: 16.4.7(@types/react@19.2.8)(lucide-react@0.562.0(react@19.2.3))(next@16.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) From d0b612718ca226e4d389f348f4445882a4782289 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:13:09 +0000 Subject: [PATCH 4/5] refactor: improve cookie handling and documentation - Add setLocaleCookie helper function with consistent cookie options - Set sameSite=lax and path=/ for security and consistency - Update comment for SUPPORTED_LANGUAGES constant - Ensure all cookie operations use the same configuration Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- apps/docs/middleware.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 32a9d6d5d..0252825df 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -5,10 +5,20 @@ import { i18n } from '@/lib/i18n'; const LOCALE_COOKIE = 'FD_LOCALE'; /** - * Helper to check if a language is supported + * Supported languages extracted from i18n configuration */ const SUPPORTED_LANGUAGES = i18n.languages as readonly string[]; +/** + * Set locale cookie with consistent options + */ +function setLocaleCookie(response: NextResponse, locale: string): void { + response.cookies.set(LOCALE_COOKIE, locale, { + sameSite: 'lax', + path: '/', + }); +} + /** * Language code mapping * Maps browser language codes to our supported language codes @@ -93,7 +103,7 @@ export default function middleware(request: NextRequest) { const url = new URL(request.url); url.pathname = pathname.replace(`/${i18n.defaultLanguage}`, '') || '/'; const response = NextResponse.redirect(url); - response.cookies.set(LOCALE_COOKIE, locale); + setLocaleCookie(response, locale); return response; } @@ -114,7 +124,7 @@ export default function middleware(request: NextRequest) { const url = new URL(request.url); url.pathname = `/${preferredLanguage}${pathname}`; const response = NextResponse.redirect(url); - response.cookies.set(LOCALE_COOKIE, preferredLanguage); + setLocaleCookie(response, preferredLanguage); return response; } From 2725439b441ea660ff538157e8273c152b144385 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:20:21 +0000 Subject: [PATCH 5/5] fix: handle edge cases in path construction - Handle root path (/) specially to avoid double slashes - Use precise regex for locale prefix removal - Ensure clean URLs: /cn instead of /cn/ - Improve path normalization logic Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- apps/docs/middleware.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 0252825df..fcc1a6000 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -101,7 +101,8 @@ export default function middleware(request: NextRequest) { // If it's the default locale and hideLocale is 'default-locale', redirect to remove locale prefix if (locale === i18n.defaultLanguage && i18n.hideLocale === 'default-locale') { const url = new URL(request.url); - url.pathname = pathname.replace(`/${i18n.defaultLanguage}`, '') || '/'; + // Remove locale prefix more precisely to avoid issues with partial matches + url.pathname = pathname.replace(new RegExp(`^/${i18n.defaultLanguage}(/|$)`), '$1') || '/'; const response = NextResponse.redirect(url); setLocaleCookie(response, locale); return response; @@ -116,13 +117,15 @@ export default function middleware(request: NextRequest) { // If preferred language is the default, rewrite internally (keep URL clean) if (preferredLanguage === i18n.defaultLanguage && i18n.hideLocale === 'default-locale') { const url = new URL(request.url); - url.pathname = `/${i18n.defaultLanguage}${pathname}`; + // Handle root path specially to avoid double slashes + url.pathname = pathname === '/' ? `/${i18n.defaultLanguage}` : `/${i18n.defaultLanguage}${pathname}`; return NextResponse.rewrite(url); } // For non-default languages, redirect to the localized path const url = new URL(request.url); - url.pathname = `/${preferredLanguage}${pathname}`; + // Handle root path specially to avoid double slashes + url.pathname = pathname === '/' ? `/${preferredLanguage}` : `/${preferredLanguage}${pathname}`; const response = NextResponse.redirect(url); setLocaleCookie(response, preferredLanguage); return response;