Skip to content

feat(i18n): M3 locale URLs, translations and RTL (en/ms/ar, hreflang, Intl formatting) - #12

Merged
AdilAzhariOmsan merged 7 commits into
mainfrom
feat/i18n-rtl
Nov 8, 2026
Merged

AdilAzhariOmsan merged 7 commits into
mainfrom
feat/i18n-rtl

Conversation

@AdilAzhariOmsan

@AdilAzhariOmsan AdilAzhariOmsan commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

M3 internationalisation and RTL:

  • locale-prefixed storefront URLs (/, /ms, /ar) with hreflang;
  • en/ms/ar translation catalogues with Arabic plurals;
  • Intl money, number and date formatting in MYT;
  • CI checks for translation keys and logical direction (F4);
  • the Arabic font preloaded only on Arabic pages.

No new dependencies.

Merge condition: this PR merges only when both PHP 8.5 / Laravel 13 and Node 24 / Vue 3 are green on the final head commit (follow-up F-1 from #10).

Summary

Seven commits on main (c109eba):

  1. feat(i18n): locale formatters and message translation
  2. feat(i18n): per-locale catalogues, coverage check, translated home page
  3. feat(storefront): locale URL prefixes with hreflang
  4. perf(fonts): preload the Arabic face only on Arabic pages (M2 review finding F4)
  5. test(rules): logical-direction enforcement (dependency rule F4)
  6. docs(engineering): i18n conventions and F4 enforcement
  7. fix(storefront): keep the /en canonical redirect on the same host (open redirect found in review)

Related Issues

N/A

Requirement

M3 (i18n and RTL), per the Phase 3 milestone plan:

  • NFR-I18N-01 (100 % key coverage, CI fails on missing keys);
  • NFR-I18N-02 (logical properties, lint rule);
  • NFR-I18N-03 (Intl formatting, MYT);
  • NFR-I18N-04 (locale in URL, hreflang);
  • ADR-0007 (locales), ADR-0018 §3 (Latin digits in Arabic), ADR-0008 §4 (port, don't copy).

Type of Change

  • Feature
  • Tests
  • Documentation

What Changed

Locale-prefixed URLs and canonical English URLs

  • English is unprefixed (/), Malay is /ms, Arabic is /ar.
  • LocalizedRoutes::group() registers storefront routes once per locale. Routes are named {locale}.{name} (e.g. ar.storefront.home) and carry locale:{code} middleware (SetLocale).
  • /en and /en/... redirect 301 to the unprefixed URL, query string included. This redirect is registered before the localised groups, so a future catch-all route cannot shadow it.
  • The redirect target is built with url() after stripping leading slashes, so it is always absolute and on the application's own host (see Security).
  • /enquiries is not treated as /en, and unsupported prefixes (/fr, /AR, /zh/) 404.
  • App\Http\Localization\Locale is the PHP source of truth (direction, prefix); resources/js/i18n/locales.ts mirrors it.
  • HandleInertiaRequests now shares locale and direction lazily, because it runs before the route's locale middleware.
  • The locale does not carry over between requests (tested).

hreflang and x-default

  • Every localised page emits <link rel="alternate" hreflang> for en, ms and ar, plus x-default (English).
  • Routes that aren't localised (/up, /dev/tokens) emit none.

Arabic RTL and conditional font preloading

  • /ar renders <html lang="ar" dir="rtl">.
  • On client-side visits between locales, activateLocale() keeps <html lang dir> in sync.
  • IBM Plex Sans Arabic weights 400 and 600 are preloaded only when the locale is ar, through Vite::usePreloadTagAttributes. Its @font-face stays on every page (unicode-range limits it to Arabic glyphs), so Arabic names inside English or Malay pages still use it. This closes M2 review finding F4.

Translation catalogues and Arabic pluralisation

  • Messages live in resources/js/i18n/messages/{locale}/{namespace}.json; a key is {namespace}.{path}.
  • Catalogues are code-split per locale: a page loads only its locale plus English (the fallback).
  • The Inertia resolver awaits activateLocale(page.props.locale), so a page never renders before its strings are loaded. That holds on the first load and on client-side locale switches; Chrome showed no text in the wrong language.
  • The useI18n() composable exposes t, locale, direction and the bound formatters. It is in-house, about 100 lines, with no i18n dependency.
  • Plurals are objects keyed by Intl.PluralRules category; Arabic uses all six (zero/one/two/few/many/other).
  • Parameters use {name}.
  • A missing key renders as the key itself, never blank.
  • The home page is translated in en, ms and ar.

Translation-key enforcement (NFR-I18N-01)

resources/js/i18n/coverage.test.ts runs in the Node 24 / Vue 3 job and fails when:

  • ms or ar is missing an English key or has extra keys;
  • a plural lacks a category the language uses, or a message is plural in one locale but not another;
  • placeholders differ from English;
  • application source calls t('…') with a key that does not exist in English.

Logical-direction enforcement (dependency rule F4, NFR-I18N-02)

  • resources/js/rules/logical-direction.test.ts scans .vue, .ts, Blade and CSS for:
    • physical Tailwind utilities (ml-, pr-, left-, text-right, border-l, rounded-tl, … including variants and negatives);
    • physical CSS (margin-left, left:, text-align: right, …).
  • It includes a self-check against known samples.
  • A line may opt out with f4-allow and a written reason.

Locale-aware currency, number and date formatting (NFR-I18N-03)

  • formatMoney({ amount, currency }):
    • takes integer minor units (sen) and rejects non-integers (ADR-0013);
    • uses no arithmetic beyond display scaling (F3);
    • strips bidi marks.
  • formatNumber: per locale, Latin digits for Arabic (ar-u-nu-latn).
  • formatDate / formatDateTime:
    • display in Asia/Kuala_Lumpur;
    • require ISO 8601 with an offset (ADR-0018);
    • Arabic shows Arabic month names (9 نوفمبر 2026).
  • types/money.ts is a temporary hand-written shape until ADR-0018, like types/shared.ts.

Docs

  • code-quality.md gains an "Internationalisation" section.
  • dependency-rules.md records how F4 is enforced and its ESLint migration path (below).

Design / Architecture

Decisions recorded

  1. Malaysian currency presentation in every locale, Arabic included: RM 2,418.50. Latin digits, symbol first, LTR-isolated inside RTL text. This matches the React reference and the Arabic sample approved in M2. CLDR's Arabic pattern would be 2,418.50 RM, and switching to it is a one-line change in format.ts.
  2. URL prefixes are authoritative. There is no automatic redirect based on Accept-Language or a cookie: links stay shareable and indexable, and the same URL always serves the same language.
  3. Malay and Arabic copy needs native-speaker review. The current ms and ar strings are the author's translations. The coverage test guarantees completeness, not quality.

Other design notes

  • F4 enforcement and the ESLint migration path. dependency-rules.md plans F1–F4 as ESLint rules. ESLint is not installed (owner decision), so F4 runs as a Vitest check in the existing CI job and is documented as such. If ESLint is approved, its regexes become a custom rule or Tailwind class lint and the Vitest file is removed. The opt-out marker (f4-allow) maps to an eslint-disable-next-line with a reason.
  • Locale loading in the resolver rather than a watcher: Inertia 3 passes the page to resolve(name, page), so the catalogue and the page component load in parallel and the swap happens once both are ready.
  • Lazy shared props: Inertia's middleware is in the web group and runs before route middleware, so locale and direction are closures resolved at render time.

Testing

  • Unit tests (Vitest: formatters in en/ms/ar, translate/plurals, coverage, F4, Home in three locales)
  • Feature/integration tests (Pest: prefixes, canonical redirect, 404s, locale isolation, hreflang, font preloads)
  • Architecture tests (existing suite)
  • Manual verification (Chrome)

Test Evidence

Local, Node 24.22.2 / npm 11.19.1, PHP 8.5, with APP_CONFIG_CACHE pointed at a path that doesn't exist, from a clean node_modules / public/build, on the content of head 2c78b35:

Step Result
npm ci ok
npm run build ok
composer check composer.json valid · 0 advisories · Pint passed · PHPStan 0 errors · Pest 33 passed (161 assertions)
npm run typecheck ok
npm test 105 passed (9 files)
npm audit --omit=dev 0 vulnerabilities
AC Evidence
/ar renders dir="rtl" Pest: /ar → <html lang="ar" dir="rtl">, props locale=ar, direction=rtl; /ms and / are ltr
URL prefixes, en unprefixed (NFR-I18N-04) Pest: /, /ms, /ar → 200 with the matching locale; /en, /en/, /en/products/kopi?sort=new → 301 to the unprefixed URL; /fr, /AR, /zh/, /enquiries → 404; /en//evil.com, /en/%2Fevil.com, /en/%2F%2Fevil.com → 301 to the app's own host
hreflang alternates Pest: each of /, /ms, /ar lists en, ms, ar and x-default with absolute URLs; checked again in Chrome
CI fails on a missing key (NFR-I18N-01) Deleting storefront.home.eyebrow from ms fails "ms has exactly the English keys"; using t('storefront.home.nope') fails "defines every key the application source asks for". Both reverted
Formatter tests in all three locales format.test.ts: RM 2,418.50 in en/ms/ar; 1,234,567.891 in each; 9 Nov 2026 / 9 Nov 2026 / 9 نوفمبر 2026 for 2026-11-08T17:30Z (MYT rollover); date-time 1:30 am / 1:30 PG / 1:30 ص
F4 lint (NFR-I18N-02) Adding ml-2 to Home.vue fails with Home.vue:14 ml-2; the self-check covers variants, negatives, arbitrary values and CSS properties
Arabic plurals translate.test.ts: 0 → zero, 2 → two, 5 → few, 11 → many
Conditional font preload (M2 F4) Pest: /ar preloads Plex Arabic 400 and 600; / and /ms preload Instrument Sans but not Plex Arabic, and still include its @font-face
Client-side locale switching Chrome: Inertia visits /ar → /ms → / → /ar update lang, dir, title and copy together; a DOM observer saw no text in the wrong language
Bundle budget Initial storefront JS ≈ 65 kB gzip (≤ 200 kB): dist 62.06 + app 2.53 + one locale catalogue ≈ 0.14 + Home 0.44. CSS 5.52 kB gzip

Security / Data Isolation

  • No cross-vendor data leakage introduced (no vendor or auth code)

  • Sensitive data handling reviewed: shared props are still only locale and direction.

  • Open redirect found in review and fixed (2c78b35). An earlier version of this PR said the /en redirect was not an open redirect. That was wrong. As first pushed (173ee8b), it built its target as '/'.$path from a .* route parameter. A path starting with a slash produced a protocol-relative Location:

    Request Before After
    /en//evil.com //evil.com http://<app-host>/evil.com
    /en/%2Fevil.com //evil.com (survives nginx merge_slashes) http://<app-host>/evil.com
    /en/%2F%2Fevil.com ///evil.com http://<app-host>/evil.com
    • Fix: LocalizedRoutes strips leading slashes from the captured path and builds the target with url(), so the Location is always absolute and same-host. Legitimate paths and query strings are unchanged.
    • Regression tests: Pest "never redirects the English prefix off-site" runs all three payloads and asserts a 301 whose Location has the app's scheme and host, is not protocol-relative, and keeps the path on-site.
    • Encoded backslashes (%5C) were already same-host, and raw backslashes are rejected by Symfony.
    • The host comes from the request, as with every Laravel url() call. Production should set APP_URL and keep trusted-host checks at the proxy or with Laravel's TrustHosts; that is unchanged by this PR.
  • The start-of-milestone full npm audit found the same two dev-only advisories recorded in security.md (already reassessed 2026-11-08), nothing new.

Database / Migration

  • No database changes

Screenshots / Recording

Run npm run build && php artisan serve, then open /, /ms and /ar. The home page shows translated copy and /ar is right to left. Screenshots can be added on request.

Documentation

  • Documentation updated (docs/engineering/code-quality.md, docs/architecture/dependency-rules.md)

Reviewer Notes

  • Focus:
    • LocalizedRoutes (registration order, alternates);
    • lazy shared props in HandleInertiaRequests;
    • the resolver in app.ts;
    • the coverage rules;
    • the F4 regexes.
  • Known items:
    • Native-speaker review of ms and ar copy (decision 3).
    • The client updates <html lang dir> on locale switches, but the hreflang links in <head> only refresh on full page loads. Crawlers do full loads, so this only affects in-app navigation.
  • Follow-ups:

Definition of Done

  • Acceptance criteria are satisfied
  • composer check passes locally (validate, audit, Pint, PHPStan, Pest)
  • No unrelated changes
  • No secrets committed
  • Self-review completed
  • Reviewer feedback addressed

- locales.ts: en, ms, ar (ADR-0007), directions, Intl tags. Arabic keeps
  Latin digits through -u-nu-latn (owner decision, ADR-0018 §3).
- format.ts (NFR-I18N-03), ported from the React reference rather than
  copied (ADR-0008 §4):
  - formatMoney takes { amount: integer sen, currency } and renders the
    Malaysian convention in every locale ("RM 2,418.50"): Latin digits,
    symbol first, no bidi marks. Rejects non-integer minor units.
  - formatNumber per locale; formatDate / formatDateTime in
    Asia/Kuala_Lumpur, rejecting strings without an ISO 8601 offset.
- translate.ts: dotted-key lookup with English fallback, {name}
  interpolation, and plural objects selected by Intl.PluralRules (all six
  Arabic categories). A missing key renders as the key, never blank.
- types/money.ts: temporary hand-written Money shape until ADR-0018.

Tests cover all three locales, MYT date rollover, and Arabic plurals.
- resources/js/i18n/messages/{locale}/{namespace}.json, code-split per
  locale; a page loads only its locale plus English.
- app.ts awaits activateLocale(page.props.locale) inside the Inertia
  resolver, so every page (first load and client-side visits between
  locales) renders with its strings already loaded; <html lang dir> is
  kept in sync on client visits.
- useI18n() exposes t, locale, direction and the formatters bound to the
  active locale. No i18n dependency added.
- coverage.test.ts (NFR-I18N-01) 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 an undefined key.
- Home uses t() in en, ms and ar; SharedProps.locale is now the Locale
  union.
NFR-I18N-04: English is unprefixed, Malay is /ms, Arabic is /ar.

- App\Http\Localization\Locale enum: supported locales, direction, prefix.
- LocalizedRoutes::group() registers storefront routes once per locale,
  named {locale}.{name}, with `locale:` middleware (SetLocale). /en/...
  redirects 301 to the unprefixed URL, registered first so a future
  catch-all cannot shadow it. Unknown prefixes 404.
- app.blade.php emits hreflang alternates for every locale plus
  x-default on localised routes.
- HandleInertiaRequests shares locale and direction lazily: it runs
  before the route's locale middleware.
- The M1 Arabic home test now requests /ar instead of setting the locale.
M2 review finding F4. IBM Plex Sans Arabic now declares preloads for
weights 400 and 600, and a Vite preload-tag resolver drops them unless the
locale is ar. Its @font-face stays on every page (unicode-range limits it
to Arabic glyphs), so Arabic names inside other locales still use it.

Tests use one request each: Laravel's Vite service skips assets it has
already preloaded within the same application instance.
NFR-I18N-02 / F4: no physical left/right styling. ESLint is not installed
(owner decision), so a Vitest check scans Vue, TypeScript, Blade and CSS
for physical Tailwind utilities (ml-, pr-, left-, text-right, border-l,
rounded-tl, ...) and physical CSS properties (margin-left, left:,
text-align: right, ...). It includes a self-check against known samples;
a line may opt out with `f4-allow` and a reason.
- code-quality.md: locales, localised routes, message files, coverage
  rules, formatters and logical-direction rule.
- dependency-rules.md: F4 is enforced by Vitest until ESLint is approved.
Security: the /en/{path} redirect built its target as '/'.$path from a
'.*' route parameter. A path starting with a slash produced a
protocol-relative Location, an open redirect:

  /en//evil.com       -> //evil.com
  /en/%2Fevil.com     -> //evil.com   (survives nginx merge_slashes)
  /en/%2F%2Fevil.com  -> ///evil.com

Leading slashes are now stripped and the target is built with url(), so
the Location is always absolute on the application's own host
(/en//evil.com -> http://<app-host>/evil.com). Legitimate paths and query
strings are unchanged.

Regression tests cover all three payloads and assert a 301 whose Location
has the app's scheme and host, is not protocol-relative, and keeps the
path on-site.

@AdilAzhariOmsan AdilAzhariOmsan left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@AdilAzhariOmsan
AdilAzhariOmsan merged commit 7b1fdea into main Nov 8, 2026
2 checks passed
@AdilAzhariOmsan
AdilAzhariOmsan deleted the feat/i18n-rtl branch November 8, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant