From 85120ac8f352ed761ce07fec0a050c07e5b0cc34 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 15:38:40 -0300 Subject: [PATCH] Add UI localization infrastructure and RTL support for 12 new locales Register the 12 additional languages (fr, de, it, nl, pl, el, ja, ko, zh, ru, tr, ar) as available UI locales so the language switcher and the API accept them, keeping the set in lockstep with the ContentLanguage enum. - config/languages.php lists all 15 UI locales with their native names. - ContentLanguage::isRtl() drives the document `dir`; SetLocale shares it to the Blade root and HandleInertiaRequests shares it to Inertia, and app.ts mirrors it on SPA navigations so RTL locales lay out correctly. - dayjs imports the 12 new locales so dates localize instead of falling back to English. - A LocalizationParityTest guards against key drift: every locale must ship every base translation file with exactly the keys of lang/en. --- app/Enums/Workspace/ContentLanguage.php | 27 +++++++++-- .../Middleware/App/HandleInertiaRequests.php | 2 + app/Http/Middleware/App/SetLocale.php | 6 ++- config/languages.php | 12 +++++ resources/js/app.ts | 5 ++ resources/js/dayjs.ts | 17 +++++-- resources/views/app.blade.php | 2 +- tests/Feature/LocalizationParityTest.php | 47 +++++++++++++++++++ 8 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/LocalizationParityTest.php diff --git a/app/Enums/Workspace/ContentLanguage.php b/app/Enums/Workspace/ContentLanguage.php index cffa22ec..a41fab09 100644 --- a/app/Enums/Workspace/ContentLanguage.php +++ b/app/Enums/Workspace/ContentLanguage.php @@ -84,19 +84,28 @@ public function englishName(): string } /** - * Resolve a raw `` value (e.g. "pt-PT", "en-US") to a supported - * language by matching its two-letter primary subtag, or null if none fits. + * Whether the language is written right-to-left, which drives the `dir` + * attribute on the document root so the whole UI mirrors for the locale. + */ + public function isRtl(): bool + { + return $this === self::Arabic; + } + + /** + * Resolve a raw `` value (e.g. "pt-PT", "zh-Hans") to a supported + * language by matching its primary subtag, or null if none is supported. */ public static function fromHtmlLang(string $lang): ?self { - $prefix = strtolower(substr(trim($lang), 0, 2)); + $subtag = self::primarySubtag($lang); - if (strlen($prefix) < 2) { + if (strlen($subtag) < 2) { return null; } foreach (self::cases() as $language) { - if (str_starts_with($language->value, $prefix)) { + if (self::primarySubtag($language->value) === $subtag) { return $language; } } @@ -104,6 +113,14 @@ public static function fromHtmlLang(string $lang): ?self return null; } + /** + * The lowercased primary subtag of a BCP 47 language tag ("pt-BR" => "pt"). + */ + private static function primarySubtag(string $tag): string + { + return strtolower(explode('-', trim($tag), 2)[0]); + } + /** * @return array */ diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 5e51d6d8..b5605acb 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware\App; +use App\Enums\Workspace\ContentLanguage; use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource; use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource; use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource; @@ -52,6 +53,7 @@ public function share(Request $request): array 'applicationUrl' => config('app.url'), 'env' => config('app.env'), 'locale' => app()->getLocale(), + 'dir' => ContentLanguage::tryFrom(app()->getLocale())?->isRtl() ? 'rtl' : 'ltr', 'languages' => collect(config('languages.available'))->map(fn ($name, $code) => [ 'code' => $code, 'name' => $name, diff --git a/app/Http/Middleware/App/SetLocale.php b/app/Http/Middleware/App/SetLocale.php index fecf0fe0..49a30cde 100644 --- a/app/Http/Middleware/App/SetLocale.php +++ b/app/Http/Middleware/App/SetLocale.php @@ -4,9 +4,11 @@ namespace App\Http\Middleware\App; +use App\Enums\Workspace\ContentLanguage; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\View; use Symfony\Component\HttpFoundation\Response; class SetLocale @@ -19,8 +21,10 @@ public function handle(Request $request, Closure $next): Response $available = config('languages.available'); $locale = $request->cookie('locale'); $isValid = $locale && array_key_exists($locale, $available); + $activeLocale = $isValid ? $locale : config('languages.default'); - App::setLocale($isValid ? $locale : config('languages.default')); + App::setLocale($activeLocale); + View::share('htmlDir', ContentLanguage::tryFrom($activeLocale)?->isRtl() ? 'rtl' : 'ltr'); $response = $next($request); diff --git a/config/languages.php b/config/languages.php index 16fb788a..37de477b 100644 --- a/config/languages.php +++ b/config/languages.php @@ -19,6 +19,18 @@ 'en' => 'English', 'es' => 'Español', 'pt-BR' => 'Português', + 'fr' => 'Français', + 'de' => 'Deutsch', + 'it' => 'Italiano', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'el' => 'Ελληνικά', + 'ja' => '日本語', + 'ko' => '한국어', + 'zh' => '中文', + 'ru' => 'Русский', + 'tr' => 'Türkçe', + 'ar' => 'العربية', ], /* diff --git a/resources/js/app.ts b/resources/js/app.ts index de757e69..403250a7 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -28,6 +28,11 @@ createInertiaApp({ // Set dayjs locale based on user's language dayjs.locale(locale.toLowerCase()); + // Mirror the layout for right-to-left locales on SPA navigations, where + // the server-rendered from the initial load does not re-run. + const dir = (props.initialPage.props as { dir?: string })?.dir || 'ltr'; + document.documentElement.dir = dir; + const auth = props.initialPage.props.auth as Auth | undefined; const flash = props.initialPage.props.flash as | { conversion_event?: string; [key: string]: unknown } diff --git a/resources/js/dayjs.ts b/resources/js/dayjs.ts index a6c505f3..1f4b1e8e 100644 --- a/resources/js/dayjs.ts +++ b/resources/js/dayjs.ts @@ -15,6 +15,18 @@ import weekday from 'dayjs/plugin/weekday'; import 'dayjs/locale/en'; import 'dayjs/locale/es'; import 'dayjs/locale/pt-br'; +import 'dayjs/locale/fr'; +import 'dayjs/locale/de'; +import 'dayjs/locale/it'; +import 'dayjs/locale/nl'; +import 'dayjs/locale/pl'; +import 'dayjs/locale/el'; +import 'dayjs/locale/ja'; +import 'dayjs/locale/ko'; +import 'dayjs/locale/zh'; +import 'dayjs/locale/ru'; +import 'dayjs/locale/tr'; +import 'dayjs/locale/ar'; // Extend dayjs with plugins dayjs.extend(utc); @@ -30,8 +42,7 @@ dayjs.extend(weekday); dayjs.extend(isBetween); // Set Monday as first day of week (to match Carbon/Laravel) -dayjs.updateLocale('en', { weekStart: 1 }); -dayjs.updateLocale('es', { weekStart: 1 }); -dayjs.updateLocale('pt-br', { weekStart: 1 }); +const weekStartMonday = ['en', 'es', 'pt-br', 'fr', 'de', 'it', 'nl', 'pl', 'el', 'ja', 'ko', 'zh', 'ru', 'tr', 'ar']; +weekStartMonday.forEach((locale) => dayjs.updateLocale(locale, { weekStart: 1 })); export default dayjs; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 578ed7c3..6192de7f 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -1,5 +1,5 @@ - + diff --git a/tests/Feature/LocalizationParityTest.php b/tests/Feature/LocalizationParityTest.php new file mode 100644 index 00000000..a6701f50 --- /dev/null +++ b/tests/Feature/LocalizationParityTest.php @@ -0,0 +1,47 @@ +toEqualCanonicalizing(ContentLanguage::values()); +}); + +test('the default UI language is a supported content language', function () { + expect(config('languages.default'))->toBeIn(ContentLanguage::values()); +}); + +test('locale ships every base translation file with identical keys', function (string $locale) { + $missingFiles = []; + $keyDrift = []; + + foreach (glob(lang_path('en/*.php')) as $basePath) { + $file = basename($basePath, '.php'); + $localePath = lang_path("{$locale}/{$file}.php"); + + if (! file_exists($localePath)) { + $missingFiles[] = "{$file}.php"; + + continue; + } + + $baseKeys = array_keys(Arr::dot(require $basePath)); + $localeKeys = array_keys(Arr::dot(require $localePath)); + + $missing = array_diff($baseKeys, $localeKeys); + $extra = array_diff($localeKeys, $baseKeys); + + if ($missing !== [] || $extra !== []) { + $keyDrift[$file] = [ + 'missing' => array_values($missing), + 'extra' => array_values($extra), + ]; + } + } + + expect($missingFiles)->toBe([], "{$locale} is missing translation files: ".implode(', ', $missingFiles)); + expect($keyDrift)->toBe([], "{$locale} has key drift: ".json_encode($keyDrift, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +})->with(ContentLanguage::values());