feat(i18n): Hebrew locale + full RTL support

This commit is contained in:
Shuki Vaknin
2026-07-08 00:07:34 +02:00
committed by Linus Rath
parent 8904d724bb
commit 22418c17cf
113 changed files with 3958 additions and 494 deletions
+38
View File
@@ -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');
}
+6
View File
@@ -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';
}
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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