Skip to content
Merged
37 changes: 37 additions & 0 deletions app/Http/Localization/Locale.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Http\Localization;

/**
* Supported locales (ADR-0007). English is primary and the fallback, and is
* served without a URL prefix (NFR-I18N-04).
*
* Mirrored by resources/js/i18n/locales.ts.
*/
enum Locale: string
{
case English = 'en';
case Malay = 'ms';
case Arabic = 'ar';

public static function fallback(): self
{
return self::English;
}

public static function current(): self
{
return self::tryFrom(app()->getLocale()) ?? self::fallback();
}

public function direction(): string
{
return $this === self::Arabic ? 'rtl' : 'ltr';
}

/** The URL prefix, or an empty string for the unprefixed fallback locale. */
public function prefix(): string
{
return $this === self::fallback() ? '' : $this->value;
}
}
69 changes: 69 additions & 0 deletions app/Http/Localization/LocalizedRoutes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace App\Http\Localization;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/**
* Registers storefront routes once per locale: English unprefixed, the
* others under /{locale} (NFR-I18N-04). Each copy is named "{locale}.{name}"
* so its alternates can be found and linked with hreflang.
*/
final class LocalizedRoutes
{
public static function group(Closure $routes): void
{
// /en/... is never canonical: send it to the unprefixed URL. Registered
// first so a later catch-all storefront route cannot shadow it.
//
// Leading slashes are stripped and the target is built with url(), so
// the redirect is always absolute and same-host. Without that,
// /en//evil.com or /en/%2Fevil.com became "//evil.com", a
// protocol-relative open redirect.
Route::get('en/{path?}', function (Request $request, ?string $path = null) {
$target = url('/'.ltrim($path ?? '', '/'));
$query = $request->getQueryString();

return redirect($target.($query === null ? '' : '?'.$query), 301);
})->where('path', '.*');

foreach (Locale::cases() as $locale) {
Route::middleware('locale:'.$locale->value)
->prefix($locale->prefix())
->name($locale->value.'.')
->group($routes);
}
}

/**
* URLs of the current page in every locale, plus x-default (English).
* Empty for routes that are not localised.
*
* @return array<string, string>
*/
public static function alternates(?Request $request = null): array
{
$route = ($request ?? request())->route();
$name = $route?->getName();

if ($route === null || $name === null || ! preg_match('/^(en|ms|ar)\.(.+)$/', $name, $matches)) {
return [];
}

$alternates = [];

foreach (Locale::cases() as $locale) {
if (Route::has($locale->value.'.'.$matches[2])) {
$alternates[$locale->value] = route($locale->value.'.'.$matches[2], $route->parameters());
}
}

if (isset($alternates[Locale::fallback()->value])) {
$alternates['x-default'] = $alternates[Locale::fallback()->value];
}

return $alternates;
}
}
16 changes: 5 additions & 11 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,12 @@

namespace App\Http\Middleware;

use App\Http\Localization\Locale;
use Illuminate\Http\Request;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
/**
* Locales written right to left.
*
* @var list<string>
*/
private const array RTL_LOCALES = ['ar'];

/**
* The root template that's loaded on the first page visit.
*
Expand All @@ -31,12 +25,12 @@ class HandleInertiaRequests extends Middleware
*/
public function share(Request $request): array
{
$locale = app()->getLocale();

// Closures: this middleware runs before the route's `locale:`
// middleware, so the locale is read when the response is built.
return [
...parent::share($request),
'locale' => $locale,
'direction' => in_array($locale, self::RTL_LOCALES, true) ? 'rtl' : 'ltr',
'locale' => fn (): string => Locale::current()->value,
'direction' => fn (): string => Locale::current()->direction(),
];
}
}
22 changes: 22 additions & 0 deletions app/Http/Middleware/SetLocale.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Http\Middleware;

use App\Http\Localization\Locale;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
* Applies the locale a route was registered for (see LocalizedRoutes).
* Used as `locale:ms`.
*/
class SetLocale
{
public function handle(Request $request, Closure $next, string $locale): Response
{
app()->setLocale(Locale::from($locale)->value);

return $next($request);
}
}
11 changes: 10 additions & 1 deletion app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace App\Providers;

