Refactor i18n/intl utils, support icu only, remove renderText
This commit is contained in:
parent
e154d98688
commit
b76c7269f8
13 changed files with 361 additions and 6478 deletions
File diff suppressed because it is too large
Load diff
|
@ -14,6 +14,7 @@ import { deepEqual } from 'assert';
|
|||
import type { Rule } from './utils/rule';
|
||||
|
||||
import icuPrefix from './rules/icuPrefix';
|
||||
import wrapEmoji from './rules/wrapEmoji';
|
||||
import onePlural from './rules/onePlural';
|
||||
import noLegacyVariables from './rules/noLegacyVariables';
|
||||
import noNestedChoice from './rules/noNestedChoice';
|
||||
|
@ -24,6 +25,7 @@ import pluralPound from './rules/pluralPound';
|
|||
|
||||
const RULES = [
|
||||
icuPrefix,
|
||||
wrapEmoji,
|
||||
noLegacyVariables,
|
||||
noNestedChoice,
|
||||
noOffset,
|
||||
|
@ -74,6 +76,26 @@ const tests: Record<string, Test> = {
|
|||
messageformat: '$a$',
|
||||
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 = {
|
||||
|
|
73
build/intl-linter/rules/wrapEmoji.ts
Normal file
73
build/intl-linter/rules/wrapEmoji.ts
Normal 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
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
|
@ -277,12 +277,14 @@
|
|||
"eslint-plugin-mocha": "10.1.0",
|
||||
"eslint-plugin-more": "1.0.5",
|
||||
"eslint-plugin-react": "7.31.10",
|
||||
"execa": "5.1.1",
|
||||
"html-webpack-plugin": "5.3.1",
|
||||
"json-to-ast": "2.1.0",
|
||||
"mocha": "9.1.3",
|
||||
"node-gyp": "9.0.0",
|
||||
"npm-run-all": "4.1.5",
|
||||
"nyc": "11.4.1",
|
||||
"p-limit": "3.1.0",
|
||||
"patch-package": "6.4.7",
|
||||
"playwright": "1.33.0",
|
||||
"prettier": "2.8.0",
|
||||
|
|
|
@ -20,7 +20,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
i18n,
|
||||
id: overrideProps.id || '',
|
||||
components: overrideProps.components,
|
||||
renderText: overrideProps.renderText,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
|
@ -29,18 +28,18 @@ const Template: Story<Props> = args => <Intl {...args} />;
|
|||
|
||||
export const NoReplacements = Template.bind({});
|
||||
NoReplacements.args = createProps({
|
||||
id: 'deleteAndRestart',
|
||||
id: 'icu:deleteAndRestart',
|
||||
});
|
||||
|
||||
export const SingleStringReplacement = Template.bind({});
|
||||
SingleStringReplacement.args = createProps({
|
||||
id: 'leftTheGroup',
|
||||
id: 'icu:leftTheGroup',
|
||||
components: { name: 'Theodora' },
|
||||
});
|
||||
|
||||
export const SingleTagReplacement = Template.bind({});
|
||||
SingleTagReplacement.args = createProps({
|
||||
id: 'leftTheGroup',
|
||||
id: 'icu:leftTheGroup',
|
||||
components: {
|
||||
name: (
|
||||
<button type="button" key="a-button">
|
||||
|
@ -52,7 +51,7 @@ SingleTagReplacement.args = createProps({
|
|||
|
||||
export const MultipleStringReplacement = Template.bind({});
|
||||
MultipleStringReplacement.args = createProps({
|
||||
id: 'changedRightAfterVerify',
|
||||
id: 'icu:changedRightAfterVerify',
|
||||
components: {
|
||||
name1: 'Fred',
|
||||
name2: 'The Fredster',
|
||||
|
@ -61,19 +60,22 @@ MultipleStringReplacement.args = createProps({
|
|||
|
||||
export const MultipleTagReplacement = Template.bind({});
|
||||
MultipleTagReplacement.args = createProps({
|
||||
id: 'changedRightAfterVerify',
|
||||
id: 'icu:changedRightAfterVerify',
|
||||
components: {
|
||||
name1: <b>Fred</b>,
|
||||
name2: <b>The Fredster</b>,
|
||||
},
|
||||
});
|
||||
|
||||
export const CustomRender = Template.bind({});
|
||||
CustomRender.args = createProps({
|
||||
id: 'deleteAndRestart',
|
||||
renderText: ({ text: theText, key }) => (
|
||||
<div style={{ backgroundColor: 'purple', color: 'orange' }} key={key}>
|
||||
{theText}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
export function Emoji(): JSX.Element {
|
||||
const customI18n = setupI18n('en', {
|
||||
'icu:emoji': {
|
||||
messageformat: '<emoji>👋</emoji> Hello, world!',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||
<Intl i18n={customI18n} id="icu:emoji" />
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import React from 'react';
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
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 * as log from '../logging/log';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
@ -23,118 +23,29 @@ export type Props = {
|
|||
id: string;
|
||||
i18n: LocalizerType;
|
||||
components?: IntlComponentsType;
|
||||
renderText?: RenderTextCallbackType;
|
||||
};
|
||||
|
||||
const defaultRenderText: RenderTextCallbackType = ({ text, key }) => (
|
||||
<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>;
|
||||
export function Intl({
|
||||
components,
|
||||
id,
|
||||
// Indirection for linter/migration tooling
|
||||
i18n: localizer,
|
||||
}: Props): JSX.Element | null {
|
||||
if (!id) {
|
||||
log.error('Error: Intl id prop not provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
public override render() {
|
||||
const {
|
||||
components,
|
||||
id,
|
||||
// Indirection for linter/migration tooling
|
||||
i18n: localizer,
|
||||
renderText = defaultRenderText,
|
||||
} = this.props;
|
||||
strictAssert(
|
||||
!localizer.isLegacyFormat(id),
|
||||
`Legacy message format is no longer supported ${id}`
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
log.error('Error: Intl id prop not provided');
|
||||
return null;
|
||||
}
|
||||
strictAssert(
|
||||
!Array.isArray(components),
|
||||
`components cannot be an array for ICU message ${id}`
|
||||
);
|
||||
|
||||
if (!localizer.isLegacyFormat(id)) {
|
||||
strictAssert(
|
||||
!Array.isArray(components),
|
||||
`components cannot be an array for ICU message ${id}`
|
||||
);
|
||||
const intl = localizer.getIntl();
|
||||
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;
|
||||
}
|
||||
const intl = localizer.getIntl();
|
||||
return <>{intl.formatMessage({ id }, components, {})}</>;
|
||||
}
|
||||
|
|
|
@ -7,8 +7,7 @@ import moment from 'moment';
|
|||
|
||||
import { Modal } from './Modal';
|
||||
import { Intl } from './Intl';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
|
||||
export type PropsType = {
|
||||
hideWhatsNewModal: () => unknown;
|
||||
|
@ -21,10 +20,6 @@ type ReleaseNotesType = {
|
|||
features: Array<JSX.Element>;
|
||||
};
|
||||
|
||||
const renderText: RenderTextCallbackType = ({ key, text }) => (
|
||||
<Emojify key={key} text={text} />
|
||||
);
|
||||
|
||||
export function WhatsNewModal({
|
||||
i18n,
|
||||
hideWhatsNewModal,
|
||||
|
@ -35,11 +30,10 @@ export function WhatsNewModal({
|
|||
date: new Date(window.getBuildCreation?.() || Date.now()),
|
||||
version: window.getVersion?.(),
|
||||
features: [
|
||||
<Intl i18n={i18n} id="icu:WhatsNew__v6.21--0" renderText={renderText} />,
|
||||
<Intl i18n={i18n} id="icu:WhatsNew__v6.21--0" />,
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:WhatsNew__v6.21--1"
|
||||
renderText={renderText}
|
||||
components={{
|
||||
complexspaces: (
|
||||
<a href="https://github.com/complexspaces">@complexspaces</a>
|
||||
|
|
|
@ -1,66 +1,104 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// 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 { spawnSync } from 'child_process';
|
||||
import type { StdioOptions } from 'child_process';
|
||||
|
||||
import { MONTH } from '../util/durations';
|
||||
import { isOlderThan } from '../util/timestamp';
|
||||
|
||||
const ROOT_DIR = path.join(__dirname, '..', '..');
|
||||
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'));
|
||||
|
||||
for (const [key, value] of Object.entries(messages)) {
|
||||
const match = (value as Record<string, string>).description?.match(
|
||||
/\(\s*deleted\s+(\d{4}\/\d{2}\/\d{2})\s*\)/
|
||||
);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const stillUsed = new Set<string>();
|
||||
|
||||
const deletedAt = new Date(match[1]).getTime();
|
||||
if (!isOlderThan(deletedAt, MONTH)) {
|
||||
continue;
|
||||
}
|
||||
await Promise.all(
|
||||
Object.keys(messages).map(key =>
|
||||
limitter(async () => {
|
||||
const value = messages[key];
|
||||
|
||||
// Find uses in either:
|
||||
// - `i18n('key')`
|
||||
// - `<Intl id="key"/>`
|
||||
const { status, stdout } = spawnSync(
|
||||
'git',
|
||||
['grep', '--extended-regexp', `'${key}'|id="${key}"`],
|
||||
SPAWN_OPTS
|
||||
const match = (value as Record<string, string>).description?.match(
|
||||
/\(\s*deleted\s+(\d{2,4}\/\d{2}\/\d{2,4})\s*\)/
|
||||
);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedAt = new Date(match[1]).getTime();
|
||||
if (!isOlderThan(deletedAt, MONTH)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find uses in either:
|
||||
// - `i18n('key')`
|
||||
// - `<Intl id="key"/>`
|
||||
|
||||
try {
|
||||
const result = await execa(
|
||||
'git',
|
||||
// prettier-ignore
|
||||
[
|
||||
'grep',
|
||||
'--extended-regexp',
|
||||
`'${key}'|id="${key}"`,
|
||||
'--',
|
||||
'**',
|
||||
':!\\_locales/**',
|
||||
':!\\sticker-creator/**',
|
||||
],
|
||||
{
|
||||
cwd: ROOT_DIR,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'inherit',
|
||||
}
|
||||
);
|
||||
|
||||
// Match found
|
||||
console.error(
|
||||
chalk.red(
|
||||
`ERROR: String is still used: "${key}", deleted on ${match[1]}`
|
||||
)
|
||||
);
|
||||
console.error(result.stdout.trim());
|
||||
console.error('');
|
||||
stillUsed.add(key);
|
||||
} catch (error) {
|
||||
if (error.exitCode === 1) {
|
||||
console.log(
|
||||
chalk.dim(`Removing string: "${key}", deleted on ${match[1]}`)
|
||||
);
|
||||
delete messages[key];
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Match found
|
||||
if (status === 0) {
|
||||
if (stillUsed.size !== 0) {
|
||||
console.error(
|
||||
`ERROR: String is still used: "${key}", deleted on ${match[1]}`
|
||||
`ERROR: Didn't remove ${stillUsed.size} strings because of errors above`,
|
||||
Array.from(stillUsed)
|
||||
.map(str => `- ${str}`)
|
||||
.join('\n')
|
||||
);
|
||||
console.error(stdout.toString().trim());
|
||||
console.error('');
|
||||
stillUsed.add(key);
|
||||
} else {
|
||||
console.log(`Removing string: "${key}", deleted on ${match[1]}`);
|
||||
delete messages[key];
|
||||
console.error('ERROR: Not saving changes');
|
||||
process.exit(1);
|
||||
}
|
||||
await fs.writeFile(MESSAGES_FILE, `${JSON.stringify(messages, null, 2)}\n`);
|
||||
}
|
||||
|
||||
if (stillUsed.size !== 0) {
|
||||
console.error(
|
||||
`ERROR: Didn't remove ${[...stillUsed]} strings because of errors above`
|
||||
);
|
||||
console.error('ERROR: Not saving changes');
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(MESSAGES_FILE, `${JSON.stringify(messages, null, 2)}\n`);
|
||||
});
|
||||
|
|
|
@ -14,9 +14,18 @@ describe('setupI18n', () => {
|
|||
});
|
||||
|
||||
describe('i18n', () => {
|
||||
it('returns empty string for unknown string', () => {
|
||||
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||
assert.strictEqual(i18n('random'), '');
|
||||
it('throws an error for legacy strings', () => {
|
||||
assert.throws(() => {
|
||||
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||
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', () => {
|
||||
assert.strictEqual(i18n('icu:reportIssue'), 'Contact Support');
|
||||
|
|
|
@ -16,7 +16,6 @@ type SmartlingConfigType = {
|
|||
};
|
||||
|
||||
export type LocaleMessageType = {
|
||||
message?: string;
|
||||
messageformat?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
|
|
@ -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
110
ts/util/setupI18n.tsx
Normal 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;
|
||||
}
|
44
yarn.lock
44
yarn.lock
|
@ -8949,6 +8949,21 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
|
|||
md5.js "^1.3.4"
|
||||
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:
|
||||
version "0.7.0"
|
||||
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"
|
||||
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:
|
||||
version "0.1.5"
|
||||
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"
|
||||
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:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc"
|
||||
|
@ -14021,13 +14028,6 @@ p-limit@^2.1.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
|
||||
|
|
Loading…
Reference in a new issue