Refactor i18n/intl utils, support icu only, remove renderText

This commit is contained in:
Jamie Kyle 2023-06-14 16:26:05 -07:00 committed by GitHub
parent e154d98688
commit b76c7269f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 361 additions and 6478 deletions

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ import { deepEqual } from 'assert';
import type { Rule } from './utils/rule'; import type { Rule } from './utils/rule';
import icuPrefix from './rules/icuPrefix'; import icuPrefix from './rules/icuPrefix';
import wrapEmoji from './rules/wrapEmoji';
import onePlural from './rules/onePlural'; import onePlural from './rules/onePlural';
import noLegacyVariables from './rules/noLegacyVariables'; import noLegacyVariables from './rules/noLegacyVariables';
import noNestedChoice from './rules/noNestedChoice'; import noNestedChoice from './rules/noNestedChoice';
@ -24,6 +25,7 @@ import pluralPound from './rules/pluralPound';
const RULES = [ const RULES = [
icuPrefix, icuPrefix,
wrapEmoji,
noLegacyVariables, noLegacyVariables,
noNestedChoice, noNestedChoice,
noOffset, noOffset,
@ -74,6 +76,26 @@ const tests: Record<string, Test> = {
messageformat: '$a$', messageformat: '$a$',
expectErrors: ['noLegacyVariables'], expectErrors: ['noLegacyVariables'],
}, },
'icu:wrapEmoji:1': {
messageformat: '👩',
expectErrors: ['wrapEmoji'],
},
'icu:wrapEmoji:2': {
messageformat: '<emoji>👩 extra</emoji>',
expectErrors: ['wrapEmoji'],
},
'icu:wrapEmoji:3': {
messageformat: '<emoji>👩👩</emoji>',
expectErrors: ['wrapEmoji'],
},
'icu:wrapEmoji:4': {
messageformat: '<emoji>{emoji}</emoji>',
expectErrors: ['wrapEmoji'],
},
'icu:wrapEmoji:5': {
messageformat: '<emoji>👩</emoji>',
expectErrors: [],
},
}; };
type Report = { type Report = {

View File

@ -0,0 +1,73 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import getEmojiRegex from 'emoji-regex';
import type {
MessageFormatElement,
TagElement,
} from '@formatjs/icu-messageformat-parser';
import {
isTagElement,
isLiteralElement,
} from '@formatjs/icu-messageformat-parser';
import { rule } from '../utils/rule';
function isEmojiTag(
element: MessageFormatElement | null
): element is TagElement {
return element != null && isTagElement(element) && element.value === 'emoji';
}
export default rule('wrapEmoji', context => {
const emojiRegex = getEmojiRegex();
return {
enterTag(element) {
if (!isEmojiTag(element)) {
return;
}
if (element.children.length !== 1) {
// multiple children
context.report(
'Only use a single literal emoji in <emoji> tags with no additional text.',
element.location
);
return;
}
const child = element.children[0];
if (!isLiteralElement(child)) {
// non-literal
context.report(
'Only use a single literal emoji in <emoji> tags with no additional text.',
child.location
);
}
},
enterLiteral(element, parent) {
const match = element.value.match(emojiRegex);
if (match == null) {
// no emoji
return;
}
if (!isEmojiTag(parent)) {
// unwrapped
context.report(
'Use <emoji> to wrap emoji in translation strings.',
element.location
);
return;
}
const emoji = match[0];
if (emoji !== element.value) {
// extra text other than emoji
context.report(
'Only use a single literal emoji in <emoji> tags with no additional text.',
element.location
);
}
},
};
});

View File

@ -277,12 +277,14 @@
"eslint-plugin-mocha": "10.1.0", "eslint-plugin-mocha": "10.1.0",
"eslint-plugin-more": "1.0.5", "eslint-plugin-more": "1.0.5",
"eslint-plugin-react": "7.31.10", "eslint-plugin-react": "7.31.10",
"execa": "5.1.1",
"html-webpack-plugin": "5.3.1", "html-webpack-plugin": "5.3.1",
"json-to-ast": "2.1.0", "json-to-ast": "2.1.0",
"mocha": "9.1.3", "mocha": "9.1.3",
"node-gyp": "9.0.0", "node-gyp": "9.0.0",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"nyc": "11.4.1", "nyc": "11.4.1",
"p-limit": "3.1.0",
"patch-package": "6.4.7", "patch-package": "6.4.7",
"playwright": "1.33.0", "playwright": "1.33.0",
"prettier": "2.8.0", "prettier": "2.8.0",

View File

@ -20,7 +20,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n, i18n,
id: overrideProps.id || '', id: overrideProps.id || '',
components: overrideProps.components, components: overrideProps.components,
renderText: overrideProps.renderText,
}); });
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
@ -29,18 +28,18 @@ const Template: Story<Props> = args => <Intl {...args} />;
export const NoReplacements = Template.bind({}); export const NoReplacements = Template.bind({});
NoReplacements.args = createProps({ NoReplacements.args = createProps({
id: 'deleteAndRestart', id: 'icu:deleteAndRestart',
}); });
export const SingleStringReplacement = Template.bind({}); export const SingleStringReplacement = Template.bind({});
SingleStringReplacement.args = createProps({ SingleStringReplacement.args = createProps({
id: 'leftTheGroup', id: 'icu:leftTheGroup',
components: { name: 'Theodora' }, components: { name: 'Theodora' },
}); });
export const SingleTagReplacement = Template.bind({}); export const SingleTagReplacement = Template.bind({});
SingleTagReplacement.args = createProps({ SingleTagReplacement.args = createProps({
id: 'leftTheGroup', id: 'icu:leftTheGroup',
components: { components: {
name: ( name: (
<button type="button" key="a-button"> <button type="button" key="a-button">
@ -52,7 +51,7 @@ SingleTagReplacement.args = createProps({
export const MultipleStringReplacement = Template.bind({}); export const MultipleStringReplacement = Template.bind({});
MultipleStringReplacement.args = createProps({ MultipleStringReplacement.args = createProps({
id: 'changedRightAfterVerify', id: 'icu:changedRightAfterVerify',
components: { components: {
name1: 'Fred', name1: 'Fred',
name2: 'The Fredster', name2: 'The Fredster',
@ -61,19 +60,22 @@ MultipleStringReplacement.args = createProps({
export const MultipleTagReplacement = Template.bind({}); export const MultipleTagReplacement = Template.bind({});
MultipleTagReplacement.args = createProps({ MultipleTagReplacement.args = createProps({
id: 'changedRightAfterVerify', id: 'icu:changedRightAfterVerify',
components: { components: {
name1: <b>Fred</b>, name1: <b>Fred</b>,
name2: <b>The Fredster</b>, name2: <b>The Fredster</b>,
}, },
}); });
export const CustomRender = Template.bind({}); export function Emoji(): JSX.Element {
CustomRender.args = createProps({ const customI18n = setupI18n('en', {
id: 'deleteAndRestart', 'icu:emoji': {
renderText: ({ text: theText, key }) => ( messageformat: '<emoji>👋</emoji> Hello, world!',
<div style={{ backgroundColor: 'purple', color: 'orange' }} key={key}> },
{theText} });
</div>
), return (
}); // eslint-disable-next-line local-rules/valid-i18n-keys
<Intl i18n={customI18n} id="icu:emoji" />
);
}

View File

@ -5,7 +5,7 @@ import React from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { FormatXMLElementFn } from 'intl-messageformat'; import type { FormatXMLElementFn } from 'intl-messageformat';
import type { LocalizerType, RenderTextCallbackType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import type { ReplacementValuesType } from '../types/I18N'; import type { ReplacementValuesType } from '../types/I18N';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
@ -23,118 +23,29 @@ export type Props = {
id: string; id: string;
i18n: LocalizerType; i18n: LocalizerType;
components?: IntlComponentsType; components?: IntlComponentsType;
renderText?: RenderTextCallbackType;
}; };
const defaultRenderText: RenderTextCallbackType = ({ text, key }) => ( export function Intl({
<React.Fragment key={key}>{text}</React.Fragment>
);
export class Intl extends React.Component<Props> {
public getComponent(
index: number,
placeholderName: string,
key: number
): JSX.Element | null {
const { id, components } = this.props;
if (!components) {
log.error(
`Error: Intl component prop not provided; Metadata: id '${id}', index ${index}, placeholder '${placeholderName}'`
);
return null;
}
if (Array.isArray(components)) {
if (!components || !components.length || components.length <= index) {
log.error(
`Error: Intl missing provided component for id '${id}', index ${index}`
);
return null;
}
return <React.Fragment key={key}>{components[index]}</React.Fragment>;
}
const value = components[placeholderName];
if (!value) {
log.error(
`Error: Intl missing provided component for id '${id}', placeholder '${placeholderName}'`
);
return null;
}
return <React.Fragment key={key}>{value}</React.Fragment>;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public override render() {
const {
components, components,
id, id,
// Indirection for linter/migration tooling // Indirection for linter/migration tooling
i18n: localizer, i18n: localizer,
renderText = defaultRenderText, }: Props): JSX.Element | null {
} = this.props;
if (!id) { if (!id) {
log.error('Error: Intl id prop not provided'); log.error('Error: Intl id prop not provided');
return null; return null;
} }
if (!localizer.isLegacyFormat(id)) { strictAssert(
!localizer.isLegacyFormat(id),
`Legacy message format is no longer supported ${id}`
);
strictAssert( strictAssert(
!Array.isArray(components), !Array.isArray(components),
`components cannot be an array for ICU message ${id}` `components cannot be an array for ICU message ${id}`
); );
const intl = localizer.getIntl(); const intl = localizer.getIntl();
return intl.formatMessage({ id }, components); return <>{intl.formatMessage({ id }, components, {})}</>;
}
const text = localizer(id);
const results: Array<
string | JSX.Element | Array<string | JSX.Element> | null
> = [];
const FIND_REPLACEMENTS = /\$([^$]+)\$/g;
if (Array.isArray(components) && components.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
);
}
let componentIndex = 0;
let key = 0;
let lastTextIndex = 0;
let match = FIND_REPLACEMENTS.exec(text);
if (!match) {
return renderText({ text, key: 0 });
}
while (match) {
if (lastTextIndex < match.index) {
const textWithNoReplacements = text.slice(lastTextIndex, match.index);
results.push(renderText({ text: textWithNoReplacements, key }));
key += 1;
}
const placeholderName = match[1];
results.push(this.getComponent(componentIndex, placeholderName, key));
componentIndex += 1;
key += 1;
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
match = FIND_REPLACEMENTS.exec(text);
}
if (lastTextIndex < text.length) {
results.push(renderText({ text: text.slice(lastTextIndex), key }));
key += 1;
}
return results;
}
} }

View File

@ -7,8 +7,7 @@ import moment from 'moment';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { Intl } from './Intl'; import { Intl } from './Intl';
import { Emojify } from './conversation/Emojify'; import type { LocalizerType } from '../types/Util';
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
export type PropsType = { export type PropsType = {
hideWhatsNewModal: () => unknown; hideWhatsNewModal: () => unknown;
@ -21,10 +20,6 @@ type ReleaseNotesType = {
features: Array<JSX.Element>; features: Array<JSX.Element>;
}; };
const renderText: RenderTextCallbackType = ({ key, text }) => (
<Emojify key={key} text={text} />
);
export function WhatsNewModal({ export function WhatsNewModal({
i18n, i18n,
hideWhatsNewModal, hideWhatsNewModal,
@ -35,11 +30,10 @@ export function WhatsNewModal({
date: new Date(window.getBuildCreation?.() || Date.now()), date: new Date(window.getBuildCreation?.() || Date.now()),
version: window.getVersion?.(), version: window.getVersion?.(),
features: [ features: [
<Intl i18n={i18n} id="icu:WhatsNew__v6.21--0" renderText={renderText} />, <Intl i18n={i18n} id="icu:WhatsNew__v6.21--0" />,
<Intl <Intl
i18n={i18n} i18n={i18n}
id="icu:WhatsNew__v6.21--1" id="icu:WhatsNew__v6.21--1"
renderText={renderText}
components={{ components={{
complexspaces: ( complexspaces: (
<a href="https://github.com/complexspaces">@complexspaces</a> <a href="https://github.com/complexspaces">@complexspaces</a>

View File

@ -1,66 +1,104 @@
// Copyright 2023 Signal Messenger, LLC // Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import fs from 'fs'; import chalk from 'chalk';
import execa from 'execa';
import fs from 'fs/promises';
import pLimit from 'p-limit';
import path from 'path'; import path from 'path';
import { spawnSync } from 'child_process';
import type { StdioOptions } from 'child_process';
import { MONTH } from '../util/durations'; import { MONTH } from '../util/durations';
import { isOlderThan } from '../util/timestamp'; import { isOlderThan } from '../util/timestamp';
const ROOT_DIR = path.join(__dirname, '..', '..'); const ROOT_DIR = path.join(__dirname, '..', '..');
const MESSAGES_FILE = path.join(ROOT_DIR, '_locales', 'en', 'messages.json'); const MESSAGES_FILE = path.join(ROOT_DIR, '_locales', 'en', 'messages.json');
const SPAWN_OPTS = {
cwd: ROOT_DIR,
stdio: [null, 'pipe', 'inherit'] as StdioOptions,
};
const messages = JSON.parse(fs.readFileSync(MESSAGES_FILE).toString()); const limitter = pLimit(10);
const stillUsed = new Set<string>(); async function main() {
const messages = JSON.parse(await fs.readFile(MESSAGES_FILE, 'utf-8'));
const stillUsed = new Set<string>();
await Promise.all(
Object.keys(messages).map(key =>
limitter(async () => {
const value = messages[key];
for (const [key, value] of Object.entries(messages)) {
const match = (value as Record<string, string>).description?.match( const match = (value as Record<string, string>).description?.match(
/\(\s*deleted\s+(\d{4}\/\d{2}\/\d{2})\s*\)/ /\(\s*deleted\s+(\d{2,4}\/\d{2}\/\d{2,4})\s*\)/
); );
if (!match) { if (!match) {
continue; return;
} }
const deletedAt = new Date(match[1]).getTime(); const deletedAt = new Date(match[1]).getTime();
if (!isOlderThan(deletedAt, MONTH)) { if (!isOlderThan(deletedAt, MONTH)) {
continue; return;
} }
// Find uses in either: // Find uses in either:
// - `i18n('key')` // - `i18n('key')`
// - `<Intl id="key"/>` // - `<Intl id="key"/>`
const { status, stdout } = spawnSync(
try {
const result = await execa(
'git', 'git',
['grep', '--extended-regexp', `'${key}'|id="${key}"`], // prettier-ignore
SPAWN_OPTS [
'grep',
'--extended-regexp',
`'${key}'|id="${key}"`,
'--',
'**',
':!\\_locales/**',
':!\\sticker-creator/**',
],
{
cwd: ROOT_DIR,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'inherit',
}
); );
// Match found // Match found
if (status === 0) {
console.error( console.error(
chalk.red(
`ERROR: String is still used: "${key}", deleted on ${match[1]}` `ERROR: String is still used: "${key}", deleted on ${match[1]}`
)
); );
console.error(stdout.toString().trim()); console.error(result.stdout.trim());
console.error(''); console.error('');
stillUsed.add(key); stillUsed.add(key);
} else { } catch (error) {
console.log(`Removing string: "${key}", deleted on ${match[1]}`); if (error.exitCode === 1) {
console.log(
chalk.dim(`Removing string: "${key}", deleted on ${match[1]}`)
);
delete messages[key]; delete messages[key];
} else {
throw error;
} }
} }
})
)
);
if (stillUsed.size !== 0) { if (stillUsed.size !== 0) {
console.error( console.error(
`ERROR: Didn't remove ${[...stillUsed]} strings because of errors above` `ERROR: Didn't remove ${stillUsed.size} strings because of errors above`,
Array.from(stillUsed)
.map(str => `- ${str}`)
.join('\n')
); );
console.error('ERROR: Not saving changes'); console.error('ERROR: Not saving changes');
process.exit(1); process.exit(1);
}
await fs.writeFile(MESSAGES_FILE, `${JSON.stringify(messages, null, 2)}\n`);
} }
fs.writeFileSync(MESSAGES_FILE, `${JSON.stringify(messages, null, 2)}\n`);
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@ -14,9 +14,18 @@ describe('setupI18n', () => {
}); });
describe('i18n', () => { describe('i18n', () => {
it('returns empty string for unknown string', () => { it('throws an error for legacy strings', () => {
assert.throws(() => {
// eslint-disable-next-line local-rules/valid-i18n-keys // eslint-disable-next-line local-rules/valid-i18n-keys
assert.strictEqual(i18n('random'), ''); i18n('legacystring');
}, /Legacy message format is no longer supported/);
});
it('throws an error for unknown string', () => {
assert.throws(() => {
// eslint-disable-next-line local-rules/valid-i18n-keys
assert.strictEqual(i18n('icu:random'), '');
}, /missing translation/);
}); });
it('returns message for given string', () => { it('returns message for given string', () => {
assert.strictEqual(i18n('icu:reportIssue'), 'Contact Support'); assert.strictEqual(i18n('icu:reportIssue'), 'Contact Support');

View File

@ -16,7 +16,6 @@ type SmartlingConfigType = {
}; };
export type LocaleMessageType = { export type LocaleMessageType = {
message?: string;
messageformat?: string; messageformat?: string;
description?: string; description?: string;
}; };

View File

@ -1,181 +0,0 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import memoize from '@formatjs/fast-memoize';
import type { IntlShape } from 'react-intl';
import { createIntl, createIntlCache } from 'react-intl';
import type { LocaleMessageType, LocaleMessagesType } from '../types/I18N';
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
import * as log from '../logging/log';
import { strictAssert } from './assert';
export const formatters = {
getNumberFormat: memoize((locale, opts) => {
return new Intl.NumberFormat(locale, opts);
}),
getDateTimeFormat: memoize((locale, opts) => {
return new Intl.DateTimeFormat(locale, opts);
}),
getPluralRules: memoize((locale, opts) => {
return new Intl.PluralRules(locale, opts);
}),
};
export function isLocaleMessageType(
value: unknown
): value is LocaleMessageType {
return (
typeof value === 'object' &&
value != null &&
(Object.hasOwn(value, 'message') || Object.hasOwn(value, 'messageformat'))
);
}
export function classifyMessages(messages: LocaleMessagesType): {
icuMessages: Record<string, string>;
legacyMessages: Record<string, string>;
} {
const icuMessages: Record<string, string> = {};
const legacyMessages: Record<string, string> = {};
for (const [key, value] of Object.entries(messages)) {
if (isLocaleMessageType(value)) {
if (value.messageformat != null) {
icuMessages[key] = value.messageformat;
} else if (value.message != null) {
legacyMessages[key] = value.message;
}
}
}
return { icuMessages, legacyMessages };
}
export function createCachedIntl(
locale: string,
icuMessages: Record<string, string>
): IntlShape {
const intlCache = createIntlCache();
const intl = createIntl(
{
locale: locale.replace('_', '-'), // normalize supported locales to browser format
messages: icuMessages,
},
intlCache
);
return intl;
}
export function formatIcuMessage(
intl: IntlShape,
id: string,
substitutions: ReplacementValuesType | undefined
): string {
strictAssert(
!Array.isArray(substitutions),
`substitutions must be an object for ICU message ${id}`
);
const result = intl.formatMessage({ id }, substitutions);
strictAssert(
typeof result === 'string',
'i18n: formatted translation result must be a string, must use <Intl/> component to render JSX'
);
return result;
}
export function setupI18n(
locale: string,
messages: LocaleMessagesType
): LocalizerType {
if (!locale) {
throw new Error('i18n: locale parameter is required');
}
if (!messages) {
throw new Error('i18n: messages parameter is required');
}
const { icuMessages, legacyMessages } = classifyMessages(messages);
const intl = createCachedIntl(locale, icuMessages);
const getMessage: LocalizerType = (key, substitutions) => {
const messageformat = icuMessages[key];
if (messageformat != null) {
return formatIcuMessage(intl, key, substitutions);
}
const message = legacyMessages[key];
if (message == null) {
log.error(
`i18n: Attempted to get translation for nonexistent key '${key}'`
);
return '';
}
if (Array.isArray(substitutions) && substitutions.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
);
}
if (
typeof substitutions === 'string' ||
typeof substitutions === 'number'
) {
throw new Error('You must provide either a map or an array');
}
if (!substitutions) {
return message;
}
if (Array.isArray(substitutions)) {
return substitutions.reduce(
(result, substitution) => result.replace(/\$.+?\$/, substitution),
message
);
}
const FIND_REPLACEMENTS = /\$([^$]+)\$/g;
let match = FIND_REPLACEMENTS.exec(message);
let builder = '';
let lastTextIndex = 0;
while (match) {
if (lastTextIndex < match.index) {
builder += message.slice(lastTextIndex, match.index);
}
const placeholderName = match[1];
let value = substitutions[placeholderName];
if (value == null) {
log.error(
`i18n: Value not provided for placeholder ${placeholderName} in key '${key}'`
);
value = '';
}
builder += value;
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
match = FIND_REPLACEMENTS.exec(message);
}
if (lastTextIndex < message.length) {
builder += message.slice(lastTextIndex);
}
return builder;
};
getMessage.getIntl = () => {
return intl;
};
getMessage.isLegacyFormat = (key: string) => {
return legacyMessages[key] != null;
};
getMessage.getLocale = () => locale;
getMessage.getLocaleMessages = () => messages;
getMessage.getLocaleDirection = () => {
return window.getResolvedMessagesLocaleDirection();
};
return getMessage;
}

110
ts/util/setupI18n.tsx Normal file
View File

@ -0,0 +1,110 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { IntlShape } from 'react-intl';
import { createIntl, createIntlCache } from 'react-intl';
import type { LocaleMessageType, LocaleMessagesType } from '../types/I18N';
import type { LocalizerType } from '../types/Util';
import { strictAssert } from './assert';
import { Emojify } from '../components/conversation/Emojify';
export function isLocaleMessageType(
value: unknown
): value is LocaleMessageType {
return (
typeof value === 'object' &&
value != null &&
Object.hasOwn(value, 'messageformat')
);
}
function filterLegacyMessages(
messages: LocaleMessagesType
): Record<string, string> {
const icuMessages: Record<string, string> = {};
for (const [key, value] of Object.entries(messages)) {
if (isLocaleMessageType(value) && value.messageformat != null) {
icuMessages[key] = value.messageformat;
}
}
return icuMessages;
}
export function renderEmoji(parts: ReadonlyArray<unknown>): JSX.Element {
strictAssert(parts.length === 1, '<emoji> must contain only one child');
const text = parts[0];
strictAssert(typeof text === 'string', '<emoji> must contain only text');
return <Emojify text={text} />;
}
export function createCachedIntl(
locale: string,
icuMessages: Record<string, string>
): IntlShape {
const intlCache = createIntlCache();
const intl = createIntl(
{
locale: locale.replace('_', '-'), // normalize supported locales to browser format
messages: icuMessages,
defaultRichTextElements: {
emoji: renderEmoji,
},
},
intlCache
);
return intl;
}
export function setupI18n(
locale: string,
messages: LocaleMessagesType
): LocalizerType {
if (!locale) {
throw new Error('i18n: locale parameter is required');
}
if (!messages) {
throw new Error('i18n: messages parameter is required');
}
const intl = createCachedIntl(locale, filterLegacyMessages(messages));
const localizer: LocalizerType = (key, substitutions) => {
strictAssert(
!localizer.isLegacyFormat(key),
`i18n: Legacy message format is no longer supported "${key}"`
);
strictAssert(
!Array.isArray(substitutions),
`i18n: Substitutions must be an object for ICU message "${key}"`
);
const result = intl.formatMessage({ id: key }, substitutions);
strictAssert(
typeof result === 'string',
'i18n: Formatted translation result must be a string, must use <Intl/> component to render JSX'
);
strictAssert(result !== key, `i18n: missing translation for "${key}"`);
return result;
};
localizer.getIntl = () => {
return intl;
};
localizer.isLegacyFormat = (key: string) => {
return !key.startsWith('icu:');
};
localizer.getLocale = () => locale;
localizer.getLocaleMessages = () => messages;
localizer.getLocaleDirection = () => {
return window.getResolvedMessagesLocaleDirection();
};
return localizer;
}

View File

@ -8949,6 +8949,21 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
md5.js "^1.3.4" md5.js "^1.3.4"
safe-buffer "^5.1.1" safe-buffer "^5.1.1"
execa@5.1.1, execa@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
dependencies:
cross-spawn "^7.0.3"
get-stream "^6.0.0"
human-signals "^2.1.0"
is-stream "^2.0.0"
merge-stream "^2.0.0"
npm-run-path "^4.0.1"
onetime "^5.1.2"
signal-exit "^3.0.3"
strip-final-newline "^2.0.0"
execa@^0.7.0: execa@^0.7.0:
version "0.7.0" version "0.7.0"
resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
@ -8991,21 +9006,6 @@ execa@^5.0.0:
signal-exit "^3.0.3" signal-exit "^3.0.3"
strip-final-newline "^2.0.0" strip-final-newline "^2.0.0"
execa@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
dependencies:
cross-spawn "^7.0.3"
get-stream "^6.0.0"
human-signals "^2.1.0"
is-stream "^2.0.0"
merge-stream "^2.0.0"
npm-run-path "^4.0.1"
onetime "^5.1.2"
signal-exit "^3.0.3"
strip-final-newline "^2.0.0"
expand-brackets@^0.1.4: expand-brackets@^0.1.4:
version "0.1.5" version "0.1.5"
resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
@ -14003,6 +14003,13 @@ p-finally@^2.0.0:
resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561"
integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==
p-limit@3.1.0, p-limit@^3.0.2, p-limit@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
dependencies:
yocto-queue "^0.1.0"
p-limit@^1.1.0: p-limit@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc"
@ -14021,13 +14028,6 @@ p-limit@^2.1.0:
dependencies: dependencies:
p-try "^2.0.0" p-try "^2.0.0"
p-limit@^3.0.2, p-limit@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
dependencies:
yocto-queue "^0.1.0"
p-locate@^2.0.0: p-locate@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"