this repo has no description
1import type I18N from '@amp/web-apps-localization';
2import type { LoggerFactory, Logger } from '@amp/web-apps-logger';
3import { isNothing } from '@jet/environment';
4
5import type { Locale } from './locale';
6import { abbreviateNumber } from '~/utils/number-formatting';
7import { getFileSizeParts } from '~/utils/file-size';
8import {
9 getPlural,
10 interpolateString,
11} from '@amp/web-apps-localization/src/translator';
12import type { Locale as SupportedLanguageIdentifier } from '@amp/web-apps-localization';
13
14const SECONDS_PER_MINUTE = 60;
15const SECONDS_PER_HOUR = 60 * 60;
16const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24;
17const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365;
18
19export 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 */
30function 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 */
44function 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 */
63function transformServerKeyToSupportedFormat(key: string): string {
64 return `AppStore.${key}`;
65}
66
67/**
68 * Capitalizes the first character in {@linkcode input}
69 */
70function 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 */
93function 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 */
106export 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}