Repository navigation
feat(i18n): M3 locale URLs, translations and RTL (en/ms/ar, hreflang, Intl formatting) - #12
Merged
Merged
Conversation
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
M3 internationalisation and RTL:
/,/ms,/ar) withhreflang;Intlmoney, number and date formatting in MYT;No new dependencies.
Summary
Seven commits on
main(c109eba):feat(i18n): locale formatters and message translationfeat(i18n): per-locale catalogues, coverage check, translated home pagefeat(storefront): locale URL prefixes withhreflangperf(fonts): preload the Arabic face only on Arabic pages (M2 review finding F4)test(rules): logical-direction enforcement (dependency rule F4)docs(engineering): i18n conventions and F4 enforcementfix(storefront): keep the/encanonical 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:
Intlformatting, MYT);hreflang);Type of Change
What Changed
Locale-prefixed URLs and canonical English URLs
/), 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 carrylocale:{code}middleware (SetLocale)./enand/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.url()after stripping leading slashes, so it is always absolute and on the application's own host (see Security)./enquiriesis not treated as/en, and unsupported prefixes (/fr,/AR,/zh/) 404.App\Http\Localization\Localeis the PHP source of truth (direction, prefix);resources/js/i18n/locales.tsmirrors it.HandleInertiaRequestsnow shareslocaleanddirectionlazily, because it runs before the route's locale middleware.hreflangandx-default<link rel="alternate" hreflang>foren,msandar, plusx-default(English)./up,/dev/tokens) emit none.Arabic RTL and conditional font preloading
/arrenders<html lang="ar" dir="rtl">.activateLocale()keeps<html lang dir>in sync.ar, throughVite::usePreloadTagAttributes. Its@font-facestays on every page (unicode-rangelimits 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
resources/js/i18n/messages/{locale}/{namespace}.json; a key is{namespace}.{path}.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.useI18n()composable exposest,locale,directionand the bound formatters. It is in-house, about 100 lines, with no i18n dependency.Intl.PluralRulescategory; Arabic uses all six (zero/one/two/few/many/other).{name}.Translation-key enforcement (NFR-I18N-01)
resources/js/i18n/coverage.test.tsruns in theNode 24 / Vue 3job and fails when:msoraris missing an English key or has extra keys;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.tsscans.vue,.ts, Blade and CSS for:ml-,pr-,left-,text-right,border-l,rounded-tl, … including variants and negatives);margin-left,left:,text-align: right, …).f4-allowand a written reason.Locale-aware currency, number and date formatting (NFR-I18N-03)
formatMoney({ amount, currency }):formatNumber: per locale, Latin digits for Arabic (ar-u-nu-latn).formatDate/formatDateTime:Asia/Kuala_Lumpur;9 نوفمبر 2026).types/money.tsis a temporary hand-written shape until ADR-0018, liketypes/shared.ts.Docs
code-quality.mdgains an "Internationalisation" section.dependency-rules.mdrecords how F4 is enforced and its ESLint migration path (below).Design / Architecture
Decisions recorded
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 be2,418.50 RM, and switching to it is a one-line change informat.ts.Accept-Languageor a cookie: links stay shareable and indexable, and the same URL always serves the same language.msandarstrings are the author's translations. The coverage test guarantees completeness, not quality.Other design notes
dependency-rules.mdplans 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 aneslint-disable-next-linewith a reason.resolve(name, page), so the catalogue and the page component load in parallel and the swap happens once both are ready.webgroup and runs before route middleware, solocaleanddirectionare closures resolved at render time.Testing
Test Evidence
Local, Node 24.22.2 / npm 11.19.1, PHP 8.5, with
APP_CONFIG_CACHEpointed at a path that doesn't exist, from a cleannode_modules/public/build, on the content of head2c78b35:npm cinpm run buildcomposer checkcomposer.jsonvalid · 0 advisories · Pint passed · PHPStan 0 errors · Pest 33 passed (161 assertions)npm run typechecknpm testnpm audit --omit=dev/arrendersdir="rtl"/ar→<html lang="ar" dir="rtl">, propslocale=ar,direction=rtl;/msand/areltrenunprefixed (NFR-I18N-04)/,/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 hosthreflangalternates/,/ms,/arlistsen,ms,arandx-defaultwith absolute URLs; checked again in Chromestorefront.home.eyebrowfrommsfails "ms has exactly the English keys"; usingt('storefront.home.nope')fails "defines every key the application source asks for". Both revertedformat.test.ts:RM 2,418.50in en/ms/ar;1,234,567.891in each;9 Nov 2026/9 Nov 2026/9 نوفمبر 2026for2026-11-08T17:30Z(MYT rollover); date-time1:30 am/1:30 PG/1:30 صml-2toHome.vuefails withHome.vue:14 ml-2; the self-check covers variants, negatives, arbitrary values and CSS propertiestranslate.test.ts: 0 → zero, 2 → two, 5 → few, 11 → many/arpreloads Plex Arabic 400 and 600;/and/mspreload Instrument Sans but not Plex Arabic, and still include its@font-face/ar→/ms→/→/arupdatelang,dir, title and copy together; a DOM observer saw no text in the wrong languagedist62.06 +app2.53 + one locale catalogue ≈ 0.14 +Home0.44. CSS 5.52 kB gzipSecurity / Data Isolation
No cross-vendor data leakage introduced (no vendor or auth code)
Sensitive data handling reviewed: shared props are still only
localeanddirection.Open redirect found in review and fixed (
2c78b35). An earlier version of this PR said the/enredirect was not an open redirect. That was wrong. As first pushed (173ee8b), it built its target as'/'.$pathfrom a.*route parameter. A path starting with a slash produced a protocol-relativeLocation:/en//evil.com//evil.comhttp://<app-host>/evil.com/en/%2Fevil.com//evil.com(survives nginxmerge_slashes)http://<app-host>/evil.com/en/%2F%2Fevil.com///evil.comhttp://<app-host>/evil.comLocalizedRoutesstrips leading slashes from the captured path and builds the target withurl(), so theLocationis always absolute and same-host. Legitimate paths and query strings are unchanged.Locationhas the app's scheme and host, is not protocol-relative, and keeps the path on-site.%5C) were already same-host, and raw backslashes are rejected by Symfony.url()call. Production should setAPP_URLand keep trusted-host checks at the proxy or with Laravel'sTrustHosts; that is unchanged by this PR.The start-of-milestone full
npm auditfound the same two dev-only advisories recorded insecurity.md(already reassessed 2026-11-08), nothing new.Database / Migration
Screenshots / Recording
Run
npm run build && php artisan serve, then open/,/msand/ar. The home page shows translated copy and/aris right to left. Screenshots can be added on request.Documentation
docs/engineering/code-quality.md,docs/architecture/dependency-rules.md)Reviewer Notes
LocalizedRoutes(registration order, alternates);HandleInertiaRequests;app.ts;msandarcopy (decision 3).<html lang dir>on locale switches, but thehreflanglinks in<head>only refresh on full page loads. Crawlers do full loads, so this only affects in-app navigation.Node 24 / Vue 3a required check.types/shared.tsandtypes/money.ts(ADR-0018).Definition of Done
composer checkpasses locally (validate, audit, Pint, PHPStan, Pest)