diff options
Diffstat (limited to 'shared/localization/src')
| -rw-r--r-- | shared/localization/src/getLocAttributes.ts | 78 | ||||
| -rw-r--r-- | shared/localization/src/getPageDir.ts | 40 | ||||
| -rw-r--r-- | shared/localization/src/i18n.ts | 104 | ||||
| -rw-r--r-- | shared/localization/src/setHTMLAttributes.ts | 15 | ||||
| -rw-r--r-- | shared/localization/src/translator.ts | 174 |
5 files changed, 411 insertions, 0 deletions
diff --git a/shared/localization/src/getLocAttributes.ts b/shared/localization/src/getLocAttributes.ts new file mode 100644 index 0000000..2f462db --- /dev/null +++ b/shared/localization/src/getLocAttributes.ts @@ -0,0 +1,78 @@ +import { getPageDir } from './getPageDir'; + +/** + * Checks if a string contains language script + * ex. "zh-Hant-HK", "zh-Hant-TW", "zh-Hans-CN" + * @param {string} locale + * @returns {boolean} + */ +const hasSupportedLanguageScript = (locale: string): boolean => { + const SUPPORTED_SCRIPTS = ['-hans-', '-hant-']; + + const formattedLocale = locale.toLowerCase(); + return SUPPORTED_SCRIPTS.some((item) => formattedLocale.includes(item)); +}; + +/** + * + * BCP47 https://www.w3.org/International/articles/language-tags/ + * + * @param {string} language https://en.wikipedia.org/wiki/ISO_639 + * @param {string} region https://en.wikipedia.org/wiki/ISO_3166-1 + * @param {string} script https://en.wikipedia.org/wiki/ISO_15924 + + */ +const buildBcp47String = ( + language: string, + region: string, + script?: string, +): string => { + let capitalizeScript: string | null = null; + if (script) { + capitalizeScript = + script[0].toUpperCase() + script.substring(1).toLowerCase(); + } + let bcp47Arr = [ + language.toLowerCase(), + capitalizeScript, + region.toUpperCase(), + ]; + + return bcp47Arr.filter((item) => item !== null).join('-'); +}; + +/** + * @description + * get values to be used in <html> tag lang and dir attributes. + * + * @param {string} locale + * @returns { { dir: 'rtl' | 'ltr', lang: string }} HTML dir + lang values + */ + +export function getLocAttributes(locale: string): { + dir: 'rtl' | 'ltr'; + lang: string; +} { + const pageDir = getPageDir(locale); + let bcp47 = locale; + + const localeStrings = locale.split('-'); + + // region index in array + const regionIndex = hasSupportedLanguageScript(locale) ? 2 : 1; + + const language = localeStrings[0]; + const script = hasSupportedLanguageScript(locale) + ? localeStrings[1] + : undefined; + const region = localeStrings[regionIndex]; + + if (language && region) { + bcp47 = buildBcp47String(language, region, script); + } + + return { + dir: pageDir, + lang: bcp47, + }; +} diff --git a/shared/localization/src/getPageDir.ts b/shared/localization/src/getPageDir.ts new file mode 100644 index 0000000..47b855d --- /dev/null +++ b/shared/localization/src/getPageDir.ts @@ -0,0 +1,40 @@ +/** + * TODO: rdar://73010072 (Make localization utils its own package) + * Copied from: + * https://github.pie.apple.com/amp-ui/desktop-music-app/blob/main/app/utils/page-dir.js + */ + +// these overrides were determined to always show page in RTL, even if the global elements dont contain +// an he_il entry +// <rdar://problem/49297213> LOC: IW-IL: RTL: Web Preview Pages: The Preview Pages are not RTL. +const RTL_LANG_CODES_OVERRIDE = [ + 'he', // hebrew +]; + +const RTL_LANG_CODES = [ + 'ar', // arabic + 'he', // hebrew + 'ku', // kurdish + 'ur', // urdu + 'ps', // pashto + 'yi', // yiddish +]; + +/** + * Determine the page-direction for a given locale + * + * @param {String} localeCode - A string containing a language code and region code separated by a hyphen. + * @param {String|undefined|null} langParam - A language code passed from the `l=` query param. + */ +export function getPageDir( + localeCode: string, + langParam: string | undefined | null = null, +) { + const twoLettersLangCode = localeCode.split('-')[0]; + const isRTLLang = RTL_LANG_CODES.includes(twoLettersLangCode); + const isRTLLangOverride = + typeof langParam === 'string' && + RTL_LANG_CODES_OVERRIDE.includes(langParam); + + return isRTLLang || isRTLLangOverride ? 'rtl' : 'ltr'; +} diff --git a/shared/localization/src/i18n.ts b/shared/localization/src/i18n.ts new file mode 100644 index 0000000..bcd5e28 --- /dev/null +++ b/shared/localization/src/i18n.ts @@ -0,0 +1,104 @@ +import Translator from './translator'; +import type { + Locale, + InterpolationOptions, + ILocaleJSON, + ITranslator, +} from './types'; +import type { Logger } from '@amp/web-apps-logger'; + +/** @internal */ +const formatOptions = ( + options: InterpolationOptions | number, +): InterpolationOptions => + typeof options === 'number' ? { count: options } : options; + +/** + * + * Adapter class to expose expected LOC interface + * @category Localization + */ +export class I18N { + private readonly log: Logger; + private readonly locale: Locale; + private readonly translator: ITranslator; + private readonly keys: ILocaleJSON; + private readonly alwaysShowScreamers: boolean; + + /** + * builds a new I18N class + * @param locale - the locale to use default:`'en-us'` + * @param translation - translation object default: `{}` + * @param alwaysShowScreamers - optional boolean that is set upstream + * by a FeatureKit feature flag. This makes it so the LOC keys themselves are + * printed to the DOM, rather than their translations, which is helpful for QA testing + */ + constructor( + log: Logger, + locale: Locale = 'en-us', + translation: ILocaleJSON = {}, + alwaysShowScreamers: boolean = false, + ) { + this.log = log; + this.locale = locale; + this.translator = new Translator(locale, translation, { + onMissingKeyFn: (key: string): string => { + log.warn('key missing:', key); + return `**${key}**`; + }, + onMissingInterpolationFn: (key: string, interpolation: string) => { + log.warn(`key ${key} missing interpolation:`, interpolation); + }, + }); + this.keys = translation; + this.alwaysShowScreamers = alwaysShowScreamers; + } + + get currentLocale(): Locale { + return this.locale; + } + + get currentKeys(): ILocaleJSON { + return this.keys; + } + + /** + * Gets non-interpolated string. + * @category Localization + * @param key key to lookup in the translation.json + * @returns an uninterpolated string value + */ + getUninterpolatedString(key: string): string { + if (this.alwaysShowScreamers) { + return key; + } else { + return this.translator.getUninterpolatedString(key); + } + } + + /** + * Method for fetching translation based on key. + * + * If alwaysShowScreamers is true, return the key itself for QA testing purposes + * (our app tends to call into this method within Svelte templates) + * + * @category Localization + * @param key key to lookup in the translation.json + * @param options options for translations + * @returns interpolated string + */ + t = (key: string, options: number | InterpolationOptions = {}): string => { + if (this.alwaysShowScreamers) { + return key; + } + + let internalOptions: InterpolationOptions = formatOptions(options); + if (typeof key !== 'string') { + this.log.warn('received non-string key:', key); + return ''; + } + return this.translator.translate(key, internalOptions); + }; +} + +export default I18N; diff --git a/shared/localization/src/setHTMLAttributes.ts b/shared/localization/src/setHTMLAttributes.ts new file mode 100644 index 0000000..3bc0725 --- /dev/null +++ b/shared/localization/src/setHTMLAttributes.ts @@ -0,0 +1,15 @@ +import { getLocAttributes } from './getLocAttributes'; + +/** + * sets Language attributes to HTML tag. + * @param {string} language + * @returns {void} + */ +export function setHTMLAttributes(language: string): void { + if (typeof window === 'undefined') return; + const attributes = getLocAttributes(language); + + for (let [attribute, value] of Object.entries(attributes)) { + window.document.documentElement.setAttribute(attribute, value); + } +} diff --git a/shared/localization/src/translator.ts b/shared/localization/src/translator.ts new file mode 100644 index 0000000..48b901f --- /dev/null +++ b/shared/localization/src/translator.ts @@ -0,0 +1,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; |
