// eslint-disable-next-line no-restricted-imports import type { I18nMethods } from '../types/localizer'; import { CrowdinLocale } from './constants'; import { pluralsDictionary, simpleDictionary } from './locales'; type SimpleDictionary = typeof simpleDictionary; type PluralDictionary = typeof pluralsDictionary; export type SimpleLocalizerTokens = keyof SimpleDictionary; type PluralLocalizerTokens = keyof PluralDictionary; export type MergedLocalizerTokens = SimpleLocalizerTokens | PluralLocalizerTokens; let localeInUse: CrowdinLocale = 'en'; type Logger = (message: string) => void; let logger: Logger | undefined; /** * Simpler than lodash. Duplicated to avoid having to import lodash in the file. * Because we share it with QA, but also to have a self contained localized tool that we can copy/paste */ function isEmptyObject(obj: unknown) { if (!obj) { return true; } if (typeof obj !== 'object') { return false; } return Object.keys(obj).length === 0; } export function setLogger(cb: Logger) { if (logger) { // eslint-disable-next-line no-console console.log('logger already initialized. overwriding it'); } logger = cb; } export function setLocaleInUse(crowdinLocale: CrowdinLocale) { localeInUse = crowdinLocale; } function log(message: Parameters[0]) { if (!logger) { // eslint-disable-next-line no-console console.log('logger is not set'); return; } logger(message); } export function isSimpleToken(token: string): token is SimpleLocalizerTokens { return token in simpleDictionary; } export function isPluralToken(token: string): token is PluralLocalizerTokens { return token in pluralsDictionary; } /** * This type extracts from a dictionary, the keys that have a property 'args' set (i.e. not undefined or never). */ type TokenWithArgs = { [Key in keyof Dict]: Dict[Key] extends { args: undefined } | { args: never } ? never : Key; }[keyof Dict]; type MergedTokenWithArgs = TokenWithArgs | TokenWithArgs; export function isTokenWithArgs(token: string): token is MergedTokenWithArgs { return ( (isSimpleToken(token) && !isEmptyObject(simpleDictionary[token]?.args)) || (isPluralToken(token) && !isEmptyObject(pluralsDictionary[token]?.args)) ); } type DynamicArgStr = 'string' | 'number'; export type LocalizerDictionary = SimpleDictionary; type ArgsTypeStrToTypes = T extends 'string' ? string : T extends 'number' ? number : never; // those are still a string of the type "string" | "number" and not the typescript types themselves type ArgsFromTokenStr = T extends SimpleLocalizerTokens ? SimpleDictionary[T] extends { args: infer A } ? A extends Record ? A : never : never : T extends PluralLocalizerTokens ? PluralDictionary[T] extends { args: infer A } ? A extends Record ? A : never : never : never; export type ArgsFromToken = MappedToTsTypes>; /** The arguments for retrieving a localized message */ export type GetMessageArgs = T extends MergedLocalizerTokens ? T extends MergedTokenWithArgs ? [T, ArgsFromToken] : [T] : never; type MappedToTsTypes> = { [K in keyof T]: ArgsTypeStrToTypes; }; function propsToTuple( opts: LocalizerComponentProps ): GetMessageArgs { return ( isTokenWithArgs(opts.token) ? [opts.token, opts.args] : [opts.token] ) as GetMessageArgs; } /** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.inEnglish } and {@link window.i18n.inEnglish } */ /** * Retrieves a message string in the {@link en} locale, substituting variables where necessary. * * NOTE: This does not work for plural strings. This function should only be used for debug and * non-user-facing strings. Plural string support can be added splitting out the logic for * {@link setupI18n.formatMessageWithArgs} and creating a new getMessageFromDictionary, which * specifies takes a dictionary as an argument. This is left as an exercise for the reader. * @deprecated this will eventually be replaced by LocalizedStringBuilder * * @param token - The token identifying the message to retrieve. * @param args - An optional record of substitution variables and their replacement values. This is equired if the string has dynamic variables. */ export const inEnglish: I18nMethods['inEnglish'] = token => { if (!isSimpleToken(token)) { throw new Error('inEnglish only supports simple strings for now'); } const rawMessage = simpleDictionary[token].en; if (!rawMessage) { log(`Attempted to get forced en string for nonexistent key: '${token}' in fallback dictionary`); return token; } return formatMessageWithArgs(rawMessage); }; /** * Retrieves a localized message string, substituting variables where necessary. * * @param token - The token identifying the message to retrieve. * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. * * @returns The localized message string with substitutions applied. */ export function getMessageDefault( ...props: GetMessageArgs ): string { const token = props[0]; try { return localizeFromOld(props[0], props[1] as ArgsFromToken).toString(); } catch (error) { log(error.message); return token; } } /** * Retrieves a localized message string, substituting variables where necessary. Then strips the message of any HTML and custom tags. * * @deprecated This will eventually be replaced altogether by LocalizedStringBuilder * * @param token - The token identifying the message to retrieve. * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. * * @returns The localized message string with substitutions applied. Any HTML and custom tags are removed. */ export const stripped: I18nMethods['stripped'] = (...[token, args]) => { const sanitizedArgs = args ? sanitizeArgs(args, '\u200B') : undefined; // Note: the `as any` is needed because we don't have the template argument available // when enforcing the type of the stripped function to be the one defined by I18nMethods const i18nString = getMessageDefault(...([token, sanitizedArgs] as GetMessageArgs)); const strippedString = i18nString.replaceAll(/<[^>]*>/g, ''); return deSanitizeHtmlTags(strippedString, '\u200B'); }; export const strippedWithObj: I18nMethods['strippedWithObj'] = opts => { return stripped(...propsToTuple(opts)); }; /** * Sanitizes the args to be used in the i18n function * @param args The args to sanitize * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. * @returns The sanitized args */ export function sanitizeArgs( args: Record, identifier?: string ): Record { return Object.fromEntries( Object.entries(args).map(([key, value]) => [ key, typeof value === 'string' ? sanitizeHtmlTags(value, identifier) : value, ]) ); } /** * Formats a localized message string with arguments and returns the formatted string. * @param rawMessage - The raw message string to format. After using @see {@link getRawMessage} to get the raw string. * @param args - An optional record of substitution variables and their replacement values. This * is required if the string has dynamic variables. This can be optional as a strings args may be defined in @see {@link LOCALE_DEFAULTS} * * @returns The formatted message string. * * @deprecated * */ export const formatMessageWithArgs: I18nMethods['formatMessageWithArgs'] = (rawMessage, args) => { /** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */ return rawMessage.replace(/\{(\w+)\}/g, (match: any, arg: string) => { const matchedArg = args ? args[arg as keyof typeof args] : undefined; return matchedArg?.toString() ?? match; }); }; /** * Retrieves a localized message string, without substituting any variables. This resolves any plural forms using the given args * @param token - The token identifying the message to retrieve. * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. * * @returns The localized message string with substitutions applied. * * NOTE: This is intended to be used to get the raw string then format it with {@link formatMessageWithArgs} */ export const getRawMessage: I18nMethods['getRawMessage'] = (crowdinLocale, ...[token, args]) => { try { if ( typeof window !== 'undefined' && window?.sessionFeatureFlags?.replaceLocalizedStringsWithKeys ) { return token; } if (isSimpleToken(token)) { return simpleDictionary[token][crowdinLocale]; } if (!isPluralToken(token)) { throw new Error('invalid token, neither simple nor plural'); } const pluralsObjects = pluralsDictionary[token]; const localePluralsObject = pluralsObjects[crowdinLocale]; if (!localePluralsObject || isEmptyObject(localePluralsObject)) { log(`Attempted to get translation for nonexistent key: '${token}'`); return token; } const num = args && 'count' in args ? args.count : 0; const cardinalRule = new Intl.PluralRules(crowdinLocale).select(num); const pluralString = getStringForRule({ dictionary: pluralsDictionary, crowdinLocale, cardinalRule, token, }); if (!pluralString) { log(`Plural string not found for cardinal '${cardinalRule}': '${pluralString}'`); return token; } return pluralString.replaceAll('#', `${num}`); } catch (error) { log(error.message); return token; } }; function getStringForRule({ dictionary, token, crowdinLocale, cardinalRule, }: { dictionary: PluralDictionary; token: PluralLocalizerTokens; crowdinLocale: CrowdinLocale; cardinalRule: Intl.LDMLPluralRule; }) { const dictForLocale = dictionary[token][crowdinLocale]; return cardinalRule in dictForLocale ? ((dictForLocale as any)[cardinalRule] as string) : token; } /** * Replaces all html tag identifiers with their escaped equivalents * @param str The string to sanitize * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. * @returns The sanitized string */ function sanitizeHtmlTags(str: string, identifier: string = ''): string { if (identifier && /[a-zA-Z0-9>/g, `${identifier}>${identifier}`); } /** * Replaces all sanitized html tags with their real equivalents * @param str The string to de-sanitize * @param identifier The identifier used when the args were sanitized * @returns The de-sanitized string */ function deSanitizeHtmlTags(str: string, identifier: string): string { if (!identifier || /[a-zA-Z0-9>'); } class LocalizedStringBuilder extends String { private readonly token: T; private args?: ArgsFromToken; private isStripped = false; private isEnglishForced = false; private crowdinLocale: CrowdinLocale; private readonly renderStringAsToken: boolean; constructor(token: T, crowdinLocale: CrowdinLocale, renderStringAsToken?: boolean) { super(token); this.token = token; this.crowdinLocale = crowdinLocale; this.renderStringAsToken = renderStringAsToken || false; } public toString(): string { try { if (this.renderStringAsToken) { return this.token; } const rawString = this.getRawString(); const str = this.formatStringWithArgs(rawString); if (this.isStripped) { return this.postProcessStrippedString(str); } return str; } catch (error) { log(error); return this.token; } } withArgs(args: ArgsFromToken): Omit { this.args = args; return this; } forceEnglish(): Omit { this.isEnglishForced = true; return this; } strip(): Omit { const sanitizedArgs = this.args ? sanitizeArgs(this.args, '\u200B') : undefined; if (sanitizedArgs) { this.args = sanitizedArgs as ArgsFromToken; } this.isStripped = true; return this; } private postProcessStrippedString(str: string): string { const strippedString = str.replaceAll(/<[^>]*>/g, ''); return deSanitizeHtmlTags(strippedString, '\u200B'); } private localeToTarget(): CrowdinLocale { return this.isEnglishForced ? 'en' : this.crowdinLocale; } private getRawString(): string { try { if (this.renderStringAsToken) { return this.token; } if (isSimpleToken(this.token)) { return simpleDictionary[this.token][this.localeToTarget()]; } if (!isPluralToken(this.token)) { throw new Error('invalid token provided'); } return this.resolvePluralString(); } catch (error) { log(error.message); return this.token; } } private resolvePluralString(): string { const pluralKey = 'count' as const; let num: number | string | undefined = this.args?.[pluralKey as keyof ArgsFromToken]; if (num === undefined) { log( `Attempted to get plural count for missing argument '${pluralKey} for token '${this.token}'` ); num = 0; } if (typeof num !== 'number') { log( `Attempted to get plural count for argument '${pluralKey}' which is not a number for token '${this.token}'` ); num = parseInt(num, 10); if (Number.isNaN(num)) { log( `Attempted to get parsed plural count for argument '${pluralKey}' which is not a number for token '${this.token}'` ); num = 0; } } const localeToTarget = this.localeToTarget(); const cardinalRule = new Intl.PluralRules(localeToTarget).select(num); if (!isPluralToken(this.token)) { throw new Error('resolvePluralString can only be called with a plural string'); } let pluralString = getStringForRule({ cardinalRule, crowdinLocale: localeToTarget, dictionary: pluralsDictionary, token: this.token, }); if (!pluralString) { log( `Plural string not found for cardinal '${cardinalRule}': '${this.token}' Falling back to 'other' cardinal` ); pluralString = getStringForRule({ cardinalRule: 'other', crowdinLocale: localeToTarget, dictionary: pluralsDictionary, token: this.token, }); if (!pluralString) { log(`Plural string not found for fallback cardinal 'other': '${this.token}'`); return this.token; } } return pluralString.replaceAll('#', `${num}`); } private formatStringWithArgs(str: string): string { /** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */ return str.replace(/\{(\w+)\}/g, (match, arg: string) => { const matchedArg = this.args ? this.args[arg as keyof ArgsFromToken]?.toString() : undefined; return matchedArg ?? match; }); } } export function localize(token: T) { return new LocalizedStringBuilder(token, localeInUse); } export function localizeFromOld(token: T, args: ArgsFromToken) { return localize(token).withArgs(args); } export type LocalizerHtmlTag = 'span' | 'div'; /** Basic props for all calls of the Localizer component */ type LocalizerComponentBaseProps = { token: T; asTag?: LocalizerHtmlTag; className?: string; }; /** The props for the localization component */ export type LocalizerComponentProps = T extends MergedLocalizerTokens ? ArgsFromToken extends never ? LocalizerComponentBaseProps & { args?: undefined } : ArgsFromToken extends Record ? LocalizerComponentBaseProps & { args?: undefined } : LocalizerComponentBaseProps & { args: ArgsFromToken } : never; export type LocalizerComponentPropsObject = LocalizerComponentProps;