mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web): translations containing html (#10491)
* feat(web): translations containing html * add tests and more translations * more translations * rename FormatTags --> FormatMessage * update version_announcement_message
This commit is contained in:
parent
1129020159
commit
b3252ffdac
16 changed files with 313 additions and 101 deletions
78
web/src/lib/components/i18n/__test__/format-message.spec.ts
Normal file
78
web/src/lib/components/i18n/__test__/format-message.spec.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import FormatTagB from '$lib/components/i18n/__test__/format-tag-b.svelte';
|
||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { init, json, locale, register, waitLocale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
describe('FormatMessage component', () => {
|
||||
let $json: (id: string, locale?: string | undefined) => unknown;
|
||||
|
||||
beforeAll(async () => {
|
||||
register('en', () =>
|
||||
Promise.resolve({
|
||||
hello: 'Hello {name}',
|
||||
html: 'Hello <b>{name}</b>',
|
||||
plural: 'You have <b>{count, plural, one {# item} other {# items}}</b>',
|
||||
xss: '<image/src/onerror=prompt(8)>',
|
||||
}),
|
||||
);
|
||||
|
||||
await init({ fallbackLocale: 'en' });
|
||||
await waitLocale('en');
|
||||
$json = get(json);
|
||||
});
|
||||
|
||||
it('formats a plain text message', () => {
|
||||
render(FormatMessage, {
|
||||
message: $json('hello'),
|
||||
values: { name: 'test' },
|
||||
});
|
||||
expect(screen.getByText('Hello test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('throws an error when locale is empty', async () => {
|
||||
await locale.set(undefined);
|
||||
expect(() => render(FormatMessage, { message: undefined })).toThrowError();
|
||||
await locale.set('en');
|
||||
});
|
||||
|
||||
it('shows raw message when value is empty', () => {
|
||||
render(FormatMessage, {
|
||||
message: $json('hello'),
|
||||
});
|
||||
expect(screen.getByText('Hello {name}')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows message when slot is empty', () => {
|
||||
render(FormatMessage, {
|
||||
message: $json('html'),
|
||||
values: { name: 'test' },
|
||||
});
|
||||
expect(screen.getByText('Hello test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a message with html', () => {
|
||||
const { container } = render(FormatTagB, {
|
||||
message: $json('html'),
|
||||
values: { name: 'test' },
|
||||
});
|
||||
expect(container.innerHTML).toBe('Hello <strong>test</strong>');
|
||||
});
|
||||
|
||||
it('renders a message with html and plural', () => {
|
||||
const { container } = render(FormatTagB, {
|
||||
message: $json('plural'),
|
||||
values: { count: 1 },
|
||||
});
|
||||
expect(container.innerHTML).toBe('You have <strong>1 item</strong>');
|
||||
});
|
||||
|
||||
it('protects agains XSS injection', () => {
|
||||
render(FormatMessage, {
|
||||
message: $json('xss'),
|
||||
});
|
||||
expect(screen.getByText('<image/src/onerror=prompt(8)>')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
13
web/src/lib/components/i18n/__test__/format-tag-b.svelte
Normal file
13
web/src/lib/components/i18n/__test__/format-tag-b.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import FormatMessage from '../format-message.svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
export let message: unknown;
|
||||
export let values: ComponentProps<FormatMessage>['values'];
|
||||
</script>
|
||||
|
||||
<FormatMessage {message} {values} let:tag let:message>
|
||||
{#if tag === 'b'}
|
||||
<strong>{message}</strong>
|
||||
{/if}
|
||||
</FormatMessage>
|
||||
57
web/src/lib/components/i18n/format-message.svelte
Normal file
57
web/src/lib/components/i18n/format-message.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
import { IntlMessageFormat, type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat';
|
||||
import { TYPE, type MessageFormatElement } from '@formatjs/icu-messageformat-parser';
|
||||
import { locale as i18nLocale } from 'svelte-i18n';
|
||||
|
||||
type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
|
||||
|
||||
export let message: unknown;
|
||||
export let values: InterpolationValues = {};
|
||||
|
||||
const getLocale = (locale?: string | null) => {
|
||||
if (locale == null) {
|
||||
throw new Error('Cannot format a message without first setting the initial locale.');
|
||||
}
|
||||
|
||||
return locale;
|
||||
};
|
||||
|
||||
const getElements = (message: unknown, locale: string): MessageFormatElement[] => {
|
||||
return new IntlMessageFormat(message as string, locale, undefined, {
|
||||
ignoreTag: false,
|
||||
}).getAst();
|
||||
};
|
||||
|
||||
const getParts = (message: unknown, locale: string) => {
|
||||
try {
|
||||
const elements = getElements(message, locale);
|
||||
|
||||
return elements.map((element) => {
|
||||
const isTag = element.type === TYPE.tag;
|
||||
|
||||
return {
|
||||
tag: isTag ? element.value : undefined,
|
||||
message: new IntlMessageFormat(isTag ? element.children : [element], locale, undefined, {
|
||||
ignoreTag: true,
|
||||
}).format(values) as string,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.warn(`Message "${message}" has syntax error:`, error.message);
|
||||
}
|
||||
return [{ message: message as string, tag: undefined }];
|
||||
}
|
||||
};
|
||||
|
||||
$: locale = getLocale($i18nLocale);
|
||||
$: parts = getParts(message, locale);
|
||||
</script>
|
||||
|
||||
{#each parts as { tag, message }}
|
||||
{#if tag}
|
||||
<slot {tag} {message}>{message}</slot>
|
||||
{:else}
|
||||
{message}
|
||||
{/if}
|
||||
{/each}
|
||||
Loading…
Add table
Add a link
Reference in a new issue