feat(i18n): auto-detect browser language on first visit (#5979)

* feat(i18n): auto-detect browser language on first visit

Adds a pure detectBrowserLocale() matcher (exact match, zh-HK/zh-MO
folded to zh-TW, language-prefix match, else null) plus a client-only
LocaleAutoDetect component mounted once in the root layout. On first
visit (no locale cookie set), it reads navigator.languages, computes a
match against the supported locales, and persists it via the same
cookie/localStorage writer LanguageSelector already used for manual
selection (now extracted to shared/lib/persistLocale.ts) before
refreshing the router.

Co-authored-by: anmingwei <anmingwei@dobest.com>
Inspired-by: https://github.com/decolua/9router/pull/1324

* chore(changelog): restore release entries + add browser-lang-detect bullet

---------

Co-authored-by: anmingwei <anmingwei@dobest.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-03 00:57:58 -03:00
committed by GitHub
parent 1bd4b02110
commit c9032e478b
7 changed files with 158 additions and 11 deletions

View File

@@ -28,6 +28,7 @@
- **feat(providers):** add SumoPod and X5Lab as OpenAI-compatible (API-key) providers. (thanks @rigelra15)
- **feat(server):** support reverse-proxy subpath deployment via OMNIROUTE_BASE_PATH (basePath-aware auth redirects). (thanks @SillyHippy)
- **feat(cli-tools):** add CodeWhale CLI tool (successor to DeepSeek TUI). (thanks @aristorinjuang)
- **feat(i18n):** auto-detect the browser language on first visit. (thanks @ayanmw)
### 🔧 Bug Fixes

View File

@@ -8,6 +8,7 @@ import { normalizeComplianceEventTypes } from "@/i18n/request";
import { getSettings } from "@/lib/db/settings";
import type { Viewport } from "next";
import { PwaRegister } from "@/shared/components/PwaRegister";
import { LocaleAutoDetect } from "@/shared/components/LocaleAutoDetect";
const inter = Inter({
subsets: ["latin"],
@@ -109,6 +110,7 @@ export default async function RootLayout({ children }) {
</a>
<NextIntlClientProvider locale={locale} messages={messages}>
<PwaRegister />
<LocaleAutoDetect />
<ThemeProvider>{children}</ThemeProvider>
</NextIntlClientProvider>
</body>

View File

@@ -0,0 +1,52 @@
/**
* Pure browser-language detector used to pick an initial locale on first
* visit, before the user has made an explicit selection (no cookie set).
*
* Matching order:
* 1. Exact match against `navigator.languages` entries (case-insensitive).
* 2. `zh-HK` / `zh-MO` are treated as `zh-TW` (Traditional Chinese) since
* OmniRoute does not ship a dedicated Hong-Kong/Macau locale.
* 3. Language-prefix match — e.g. `en-US` matches a supported `en` locale.
* 4. No match → `null` (caller should keep the existing default).
*
* Kept dependency-free (no DOM/`navigator` access) so it is trivially unit
* testable and reusable from both client components and future server code.
*/
export function detectBrowserLocale(
languages: readonly string[],
locales: readonly string[]
): string | null {
if (!languages || languages.length === 0 || !locales || locales.length === 0) {
return null;
}
const normalizedLocales = locales.map((locale) => locale.toLowerCase());
for (const rawLanguage of languages) {
if (!rawLanguage) continue;
const language = rawLanguage.toLowerCase();
// 1. Exact match.
const exactIndex = normalizedLocales.indexOf(language);
if (exactIndex !== -1) {
return locales[exactIndex];
}
// 2. zh-HK / zh-MO fold to zh-TW when zh-TW is supported.
if (language === "zh-hk" || language === "zh-mo") {
const zhTwIndex = normalizedLocales.indexOf("zh-tw");
if (zhTwIndex !== -1) {
return locales[zhTwIndex];
}
}
// 3. Language-prefix match (e.g. "en-US" -> "en").
const prefix = language.split("-")[0];
const prefixIndex = normalizedLocales.indexOf(prefix);
if (prefixIndex !== -1) {
return locales[prefixIndex];
}
}
return null;
}

View File

@@ -2,19 +2,10 @@
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { LANGUAGES, LOCALE_COOKIE } from "@/i18n/config";
import { LANGUAGES } from "@/i18n/config";
import type { Locale } from "@/i18n/config";
import { useLocale } from "next-intl";
/** Persist locale preference in cookie + localStorage (outside component scope for ESLint) */
function persistLocale(code: Locale) {
document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
try {
localStorage.setItem(LOCALE_COOKIE, code);
} catch {
// Ignore
}
}
import { persistLocale } from "@/shared/lib/persistLocale";
function CountryFlag({ emoji, alt }: { emoji: string; alt: string }) {
const [error, setError] = useState(false);

View File

@@ -0,0 +1,39 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { LOCALES, LOCALE_COOKIE } from "@/i18n/config";
import type { Locale } from "@/i18n/config";
import { detectBrowserLocale } from "@/i18n/detectBrowserLocale";
import { persistLocale } from "@/shared/lib/persistLocale";
function hasLocaleCookie(): boolean {
return document.cookie
.split(";")
.some((entry) => entry.trim().startsWith(`${LOCALE_COOKIE}=`));
}
/**
* Auto-detects the browser language on first visit (no locale cookie set
* yet) and persists it via the same writer `LanguageSelector` uses for a
* manual selection, then refreshes the router so the server re-renders with
* the detected locale. Mounted once in the root layout; renders nothing.
*/
export function LocaleAutoDetect() {
const router = useRouter();
useEffect(() => {
if (typeof navigator === "undefined" || hasLocaleCookie()) return;
const detected = detectBrowserLocale(navigator.languages ?? [navigator.language], LOCALES);
if (!detected) return;
persistLocale(detected as Locale);
router.refresh();
// Run once on mount only — this is a first-visit detection, not a
// reactive effect that should re-run on router identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}

View File

@@ -0,0 +1,19 @@
import { LOCALE_COOKIE } from "@/i18n/config";
import type { Locale } from "@/i18n/config";
/**
* Persist the locale preference in the cookie `src/i18n/request.ts` reads on
* the server, plus localStorage as a client-side convenience mirror.
*
* Shared by every client-side locale writer (manual selection in
* `LanguageSelector`, first-visit auto-detection in `LocaleAutoDetect`) so
* there is a single source of truth for the cookie name/format.
*/
export function persistLocale(code: Locale): void {
document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
try {
localStorage.setItem(LOCALE_COOKIE, code);
} catch {
// Ignore (e.g. storage disabled/full)
}
}

View File

@@ -0,0 +1,43 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { detectBrowserLocale } from "../../src/i18n/detectBrowserLocale";
const SUPPORTED_LOCALES = ["en", "pt-BR", "es", "zh-TW", "fr", "de"] as const;
describe("detectBrowserLocale", () => {
it("returns the exact match when a browser language equals a supported locale", () => {
assert.equal(detectBrowserLocale(["pt-BR"], SUPPORTED_LOCALES), "pt-BR");
});
it("folds zh-HK to zh-TW when zh-TW is supported", () => {
assert.equal(detectBrowserLocale(["zh-HK"], SUPPORTED_LOCALES), "zh-TW");
});
it("folds zh-MO to zh-TW when zh-TW is supported", () => {
assert.equal(detectBrowserLocale(["zh-MO"], SUPPORTED_LOCALES), "zh-TW");
});
it("falls back to a language-prefix match when no exact match exists", () => {
assert.equal(detectBrowserLocale(["en-US"], SUPPORTED_LOCALES), "en");
});
it("returns null when nothing matches", () => {
assert.equal(detectBrowserLocale(["ja-JP"], SUPPORTED_LOCALES), null);
});
it("returns null for an empty languages list", () => {
assert.equal(detectBrowserLocale([], SUPPORTED_LOCALES), null);
});
it("returns null for an empty locales list", () => {
assert.equal(detectBrowserLocale(["en-US"], []), null);
});
it("tries each browser language in order until one matches", () => {
assert.equal(detectBrowserLocale(["ja-JP", "fr-CA"], SUPPORTED_LOCALES), "fr");
});
it("is case-insensitive", () => {
assert.equal(detectBrowserLocale(["PT-br"], SUPPORTED_LOCALES), "pt-BR");
});
});