Refactor i18n/intl utils, support icu only, remove renderText
This commit is contained in:
parent
e154d98688
commit
b76c7269f8
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 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 = {
|
||||||
|
|
|
@ -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-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",
|
||||||
|
|
|
@ -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" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -16,7 +16,6 @@ type SmartlingConfigType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LocaleMessageType = {
|
export type LocaleMessageType = {
|
||||||
message?: string;
|
|
||||||
messageformat?: string;
|
messageformat?: string;
|
||||||
description?: 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;
|
|
||||||
}
|
|
|
@ -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"
|
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"
|
||||||
|
|
Loading…
Reference in New Issue