this repo has no description
at main 523 lines 17 kB view raw
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}