From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- src/jet/dependencies/localization.ts | 523 +++++++++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 src/jet/dependencies/localization.ts (limited to 'src/jet/dependencies/localization.ts') diff --git a/src/jet/dependencies/localization.ts b/src/jet/dependencies/localization.ts new file mode 100644 index 0000000..d6961e4 --- /dev/null +++ b/src/jet/dependencies/localization.ts @@ -0,0 +1,523 @@ +import type I18N from '@amp/web-apps-localization'; +import type { LoggerFactory, Logger } from '@amp/web-apps-logger'; +import { isNothing } from '@jet/environment'; + +import type { Locale } from './locale'; +import { abbreviateNumber } from '~/utils/number-formatting'; +import { getFileSizeParts } from '~/utils/file-size'; +import { + getPlural, + interpolateString, +} from '@amp/web-apps-localization/src/translator'; +import type { Locale as SupportedLanguageIdentifier } from '@amp/web-apps-localization'; + +const SECONDS_PER_MINUTE = 60; +const SECONDS_PER_HOUR = 60 * 60; +const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24; +const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365; + +export function makeWebDoesNotImplementException(property: keyof Localization) { + return new Error( + `\`Localization\` method \`${property}\` is not implemented for the "web" platform`, + ); +} + +/** + * Determines if {@linkcode key} appears to be a "client" translation key + * + * "Client" keys are defined in `SCREAMING_SNAKE_CASE` + */ +function isClientLocalizationKey(key: string): boolean { + return /^[A-Z_]+$/.test(key); +} + +/** + * Transforms an App Store Client-used translation key to the format that we have + * a value for. + * + * This accounts for the fact that the "raw" key used by the App Store Client + * is either a "client" key, that we filed an analogue for in our own translations, + * or a "server" key that exists in the App Store Client translations under their + * own namespace. In either case, we need to perform a transformation on the key as + * they use it into a format that we have a value for. + */ +function transformKeyToSupportedFormat(key: string): string { + return isClientLocalizationKey(key) + ? transformClientKeyToSupportedFormat(key) + : transformServerKeyToSupportedFormat(key); +} + +/** + * Transforms an App Store Client server-side translation key into the format + * that we have a value for. + * + * This handles the fact that the App Store Client namespaces all of + * their translation strings under `AppStore.` but does not include + * that namespace when referencing the key. Since their tooling implicitly + * injects that namespace for them, we have to do the same thing manually. + + * @example + * transformServerKeyToSupportedFormat('Account.Purchases'); + * // "AppStore.Account.Purchases" + */ +function transformServerKeyToSupportedFormat(key: string): string { + return `AppStore.${key}`; +} + +/** + * Capitalizes the first character in {@linkcode input} + */ +function capitalizeFirstCharacter(input: string): string { + const [first, ...rest] = input; + + return first.toUpperCase() + rest.join(''); +} + +/** + * Transforms an App Store Client client-side translation key into the format + * that we have a value for. + * + * "Client" keys used by the App Store Client are typically provided by the OS; + * this is not available to a web application, we need an alternative to providing + * values for these translation keys. + * + * To accomplish this, we have submitted these keys to the server-side localization + * service ourelves, under a specific namespace that designates that they are the + * client-side keys that we needed to define. Other formatting changes are made to + * the key at the request of the LOC team. + * + * @example + * transformClientKeyToSupportedFormat('ACCOUNT_PURCHASES'); + * // "ASE.Web.AppStoreClient.Account.Purchases" + */ +function transformClientKeyToSupportedFormat(key: string): string { + const keyInSrvLocFormat = key + .toLowerCase() + .split('_') + .map((segment) => capitalizeFirstCharacter(segment)) + .join('.'); + + return `ASE.Web.AppStoreClient.${keyInSrvLocFormat}`; +} + +/** + * "Web" implementation of the `AppStoreKit` {@linkcode Localization} dependency + */ +export class WebLocalization implements Localization { + private readonly locale: Locale; + private readonly logger: Logger; + + constructor(locale: Locale, loggerFactory: LoggerFactory) { + this.locale = locale; + this.logger = loggerFactory.loggerFor('jet/dependency/localization'); + } + + get i18n(): I18N { + if (this.locale.i18n) { + return this.locale.i18n; + } + + throw new Error('`i18n` not yet configured '); + } + + /** + * The `BCP 47` identifier for the active locale + * + * @see {@link https://developer.apple.com/documentation/foundation/locale | Foundation Frameworks Locale Documentation} + * @see {@link https://en.wikipedia.org/wiki/IETF_language_tag | BCP 47} + */ + get identifier() { + return this.locale.activeLanguage; + } + + decimal( + n: number | null | undefined, + decimalPlaces?: number | null | undefined, + ): string | null { + if (isNothing(n)) { + return null; + } + + let langCode: string = this.locale.activeLanguage; + + if (!langCode.includes('-')) { + langCode = `${this.locale.activeLanguage}-${this.locale.activeStorefront}`; + } + + const numberingSystem = new Intl.NumberFormat( + langCode, + ).resolvedOptions().numberingSystem; + + const formatter = new Intl.NumberFormat(this.locale.activeLanguage, { + numberingSystem, + minimumFractionDigits: decimalPlaces ?? undefined, + maximumFractionDigits: decimalPlaces ?? undefined, + }); + + return formatter.format(n); + } + + string(key: string): string { + const keyInSupportedFormat = transformKeyToSupportedFormat(key); + + // `.getUninterpolatedString` is used instead of `.t` here to match + // the behavior of the `.stringWithCount` method + return this.i18n.getUninterpolatedString(keyInSupportedFormat); + } + + stringForPreferredLocale(_key: string, _locale: string | null): string { + throw makeWebDoesNotImplementException('stringForPreferredLocale'); + } + + stringWithCount(key: string, count: number): string { + let keyInSupportedFormat = transformKeyToSupportedFormat(key); + + // The App Store Client has some behavior around pluralization that differs + // from how the Media Apps localization normally works. In order to handle + // this, we have to avoid the default pluralization behavior of the `.i18n.t` + // method and do the pluralization ourselves + const keyWithPluralizationSuffix = getPlural( + count, + keyInSupportedFormat, + this.identifier as SupportedLanguageIdentifier, + ); + + // The key difference in pluralization logic is that the `other` case is + // actually handled by the "base" key without any suffix. + // Therefore, we should only use the pluralized key if it does not reflect + // the `other` case + if (!keyWithPluralizationSuffix.endsWith('.other')) { + keyInSupportedFormat = keyWithPluralizationSuffix; + } + + const uninterpolatedValue = + this.i18n.getUninterpolatedString(keyInSupportedFormat); + + // Since the `count` might be interpolated into the localization string, + // we need to run the interpolation ourselves on uninterpolated value + return interpolateString( + key, + uninterpolatedValue, + { count }, + null, + this.identifier as SupportedLanguageIdentifier, + ); + } + + stringWithCounts(_key: string, _counts: number[]): string { + throw makeWebDoesNotImplementException('stringWithCounts'); + } + + uppercased(_value: string): string { + throw makeWebDoesNotImplementException('uppercased'); + } + + /** + * Converts a number of bytes into a localized file size string + * + * @param bytes The number of bytes to convert + * @return The localized file size string + */ + fileSize(bytes: number): string | null { + let { count, unit } = getFileSizeParts(bytes); + + return this.i18n.t(`ASE.Web.AppStore.FileSize.${unit}`, { + count, + }); + } + + formattedCount(count: number | null | undefined): string | null { + if (isNothing(count)) { + return null; + } + + return abbreviateNumber(count, this.locale.activeLanguage); + } + + formattedCountForPreferredLocale( + count: number | null, + locale: string | null, + ): string | null { + if (isNothing(count)) { + return null; + } + + return isNothing(locale) + ? abbreviateNumber(count, this.locale.activeLanguage) + : abbreviateNumber(count, locale); + } + + /** + * Convert a date into a time ago label, showing how long ago + * the date occurred. + * + * @param date The date object to convert + * @return The localized string representing the amount of time that has passed + */ + timeAgo(date: Date | null | undefined): string | null { + if (!date || !(date instanceof Date) || isNaN(date.getTime())) { + return null; + } + + const relativeTimeIntl = new Intl.RelativeTimeFormat( + this.locale.activeLanguage, + { + style: 'narrow', + }, + ); + + const now = new Date(); + + const secondsAgo = (now.getTime() - date.getTime()) / 1000; + const minutesAgo = Math.floor(secondsAgo / SECONDS_PER_MINUTE); + const hoursAgo = Math.floor(secondsAgo / SECONDS_PER_HOUR); + const daysAgo = Math.floor(secondsAgo / SECONDS_PER_DAY); + const yearsAgo = Math.floor(secondsAgo / SECONDS_PER_YEAR); + const isSameYear = now.getFullYear() === date.getFullYear(); + const isUpcoming = date.getTime() > now.getTime(); + + if (secondsAgo < 0 && isUpcoming) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + month: 'short', + day: 'numeric', + }).format(date); + } + + if (secondsAgo < 60) { + return relativeTimeIntl.format(-secondsAgo, 'seconds'); + } + + if (minutesAgo < 60) { + return relativeTimeIntl.format(-minutesAgo, 'minutes'); + } + + if (hoursAgo < 24) { + return relativeTimeIntl.format(-hoursAgo, 'hours'); + } + + if (daysAgo < 7) { + return relativeTimeIntl.format(-daysAgo, 'days'); + } + + if (isSameYear) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + month: 'short', + day: 'numeric', + }).format(date); + } + + if (yearsAgo >= 0) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(date); + } + + // this return statement is here to satisfy typescript, all possible cases are + // satisfied by the above conditionals. + return null; + } + + timeAgoWithContext( + _date: Date | null | undefined, + _context: DateContext, + ): string | null { + return null; + } + + formatDate(format: string, date: Date | null | undefined): string | null { + if (isNothing(date)) { + return null; + } + + let formatterConfiguration: Intl.DateTimeFormatOptions | undefined; + + switch (format) { + case 'MMM d': // e.g. Jan 29 + formatterConfiguration = { + month: 'short', + day: 'numeric', + }; + break; + case 'MMMM d': // e.g. January 29 + formatterConfiguration = { + month: 'long', + day: 'numeric', + }; + break; + case 'j:mm': // e.g. 9:00 + formatterConfiguration = { + hour: 'numeric', + minute: '2-digit', + }; + break; + case 'MMM d, y': // e.g. Jan 29, 2025 + formatterConfiguration = { + month: 'short', + day: 'numeric', + year: 'numeric', + }; + break; + case 'MMMM d, y': // e.g. "January 29, 2025" + formatterConfiguration = { + year: 'numeric', + month: 'long', + day: 'numeric', + }; + break; + case 'EEE j:mm': // e.g. "SAT 9:00PM" + formatterConfiguration = { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }; + break; + case 'd، MMM، yyyy': // e.g. "29 Jan 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'MMM d, yyyy': // e.g. "Jan 29, 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd MMM yyyy': // e.g. "29 January 2025" + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + break; + case 'yyyy MMMM d': // e.g. "2025 January 29" + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + case 'd M yyyy': + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd MMM., yyyy': + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + break; + case 'dd/MM/yyyy': // e.g. "29/01/2025" + formatterConfiguration = { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }; + break; + case 'd MMM , yyyy': // e.g. "29 Jan , 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd. MMM. yyyy.': // e.g. "29. Jan. 2025." + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'd. MMM yyyy': // e.g. "29. Jan 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'yyyy. MMM d.': // e.g. "2025. Jan 29." + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'd.M.yyyy': // e.g. "29.1.2025" + formatterConfiguration = { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }; + break; + + case 'd/M/yyyy': // e.g. "29/1/2025" + formatterConfiguration = { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }; + break; + default: + this.logger.warn( + `\`formatDate\` called with unexpected format \`${format}\``, + ); + return null; + } + + return new Intl.DateTimeFormat( + this.locale.activeLanguage, + formatterConfiguration, + ).format(date); + } + + formatDateWithContext( + format: string, + date: Date | null | undefined, + _context: DateContext, + ): string | null { + return this.formatDate(format, date); + } + + formatDateInSentence( + sentence: string, + format: string, + date: Date | null | undefined, + ): string | null { + const formattedDate = this.formatDate(format, date); + + if (isNothing(formattedDate)) { + return null; + } + + return ( + sentence + // "Server-Side" LOC keys us `@@date@@` to mark the date to replace + .replace('@@date@@', formattedDate) + // "Client-Side" LOC keys use `%@` to mark the date to replace + .replace('%@', formattedDate) + ); + } + + relativeDate(date: Date | null | undefined): string | null { + if (isNothing(date)) { + return null; + } + + return date.toString(); + } + + formatDuration(_value: number, _unit: TimeUnit): string | null { + throw makeWebDoesNotImplementException('formatDuration'); + } +} -- cgit v1.2.3