summaryrefslogtreecommitdiff
path: root/src/jet/dependencies/localization.ts
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /src/jet/dependencies/localization.ts
init commit
Diffstat (limited to 'src/jet/dependencies/localization.ts')
-rw-r--r--src/jet/dependencies/localization.ts523
1 files changed, 523 insertions, 0 deletions
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');
+ }
+}