/ src / jet / dependencies / localization.ts
localization.ts
  1  import type I18N from '@amp/web-apps-localization';
  2  import type { LoggerFactory, Logger } from '@amp/web-apps-logger';
  3  import { isNothing } from '@jet/environment';
  4  
  5  import type { Locale } from './locale';
  6  import { abbreviateNumber } from '~/utils/number-formatting';
  7  import { getFileSizeParts } from '~/utils/file-size';
  8  import {
  9      getPlural,
 10      interpolateString,
 11  } from '@amp/web-apps-localization/src/translator';
 12  import type { Locale as SupportedLanguageIdentifier } from '@amp/web-apps-localization';
 13  
 14  const SECONDS_PER_MINUTE = 60;
 15  const SECONDS_PER_HOUR = 60 * 60;
 16  const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24;
 17  const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365;
 18  
 19  export function makeWebDoesNotImplementException(property: keyof Localization) {
 20      return new Error(
 21          `\`Localization\` method \`${property}\` is not implemented for the "web" platform`,
 22      );
 23  }
 24  
 25  /**
 26   * Determines if {@linkcode key} appears to be a "client" translation key
 27   *
 28   * "Client" keys are defined in `SCREAMING_SNAKE_CASE`
 29   */
 30  function isClientLocalizationKey(key: string): boolean {
 31      return /^[A-Z_]+$/.test(key);
 32  }
 33  
 34  /**
 35   * Transforms an App Store Client-used translation key to the format that we have
 36   * a value for.
 37   *
 38   * This accounts for the fact that the "raw" key used by the App Store Client
 39   * is either a "client" key, that we filed an analogue for in our own translations,
 40   * or a "server" key that exists in the App Store Client translations under their
 41   * own namespace. In either case, we need to perform a transformation on the key as
 42   * they use it into a format that we have a value for.
 43   */
 44  function transformKeyToSupportedFormat(key: string): string {
 45      return isClientLocalizationKey(key)
 46          ? transformClientKeyToSupportedFormat(key)
 47          : transformServerKeyToSupportedFormat(key);
 48  }
 49  
 50  /**
 51   * Transforms an App Store Client server-side translation key into the format
 52   * that we have a value for.
 53   *
 54   * This handles the fact that the App Store Client namespaces all of
 55   * their translation strings under `AppStore.` but does not include
 56   * that namespace when referencing the key. Since their tooling implicitly
 57   * injects that namespace for them, we have to do the same thing manually.
 58  
 59   * @example
 60   * transformServerKeyToSupportedFormat('Account.Purchases');
 61   * // "AppStore.Account.Purchases"
 62   */
 63  function transformServerKeyToSupportedFormat(key: string): string {
 64      return `AppStore.${key}`;
 65  }
 66  
 67  /**
 68   * Capitalizes the first character in {@linkcode input}
 69   */
 70  function capitalizeFirstCharacter(input: string): string {
 71      const [first, ...rest] = input;
 72  
 73      return first.toUpperCase() + rest.join('');
 74  }
 75  
 76  /**
 77   * Transforms an App Store Client client-side translation key into the format
 78   * that we have a value for.
 79   *
 80   * "Client" keys used by the App Store Client are typically provided by the OS;
 81   * this is not available to a web application, we need an alternative to providing
 82   * values for these translation keys.
 83   *
 84   * To accomplish this, we have submitted these keys to the server-side localization
 85   * service ourelves, under a specific namespace that designates that they are the
 86   * client-side keys that we needed to define. Other formatting changes are made to
 87   * the key at the request of the LOC team.
 88   *
 89   * @example
 90   * transformClientKeyToSupportedFormat('ACCOUNT_PURCHASES');
 91   * // "ASE.Web.AppStoreClient.Account.Purchases"
 92   */
 93  function transformClientKeyToSupportedFormat(key: string): string {
 94      const keyInSrvLocFormat = key
 95          .toLowerCase()
 96          .split('_')
 97          .map((segment) => capitalizeFirstCharacter(segment))
 98          .join('.');
 99  
100      return `ASE.Web.AppStoreClient.${keyInSrvLocFormat}`;
101  }
102  
103  /**
104   * "Web" implementation of the `AppStoreKit` {@linkcode Localization} dependency
105   */
106  export class WebLocalization implements Localization {
107      private readonly locale: Locale;
108      private readonly logger: Logger;
109  
110      constructor(locale: Locale, loggerFactory: LoggerFactory) {
111          this.locale = locale;
112          this.logger = loggerFactory.loggerFor('jet/dependency/localization');
113      }
114  
115      get i18n(): I18N {
116          if (this.locale.i18n) {
117              return this.locale.i18n;
118          }
119  
120          throw new Error('`i18n` not yet configured ');
121      }
122  
123      /**
124       * The `BCP 47` identifier for the active locale
125       *
126       * @see {@link https://developer.apple.com/documentation/foundation/locale | Foundation Frameworks Locale Documentation}
127       * @see {@link https://en.wikipedia.org/wiki/IETF_language_tag | BCP 47}
128       */
129      get identifier() {
130          return this.locale.activeLanguage;
131      }
132  
133      decimal(
134          n: number | null | undefined,
135          decimalPlaces?: number | null | undefined,
136      ): string | null {
137          if (isNothing(n)) {
138              return null;
139          }
140  
141          let langCode: string = this.locale.activeLanguage;
142  
143          if (!langCode.includes('-')) {
144              langCode = `${this.locale.activeLanguage}-${this.locale.activeStorefront}`;
145          }
146  
147          const numberingSystem = new Intl.NumberFormat(
148              langCode,
149          ).resolvedOptions().numberingSystem;
150  
151          const formatter = new Intl.NumberFormat(this.locale.activeLanguage, {
152              numberingSystem,
153              minimumFractionDigits: decimalPlaces ?? undefined,
154              maximumFractionDigits: decimalPlaces ?? undefined,
155          });
156  
157          return formatter.format(n);
158      }
159  
160      string(key: string): string {
161          const keyInSupportedFormat = transformKeyToSupportedFormat(key);
162  
163          // `.getUninterpolatedString` is used instead of `.t` here to match
164          // the behavior of the `.stringWithCount` method
165          return this.i18n.getUninterpolatedString(keyInSupportedFormat);
166      }
167  
168      stringForPreferredLocale(_key: string, _locale: string | null): string {
169          throw makeWebDoesNotImplementException('stringForPreferredLocale');
170      }
171  
172      stringWithCount(key: string, count: number): string {
173          let keyInSupportedFormat = transformKeyToSupportedFormat(key);
174  
175          // The App Store Client has some behavior around pluralization that differs
176          // from how the Media Apps localization normally works. In order to handle
177          // this, we have to avoid the default pluralization behavior of the `.i18n.t`
178          // method and do the pluralization ourselves
179          const keyWithPluralizationSuffix = getPlural(
180              count,
181              keyInSupportedFormat,
182              this.identifier as SupportedLanguageIdentifier,
183          );
184  
185          // The key difference in pluralization logic is that the `other` case is
186          // actually handled by the "base" key without any suffix.
187          // Therefore, we should only use the pluralized key if it does not reflect
188          // the `other` case
189          if (!keyWithPluralizationSuffix.endsWith('.other')) {
190              keyInSupportedFormat = keyWithPluralizationSuffix;
191          }
192  
193          const uninterpolatedValue =
194              this.i18n.getUninterpolatedString(keyInSupportedFormat);
195  
196          // Since the `count` might be interpolated into the localization string,
197          // we need to run the interpolation ourselves on uninterpolated value
198          return interpolateString(
199              key,
200              uninterpolatedValue,
201              { count },
202              null,
203              this.identifier as SupportedLanguageIdentifier,
204          );
205      }
206  
207      stringWithCounts(_key: string, _counts: number[]): string {
208          throw makeWebDoesNotImplementException('stringWithCounts');
209      }
210  
211      uppercased(_value: string): string {
212          throw makeWebDoesNotImplementException('uppercased');
213      }
214  
215      /**
216       * Converts a number of bytes into a localized file size string
217       *
218       * @param bytes The number of bytes to convert
219       * @return The localized file size string
220       */
221      fileSize(bytes: number): string | null {
222          let { count, unit } = getFileSizeParts(bytes);
223  
224          return this.i18n.t(`ASE.Web.AppStore.FileSize.${unit}`, {
225              count,
226          });
227      }
228  
229      formattedCount(count: number | null | undefined): string | null {
230          if (isNothing(count)) {
231              return null;
232          }
233  
234          return abbreviateNumber(count, this.locale.activeLanguage);
235      }
236  
237      formattedCountForPreferredLocale(
238          count: number | null,
239          locale: string | null,
240      ): string | null {
241          if (isNothing(count)) {
242              return null;
243          }
244  
245          return isNothing(locale)
246              ? abbreviateNumber(count, this.locale.activeLanguage)
247              : abbreviateNumber(count, locale);
248      }
249  
250      /**
251       * Convert a date into a time ago label, showing how long ago
252       * the date occurred.
253       *
254       * @param date The date object to convert
255       * @return     The localized string representing the amount of time that has passed
256       */
257      timeAgo(date: Date | null | undefined): string | null {
258          if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
259              return null;
260          }
261  
262          const relativeTimeIntl = new Intl.RelativeTimeFormat(
263              this.locale.activeLanguage,
264              {
265                  style: 'narrow',
266              },
267          );
268  
269          const now = new Date();
270  
271          const secondsAgo = (now.getTime() - date.getTime()) / 1000;
272          const minutesAgo = Math.floor(secondsAgo / SECONDS_PER_MINUTE);
273          const hoursAgo = Math.floor(secondsAgo / SECONDS_PER_HOUR);
274          const daysAgo = Math.floor(secondsAgo / SECONDS_PER_DAY);
275          const yearsAgo = Math.floor(secondsAgo / SECONDS_PER_YEAR);
276          const isSameYear = now.getFullYear() === date.getFullYear();
277          const isUpcoming = date.getTime() > now.getTime();
278  
279          if (secondsAgo < 0 && isUpcoming) {
280              return new Intl.DateTimeFormat(this.locale.activeLanguage, {
281                  month: 'short',
282                  day: 'numeric',
283              }).format(date);
284          }
285  
286          if (secondsAgo < 60) {
287              return relativeTimeIntl.format(-secondsAgo, 'seconds');
288          }
289  
290          if (minutesAgo < 60) {
291              return relativeTimeIntl.format(-minutesAgo, 'minutes');
292          }
293  
294          if (hoursAgo < 24) {
295              return relativeTimeIntl.format(-hoursAgo, 'hours');
296          }
297  
298          if (daysAgo < 7) {
299              return relativeTimeIntl.format(-daysAgo, 'days');
300          }
301  
302          if (isSameYear) {
303              return new Intl.DateTimeFormat(this.locale.activeLanguage, {
304                  month: 'short',
305                  day: 'numeric',
306              }).format(date);
307          }
308  
309          if (yearsAgo >= 0) {
310              return new Intl.DateTimeFormat(this.locale.activeLanguage, {
311                  day: '2-digit',
312                  month: '2-digit',
313                  year: 'numeric',
314              }).format(date);
315          }
316  
317          // this return statement is here to satisfy typescript, all possible cases are
318          // satisfied by the above conditionals.
319          return null;
320      }
321  
322      timeAgoWithContext(
323          _date: Date | null | undefined,
324          _context: DateContext,
325      ): string | null {
326          return null;
327      }
328  
329      formatDate(format: string, date: Date | null | undefined): string | null {
330          if (isNothing(date)) {
331              return null;
332          }
333  
334          let formatterConfiguration: Intl.DateTimeFormatOptions | undefined;
335  
336          switch (format) {
337              case 'MMM d': // e.g. Jan 29
338                  formatterConfiguration = {
339                      month: 'short',
340                      day: 'numeric',
341                  };
342                  break;
343              case 'MMMM d': // e.g. January 29
344                  formatterConfiguration = {
345                      month: 'long',
346                      day: 'numeric',
347                  };
348                  break;
349              case 'j:mm': // e.g. 9:00
350                  formatterConfiguration = {
351                      hour: 'numeric',
352                      minute: '2-digit',
353                  };
354                  break;
355              case 'MMM d, y': // e.g. Jan 29, 2025
356                  formatterConfiguration = {
357                      month: 'short',
358                      day: 'numeric',
359                      year: 'numeric',
360                  };
361                  break;
362              case 'MMMM d, y': // e.g. "January 29, 2025"
363                  formatterConfiguration = {
364                      year: 'numeric',
365                      month: 'long',
366                      day: 'numeric',
367                  };
368                  break;
369              case 'EEE j:mm': // e.g. "SAT 9:00PM"
370                  formatterConfiguration = {
371                      weekday: 'short',
372                      hour: 'numeric',
373                      minute: '2-digit',
374                      hour12: true,
375                  };
376                  break;
377              case 'd، MMM، yyyy': // e.g. "29 Jan 2025"
378                  formatterConfiguration = {
379                      day: 'numeric',
380                      month: 'short',
381                      year: 'numeric',
382                  };
383                  break;
384              case 'MMM d, yyyy': // e.g. "Jan 29, 2025"
385                  formatterConfiguration = {
386                      day: 'numeric',
387                      month: 'short',
388                      year: 'numeric',
389                  };
390                  break;
391              case 'd MMM yyyy': // e.g. "29 January 2025"
392                  formatterConfiguration = {
393                      day: 'numeric',
394                      month: 'long',
395                      year: 'numeric',
396                  };
397                  break;
398              case 'yyyy MMMM d': // e.g. "2025 January 29"
399                  formatterConfiguration = {
400                      day: 'numeric',
401                      month: 'long',
402                      year: 'numeric',
403                  };
404              case 'd M yyyy':
405                  formatterConfiguration = {
406                      day: 'numeric',
407                      month: 'short',
408                      year: 'numeric',
409                  };
410                  break;
411              case 'd MMM., yyyy':
412                  formatterConfiguration = {
413                      day: 'numeric',
414                      month: 'long',
415                      year: 'numeric',
416                  };
417                  break;
418              case 'dd/MM/yyyy': // e.g. "29/01/2025"
419                  formatterConfiguration = {
420                      day: '2-digit',
421                      month: '2-digit',
422                      year: 'numeric',
423                  };
424                  break;
425              case 'd MMM , yyyy': // e.g. "29 Jan , 2025"
426                  formatterConfiguration = {
427                      day: 'numeric',
428                      month: 'short',
429                      year: 'numeric',
430                  };
431                  break;
432              case 'd. MMM. yyyy.': // e.g. "29. Jan. 2025."
433                  formatterConfiguration = {
434                      day: 'numeric',
435                      month: 'short',
436                      year: 'numeric',
437                  };
438                  break;
439  
440              case 'd. MMM yyyy': // e.g. "29. Jan 2025"
441                  formatterConfiguration = {
442                      day: 'numeric',
443                      month: 'short',
444                      year: 'numeric',
445                  };
446                  break;
447  
448              case 'yyyy. MMM d.': // e.g. "2025. Jan 29."
449                  formatterConfiguration = {
450                      day: 'numeric',
451                      month: 'short',
452                      year: 'numeric',
453                  };
454                  break;
455  
456              case 'd.M.yyyy': // e.g. "29.1.2025"
457                  formatterConfiguration = {
458                      day: 'numeric',
459                      month: 'numeric',
460                      year: 'numeric',
461                  };
462                  break;
463  
464              case 'd/M/yyyy': // e.g. "29/1/2025"
465                  formatterConfiguration = {
466                      day: 'numeric',
467                      month: 'numeric',
468                      year: 'numeric',
469                  };
470                  break;
471              default:
472                  this.logger.warn(
473                      `\`formatDate\` called with unexpected format \`${format}\``,
474                  );
475                  return null;
476          }
477  
478          return new Intl.DateTimeFormat(
479              this.locale.activeLanguage,
480              formatterConfiguration,
481          ).format(date);
482      }
483  
484      formatDateWithContext(
485          format: string,
486          date: Date | null | undefined,
487          _context: DateContext,
488      ): string | null {
489          return this.formatDate(format, date);
490      }
491  
492      formatDateInSentence(
493          sentence: string,
494          format: string,
495          date: Date | null | undefined,
496      ): string | null {
497          const formattedDate = this.formatDate(format, date);
498  
499          if (isNothing(formattedDate)) {
500              return null;
501          }
502  
503          return (
504              sentence
505                  // "Server-Side" LOC keys us `@@date@@` to mark the date to replace
506                  .replace('@@date@@', formattedDate)
507                  // "Client-Side" LOC keys use `%@` to mark the date to replace
508                  .replace('%@', formattedDate)
509          );
510      }
511  
512      relativeDate(date: Date | null | undefined): string | null {
513          if (isNothing(date)) {
514              return null;
515          }
516  
517          return date.toString();
518      }
519  
520      formatDuration(_value: number, _unit: TimeUnit): string | null {
521          throw makeWebDoesNotImplementException('formatDuration');
522      }
523  }