/ shared / localization / src / translator.ts
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;