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