summaryrefslogtreecommitdiff
path: root/shared/localization/src/translator.ts
blob: 48b901f81a17b0e06e5eab88d7640851819cbecf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
//TODO: rdar://73157363 (Limit loc plural functions to only use supported locales)
import * as cardinals from 'make-plural/cardinals';
import type {
    Locale,
    ILocaleJSON,
    InterpolationOptions,
    TranslatorOptions,
    ImissingInterpolationFn,
    ImissingKeyFn,
    ITranslator,
} from './types';

const DEFAULT_MISSING_FN: ImissingKeyFn = (key: string): string => `**${key}**`;
const DEFAULT_INTERPOLATION_REGEX: RegExp = /@@(.*?)@@/g;

/**
 * Interpolates string and returns result.
 * @category Localization
 * @param phrase phrase to be interpolated ex. ```"hello my name is @@name@@" ```
 * @param options object containing values to subsitute ex. ``` { name: "Joe" } ```
 * @param onMissingInterpolationFn callback to be called if options object does not contain a value for the interpolation schema
 *
 * @returns translated string ex ``` "hello my name is Joe" ```
 */
export function interpolateString(
    key: string,
    phrase: string,
    options: InterpolationOptions,
    onMissingInterpolationFn: ImissingInterpolationFn | null,
    locale: Locale,
): string {
    const result = phrase.replace(
        DEFAULT_INTERPOLATION_REGEX,
        function (expression: string, argument: string) {
            const optionHasProperty = options.hasOwnProperty(argument);
            const optionType = typeof options[argument];
            const argumentIsUndefined = optionType === 'undefined';
            const argumentIsValid =
                optionType === 'string' || optionType === 'number';
            let value: string = expression;
            if (optionHasProperty && argumentIsValid) {
                let validValue: string | number = options[argument];
                if (
                    optionType === 'number' &&
                    options.hasOwnProperty('count')
                ) {
                    validValue = (validValue as number).toLocaleString([
                        locale,
                        'en-US',
                    ]);
                }
                value = validValue as string;
            } else if (onMissingInterpolationFn && argumentIsUndefined) {
                onMissingInterpolationFn(key, value);
            }
            return value;
        },
    );

    return result;
}

type Cardinal = (n: number | string) => cardinals.PluralCategory;

function getCardinal(selectedLang: string): Cardinal | undefined {
    // @ts-ignore-error TypeScript does not allow us to index into a namespace dynamically
    return cardinals[selectedLang];
}

/**
 * TODO: rdar://73157363 (Limit loc plural functions to only use supported locales)
 * Used to select the locale specific cardinal plural form key.
 * @category Localization
 * @param count number to determine the cardinal value
 * @param key base key
 * @param locale to lookup plural
 *
 * Reference:
 * https://confluence.sd.apple.com/pages/viewpage.action?spaceKey=ASL&title=Pluralization+Rules
 *
 * @returns key + correct plural ex. ```[key].[ 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'] ```
 */

export const getPlural = (
    count: number,
    key: string,
    locale: Locale,
): string => {
    const lang = locale.split('-')[0];

    // use english plural for dev strings
    const selectedLang = lang === 'dev' ? 'en' : lang;
    const cardinal = getCardinal(selectedLang);

    let plural: cardinals.PluralCategory | null = null;
    if (cardinal) {
        plural = cardinal(count);
        // TODO: rdar://93665757 (JMOTW: investigate where to use 'few' and 'many' loc keys)
        if (plural === 'few' || plural === 'many') plural = 'other';
    }
    return plural ? `${key}.${plural}` : key;
};

/**
 * Class that manages translations, plural rules,
 * and interpolation for a single locale.
 * @category Localization
 */
class Translator implements ITranslator {
    private translationMap: Map<string, string>;
    private locale: Locale;
    private onMissingKeyFn: ImissingKeyFn;
    private onMissingInterpolationFn: ImissingInterpolationFn | null;
    constructor(
        locale: Locale,
        phrases: ILocaleJSON,
        options: TranslatorOptions = {},
    ) {
        const {
            onMissingKeyFn = DEFAULT_MISSING_FN,
            onMissingInterpolationFn = null,
        } = options;
        this.locale = locale;
        this.translationMap = new Map(Object.entries(phrases));
        this.onMissingKeyFn = onMissingKeyFn;
        this.onMissingInterpolationFn = onMissingInterpolationFn;
    }

    /**
     * Gets the correct value from the translation map.
     * @category Localization
     * @param key used to look up the value
     */
    private getValue(key: string): string | null {
        return this.translationMap.get(key) || null;
    }
    /**
     * Gets an uniterpolated value of key.
     * @category Localization
     * @param key used to look up the value
     */
    getUninterpolatedString(key: string) {
        const keyValue = this.getValue(key);
        return keyValue ? keyValue : this.onMissingKeyFn(key);
    }
    /**
     * Translate string based on translation map, plural rules interpolates values.
     * @category Localization
     * @param key used to look up the value
     * @param options used for interpolation
     * @returns translated string
     */
    translate(key: string, options: InterpolationOptions = {}): string {
        let internalKey = key;
        const { count } = options;

        if (count && !isNaN(count)) {
            internalKey = getPlural(count, key, this.locale);
        }

        const keyValue = this.getValue(internalKey);
        return keyValue
            ? interpolateString(
                  internalKey,
                  keyValue,
                  options,
                  this.onMissingInterpolationFn,
                  this.locale,
              )
            : this.onMissingKeyFn(internalKey);
    }
}

export default Translator;