init commit

This commit is contained in:
rxliuli
2025-11-04 05:03:50 +08:00
commit bce557cc2d
1396 changed files with 172991 additions and 0 deletions

View File

@@ -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,
};
}

View File

@@ -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';
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;