mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 12:15:47 -04:00
feat(web): html tags inside plural and select messages (#10696)
* feat(web): html tags inside plural and select messages * add component docs
This commit is contained in:
parent
b54dd4e135
commit
ac51cad075
@ -13,6 +13,9 @@ describe('FormatMessage component', () => {
|
|||||||
html: 'Hello <b>{name}</b>',
|
html: 'Hello <b>{name}</b>',
|
||||||
plural: 'You have <b>{count, plural, one {# item} other {# items}}</b>',
|
plural: 'You have <b>{count, plural, one {# item} other {# items}}</b>',
|
||||||
xss: '<image/src/onerror=prompt(8)>',
|
xss: '<image/src/onerror=prompt(8)>',
|
||||||
|
plural_with_html: 'You have {count, plural, other {<b>#</b> items}}',
|
||||||
|
select_with_html: 'Item is {status, select, other {<b>disabled</b>}}',
|
||||||
|
ordinal_with_html: '{count, selectordinal, other {<b>#th</b>}} item',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -76,4 +79,28 @@ describe('FormatMessage component', () => {
|
|||||||
render(FormatMessage, { key: 'invalid.key' });
|
render(FormatMessage, { key: 'invalid.key' });
|
||||||
expect(screen.getByText('invalid.key')).toBeInTheDocument();
|
expect(screen.getByText('invalid.key')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports html tags inside plurals', () => {
|
||||||
|
const { container } = render(FormatTagB, {
|
||||||
|
key: 'plural_with_html',
|
||||||
|
values: { count: 10 },
|
||||||
|
});
|
||||||
|
expect(container.innerHTML).toBe('You have <strong>10</strong> items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports html tags inside select', () => {
|
||||||
|
const { container } = render(FormatTagB, {
|
||||||
|
key: 'select_with_html',
|
||||||
|
values: { status: true },
|
||||||
|
});
|
||||||
|
expect(container.innerHTML).toBe('Item is <strong>disabled</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports html tags inside selectordinal', () => {
|
||||||
|
const { container } = render(FormatTagB, {
|
||||||
|
key: 'ordinal_with_html',
|
||||||
|
values: { count: 4 },
|
||||||
|
});
|
||||||
|
expect(container.innerHTML).toBe('<strong>4th</strong> item');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { IntlMessageFormat, type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat';
|
import { IntlMessageFormat, type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat';
|
||||||
import { TYPE, type MessageFormatElement } from '@formatjs/icu-messageformat-parser';
|
import {
|
||||||
|
TYPE,
|
||||||
|
type MessageFormatElement,
|
||||||
|
type PluralElement,
|
||||||
|
type SelectElement,
|
||||||
|
} from '@formatjs/icu-messageformat-parser';
|
||||||
import { locale as i18nLocale, json } from 'svelte-i18n';
|
import { locale as i18nLocale, json } from 'svelte-i18n';
|
||||||
|
|
||||||
type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
|
type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
|
||||||
|
|
||||||
|
type MessagePart = {
|
||||||
|
message: string;
|
||||||
|
tag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export let key: string;
|
export let key: string;
|
||||||
export let values: InterpolationValues = {};
|
export let values: InterpolationValues = {};
|
||||||
|
|
||||||
@ -16,31 +26,70 @@
|
|||||||
return locale;
|
return locale;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElements = (message: string, locale: string): MessageFormatElement[] => {
|
const getElements = (message: string | MessageFormatElement[], locale: string): MessageFormatElement[] => {
|
||||||
return new IntlMessageFormat(message as string, locale, undefined, {
|
return new IntlMessageFormat(message, locale, undefined, {
|
||||||
ignoreTag: false,
|
ignoreTag: false,
|
||||||
}).getAst();
|
}).getAst();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTagReplacements = (element: PluralElement | SelectElement) => {
|
||||||
|
const replacements: Record<string, FormatXMLElementFn<unknown>> = {};
|
||||||
|
|
||||||
|
for (const option of Object.values(element.options)) {
|
||||||
|
for (const pluralElement of option.value) {
|
||||||
|
if (pluralElement.type === TYPE.tag) {
|
||||||
|
const tag = pluralElement.value;
|
||||||
|
replacements[tag] = (...parts) => `<${tag}>${parts}</${tag}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacements;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatElementToParts = (element: MessageFormatElement, values: InterpolationValues) => {
|
||||||
|
const message = new IntlMessageFormat([element], locale, undefined, {
|
||||||
|
ignoreTag: true,
|
||||||
|
}).format(values) as string;
|
||||||
|
|
||||||
|
const pluralElements = new IntlMessageFormat(message, locale, undefined, {
|
||||||
|
ignoreTag: false,
|
||||||
|
}).getAst();
|
||||||
|
|
||||||
|
return pluralElements.map((element) => elementToPart(element));
|
||||||
|
};
|
||||||
|
|
||||||
|
const elementToPart = (element: MessageFormatElement): MessagePart => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getParts = (message: string, locale: string) => {
|
const getParts = (message: string, locale: string) => {
|
||||||
try {
|
try {
|
||||||
const elements = getElements(message, locale);
|
const elements = getElements(message, locale);
|
||||||
|
const parts: MessagePart[] = [];
|
||||||
|
|
||||||
return elements.map((element) => {
|
for (const element of elements) {
|
||||||
const isTag = element.type === TYPE.tag;
|
if (element.type === TYPE.plural || element.type === TYPE.select) {
|
||||||
|
const replacements = getTagReplacements(element);
|
||||||
|
parts.push(...formatElementToParts(element, { ...values, ...replacements }));
|
||||||
|
} else {
|
||||||
|
parts.push(elementToPart(element));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return parts;
|
||||||
tag: isTag ? element.value : undefined,
|
|
||||||
message: new IntlMessageFormat(isTag ? element.children : [element], locale, undefined, {
|
|
||||||
ignoreTag: true,
|
|
||||||
}).format(values) as string,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
console.warn(`Message "${key}" has syntax error:`, error.message);
|
console.warn(`Message "${key}" has syntax error:`, error.message);
|
||||||
}
|
}
|
||||||
return [{ message: message as string, tag: undefined }];
|
return [{ message, tag: undefined }];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,6 +98,33 @@
|
|||||||
$: parts = getParts(message, locale);
|
$: parts = getParts(message, locale);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
@component
|
||||||
|
Formats an [ICU message](https://formatjs.io/docs/core-concepts/icu-syntax) that contains HTML tags
|
||||||
|
|
||||||
|
### Props
|
||||||
|
- `key` - Key of a defined message
|
||||||
|
- `values` - Object with a value for each placeholder in the message (optional)
|
||||||
|
|
||||||
|
### Default Slot
|
||||||
|
Used for every occurrence of an HTML tag in a message
|
||||||
|
- `tag` - Name of the tag
|
||||||
|
- `message` - Formatted text inside the tag
|
||||||
|
|
||||||
|
@example
|
||||||
|
```svelte
|
||||||
|
{"message": "Visit <link>docs</link> <b>{time}</b>"}
|
||||||
|
<FormattedMessage key="message" values={{ time: 'now' }} let:tag let:message>
|
||||||
|
{#if tag === 'link'}
|
||||||
|
<a href="">{message}</a>
|
||||||
|
{:else if tag === 'b'}
|
||||||
|
<strong>{message}</strong>
|
||||||
|
{/if}
|
||||||
|
</FormattedMessage>
|
||||||
|
|
||||||
|
Result: Visit <a href="">docs</a> <strong>now</strong>
|
||||||
|
```
|
||||||
|
-->
|
||||||
{#each parts as { tag, message }}
|
{#each parts as { tag, message }}
|
||||||
{#if tag}
|
{#if tag}
|
||||||
<slot {tag} {message}>{message}</slot>
|
<slot {tag} {message}>{message}</slot>
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import Checkbox from '$lib/components/elements/checkbox.svelte';
|
import Checkbox from '$lib/components/elements/checkbox.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
|
||||||
export let size: number;
|
export let size: number;
|
||||||
|
|
||||||
@ -30,12 +31,9 @@
|
|||||||
>
|
>
|
||||||
<svelte:fragment slot="prompt">
|
<svelte:fragment slot="prompt">
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to permanently delete
|
<FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }} let:message>
|
||||||
{#if size > 1}
|
<b>{message}</b>
|
||||||
these <b>{size}</b> assets? This will also remove them from their album(s).
|
</FormatMessage>
|
||||||
{:else}
|
|
||||||
this asset? This will also remove it from its album(s).
|
|
||||||
{/if}
|
|
||||||
</p>
|
</p>
|
||||||
<p><b>{$t('cannot_undo_this_action')}</b></p>
|
<p><b>{$t('cannot_undo_this_action')}</b></p>
|
||||||
|
|
||||||
|
@ -868,6 +868,7 @@
|
|||||||
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
|
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
|
||||||
"permanently_delete": "Permanently delete",
|
"permanently_delete": "Permanently delete",
|
||||||
"permanently_delete_assets_count": "Permanently delete {count, plural, one {asset} other {assets}}",
|
"permanently_delete_assets_count": "Permanently delete {count, plural, one {asset} other {assets}}",
|
||||||
|
"permanently_delete_assets_prompt": "Are you sure you want to permanently delete {count, plural, one {this asset?} other {these <b>#</b> assets?}} This will also remove {count, plural, one {it from its} other {them from their}} album(s).",
|
||||||
"permanently_deleted_asset": "Permanently deleted asset",
|
"permanently_deleted_asset": "Permanently deleted asset",
|
||||||
"permanently_deleted_assets_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
|
"permanently_deleted_assets_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
|
||||||
"person": "Person",
|
"person": "Person",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user