feat(KAN-16): add English localization foundation
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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
@@ -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 },
|
||||
);
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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, /<script>/);
|
||||
assert.doesNotMatch(escaped, /<script>/);
|
||||
|
||||
console.log('Localization behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
Reference in New Issue
Block a user