177 lines
4.7 KiB
TypeScript
177 lines
4.7 KiB
TypeScript
// 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;
|
|
|
|
return getMessage;
|
|
}
|