immich/web/src/lib/components/i18n/format-message.svelte
Michel Heusschen ac51cad075
feat(web): html tags inside plural and select messages (#10696)
* feat(web): html tags inside plural and select messages

* add component docs
2024-07-01 11:54:13 -05:00

135 lines
3.9 KiB
Svelte

<script lang="ts">
import { IntlMessageFormat, type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat';
import {
TYPE,
type MessageFormatElement,
type PluralElement,
type SelectElement,
} from '@formatjs/icu-messageformat-parser';
import { locale as i18nLocale, json } from 'svelte-i18n';
type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
type MessagePart = {
message: string;
tag?: string;
};
export let key: string;
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: string | MessageFormatElement[], locale: string): MessageFormatElement[] => {
return new IntlMessageFormat(message, locale, undefined, {
ignoreTag: false,
}).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) => {
try {
const elements = getElements(message, locale);
const parts: MessagePart[] = [];
for (const element of elements) {
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 parts;
} catch (error) {
if (error instanceof Error) {
console.warn(`Message "${key}" has syntax error:`, error.message);
}
return [{ message, tag: undefined }];
}
};
$: message = ($json(key) as string) || key;
$: locale = getLocale($i18nLocale);
$: parts = getParts(message, locale);
</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 }}
{#if tag}
<slot {tag} {message}>{message}</slot>
{:else}
{message}
{/if}
{/each}