use App\Http\Localization\Locale;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
Expand All @@ -19,6 +21,13 @@ public function register(): void
*/
public function boot(): void
{
//
// The Arabic face is preloaded only on Arabic pages. Elsewhere its
// @font-face stays available (unicode-range limits it to Arabic
// glyphs), so Arabic names inside other locales still use it.
Vite::usePreloadTagAttributes(
fn (string $src, string $url): array|false => $src === 'fonts'
&& str_contains($url, 'ibm-plex-sans-arabic')
&& Locale::current() !== Locale::Arabic ? false : [],
);
}
}
3 changes: 3 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
Expand All @@ -19,6 +20,8 @@
},
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias(['locale' => SetLocale::class]);

$middleware->web(append: [
HandleInertiaRequests::class,
]);
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/dependency-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,5 @@ Adding to this list needs a PR that updates this table.
| F4 | No physical direction utilities (`ml-`, `pr-`, `left-`, `text-left`) in components; use logical equivalents (`ms-`, `pe-`, `start-`, `text-start`) |

Rules F1–F4 are enforced with ESLint (`import/no-restricted-paths`, plus a custom Tailwind class lint) in Phase 3.

ESLint is not installed yet (owner decision), so F4 is enforced now by a Vitest check, `resources/js/rules/logical-direction.test.ts`, which scans Vue, TypeScript, Blade and CSS for physical utilities and properties. It moves to ESLint if and when ESLint is approved.
29 changes: 29 additions & 0 deletions docs/engineering/code-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,35 @@ composer analyse
- **Arabic** switches to IBM Plex Sans Arabic and taller line heights through
`:lang(ar)`, so it also applies to Arabic content inside other locales.

## Internationalisation

- **Locales:** `en` (primary and fallback), `ms`, `ar` (RTL), per ADR-0007.
The PHP source of truth is `App\Http\Localization\Locale`; the frontend
mirror is `resources/js/i18n/locales.ts`.
- **URLs (NFR-I18N-04):** storefront routes are declared once inside
`LocalizedRoutes::group()` in `routes/web.php` and registered for `/`,
`/ms` and `/ar`, named `{locale}.{name}` (e.g. `ar.storefront.home`).
`/en/...` redirects (301) to the unprefixed URL. Every localised page
emits `hreflang` alternates plus `x-default`.
- **Strings:** `resources/js/i18n/messages/{locale}/{namespace}.json`; the
key is `{namespace}.{path}` (e.g. `storefront.home.title`). Use
`const { t } = useI18n()`; parameters are `{name}`, and plurals are objects
keyed by `Intl.PluralRules` category (Arabic needs all six). Each page loads
only its own locale plus English.
- **Coverage (NFR-I18N-01):** `resources/js/i18n/coverage.test.ts` fails CI
when a locale is missing or has extra keys, a plural lacks a category the
language uses, placeholders differ from English, or source calls `t()` with
a key that does not exist. Every UI string ships in all three locales.
- **Formatting (NFR-I18N-03):** use `formatMoney`, `formatNumber`,
`formatDate` and `formatDateTime` from `useI18n()`, never `toFixed` or
hand-built strings. Dates are shown in `Asia/Kuala_Lumpur`; Arabic uses
Latin digits; money is `RM 2,418.50` in every locale (render it
LTR-isolated inside RTL text).
- **Direction (F4, NFR-I18N-02):** logical utilities only (`ms-`, `pe-`,
`start-`, `text-start`, `border-s`, `rounded-e`). Enforced by
`resources/js/rules/logical-direction.test.ts`; a line may opt out with
`f4-allow` and a reason.

## Before every pull request

