summaryrefslogtreecommitdiff
path: root/shared/localization/src
diff options
context:
space:
mode:
Diffstat (limited to 'shared/localization/src')
-rw-r--r--shared/localization/src/getLocAttributes.ts78
-rw-r--r--shared/localization/src/getPageDir.ts40
-rw-r--r--shared/localization/src/i18n.ts104
-rw-r--r--shared/localization/src/setHTMLAttributes.ts15
-rw-r--r--shared/localization/src/translator.ts174
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;