translator.ts
1 //TODO: rdar://73157363 (Limit loc plural functions to only use supported locales) 2 import * as cardinals from 'make-plural/cardinals'; 3 import type { 4 Locale, 5 ILocaleJSON, 6 InterpolationOptions, 7 TranslatorOptions, 8 ImissingInterpolationFn, 9 ImissingKeyFn, 10 ITranslator, 11 } from './types'; 12 13 const DEFAULT_MISSING_FN: ImissingKeyFn = (key: string): string => `**${key}**`; 14 const DEFAULT_INTERPOLATION_REGEX: RegExp = /@@(.*?)@@/g; 15 16 /** 17 * Interpolates string and returns result. 18 * @category Localization 19 * @param phrase phrase to be interpolated ex. ```"hello my name is @@name@@" ``` 20 * @param options object containing values to subsitute ex. ``` { name: "Joe" } ``` 21 * @param onMissingInterpolationFn callback to be called if options object does not contain a value for the interpolation schema 22 * 23 * @returns translated string ex ``` "hello my name is Joe" ``` 24 */ 25 export function interpolateString( 26 key: string, 27 phrase: string, 28 options: InterpolationOptions, 29 onMissingInterpolationFn: ImissingInterpolationFn | null, 30 locale: Locale, 31 ): string { 32 const result = phrase.replace( 33 DEFAULT_INTERPOLATION_REGEX, 34 function (expression: string, argument: string) { 35 const optionHasProperty = options.hasOwnProperty(argument); 36 const optionType = typeof options[argument]; 37 const argumentIsUndefined = optionType === 'undefined'; 38 const argumentIsValid = 39 optionType === 'string' || optionType === 'number'; 40 let value: string = expression; 41 if (optionHasProperty && argumentIsValid) { 42 let validValue: string | number = options[argument]; 43 if ( 44 optionType === 'number' && 45 options.hasOwnProperty('count') 46 ) { 47 validValue = (validValue as number).toLocaleString([ 48 locale, 49 'en-US', 50 ]); 51 } 52 value = validValue as string; 53 } else if (onMissingInterpolationFn && argumentIsUndefined) { 54 onMissingInterpolationFn(key, value); 55 } 56 return value; 57 }, 58 ); 59 60 return result; 61 } 62 63 type Cardinal = (n: number | string) => cardinals.PluralCategory; 64 65 function getCardinal(selectedLang: string): Cardinal | undefined { 66 // @ts-ignore-error TypeScript does not allow us to index into a namespace dynamically 67 return cardinals[selectedLang]; 68 } 69 70 /** 71 * TODO: rdar://73157363 (Limit loc plural functions to only use supported locales) 72 * Used to select the locale specific cardinal plural form key. 73 * @category Localization 74 * @param count number to determine the cardinal value 75 * @param key base key 76 * @param locale to lookup plural 77 * 78 * Reference: 79 * https://confluence.sd.apple.com/pages/viewpage.action?spaceKey=ASL&title=Pluralization+Rules 80 * 81 * @returns key + correct plural ex. ```[key].[ 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'] ``` 82 */ 83 84 export const getPlural = ( 85 count: number, 86 key: string, 87 locale: Locale, 88 ): string => { 89 const lang = locale.split('-')[0]; 90 91 // use english plural for dev strings 92 const selectedLang = lang === 'dev' ? 'en' : lang; 93 const cardinal = getCardinal(selectedLang); 94 95 let plural: cardinals.PluralCategory | null = null; 96 if (cardinal) { 97 plural = cardinal(count); 98 // TODO: rdar://93665757 (JMOTW: investigate where to use 'few' and 'many' loc keys) 99 if (plural === 'few' || plural === 'many') plural = 'other'; 100 } 101 return plural ? `${key}.${plural}` : key; 102 }; 103 104 /** 105 * Class that manages translations, plural rules, 106 * and interpolation for a single locale. 107 * @category Localization 108 */ 109 class Translator implements ITranslator { 110 private translationMap: Map<string, string>; 111 private locale: Locale; 112 private onMissingKeyFn: ImissingKeyFn; 113 private onMissingInterpolationFn: ImissingInterpolationFn | null; 114 constructor( 115 locale: Locale, 116 phrases: ILocaleJSON, 117 options: TranslatorOptions = {}, 118 ) { 119 const { 120 onMissingKeyFn = DEFAULT_MISSING_FN, 121 onMissingInterpolationFn = null, 122 } = options; 123 this.locale = locale; 124 this.translationMap = new Map(Object.entries(phrases)); 125 this.onMissingKeyFn = onMissingKeyFn; 126 this.onMissingInterpolationFn = onMissingInterpolationFn; 127 } 128 129 /** 130 * Gets the correct value from the translation map. 131 * @category Localization 132 * @param key used to look up the value 133 */ 134 private getValue(key: string): string | null { 135 return this.translationMap.get(key) || null; 136 } 137 /** 138 * Gets an uniterpolated value of key. 139 * @category Localization 140 * @param key used to look up the value 141 */ 142 getUninterpolatedString(key: string) { 143 const keyValue = this.getValue(key); 144 return keyValue ? keyValue : this.onMissingKeyFn(key); 145 } 146 /** 147 * Translate string based on translation map, plural rules interpolates values. 148 * @category Localization 149 * @param key used to look up the value 150 * @param options used for interpolation 151 * @returns translated string 152 */ 153 translate(key: string, options: InterpolationOptions = {}): string { 154 let internalKey = key; 155 const { count } = options; 156 157 if (count && !isNaN(count)) { 158 internalKey = getPlural(count, key, this.locale); 159 } 160 161 const keyValue = this.getValue(internalKey); 162 return keyValue 163 ? interpolateString( 164 internalKey, 165 keyValue, 166 options, 167 this.onMissingInterpolationFn, 168 this.locale, 169 ) 170 : this.onMissingKeyFn(internalKey); 171 } 172 } 173 174 export default Translator;