mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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:
committed by
GitHub
parent
1bd4b02110
commit
c9032e478b
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
52
src/i18n/detectBrowserLocale.ts
Normal file
52
src/i18n/detectBrowserLocale.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
39
src/shared/components/LocaleAutoDetect.tsx
Normal file
39
src/shared/components/LocaleAutoDetect.tsx
Normal 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;
|
||||
}
|
||||
19
src/shared/lib/persistLocale.ts
Normal file
19
src/shared/lib/persistLocale.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
43
tests/unit/i18n-detect-browser-locale.test.ts
Normal file
43
tests/unit/i18n-detect-browser-locale.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user