Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 additions & 2 deletions apps/docs/middleware.ts
Original file line number Diff line number Diff line change
@@ -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: '/',
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie is being set without an expiration time. This means it will be a session cookie that gets deleted when the browser closes. For a language preference, it would be better to set an explicit expiration (e.g., 1 year) so the preference persists across browser sessions.

Suggested change
path: '/',
path: '/',
// Persist language preference across browser sessions (1 year)
maxAge: 60 * 60 * 24 * 365,

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie should also set the secure flag in production environments to ensure it's only transmitted over HTTPS. Consider adding secure: process.env.NODE_ENV === 'production' to the cookie options.

Suggested change
path: '/',
path: '/',
secure: process.env.NODE_ENV === 'production',

Copilot uses AI. Check for mistakes.
});
}

/**
* Language code mapping
* Maps browser language codes to our supported language codes
*/
const LANGUAGE_MAPPING: Record<string, string> = {
'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 });
Comment on lines +54 to +63
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The middleware creates a new Negotiator instance on every request even when a cookie is already set and valid. Consider skipping the Negotiator initialization if the cookie check at lines 56-59 returns early, by restructuring the code to only create the Negotiator instance after the cookie check fails.

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +63
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converting headers to a plain object using Object.fromEntries(request.headers.entries()) could potentially cause issues if there are duplicate header names (though unlikely with Accept-Language). Additionally, this creates a new object on every call. Consider passing the headers directly if the Negotiator API supports it, or at least document why this conversion is necessary.

Suggested change
const negotiatorHeaders = Object.fromEntries(request.headers.entries());
const negotiator = new Negotiator({ headers: negotiatorHeaders });
const acceptLanguage = request.headers.get('accept-language');
if (!acceptLanguage) {
return i18n.defaultLanguage;
}
const negotiator = new Negotiator({
headers: { 'accept-language': acceptLanguage },
});

Copilot uses AI. Check for mistakes.
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/"
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "redirects to '/cn/'" (with trailing slash) but the actual implementation redirects to '/cn' (without trailing slash). The documentation should be corrected to accurately reflect the implementation.

Suggested change
* - For other languages (cn): redirects to "/cn/"
* - For other languages (cn): redirects to "/cn"

Copilot uses AI. Check for mistakes.
* - 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];
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The middleware doesn't handle the case where pathname.split('/')[1] could be an empty string. If a pathname like //something is provided, locale would be an empty string, which could cause unexpected behavior. Consider adding validation to ensure the extracted locale is a non-empty string before using it.

Suggested change
const locale = pathname.split('/')[1];
const locale = pathname.split('/')[1] || '';
// If the extracted locale segment is empty or invalid, skip locale-specific handling
if (!locale) {
return NextResponse.next();
}

Copilot uses AI. Check for mistakes.

// 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') || '/';
Comment on lines +104 to +105
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regular expression ^/${i18n.defaultLanguage}(/|$) could be vulnerable to ReDoS (Regular Expression Denial of Service) if i18n.defaultLanguage contains special regex characters. While the current value 'en' is safe, it would be safer to escape the language code using a helper function or use a simpler string-based approach. Consider using pathname.slice(i18n.defaultLanguage.length + 1) or escaping the language code.

Suggested change
// Remove locale prefix more precisely to avoid issues with partial matches
url.pathname = pathname.replace(new RegExp(`^/${i18n.defaultLanguage}(/|$)`), '$1') || '/';
// Remove default locale prefix using string operations to avoid regex issues
const defaultPrefix = `/${i18n.defaultLanguage}`;
if (pathname === defaultPrefix) {
url.pathname = '/';
} else if (pathname.startsWith(`${defaultPrefix}/`)) {
url.pathname = pathname.slice(defaultPrefix.length) || '/';
} else {
// Fallback: if for some reason the pathname doesn't match the expected pattern, keep it as is
url.pathname = pathname || '/';
}

Copilot uses AI. Check for mistakes.
const response = NextResponse.redirect(url);
setLocaleCookie(response, locale);
return response;
}

return NextResponse.next();
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the pathname has a locale and it's not the default locale, the cookie is not being set. This means if a user manually navigates to a non-default locale URL (e.g., /cn), their preference won't be stored in the cookie. Consider adding a response with the cookie set in the else branch at line 111.

Suggested change
return NextResponse.next();
const response = NextResponse.next();
setLocaleCookie(response, locale);
return response;

Copilot uses AI. Check for mistakes.
}

// 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);
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie is not being set when the user's preferred language matches the default language. This means if an English-speaking user visits the site, their preference won't be stored, and the language detection will run on every subsequent request instead of using the cached cookie value. Consider setting the cookie even for the default language to improve performance on subsequent requests.

Suggested change
return NextResponse.rewrite(url);
const response = NextResponse.rewrite(url);
setLocaleCookie(response, preferredLanguage);
return response;

Copilot uses AI. Check for mistakes.
}

// 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:
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negotiator package is listed as a devDependency but it's imported and used in the middleware which runs at runtime. This package should be moved to the dependencies section instead of devDependencies to ensure it's available in production builds.

Copilot uses AI. Check for mistakes.
"postcss": "^8.5.6",
"tailwindcss": "^4.0.0",
"typescript": "^5.3.0",
Expand Down
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading