mirror of
https://github.com/immich-app/immich.git
synced 2024-11-16 02:18:50 -07:00
chore(web): enforce valid translation keys using typescript (#12106)
This commit is contained in:
parent
bab5ad7ebd
commit
9f5a3f1e84
28
web/src/app.d.ts
vendored
28
web/src/app.d.ts
vendored
@ -27,3 +27,31 @@ interface Element {
|
|||||||
// Make optional, because it's unavailable on iPhones.
|
// Make optional, because it's unavailable on iPhones.
|
||||||
requestFullscreen?(options?: FullscreenOptions): Promise<void>;
|
requestFullscreen?(options?: FullscreenOptions): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type en from '$lib/i18n/en.json';
|
||||||
|
import 'svelte-i18n';
|
||||||
|
|
||||||
|
type NestedKeys<T, K = keyof T> = K extends keyof T & string
|
||||||
|
? `${K}` | (T[K] extends object ? `${K}.${NestedKeys<T[K]>}` : never)
|
||||||
|
: never;
|
||||||
|
|
||||||
|
declare module 'svelte-i18n' {
|
||||||
|
import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import type { Readable } from 'svelte/store';
|
||||||
|
|
||||||
|
type Translations = NestedKeys<typeof en>;
|
||||||
|
|
||||||
|
interface MessageObject {
|
||||||
|
id: Translations;
|
||||||
|
locale?: string;
|
||||||
|
format?: string;
|
||||||
|
default?: string;
|
||||||
|
values?: InterpolationValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageFormatter = (id: Translations | MessageObject, options?: Omit<MessageObject, 'id'>) => string;
|
||||||
|
|
||||||
|
const format: Readable<MessageFormatter>;
|
||||||
|
const t: Readable<MessageFormatter>;
|
||||||
|
const _: Readable<MessageFormatter>;
|
||||||
|
}
|
||||||
|
@ -2,7 +2,7 @@ import FormatTagB from '$lib/components/i18n/__test__/format-tag-b.svelte';
|
|||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { init, locale, register, waitLocale } from 'svelte-i18n';
|
import { init, locale, register, waitLocale, type Translations } from 'svelte-i18n';
|
||||||
import { describe } from 'vitest';
|
import { describe } from 'vitest';
|
||||||
|
|
||||||
describe('FormatMessage component', () => {
|
describe('FormatMessage component', () => {
|
||||||
@ -25,7 +25,7 @@ describe('FormatMessage component', () => {
|
|||||||
|
|
||||||
it('formats a plain text message', () => {
|
it('formats a plain text message', () => {
|
||||||
render(FormatMessage, {
|
render(FormatMessage, {
|
||||||
key: 'hello',
|
key: 'hello' as Translations,
|
||||||
values: { name: 'test' },
|
values: { name: 'test' },
|
||||||
});
|
});
|
||||||
expect(screen.getByText('Hello test')).toBeInTheDocument();
|
expect(screen.getByText('Hello test')).toBeInTheDocument();
|
||||||
@ -33,20 +33,20 @@ describe('FormatMessage component', () => {
|
|||||||
|
|
||||||
it('throws an error when locale is empty', async () => {
|
it('throws an error when locale is empty', async () => {
|
||||||
await locale.set(undefined);
|
await locale.set(undefined);
|
||||||
expect(() => render(FormatMessage, { key: '' })).toThrowError();
|
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
|
||||||
await locale.set('en');
|
await locale.set('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows raw message when value is empty', () => {
|
it('shows raw message when value is empty', () => {
|
||||||
render(FormatMessage, {
|
render(FormatMessage, {
|
||||||
key: 'hello',
|
key: 'hello' as Translations,
|
||||||
});
|
});
|
||||||
expect(screen.getByText('Hello {name}')).toBeInTheDocument();
|
expect(screen.getByText('Hello {name}')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows message when slot is empty', () => {
|
it('shows message when slot is empty', () => {
|
||||||
render(FormatMessage, {
|
render(FormatMessage, {
|
||||||
key: 'html',
|
key: 'html' as Translations,
|
||||||
values: { name: 'test' },
|
values: { name: 'test' },
|
||||||
});
|
});
|
||||||
expect(screen.getByText('Hello test')).toBeInTheDocument();
|
expect(screen.getByText('Hello test')).toBeInTheDocument();
|
||||||
@ -54,7 +54,7 @@ describe('FormatMessage component', () => {
|
|||||||
|
|
||||||
it('renders a message with html', () => {
|
it('renders a message with html', () => {
|
||||||
const { container } = render(FormatTagB, {
|
const { container } = render(FormatTagB, {
|
||||||
key: 'html',
|
key: 'html' as Translations,
|
||||||
values: { name: 'test' },
|
values: { name: 'test' },
|
||||||
});
|
});
|
||||||
expect(container.innerHTML).toBe('Hello <strong>test</strong>');
|
expect(container.innerHTML).toBe('Hello <strong>test</strong>');
|
||||||
@ -62,7 +62,7 @@ describe('FormatMessage component', () => {
|
|||||||
|
|
||||||
it('renders a message with html and plural', () => {
|
it('renders a message with html and plural', () => {
|
||||||
const { container } = render(FormatTagB, {
|
const { container } = render(FormatTagB, {
|
||||||
key: 'plural',
|
key: 'plural' as Translations,
|
||||||
values: { count: 1 },
|
values: { count: 1 },
|
||||||
});
|
});
|
||||||
expect(container.innerHTML).toBe('You have <strong>1 item</strong>');
|
expect(container.innerHTML).toBe('You have <strong>1 item</strong>');
|
||||||
@ -70,19 +70,19 @@ describe('FormatMessage component', () => {
|
|||||||
|
|
||||||
it('protects agains XSS injection', () => {
|
it('protects agains XSS injection', () => {
|
||||||
render(FormatMessage, {
|
render(FormatMessage, {
|
||||||
key: 'xss',
|
key: 'xss' as Translations,
|
||||||
});
|
});
|
||||||
expect(screen.getByText('<image/src/onerror=prompt(8)>')).toBeInTheDocument();
|
expect(screen.getByText('<image/src/onerror=prompt(8)>')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays the message key when not found', () => {
|
it('displays the message key when not found', () => {
|
||||||
render(FormatMessage, { key: 'invalid.key' });
|
render(FormatMessage, { key: 'invalid.key' as Translations });
|
||||||
expect(screen.getByText('invalid.key')).toBeInTheDocument();
|
expect(screen.getByText('invalid.key')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports html tags inside plurals', () => {
|
it('supports html tags inside plurals', () => {
|
||||||
const { container } = render(FormatTagB, {
|
const { container } = render(FormatTagB, {
|
||||||
key: 'plural_with_html',
|
key: 'plural_with_html' as Translations,
|
||||||
values: { count: 10 },
|
values: { count: 10 },
|
||||||
});
|
});
|
||||||
expect(container.innerHTML).toBe('You have <strong>10</strong> items');
|
expect(container.innerHTML).toBe('You have <strong>10</strong> items');
|
||||||
@ -90,7 +90,7 @@ describe('FormatMessage component', () => {
|
|||||||
|
|
||||||
it('supports html tags inside select', () => {
|
it('supports html tags inside select', () => {
|
||||||
const { container } = render(FormatTagB, {
|
const { container } = render(FormatTagB, {
|
||||||
key: 'select_with_html',
|
key: 'select_with_html' as Translations,
|
||||||
values: { status: true },
|
values: { status: true },
|
||||||
});
|
});
|
||||||
expect(container.innerHTML).toBe('Item is <strong>disabled</strong>');
|
expect(container.innerHTML).toBe('Item is <strong>disabled</strong>');
|
||||||
@ -98,7 +98,7 @@ describe('FormatMessage component', () => {
|
|||||||
|
|
||||||
it('supports html tags inside selectordinal', () => {
|
it('supports html tags inside selectordinal', () => {
|
||||||
const { container } = render(FormatTagB, {
|
const { container } = render(FormatTagB, {
|
||||||
key: 'ordinal_with_html',
|
key: 'ordinal_with_html' as Translations,
|
||||||
values: { count: 4 },
|
values: { count: 4 },
|
||||||
});
|
});
|
||||||
expect(container.innerHTML).toBe('<strong>4th</strong> item');
|
expect(container.innerHTML).toBe('<strong>4th</strong> item');
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Translations } from 'svelte-i18n';
|
||||||
import FormatMessage from '../format-message.svelte';
|
import FormatMessage from '../format-message.svelte';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
export let key: string;
|
export let key: Translations;
|
||||||
export let values: ComponentProps<FormatMessage>['values'];
|
export let values: ComponentProps<FormatMessage>['values'];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte';
|
import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import type { Translations } from 'svelte-i18n';
|
||||||
|
|
||||||
export let key: string;
|
export let key: Translations;
|
||||||
export let values: InterpolationValues = {};
|
export let values: InterpolationValues = {};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -11,14 +11,14 @@
|
|||||||
type PluralElement,
|
type PluralElement,
|
||||||
type SelectElement,
|
type SelectElement,
|
||||||
} from '@formatjs/icu-messageformat-parser';
|
} from '@formatjs/icu-messageformat-parser';
|
||||||
import { locale as i18nLocale, json } from 'svelte-i18n';
|
import { locale as i18nLocale, json, type Translations } from 'svelte-i18n';
|
||||||
|
|
||||||
type MessagePart = {
|
type MessagePart = {
|
||||||
message: string;
|
message: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export let key: string;
|
export let key: Translations;
|
||||||
export let values: InterpolationValues = {};
|
export let values: InterpolationValues = {};
|
||||||
|
|
||||||
const getLocale = (locale?: string | null) => {
|
const getLocale = (locale?: string | null) => {
|
||||||
|
@ -1,39 +1,8 @@
|
|||||||
import { langs } from '$lib/constants';
|
import { langs } from '$lib/constants';
|
||||||
import messages from '$lib/i18n/en.json';
|
|
||||||
import { getClosestAvailableLocale } from '$lib/utils/i18n';
|
import { getClosestAvailableLocale } from '$lib/utils/i18n';
|
||||||
import { exec as execCallback } from 'node:child_process';
|
|
||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { promisify } from 'node:util';
|
|
||||||
|
|
||||||
type Messages = { [key: string]: string | Messages };
|
|
||||||
|
|
||||||
const exec = promisify(execCallback);
|
|
||||||
|
|
||||||
function setEmptyMessages(messages: Messages) {
|
|
||||||
const copy = { ...messages };
|
|
||||||
|
|
||||||
for (const key in copy) {
|
|
||||||
const message = copy[key];
|
|
||||||
if (typeof message === 'string') {
|
|
||||||
copy[key] = '';
|
|
||||||
} else if (typeof message === 'object') {
|
|
||||||
setEmptyMessages(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('i18n', () => {
|
describe('i18n', () => {
|
||||||
test('no missing messages', async () => {
|
|
||||||
const { stdout } = await exec('npx svelte-i18n extract -c svelte.config.js "src/**/*"');
|
|
||||||
const extractedMessages: Messages = JSON.parse(stdout);
|
|
||||||
const existingMessages = setEmptyMessages(messages);
|
|
||||||
|
|
||||||
// Only translations directly using the store seem to get extracted
|
|
||||||
expect({ ...extractedMessages, ...existingMessages }).toEqual(existingMessages);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loaders', () => {
|
describe('loaders', () => {
|
||||||
const languageFiles = readdirSync('src/lib/i18n').sort();
|
const languageFiles = readdirSync('src/lib/i18n').sort();
|
||||||
for (const filename of languageFiles) {
|
for (const filename of languageFiles) {
|
||||||
|
@ -12,7 +12,6 @@ export const ssr = false;
|
|||||||
export const csr = true;
|
export const csr = true;
|
||||||
|
|
||||||
export const load = (async ({ fetch }) => {
|
export const load = (async ({ fetch }) => {
|
||||||
let $t = (arg: string) => arg;
|
|
||||||
try {
|
try {
|
||||||
await init(fetch);
|
await init(fetch);
|
||||||
const authenticated = await loadUser();
|
const authenticated = await loadUser();
|
||||||
@ -26,7 +25,6 @@ export const load = (async ({ fetch }) => {
|
|||||||
redirect(302, AppRoute.AUTH_LOGIN);
|
redirect(302, AppRoute.AUTH_LOGIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
$t = await getFormatter();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (redirectError: any) {
|
} catch (redirectError: any) {
|
||||||
if (redirectError?.status === 302) {
|
if (redirectError?.status === 302) {
|
||||||
@ -34,6 +32,8 @@ export const load = (async ({ fetch }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: {
|
meta: {
|
||||||
title: $t('welcome') + ' 🎉',
|
title: $t('welcome') + ' 🎉',
|
||||||
|
Loading…
Reference in New Issue
Block a user