```sh
Expand Down
13 changes: 9 additions & 4 deletions resources/js/app.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { createInertiaApp } from '@inertiajs/vue3';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import { activateLocale } from './i18n';

const pages = import.meta.glob<DefineComponent>('./pages/**/*.vue');

void createInertiaApp({
title: (title) => (title ? `${title} · Nexo` : 'Nexo'),
resolve: (name) => {
const page = pages[`./pages/${name}.vue`];
resolve: async (name, page) => {
const component = pages[`./pages/${name}.vue`];

if (!page) {
if (!component) {
throw new Error(`Inertia page not found: ${name}`);
}

return page();
// Load the page's locale before it renders, on the first visit and on
// every client-side visit (switching between /, /ms and /ar included).
const [resolved] = await Promise.all([component(), activateLocale(page?.props.locale)]);

return resolved;
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
Expand Down
88 changes: 88 additions & 0 deletions resources/js/i18n/coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// NFR-I18N-01: en, ms and ar have 100 % key coverage, and CI fails on a
// missing key. Runs in the `Node 24 / Vue 3` job with the rest of Vitest.

import { describe, expect, it } from 'vitest';
import { intlTag, LOCALES, type Locale } from './locales';
import { flattenMessages, isPluralMessage, type Catalogue, type Message } from './translate';

const files = import.meta.glob<{ default: object }>('./messages/*/*.json', { eager: true });

const catalogues = Object.fromEntries(
LOCALES.map((locale) => {
const merged: Record<string, Message> = {};

for (const [path, module] of Object.entries(files)) {
if (!path.startsWith(`./messages/${locale}/`)) continue;

const namespace = path.slice(path.lastIndexOf('/') + 1, -'.json'.length);
Object.assign(merged, flattenMessages({ [namespace]: module.default }));
}

return [locale, merged];
}),
) as Record<Locale, Catalogue>;

// Application source, to find every t('literal.key') call.
const sources = import.meta.glob<string>(['../**/*.vue', '../**/*.ts', '!../**/*.test.ts', '!../i18n/**'], {
eager: true,
query: '?raw',
import: 'default',
});

const placeholders = (message: Message): string[] =>
[...(typeof message === 'string' ? message : message.other).matchAll(/\{(\w+)\}/g)]
.map((match) => match[1] as string)
.sort();

describe('translation coverage (NFR-I18N-01)', () => {
const englishKeys = Object.keys(catalogues.en).sort();

it('has English messages', () => {
expect(englishKeys.length).toBeGreaterThan(0);
});

it.each(LOCALES.filter((l) => l !== 'en'))('%s has exactly the English keys', (locale) => {
const keys = Object.keys(catalogues[locale]);

expect({
missing: englishKeys.filter((key) => !keys.includes(key)),
extra: keys.filter((key) => !englishKeys.includes(key)).sort(),
}).toEqual({ missing: [], extra: [] });
});

it.each(LOCALES)('%s plural messages define every plural category the language uses', (locale) => {
const required = new Intl.PluralRules(intlTag(locale)).resolvedOptions().pluralCategories;
const gaps = Object.entries(catalogues[locale]).flatMap(([key, message]) => {
if (catalogues.en[key] !== undefined && isPluralMessage(catalogues.en[key]) !== isPluralMessage(message)) {
return [`${key}: plural in one locale but not the other`];
}

return isPluralMessage(message)
? required.filter((category) => !(category in message)).map((category) => `${key}.${category}`)
: [];
});

expect(gaps).toEqual([]);
});

it.each(LOCALES.filter((l) => l !== 'en'))('%s uses the same placeholders as English', (locale) => {
const mismatched = englishKeys.filter((key) => {
const translated = catalogues[locale][key];

return translated !== undefined && placeholders(translated).join() !== placeholders(catalogues.en[key]!).join();
});

expect(mismatched).toEqual([]);
});

it('defines every key the application source asks for', () => {
const used = new Set<string>();

for (const source of Object.values(sources)) {
for (const match of source.matchAll(/\bt\(\s*['"`]([\w.-]+)['"`]/g)) used.add(match[1] as string);
}

expect(used.size).toBeGreaterThan(0);
expect([...used].filter((key) => catalogues.en[key] === undefined).sort()).toEqual([]);
});
});
Loading
Loading