From 5a70769efb6dc6ac62f219836c9348e16ee4cbe2 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 17:47:21 +0800 Subject: [PATCH 1/7] feat(i18n): add locale formatters and message translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/format.test.ts | 61 ++++++++++++++++++ resources/js/i18n/format.ts | 60 ++++++++++++++++++ resources/js/i18n/locales.ts | 30 +++++++++ resources/js/i18n/translate.test.ts | 61 ++++++++++++++++++ resources/js/i18n/translate.ts | 97 +++++++++++++++++++++++++++++ resources/js/types/money.ts | 7 +++ 6 files changed, 316 insertions(+) create mode 100644 resources/js/i18n/format.test.ts create mode 100644 resources/js/i18n/format.ts create mode 100644 resources/js/i18n/locales.ts create mode 100644 resources/js/i18n/translate.test.ts create mode 100644 resources/js/i18n/translate.ts create mode 100644 resources/js/types/money.ts diff --git a/resources/js/i18n/format.test.ts b/resources/js/i18n/format.test.ts new file mode 100644 index 0000000..dff7e7b --- /dev/null +++ b/resources/js/i18n/format.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { formatDate, formatDateTime, formatMoney, formatNumber } from './format'; + +// 2026-11-08T17:30Z is 01:30 on 9 November in Kuala Lumpur (UTC+8): the +// date itself changes, which proves the MYT conversion. +const LATE_EVENING_UTC = '2026-11-08T17:30:00Z'; + +describe('formatMoney', () => { + it.each([ + ['en', 'RM 2,418.50'], + ['ms', 'RM 2,418.50'], + ['ar', 'RM 2,418.50'], + ] as const)('formats MYR sen in %s with Latin digits and no bidi marks', (locale, expected) => { + expect(formatMoney({ amount: 241850, currency: 'MYR' }, locale)).toBe(expected); + }); + + it('formats zero, negative and large amounts', () => { + expect(formatMoney({ amount: 0, currency: 'MYR' }, 'en')).toBe('RM 0.00'); + expect(formatMoney({ amount: -1999, currency: 'MYR' }, 'en')).toBe('-RM 19.99'); + expect(formatMoney({ amount: 123456789, currency: 'MYR' }, 'ar')).toBe('RM 1,234,567.89'); + }); + + it('rejects non-integer minor units (money is integer sen, ADR-0013)', () => { + expect(() => formatMoney({ amount: 10.5, currency: 'MYR' }, 'en')).toThrow(/integer minor units/); + }); +}); + +describe('formatNumber', () => { + it.each([ + ['en', '1,234,567.891'], + ['ms', '1,234,567.891'], + ['ar', '1,234,567.891'], + ] as const)('formats in %s with Latin digits', (locale, expected) => { + expect(formatNumber(1234567.891, locale)).toBe(expected); + }); +}); + +describe('formatDate', () => { + it.each([ + ['en', '9 Nov 2026'], + ['ms', '9 Nov 2026'], + ['ar', '9 نوفمبر 2026'], + ] as const)('shows the MYT calendar date in %s', (locale, expected) => { + expect(formatDate(LATE_EVENING_UTC, locale)).toBe(expected); + }); + + it('rejects strings that are not ISO 8601 with an offset (ADR-0018)', () => { + expect(() => formatDate('2026-11-08 17:30:00', 'en')).toThrow(/ISO 8601/); + expect(() => formatDate('not a date', 'en')).toThrow(/ISO 8601/); + }); +}); + +describe('formatDateTime', () => { + it.each([ + ['en', '9 Nov 2026, 1:30 am'], + ['ms', '9 Nov 2026, 1:30 PG'], + ['ar', '9 نوفمبر 2026، 1:30 ص'], + ] as const)('shows MYT date and time in %s', (locale, expected) => { + expect(formatDateTime(LATE_EVENING_UTC, locale)).toBe(expected); + }); +}); diff --git a/resources/js/i18n/format.ts b/resources/js/i18n/format.ts new file mode 100644 index 0000000..11970ad --- /dev/null +++ b/resources/js/i18n/format.ts @@ -0,0 +1,60 @@ +// Locale-aware display formatting through Intl (NFR-I18N-03). +// Display only: no money arithmetic happens here (dependency rule F3). + +import type { Money } from '@/types/money'; +import { intlTag, TIME_ZONE, type Locale } from './locales'; + +// Directional marks ICU may insert, and the no-break spaces it uses. +const BIDI_MARKS = /[‎‏؜]/g; +const NO_BREAK_SPACES = /[   ]/g; + +// ISO 8601 with a time and an explicit offset or Z, as ADR-0018 requires. +const ISO_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/; + +const DATE: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' }; +const DATE_TIME: Intl.DateTimeFormatOptions = { ...DATE, hour: 'numeric', minute: '2-digit' }; + +/** + * Money follows the Malaysian convention in every locale ("RM 2,418.50"): + * Latin digits, symbol first, no bidi marks. Components render it in an + * LTR-isolated element so it reads the same inside Arabic text. + */ +export function formatMoney(money: Money, _locale: Locale): string { + if (!Number.isInteger(money.amount)) { + throw new Error(`Money must be integer minor units, got ${money.amount}`); + } + + const format = new Intl.NumberFormat('en-MY', { + style: 'currency', + currency: money.currency, + currencyDisplay: 'narrowSymbol', + }); + const digits = format.resolvedOptions().maximumFractionDigits ?? 2; + + return format + .format(money.amount / 10 ** digits) + .replace(BIDI_MARKS, '') + .replace(NO_BREAK_SPACES, ' '); +} + +export function formatNumber(value: number, locale: Locale, options?: Intl.NumberFormatOptions): string { + return new Intl.NumberFormat(intlTag(locale), options).format(value); +} + +export function formatDate(iso: string, locale: Locale): string { + return formatInstant(iso, locale, DATE); +} + +export function formatDateTime(iso: string, locale: Locale): string { + return formatInstant(iso, locale, DATE_TIME); +} + +function formatInstant(iso: string, locale: Locale, options: Intl.DateTimeFormatOptions): string { + const instant = new Date(iso); + + if (!ISO_WITH_OFFSET.test(iso) || Number.isNaN(instant.getTime())) { + throw new Error(`Expected an ISO 8601 timestamp with an offset, got "${iso}"`); + } + + return new Intl.DateTimeFormat(intlTag(locale), { ...options, timeZone: TIME_ZONE }).format(instant); +} diff --git a/resources/js/i18n/locales.ts b/resources/js/i18n/locales.ts new file mode 100644 index 0000000..9e439cb --- /dev/null +++ b/resources/js/i18n/locales.ts @@ -0,0 +1,30 @@ +// Supported locales (ADR-0007). Mirrors App\Http\Localization\Locale. +export const LOCALES = ['en', 'ms', 'ar'] as const; + +export type Locale = (typeof LOCALES)[number]; +export type Direction = 'ltr' | 'rtl'; + +export const FALLBACK_LOCALE: Locale = 'en'; + +/** Displayed dates and times are always Malaysian time (NFR-I18N-03, ADR-0018). */ +export const TIME_ZONE = 'Asia/Kuala_Lumpur'; + +// BCP 47 tags passed to Intl. Arabic keeps Latin digits (owner decision, +// ADR-0018 §3) through the -u-nu-latn extension. +const INTL_TAGS: Record = { + en: 'en-MY', + ms: 'ms-MY', + ar: 'ar-u-nu-latn', +}; + +export function isLocale(value: unknown): value is Locale { + return typeof value === 'string' && (LOCALES as readonly string[]).includes(value); +} + +export function directionOf(locale: Locale): Direction { + return locale === 'ar' ? 'rtl' : 'ltr'; +} + +export function intlTag(locale: Locale): string { + return INTL_TAGS[locale]; +} diff --git a/resources/js/i18n/translate.test.ts b/resources/js/i18n/translate.test.ts new file mode 100644 index 0000000..e01ba35 --- /dev/null +++ b/resources/js/i18n/translate.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { flattenMessages, translate } from './translate'; + +const en = flattenMessages({ + cart: { + title: 'Your cart', + greeting: 'Hello, {name}', + items: { one: '{count} item', other: '{count} items' }, + }, + only: { english: 'English only' }, +}); + +const ar = flattenMessages({ + cart: { + title: 'سلتك', + items: { + zero: 'لا عناصر', + one: 'عنصر واحد', + two: 'عنصران', + few: '{count} عناصر', + many: '{count} عنصرًا', + other: '{count} عنصر', + }, + }, +}); + +describe('flattenMessages', () => { + it('flattens nested groups into dotted keys and keeps plural objects whole', () => { + expect(Object.keys(en).sort()).toEqual(['cart.greeting', 'cart.items', 'cart.title', 'only.english']); + expect(en['cart.items']).toEqual({ one: '{count} item', other: '{count} items' }); + }); +}); + +describe('translate', () => { + it('returns the message for the active locale', () => { + expect(translate('ar', ar, en, 'cart.title')).toBe('سلتك'); + }); + + it('interpolates named parameters and leaves unknown ones visible', () => { + expect(translate('en', en, en, 'cart.greeting', { name: 'Aisyah' })).toBe('Hello, Aisyah'); + expect(translate('en', en, en, 'cart.greeting')).toBe('Hello, {name}'); + }); + + it('selects the plural form with Intl.PluralRules for the locale', () => { + expect(translate('en', en, en, 'cart.items', { count: 1 })).toBe('1 item'); + expect(translate('en', en, en, 'cart.items', { count: 3 })).toBe('3 items'); + expect(translate('ar', ar, en, 'cart.items', { count: 0 })).toBe('لا عناصر'); + expect(translate('ar', ar, en, 'cart.items', { count: 2 })).toBe('عنصران'); + expect(translate('ar', ar, en, 'cart.items', { count: 5 })).toBe('5 عناصر'); + expect(translate('ar', ar, en, 'cart.items', { count: 11 })).toBe('11 عنصرًا'); + }); + + it('falls back to English, then to the key itself', () => { + expect(translate('ar', ar, en, 'only.english')).toBe('English only'); + expect(translate('ar', ar, en, 'does.not.exist')).toBe('does.not.exist'); + }); + + it('requires a count for plural messages', () => { + expect(() => translate('en', en, en, 'cart.items')).toThrow(/count/); + }); +}); diff --git a/resources/js/i18n/translate.ts b/resources/js/i18n/translate.ts new file mode 100644 index 0000000..efd13c4 --- /dev/null +++ b/resources/js/i18n/translate.ts @@ -0,0 +1,97 @@ +// Message lookup, interpolation and plural selection. Pure functions; the +// reactive wrapper lives in ./index.ts. + +import { intlTag, type Locale } from './locales'; + +/** A plural message keyed by Intl.PluralRules category. `other` is required. */ +export type PluralMessage = Partial> & { other: string }; +export type Message = string | PluralMessage; + +/** Flat catalogue: dotted key → message. */ +export type Catalogue = Readonly>; + +export type Params = Readonly>; + +const PLURAL_CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']); + +export function isPluralMessage(value: unknown): value is PluralMessage { + return ( + typeof value === 'object' && + value !== null && + 'other' in value && + Object.keys(value).every((key) => PLURAL_CATEGORIES.has(key)) + ); +} + +/** Turns `{ cart: { title: '…' } }` into `{ 'cart.title': '…' }`; plural objects stay whole. */ +export function flattenMessages(tree: object, prefix = ''): Record { + const flat: Record = {}; + + for (const [key, value] of Object.entries(tree)) { + const path = prefix ? `${prefix}.${key}` : key; + + if (typeof value === 'string' || isPluralMessage(value)) { + flat[path] = value; + } else if (typeof value === 'object' && value !== null) { + Object.assign(flat, flattenMessages(value, path)); + } else { + throw new Error(`Message "${path}" must be a string, a plural object or a group`); + } + } + + return flat; +} + +const pluralRules = new Map(); + +function pluralCategory(locale: Locale, count: number): Intl.LDMLPluralRule { + let rules = pluralRules.get(locale); + + if (!rules) { + rules = new Intl.PluralRules(intlTag(locale)); + pluralRules.set(locale, rules); + } + + return rules.select(count); +} + +function interpolate(template: string, params: Params | undefined): string { + return template.replace(/\{(\w+)\}/g, (placeholder, name: string) => + params && name in params ? String(params[name]) : placeholder, + ); +} + +/** + * Looks `key` up in the active catalogue, then the fallback catalogue. + * A missing key renders as the key itself so it is visible, never blank; + * the coverage test keeps that from reaching a release. + */ +export function translate( + locale: Locale, + catalogue: Catalogue, + fallback: Catalogue, + key: string, + params?: Params, +): string { + const found = catalogue[key] !== undefined; + const message = found ? catalogue[key] : fallback[key]; + + if (message === undefined) { + return key; + } + + if (typeof message === 'string') { + return interpolate(message, params); + } + + const count = params?.count; + + if (typeof count !== 'number') { + throw new Error(`Plural message "${key}" needs a numeric "count" parameter`); + } + + const rulesLocale = found ? locale : 'en'; + const form = message[pluralCategory(rulesLocale, count)] ?? message.other; + + return interpolate(form, params); +} diff --git a/resources/js/types/money.ts b/resources/js/types/money.ts new file mode 100644 index 0000000..30a5fe2 --- /dev/null +++ b/resources/js/types/money.ts @@ -0,0 +1,7 @@ +// TEMPORARY until ADR-0018: hand-written mirror of the money shape the +// server sends. Replace with generated types (follow-up F-3). +export interface Money { + /** Integer minor units (sen), ADR-0013. */ + amount: number; + currency: 'MYR'; +} From bd4b3cede09eb6c4e7be9fa397deba6844f4a9c5 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 17:47:22 +0800 Subject: [PATCH 2/7] feat(i18n): load per-locale catalogues and translate the home page - 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; 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. --- resources/js/app.ts | 13 ++- resources/js/i18n/coverage.test.ts | 88 +++++++++++++++++++ resources/js/i18n/index.ts | 83 +++++++++++++++++ resources/js/i18n/messages/ar/storefront.json | 7 ++ resources/js/i18n/messages/en/storefront.json | 7 ++ resources/js/i18n/messages/ms/storefront.json | 7 ++ resources/js/pages/Storefront/Home.test.ts | 18 +++- resources/js/pages/Storefront/Home.vue | 9 +- resources/js/types/shared.ts | 6 +- 9 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 resources/js/i18n/coverage.test.ts create mode 100644 resources/js/i18n/index.ts create mode 100644 resources/js/i18n/messages/ar/storefront.json create mode 100644 resources/js/i18n/messages/en/storefront.json create mode 100644 resources/js/i18n/messages/ms/storefront.json diff --git a/resources/js/app.ts b/resources/js/app.ts index 05d7077..36aa20e 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -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('./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) }) diff --git a/resources/js/i18n/coverage.test.ts b/resources/js/i18n/coverage.test.ts new file mode 100644 index 0000000..8ce9793 --- /dev/null +++ b/resources/js/i18n/coverage.test.ts @@ -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 = {}; + + 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; + +// Application source, to find every t('literal.key') call. +const sources = import.meta.glob(['../**/*.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(); + + 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([]); + }); +}); diff --git a/resources/js/i18n/index.ts b/resources/js/i18n/index.ts new file mode 100644 index 0000000..f3100b6 --- /dev/null +++ b/resources/js/i18n/index.ts @@ -0,0 +1,83 @@ +// Active-locale state for the Vue app. +// +// Catalogues live in ./messages//.json and are code-split +// per locale: a page loads only its own locale plus English (the fallback). +// app.ts awaits activateLocale() while resolving each Inertia page, so the +// page never renders before its strings are loaded. + +import { computed, shallowRef } from 'vue'; +import type { Money } from '@/types/money'; +import * as format from './format'; +import { directionOf, FALLBACK_LOCALE, isLocale, type Locale } from './locales'; +import { flattenMessages, translate, type Catalogue, type Params } from './translate'; + +export * from './locales'; +export type { Params } from './translate'; + +type MessageModule = { default: object }; + +const loaders = import.meta.glob('./messages/*/*.json'); +const catalogues = new Map>(); + +function loadCatalogue(locale: Locale): Promise { + let pending = catalogues.get(locale); + + if (!pending) { + const files = Object.entries(loaders).filter(([path]) => path.startsWith(`./messages/${locale}/`)); + + pending = Promise.all( + files.map(async ([path, load]) => { + const namespace = path.slice(path.lastIndexOf('/') + 1, -'.json'.length); + + return flattenMessages({ [namespace]: (await load()).default }); + }), + ).then((parts) => Object.freeze(Object.assign({}, ...parts) as Catalogue)); + catalogues.set(locale, pending); + } + + return pending; +} + +interface ActiveState { + locale: Locale; + catalogue: Catalogue; + fallback: Catalogue; +} + +const active = shallowRef({ locale: FALLBACK_LOCALE, catalogue: {}, fallback: {} }); + +/** Loads `locale` (falling back to English for unknown values) and makes it active. */ +export async function activateLocale(requested: unknown): Promise { + const locale = isLocale(requested) ? requested : FALLBACK_LOCALE; + const [catalogue, fallback] = await Promise.all([loadCatalogue(locale), loadCatalogue(FALLBACK_LOCALE)]); + + active.value = { locale, catalogue, fallback }; + + if (typeof document !== 'undefined') { + // The server sets these on the first response; keep them right after + // client-side visits between locales. + document.documentElement.lang = locale; + document.documentElement.dir = directionOf(locale); + } + + return locale; +} + +export function useI18n() { + const locale = computed(() => active.value.locale); + + return { + locale, + direction: computed(() => directionOf(active.value.locale)), + t: (key: string, params?: Params): string => { + const { locale: current, catalogue, fallback } = active.value; + + return translate(current, catalogue, fallback, key, params); + }, + formatMoney: (money: Money) => format.formatMoney(money, locale.value), + formatNumber: (value: number, options?: Intl.NumberFormatOptions) => + format.formatNumber(value, locale.value, options), + formatDate: (iso: string) => format.formatDate(iso, locale.value), + formatDateTime: (iso: string) => format.formatDateTime(iso, locale.value), + }; +} diff --git a/resources/js/i18n/messages/ar/storefront.json b/resources/js/i18n/messages/ar/storefront.json new file mode 100644 index 0000000..27dffdb --- /dev/null +++ b/resources/js/i18n/messages/ar/storefront.json @@ -0,0 +1,7 @@ +{ + "home": { + "title": "الرئيسية", + "eyebrow": "سوق · ماليزيا", + "tagline": "المتجر قيد الإعداد." + } +} diff --git a/resources/js/i18n/messages/en/storefront.json b/resources/js/i18n/messages/en/storefront.json new file mode 100644 index 0000000..69c79d8 --- /dev/null +++ b/resources/js/i18n/messages/en/storefront.json @@ -0,0 +1,7 @@ +{ + "home": { + "title": "Home", + "eyebrow": "Marketplace · Malaysia", + "tagline": "The storefront is on its way." + } +} diff --git a/resources/js/i18n/messages/ms/storefront.json b/resources/js/i18n/messages/ms/storefront.json new file mode 100644 index 0000000..1d837cf --- /dev/null +++ b/resources/js/i18n/messages/ms/storefront.json @@ -0,0 +1,7 @@ +{ + "home": { + "title": "Laman Utama", + "eyebrow": "Pasaran · Malaysia", + "tagline": "Kedai dalam talian sedang disiapkan." + } +} diff --git a/resources/js/pages/Storefront/Home.test.ts b/resources/js/pages/Storefront/Home.test.ts index 5a56adf..828caaa 100644 --- a/resources/js/pages/Storefront/Home.test.ts +++ b/resources/js/pages/Storefront/Home.test.ts @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils'; import { describe, expect, it, vi } from 'vitest'; import { defineComponent } from 'vue'; +import { activateLocale } from '@/i18n'; import Home from './Home.vue'; // Inertia's needs a running Inertia app; render it as a no-op here. @@ -10,9 +11,20 @@ vi.mock('@inertiajs/vue3', async (importOriginal) => ({ })); describe('Storefront/Home', () => { - it('renders the storefront heading', () => { - const wrapper = mount(Home); + it('renders the storefront heading', async () => { + await activateLocale('en'); - expect(wrapper.get('h1').text()).toBe('Nexo'); + expect(mount(Home).get('h1').text()).toBe('Nexo'); + }); + + it.each([ + ['en', 'The storefront is on its way.'], + ['ms', 'Kedai dalam talian sedang disiapkan.'], + ['ar', 'المتجر قيد الإعداد.'], + ] as const)('renders its copy in %s', async (locale, tagline) => { + await activateLocale(locale); + + expect(mount(Home).text()).toContain(tagline); + expect(document.documentElement.dir).toBe(locale === 'ar' ? 'rtl' : 'ltr'); }); }); diff --git a/resources/js/pages/Storefront/Home.vue b/resources/js/pages/Storefront/Home.vue index 4af6564..7a7bc99 100644 --- a/resources/js/pages/Storefront/Home.vue +++ b/resources/js/pages/Storefront/Home.vue @@ -1,13 +1,16 @@ diff --git a/resources/js/types/shared.ts b/resources/js/types/shared.ts index 1f9e8f8..8978e7a 100644 --- a/resources/js/types/shared.ts +++ b/resources/js/types/shared.ts @@ -1,10 +1,12 @@ // TEMPORARY until ADR-0018: hand-written mirror of the props shared by // App\Http\Middleware\HandleInertiaRequests::share(). Replace with // generated types once ADR-0018 is decided (follow-up F-3). -export type Direction = 'ltr' | 'rtl'; +import type { Direction, Locale } from '@/i18n/locales'; + +export type { Direction, Locale }; export interface SharedProps { - locale: string; + locale: Locale; direction: Direction; } From 2e22ac5c1d4ef229edd984a0343c00f20ae41d53 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 17:47:22 +0800 Subject: [PATCH 3/7] feat(storefront): serve locales under URL prefixes with hreflang 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. --- app/Http/Localization/Locale.php | 37 ++++++++++ app/Http/Localization/LocalizedRoutes.php | 63 +++++++++++++++++ app/Http/Middleware/HandleInertiaRequests.php | 16 ++--- app/Http/Middleware/SetLocale.php | 22 ++++++ bootstrap/app.php | 3 + resources/views/app.blade.php | 3 + routes/web.php | 7 +- tests/Feature/HomePageTest.php | 6 +- tests/Feature/LocaleRoutingTest.php | 67 +++++++++++++++++++ 9 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 app/Http/Localization/Locale.php create mode 100644 app/Http/Localization/LocalizedRoutes.php create mode 100644 app/Http/Middleware/SetLocale.php create mode 100644 tests/Feature/LocaleRoutingTest.php diff --git a/app/Http/Localization/Locale.php b/app/Http/Localization/Locale.php new file mode 100644 index 0000000..f2d1236 --- /dev/null +++ b/app/Http/Localization/Locale.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/app/Http/Localization/LocalizedRoutes.php b/app/Http/Localization/LocalizedRoutes.php new file mode 100644 index 0000000..c8b8fc5 --- /dev/null +++ b/app/Http/Localization/LocalizedRoutes.php @@ -0,0 +1,63 @@ +getQueryString(); + + return redirect('/'.($path ?? '').($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 + */ + 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; + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 91499b7..1ff4bbc 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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 - */ - private const array RTL_LOCALES = ['ar']; - /** * The root template that's loaded on the first page visit. * @@ -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(), ]; } } diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..7ab2d04 --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,22 @@ +setLocale(Locale::from($locale)->value); + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index dff4955..460b4c6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ withMiddleware(function (Middleware $middleware): void { + $middleware->alias(['locale' => SetLocale::class]); + $middleware->web(append: [ HandleInertiaRequests::class, ]); diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index c57b9e6..0e02f1b 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -5,6 +5,9 @@ {{ config('app.name', 'Nexo') }} +@foreach (\App\Http\Localization\LocalizedRoutes::alternates() as $hreflang => $href) + +@endforeach @fonts @vite(['resources/css/app.css', 'resources/js/app.ts']) diff --git a/routes/web.php b/routes/web.php index a8db658..f11f3e7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,11 @@ Inertia::render('Storefront/Home')); +// Storefront: registered for every locale (/, /ms, /ar) and named +// "{locale}.storefront.*". +LocalizedRoutes::group(function () { + Route::get('/', fn () => Inertia::render('Storefront/Home'))->name('storefront.home'); +}); diff --git a/tests/Feature/HomePageTest.php b/tests/Feature/HomePageTest.php index 23b1d67..bc23720 100644 --- a/tests/Feature/HomePageTest.php +++ b/tests/Feature/HomePageTest.php @@ -18,11 +18,7 @@ }); it('derives a right-to-left direction for Arabic', function () { - // Locale routing arrives in M3; until then the locale is set directly. - // M3 replaces this with a request to the /ar storefront. - $this->app->setLocale('ar'); - - $this->get('/') + $this->get('/ar') ->assertOk() ->assertInertia(fn (Assert $page) => $page ->where('locale', 'ar') diff --git a/tests/Feature/LocaleRoutingTest.php b/tests/Feature/LocaleRoutingTest.php new file mode 100644 index 0000000..3182321 --- /dev/null +++ b/tests/Feature/LocaleRoutingTest.php @@ -0,0 +1,67 @@ +get($url) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Storefront/Home') + ->where('locale', $locale) + ->where('direction', $direction)) + ->assertSee("", escape: false); +})->with([ + 'English, unprefixed' => ['/', 'en', 'ltr'], + 'Malay' => ['/ms', 'ms', 'ltr'], + 'Arabic' => ['/ar', 'ar', 'rtl'], +]); + +it('does not carry a locale over to the next request', function () { + $this->get('/ar')->assertInertia(fn (Assert $page) => $page->where('locale', 'ar')); + + $this->get('/')->assertInertia(fn (Assert $page) => $page->where('locale', 'en')->where('direction', 'ltr')); +}); + +it('redirects the English prefix to the unprefixed canonical URL', function (string $from, string $to) { + $this->get($from)->assertStatus(301)->assertRedirect($to); +})->with([ + ['/en', '/'], + ['/en/', '/'], + ['/en/products/kopi?sort=new', '/products/kopi?sort=new'], +]); + +it('only treats a whole /en segment as the English prefix', function () { + $this->get('/enquiries')->assertNotFound(); +}); + +it('does not serve unsupported locale prefixes', function (string $url) { + $this->get($url)->assertNotFound(); +})->with(['/fr', '/AR', '/zh/']); + +it('lists hreflang alternates for every locale plus x-default', function (string $url) { + $response = $this->get($url)->assertOk(); + + foreach (['en' => '/', 'ms' => '/ms', 'ar' => '/ar', 'x-default' => '/'] as $hreflang => $path) { + $response->assertSee( + '', + escape: false, + ); + } +})->with(['/', '/ms', '/ar']); + +// One request per test: Laravel's Vite service remembers which assets it +// has preloaded and skips them on later requests in the same application. +it('preloads the Arabic font on Arabic pages', function () { + expect($this->get('/ar')->getContent()) + ->toMatch('/toMatch('/get($url)->getContent(); + + expect($html) + ->toMatch('/not->toMatch('/]+ibm-plex-sans-arabic/') + ->toContain('IBM Plex Sans Arabic'); +})->with(['/', '/ms']); From 06fa5af913c4040c0ae9d1c9017ef6753aebeb4a Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 17:47:22 +0800 Subject: [PATCH 4/7] perf(fonts): preload the Arabic face only on Arabic pages 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. --- app/Providers/AppServiceProvider.php | 11 ++++++++++- vite.config.ts | 7 ++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..81ac4f1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 @@ -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 : [], + ); } } diff --git a/vite.config.ts b/vite.config.ts index a79f2f8..f1f4cbf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,12 +14,13 @@ export default defineConfig({ weights: [400, 500, 600], }), // Arabic glyphs only; Latin text in Arabic pages falls back to - // Instrument Sans (see --nx-font-arabic). Not preloaded, since - // only Arabic pages use it. + // Instrument Sans (see --nx-font-arabic). Body and heading + // weights are preloaded, and AppServiceProvider drops those + // preloads on non-Arabic pages. bunny('IBM Plex Sans Arabic', { weights: [400, 500, 600], subsets: ['arabic'], - preload: false, + preload: [{ weight: 400 }, { weight: 600 }], }), ], }), From 6925f070cfe59a6ad94db5abb5b5074bd7e0b27f Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 17:47:23 +0800 Subject: [PATCH 5/7] test(rules): enforce logical direction (dependency rule F4) 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. --- resources/js/rules/logical-direction.test.ts | 85 ++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 resources/js/rules/logical-direction.test.ts diff --git a/resources/js/rules/logical-direction.test.ts b/resources/js/rules/logical-direction.test.ts new file mode 100644 index 0000000..1310bf5 --- /dev/null +++ b/resources/js/rules/logical-direction.test.ts @@ -0,0 +1,85 @@ +// Dependency rule F4 / NFR-I18N-02: no physical left/right styling in the +// application; use logical equivalents so RTL works without overrides +// (ms-/me- for ml-/mr-, ps-/pe-, start-/end-, text-start/text-end, +// border-s/border-e, rounded-s/rounded-e, margin-inline-start, …). +// +// docs/architecture/dependency-rules.md plans this as an ESLint rule. ESLint +// is not installed (owner decision), so the rule runs here, in Vitest, until +// it is. A line containing `f4-allow` is skipped; give the reason on it. + +import { describe, expect, it } from 'vitest'; + +const markup = import.meta.glob( + ['../**/*.vue', '../**/*.ts', '!../**/*.test.ts', '../../views/**/*.blade.php'], + { eager: true, query: '?raw', import: 'default' }, +); + +const styles = import.meta.glob(['../../css/**/*.css', '../**/*.vue'], { + eager: true, + query: '?raw', + import: 'default', +}); + +// Tailwind utilities, with or without variants (md:, hover:) or a leading `-`. +const PHYSICAL_UTILITY = new RegExp( + String.raw`(?, pattern: RegExp, only?: (source: string) => string) { + return Object.entries(files).flatMap(([path, source]) => + (only ? only(source) : source) + .split('\n') + .flatMap((line, index) => + line.includes('f4-allow') + ? [] + : [...line.matchAll(pattern)].map((m) => `${path}:${index + 1} ${m[0].trim()}`), + ), + ); +} + +const styleBlocks = (source: string) => + source.includes(')|[\s\S]+$/g, '$1') : source; + +describe('dependency rule F4: logical direction only', () => { + it('finds the files it is meant to scan', () => { + expect(Object.keys(markup).some((path) => path.endsWith('.blade.php'))).toBe(true); + expect(Object.keys(markup).some((path) => path.endsWith('.vue'))).toBe(true); + expect(Object.keys(styles).some((path) => path.endsWith('.css'))).toBe(true); + }); + + it('detects physical utilities and properties (self-check)', () => { + const sample = { + 'x.vue': 'class="md:ml-4 -mr-2 pl-[3px] left-0 text-right rounded-tl-lg border-r"\nmargin-left: 1rem; left: 0; text-align: right', + }; + + expect(violations(sample, PHYSICAL_UTILITY).map((v) => v.split(' ')[1])).toEqual([ + 'ml-4', + '-mr-2', + 'pl-[3px]', + 'left-0', + 'text-right', + 'rounded-tl-lg', + 'border-r', + ]); + expect(violations(sample, PHYSICAL_CSS)).toHaveLength(3); + expect(violations({ 'y.vue': 'class="ms-4 pe-2 start-0 text-start rounded-s-lg border-e bottom-0"' }, PHYSICAL_UTILITY)).toEqual([]); + }); + + it('uses no physical Tailwind utilities', () => { + expect(violations(markup, PHYSICAL_UTILITY)).toEqual([]); + }); + + it('uses no physical CSS properties', () => { + expect(violations(styles, PHYSICAL_CSS, styleBlocks)).toEqual([]); + }); +}); From 173ee8b9e9bbfe7ac64474443993f954a26824f6 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 17:47:23 +0800 Subject: [PATCH 6/7] docs(engineering): document i18n conventions and F4 enforcement - 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. --- docs/architecture/dependency-rules.md | 2 ++ docs/engineering/code-quality.md | 29 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docs/architecture/dependency-rules.md b/docs/architecture/dependency-rules.md index 36e29bd..4ac4e31 100644 --- a/docs/architecture/dependency-rules.md +++ b/docs/architecture/dependency-rules.md @@ -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. diff --git a/docs/engineering/code-quality.md b/docs/engineering/code-quality.md index 502f7c4..0a918b0 100644 --- a/docs/engineering/code-quality.md +++ b/docs/engineering/code-quality.md @@ -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 From 2c78b353fa0243c1adec97aaa046115e5db124f0 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 19:55:31 +0800 Subject: [PATCH 7/7] fix(storefront): keep the /en canonical redirect on the same host 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:///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. --- app/Http/Localization/LocalizedRoutes.php | 8 +++++++- tests/Feature/LocaleRoutingTest.php | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/Http/Localization/LocalizedRoutes.php b/app/Http/Localization/LocalizedRoutes.php index c8b8fc5..4298e65 100644 --- a/app/Http/Localization/LocalizedRoutes.php +++ b/app/Http/Localization/LocalizedRoutes.php @@ -17,10 +17,16 @@ 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('/'.($path ?? '').($query === null ? '' : '?'.$query), 301); + return redirect($target.($query === null ? '' : '?'.$query), 301); })->where('path', '.*'); foreach (Locale::cases() as $locale) { diff --git a/tests/Feature/LocaleRoutingTest.php b/tests/Feature/LocaleRoutingTest.php index 3182321..3f4d991 100644 --- a/tests/Feature/LocaleRoutingTest.php +++ b/tests/Feature/LocaleRoutingTest.php @@ -30,6 +30,23 @@ ['/en/products/kopi?sort=new', '/products/kopi?sort=new'], ]); +it('never redirects the English prefix off-site (open redirect)', function (string $payload, string $samePath) { + $location = $this->get($payload)->assertStatus(301)->headers->get('Location'); + $app = parse_url(url('/')); + + expect($location) + ->toBeString() + ->not->toStartWith('//') + ->not->toStartWith('/\\') + ->and(parse_url($location, PHP_URL_SCHEME))->toBe($app['scheme']) + ->and(parse_url($location, PHP_URL_HOST))->toBe($app['host']) + ->and(parse_url($location, PHP_URL_PATH))->toBe($samePath); +})->with([ + 'double slash' => ['/en//evil.com', '/evil.com'], + 'encoded slash' => ['/en/%2Fevil.com', '/evil.com'], + 'two encoded slashes' => ['/en/%2F%2Fevil.com', '/evil.com'], +]); + it('only treats a whole /en segment as the English prefix', function () { $this->get('/enquiries')->assertNotFound(); });