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.
This commit is contained in:
Paulo Castellano 2026-07-03 15:38:40 -03:00
parent f7ef13c116
commit 85120ac8f3
8 changed files with 108 additions and 10 deletions

View file

@ -84,19 +84,28 @@ public function englishName(): string
}
/**
* Resolve a raw `<html lang>` 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 `<html lang>` 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<int, string>
*/

View file

@ -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,

View file

@ -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);

View file

@ -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' => 'العربية',
],
/*

View file

@ -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 <html dir> 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 }

View file

@ -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;

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ $htmlDir ?? 'ltr' }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Enums\Workspace\ContentLanguage;
use Illuminate\Support\Arr;
test('every UI language in config matches the ContentLanguage enum', function () {
expect(array_keys(config('languages.available')))
->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());