-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement browser language detection and auto-redirect for homepage #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c74ab5a
04d3893
427d33e
d0b6127
2725439
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: '/', | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| path: '/', | |
| path: '/', | |
| secure: process.env.NODE_ENV === 'production', |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| 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
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| * - For other languages (cn): redirects to "/cn/" | |
| * - For other languages (cn): redirects to "/cn" |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| 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
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| // 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
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| return NextResponse.next(); | |
| const response = NextResponse.next(); | |
| setLocaleCookie(response, locale); | |
| return response; |
Copilot
AI
Jan 20, 2026
There was a problem hiding this comment.
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.
| return NextResponse.rewrite(url); | |
| const response = NextResponse.rewrite(url); | |
| setLocaleCookie(response, preferredLanguage); | |
| return response; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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.