diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 6c9d3d9d6..fcc1a6000 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -1,15 +1,135 @@ -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'; + +/** + * 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 + */ +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 && SUPPORTED_LANGUAGES.includes(cookieLocale)) { + return cookieLocale; + } + + // Then check Accept-Language header + const negotiatorHeaders = Object.fromEntries(request.headers.entries()); + 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 (SUPPORTED_LANGUAGES.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); + // 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; + } + + 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); + // 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); + // Handle root path specially to avoid double slashes + url.pathname = pathname === '/' ? `/${preferredLanguage}` : `/${preferredLanguage}${pathname}`; + const response = NextResponse.redirect(url); + setLocaleCookie(response, preferredLanguage); + return response; +} export const config = { // Match all routes except: diff --git a/apps/docs/package.json b/apps/docs/package.json index fd2d14460..8baa38cad 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -23,10 +23,12 @@ "devDependencies": { "@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..e6993c153 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: '@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 +73,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 @@ -1439,6 +1445,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==} @@ -4119,6 +4128,8 @@ snapshots: '@types/ms@2.1.0': {} + '@types/negotiator@0.6.4': {} + '@types/node@12.20.55': {} '@types/node@20.19.30':