feat(KAN-16): add English localization foundation

This commit is contained in:
2026-09-11 12:11:50 +00:00
parent 68995a9f4c
commit 71d9d1b63e
11 changed files with 433 additions and 5 deletions
@@ -46,6 +46,10 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'localization' => [
'locale' => app()->getLocale(),
'timeZone' => config('app.timezone'),
],
'auth' => [
'permissions' => [
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
+2
View File
@@ -4,6 +4,7 @@ import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import '../css/app.css';
import { initializeTheme } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -17,6 +18,7 @@ createInertiaApp({
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization(props.initialPage.props.localization))
.mount(el);
},
progress: {
+4 -4
View File
@@ -2,15 +2,15 @@
import { AlertCircle } from 'lucide-vue-next';
import { computed } from 'vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useLocalization } from '@/composables/useLocalization';
type Props = {
errors: string[];
title?: string;
};
const props = withDefaults(defineProps<Props>(), {
title: 'Something went wrong.',
});
const props = defineProps<Props>();
const { t } = useLocalization();
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
</script>
@@ -18,7 +18,7 @@ const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
<template>
<Alert variant="destructive">
<AlertCircle class="size-4" />
<AlertTitle>{{ title }}</AlertTitle>
<AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
<AlertDescription>
<ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index">
@@ -0,0 +1,12 @@
import { inject } from 'vue';
import { localizationKey } from '@/lib/localization';
export function useLocalization() {
const localization = inject(localizationKey);
if (!localization) {
throw new Error(
'The localization plugin must be installed before useLocalization().',
);
}
return localization;
}
+159
View File
@@ -0,0 +1,159 @@
import { readonly, ref } from 'vue';
import type { App, InjectionKey } from 'vue';
import { en } from '@/locales/en';
import type {
LocalizationSettings,
TranslationCatalog,
TranslationKey,
TranslationParameters,
} from '@/types/localization';
const defaultLocale = 'en';
const catalogs: Record<string, TranslationCatalog> = { en };
function placeholders(message: string): string[] {
return [
...new Set(
[...message.matchAll(/\{(\w+)\}/g)].map((match) => match[1]),
),
].sort();
}
/** Validate every catalog, including messages that no component has rendered yet. */
export function validateCatalog(catalog: TranslationCatalog): void {
for (const key of Object.keys(en) as TranslationKey[]) {
const message = Object.hasOwn(catalog, key) ? catalog[key] : undefined;
const source = en[key];
if (message === undefined || typeof message !== typeof source) {
throw new Error(`Missing or invalid translation: ${key}`);
}
const expected = placeholders(
typeof source === 'string' ? source : source.other,
);
const forms =
typeof message === 'string'
? [message]
: Object.values(message ?? {});
if (
typeof message !== 'string' &&
(!message || typeof message.other !== 'string')
) {
throw new Error(`Missing plural fallback: ${key}`);
}
for (const form of forms) {
if (
typeof form !== 'string' ||
form.trim() === '' ||
JSON.stringify(placeholders(form)) !== JSON.stringify(expected)
) {
throw new Error(`Invalid translation placeholders: ${key}`);
}
}
}
}
export function createLocalization(
settings: Partial<LocalizationSettings> = {},
availableCatalogs: Record<string, TranslationCatalog> = catalogs,
) {
const messages: Record<string, TranslationCatalog> = {
...availableCatalogs,
en,
};
for (const catalog of Object.values(messages)) {
validateCatalog(catalog);
}
function resolveLocale(requested: string): string {
try {
const [canonical] = Intl.getCanonicalLocales(
requested.replaceAll('_', '-'),
);
if (Object.hasOwn(messages, canonical)) {
return canonical;
}
const language = canonical.split('-')[0];
return Object.hasOwn(messages, language) ? language : defaultLocale;
} catch {
return defaultLocale;
}
}
const locale = ref(resolveLocale(settings.locale ?? defaultLocale));
const timeZone = settings.timeZone ?? 'UTC';
function number(
value: number,
options: Intl.NumberFormatOptions = {},
): string {
return new Intl.NumberFormat(locale.value, options).format(value);
}
function date(
value: Date | number,
options: Intl.DateTimeFormatOptions = {},
): string {
return new Intl.DateTimeFormat(locale.value, {
timeZone,
...options,
}).format(value);
}
/** Interpolate plain text only; render with Vue text bindings, never v-html. */
function t(
key: TranslationKey,
parameters: TranslationParameters = {},
): string {
const catalog = messages[locale.value];
if (!Object.hasOwn(catalog, key)) {
throw new Error(`Missing translation: ${key}`);
}
const message = catalog[key];
let template: string;
if (typeof message === 'string') {
template = message;
} else {
const count = parameters.count;
if (typeof count !== 'number' || !Number.isFinite(count)) {
throw new Error(`Translation requires a finite count: ${key}`);
}
template =
message[new Intl.PluralRules(locale.value).select(count)] ??
message.other;
}
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
if (!Object.hasOwn(parameters, name)) {
throw new Error(
`Missing translation parameter: ${key}.${name}`,
);
}
const value = parameters[name];
return typeof value === 'number' ? number(value) : value;
});
}
const localization = {
locale: readonly(locale),
t,
number,
date,
setLocale(requested: string): void {
locale.value = resolveLocale(requested);
},
install(app: App): void {
app.provide(localizationKey, localization);
},
};
return localization;
}
export type Localization = ReturnType<typeof createLocalization>;
export const localizationKey: InjectionKey<Localization> =
Symbol('localization');
+11
View File
@@ -0,0 +1,11 @@
export const en = {
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.loading': 'Loading…',
'common.error': 'Something went wrong.',
'pagination.page': 'Page {page} of {total}',
'tickets.count': {
one: '{count} ticket',
other: '{count} tickets',
},
} as const;
+10 -1
View File
@@ -4,6 +4,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createLocalization } from '@/lib/localization';
import type { LocalizationSettings } from '@/types/localization';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -19,7 +21,14 @@ createServer(
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
),
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) }).use(plugin),
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(
createLocalization(
props.initialPage.props
.localization as LocalizationSettings,
),
),
}),
{ cluster: true },
);
+2
View File
@@ -1,4 +1,5 @@
import type { Auth } from '@/types/auth';
import type { LocalizationSettings } from '@/types/localization';
import type { Page, Router } from '@inertiajs/core';
import { createHeadManager } from '@inertiajs/vue3';
@@ -19,6 +20,7 @@ declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: {
name: string;
localization: LocalizationSettings;
auth: Auth;
sidebarOpen: boolean;
};
+13
View File
@@ -0,0 +1,13 @@
import type { en } from '@/locales/en';
export type TranslationKey = keyof typeof en;
export type PluralMessage = Partial<Record<Intl.LDMLPluralRule, string>> & {
other: string;
};
export type TranslationCatalog = Record<TranslationKey, string | PluralMessage>;
export type TranslationParameters = Record<string, string | number>;
export type LocalizationSettings = {
locale: string;
timeZone: string;
};
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Validator;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class LocalizationTest extends TestCase
{
public function test_frontend_localization_behaviour(): void
{
$process = new Process(['node', 'tests/Fixtures/localization.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Localization behavior checks passed.', $process->getOutput());
}
public function test_initial_page_shares_the_application_locale_and_time_zone(): void
{
config(['app.timezone' => 'Europe/Berlin']);
$this->withHeader('Accept-Language', 'de-DE')->get(route('home'))
->assertOk()
->assertSee('lang="en"', false)
->assertInertia(fn (Assert $page) => $page
->where('localization.locale', 'en')
->where('localization.timeZone', 'Europe/Berlin'));
}
public function test_server_validation_remains_english(): void
{
$this->assertSame('en', config('app.locale'));
$this->assertSame('en', config('app.fallback_locale'));
$validator = Validator::make(['email' => 'invalid'], [
'title' => ['required'],
'email' => ['email'],
]);
$this->assertSame('The title field is required.', $validator->errors()->first('title'));
$this->assertSame('The email field must be a valid email address.', $validator->errors()->first('email'));
}
}
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { computed, createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
try {
const { createLocalization, validateCatalog } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { useLocalization } = await server.ssrLoadModule(
'/resources/js/composables/useLocalization.ts',
);
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
const { default: AlertError } = await server.ssrLoadModule(
'/resources/js/components/AlertError.vue',
);
const english = createLocalization();
assert.equal(english.t('common.save'), 'Save');
assert.equal(
english.t('pagination.page', { page: 0, total: 2000 }),
'Page 0 of 2,000',
);
for (const [count, expected] of [
[0, '0 tickets'],
[1, '1 ticket'],
[2, '2 tickets'],
[1.5, '1.5 tickets'],
]) {
assert.equal(english.t('tickets.count', { count }), expected);
}
assert.equal(
english.number(1234.5, { minimumFractionDigits: 2 }),
'1,234.50',
);
assert.equal(
english.number(12, { style: 'currency', currency: 'USD' }),
'$12.00',
);
assert.equal(english.date(new Date('2026-09-09T23:30:00Z')), '9/9/2026');
assert.equal(
createLocalization({ timeZone: 'Europe/Berlin' }).date(
new Date('2026-09-09T23:30:00Z'),
),
'9/10/2026',
);
assert.throws(() => english.t('missing.key'), /Missing translation/);
assert.throws(() => english.t('toString'), /Missing translation/);
assert.throws(
() => english.t('pagination.page', { page: 1 }),
/Missing translation parameter/,
);
for (const count of [undefined, '1', Infinity, NaN]) {
assert.throws(
() => english.t('tickets.count', { count }),
/finite count/,
);
}
const incomplete = { ...en };
delete incomplete['common.save'];
assert.throws(
() => validateCatalog(incomplete),
/Missing or invalid translation/,
);
assert.throws(
() =>
validateCatalog({
...en,
'tickets.count': { one: '{count} ticket' },
}),
/plural fallback/,
);
assert.throws(
() => validateCatalog({ ...en, 'pagination.page': 'Page {unknown}' }),
/placeholders/,
);
assert.throws(
() => validateCatalog({ ...en, 'common.save': '' }),
/placeholders/,
);
assert.throws(
() => createLocalization({}, { de: incomplete }),
/Missing or invalid translation/,
);
for (const locale of ['de-DE', 'en_US', 'not a locale', '']) {
assert.equal(createLocalization({ locale }).locale.value, 'en');
}
const de = {
...en,
'common.error': 'Etwas ist schiefgelaufen.',
'common.save': 'Speichern',
};
const translated = createLocalization({ locale: 'de_DE' }, { de });
assert.equal(translated.locale.value, 'de');
assert.equal(translated.number(1234.5), '1.234,5');
const label = computed(() => translated.t('common.save'));
assert.equal(label.value, 'Speichern');
translated.setLocale('en');
assert.equal(label.value, 'Save');
translated.setLocale('de');
assert.equal(label.value, 'Speichern');
assert.equal(english.locale.value, 'en');
const renderAlert = (localization, props = {}) =>
renderToString(
createSSRApp({
render: () =>
h(AlertError, {
errors: ['Invalid input', 'Invalid input'],
...props,
}),
}).use(localization),
);
const [englishHtml, germanHtml] = await Promise.all([
renderAlert(english),
renderAlert(translated),
]);
assert.match(englishHtml, /Something went wrong\./);
assert.match(germanHtml, /Etwas ist schiefgelaufen\./);
assert.equal((englishHtml.match(/Invalid input/g) ?? []).length, 1);
assert.match(
await renderAlert(translated, { title: 'Custom title' }),
/Custom title/,
);
assert.doesNotMatch(
await renderAlert(translated, { title: '' }),
/Etwas ist schiefgelaufen/,
);
const InterpolatedText = {
setup() {
const { t } = useLocalization();
return () =>
h(
'p',
t('pagination.page', {
page: '<script>alert(1)</script>',
total: 1,
}),
);
},
};
const escaped = await renderToString(
createSSRApp(InterpolatedText).use(createLocalization()),
);
assert.match(escaped, /&lt;script&gt;/);
assert.doesNotMatch(escaped, /<script>/);
console.log('Localization behavior checks passed.');
} finally {
await server.close();
}