Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): html tags inside plural and select messages #10696

Merged
merged 2 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions web/src/lib/components/i18n/__test__/format-message.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ describe('FormatMessage component', () => {
html: 'Hello <b>{name}</b>',
plural: 'You have <b>{count, plural, one {# item} other {# items}}</b>',
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',
}),
);

Expand Down Expand Up @@ -76,4 +79,28 @@ describe('FormatMessage component', () => {
render(FormatMessage, { key: 'invalid.key' });
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');
});
});
102 changes: 89 additions & 13 deletions web/src/lib/components/i18n/format-message.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
<script lang="ts">
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';

type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;

type MessagePart = {
message: string;
tag?: string;
};

export let key: string;
export let values: InterpolationValues = {};

Expand All @@ -16,31 +26,70 @@
return locale;
};

const getElements = (message: string, locale: string): MessageFormatElement[] => {
return new IntlMessageFormat(message as string, locale, undefined, {
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[] = [];

return elements.map((element) => {
const isTag = element.type === TYPE.tag;
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 {
tag: isTag ? element.value : undefined,
message: new IntlMessageFormat(isTag ? element.children : [element], locale, undefined, {
ignoreTag: true,
}).format(values) as string,
};
});
return parts;
} catch (error) {
if (error instanceof Error) {
console.warn(`Message "${key}" has syntax error:`, error.message);
}
return [{ message: message as string, tag: undefined }];
return [{ message, tag: undefined }];
}
};

Expand All @@ -49,6 +98,33 @@
$: 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>
Expand Down
10 changes: 4 additions & 6 deletions web/src/lib/components/photos-page/delete-asset-dialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { showDeleteModal } from '$lib/stores/preferences.store';
import Checkbox from '$lib/components/elements/checkbox.svelte';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';

export let size: number;

Expand All @@ -30,12 +31,9 @@
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to permanently delete
{#if size > 1}
these <b>{size}</b> assets? This will also remove them from their album(s).
{:else}
this asset? This will also remove it from its album(s).
{/if}
<FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }} let:message>
<b>{message}</b>
</FormatMessage>
</p>
<p><b>{$t('cannot_undo_this_action')}</b></p>

Expand Down
1 change: 1 addition & 0 deletions web/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,7 @@
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
"permanently_delete": "Permanently delete",
"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_assets_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}",
"person": "Person",
Expand Down
Loading