feat(i18n): Hebrew locale + full RTL support
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { routing } from './routing';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
|
||||
/**
|
||||
* Pick the best supported locale for a first-time visitor from their browser
|
||||
* language preferences. Returns `fallback` (English default) when the browser
|
||||
* prefers English, prefers nothing we support, or is unavailable (SSR) — so we
|
||||
* only auto-switch AWAY from English when the browser clearly prefers another
|
||||
* language we ship. A user's explicit choice is persisted separately and is
|
||||
* never overridden by this.
|
||||
*/
|
||||
export function detectBrowserLocale(fallback: string): string {
|
||||
if (typeof navigator === 'undefined') return fallback;
|
||||
const supported = new Set<string>(routing.locales as readonly string[]);
|
||||
const prefs = navigator.languages?.length ? navigator.languages : [navigator.language];
|
||||
for (const tag of prefs) {
|
||||
if (!tag) continue;
|
||||
const base = tag.toLowerCase().split('-')[0];
|
||||
if (base === 'en') return fallback; // English is the top preference -> keep default
|
||||
if (supported.has(base)) return base; // first supported non-English preference wins
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale to actually use for Intl/formatting and the provider: the user's
|
||||
* stored choice when it's a real locale, otherwise (empty or 'auto') the
|
||||
* resolved UI locale — never the 'auto' sentinel, which is not a valid BCP-47
|
||||
* tag and throws in Intl.* APIs.
|
||||
*/
|
||||
export function getEffectiveLocale(): string {
|
||||
const choice = useLocaleStore.getState().locale;
|
||||
if (choice && choice !== 'auto') return choice;
|
||||
if (typeof document !== 'undefined' && document.documentElement.lang) {
|
||||
return document.documentElement.lang;
|
||||
}
|
||||
return detectBrowserLocale('en');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// RTL locales: Hebrew (he) and Persian/Farsi (fa). Everything else is LTR.
|
||||
const rtlLocales = new Set(['he', 'fa']);
|
||||
|
||||
export function getLocaleDirection(locale: string): 'ltr' | 'rtl' {
|
||||
return rtlLocales.has(locale) ? 'rtl' : 'ltr';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
type Messages = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Deep-merge `override` onto `base`, returning a new object. Keys present only
|
||||
* in `base` survive — used to fall back to English for any string a locale has
|
||||
* not translated yet, so the UI shows the English text rather than a raw
|
||||
* message key (e.g. "settings.tabs.layout").
|
||||
*/
|
||||
export function mergeMessages(base: Messages, override: Messages): Messages {
|
||||
const out: Messages = { ...base };
|
||||
for (const key of Object.keys(override)) {
|
||||
const b = out[key];
|
||||
const o = override[key];
|
||||
if (b && o && typeof b === 'object' && typeof o === 'object' && !Array.isArray(b) && !Array.isArray(o)) {
|
||||
out[key] = mergeMessages(b as Messages, o as Messages);
|
||||
} else {
|
||||
out[key] = o;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+31
-1
@@ -1,11 +1,33 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { mergeMessages } from './merge-messages';
|
||||
import { routing, type Locale } from './routing';
|
||||
|
||||
// Default a first-time visitor (no explicit stored choice) to the best supported
|
||||
// language from their browser's Accept-Language header, so "Auto" is the real
|
||||
// default and the first server render already matches the browser language.
|
||||
function localeFromAcceptLanguage(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const supported = new Set<string>(routing.locales as readonly string[]);
|
||||
const ranked = header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [tag, q] = part.trim().split(';q=');
|
||||
return { base: tag.toLowerCase().split('-')[0], q: q ? parseFloat(q) : 1 };
|
||||
})
|
||||
.sort((a, b) => b.q - a.q);
|
||||
for (const { base } of ranked) {
|
||||
if (supported.has(base)) return base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
|
||||
if (!locale || !routing.locales.includes(locale as Locale)) {
|
||||
locale = routing.defaultLocale;
|
||||
const accept = (await headers()).get('accept-language');
|
||||
locale = localeFromAcceptLanguage(accept) ?? routing.defaultLocale;
|
||||
}
|
||||
|
||||
// Use static imports for better compatibility
|
||||
@@ -23,6 +45,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'es':
|
||||
messages = (await import('../locales/es/common.json')).default;
|
||||
break;
|
||||
case 'he':
|
||||
messages = (await import('../locales/he/common.json')).default;
|
||||
break;
|
||||
case 'fa':
|
||||
messages = (await import('../locales/fa/common.json')).default;
|
||||
break;
|
||||
@@ -75,6 +100,11 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
messages = (await import('../locales/en/common.json')).default;
|
||||
}
|
||||
|
||||
if (locale !== 'en') {
|
||||
const enBase = (await import('../locales/en/common.json')).default as Record<string, unknown>;
|
||||
messages = mergeMessages(enBase, messages as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages,
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'always'
|
||||
| 'as-needed';
|
||||
|
||||
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
|
||||
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
|
||||
|
||||
// Fallback locale used when the visitor's Accept-Language header does not
|
||||
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||
|
||||
Reference in New Issue
Block a user