summaryrefslogtreecommitdiff
path: root/src/jet/dependencies
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
init commit
Diffstat (limited to 'src/jet/dependencies')
-rw-r--r--src/jet/dependencies/bag.ts290
-rw-r--r--src/jet/dependencies/client.ts96
-rw-r--r--src/jet/dependencies/console.ts26
-rw-r--r--src/jet/dependencies/feature-flags.ts20
-rw-r--r--src/jet/dependencies/locale.ts99
-rw-r--r--src/jet/dependencies/localization.ts523
-rw-r--r--src/jet/dependencies/make-dependencies.ts45
-rw-r--r--src/jet/dependencies/media-token-service.ts11
-rw-r--r--src/jet/dependencies/metrics-identifiers.ts13
-rw-r--r--src/jet/dependencies/net.ts117
-rw-r--r--src/jet/dependencies/object-graph.ts59
-rw-r--r--src/jet/dependencies/properties.ts5
-rw-r--r--src/jet/dependencies/seo.ts254
-rw-r--r--src/jet/dependencies/storage.ts44
-rw-r--r--src/jet/dependencies/user.ts30
15 files changed, 1632 insertions, 0 deletions
diff --git a/src/jet/dependencies/bag.ts b/src/jet/dependencies/bag.ts
new file mode 100644
index 0000000..32f6bc7
--- /dev/null
+++ b/src/jet/dependencies/bag.ts
@@ -0,0 +1,290 @@
+import type { Bag as NativeBag, BagKeyDescriptor } from '@jet/environment';
+import type { Opt } from '@jet/environment';
+import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
+
+import type { Locale } from './locale';
+import {
+ EU_STOREFRONTS,
+ SUPPORTED_STOREFRONTS_FOR_VISION,
+ UNSUPPORTED_STOREFRONTS_FOR_ARCADE,
+} from '~/constants/storefront';
+
+export type BagRetrievalMethod = Exclude<keyof NativeBag, 'registerBagKeys'>;
+
+export function makeUnimplementedKeyRequestWarning(
+ method: BagRetrievalMethod,
+ key: string,
+) {
+ return `requested unimplemented \`${method}\` key \`${key}\``;
+}
+
+export class WebBag implements NativeBag {
+ private readonly log: Logger;
+ private readonly locale: Locale;
+
+ constructor(loggerFactory: LoggerFactory, locale: Locale) {
+ this.log = loggerFactory.loggerFor('Bag');
+ this.locale = locale;
+ }
+
+ private provideNoValue(method: BagRetrievalMethod, key: string): null {
+ this.log.warn(makeUnimplementedKeyRequestWarning(method, key));
+
+ return null;
+ }
+
+ registerBagKeys(_keys: BagKeyDescriptor[]): void {
+ // We hardcode, so registration is a no-op
+ }
+
+ double(key: string): Opt<number> {
+ switch (key) {
+ case 'game-controller-recommended-rollout-rate':
+ return 1.0; // set to 1.0 to enable `learn more` button for game controller capability
+ case 'icon-artwork-rollout-rate':
+ return 1.0; // set to 1.0 to enable new icon artwork style
+ default:
+ return this.provideNoValue('double', key);
+ }
+ }
+
+ integer(key: string): Opt<number> {
+ return this.provideNoValue('integer', key);
+ }
+
+ boolean(key: string): Opt<boolean> {
+ switch (key) {
+ case 'enableAppEvents':
+ return true;
+ case 'enable-app-accessibility-labels':
+ return true;
+ case 'enable-app-store-age-ratings':
+ return true;
+ case 'enable-external-purchase':
+ return true;
+ case 'enable-privacy-nutrition-labels':
+ return true;
+ case 'enable-system-app-reviews':
+ return true;
+ case 'enable-vision-platform':
+ return SUPPORTED_STOREFRONTS_FOR_VISION.has(
+ this.locale.activeStorefront,
+ );
+ case 'arcade-enabled':
+ return !UNSUPPORTED_STOREFRONTS_FOR_ARCADE.has(
+ this.locale.activeStorefront,
+ );
+
+ // Enable required `GroupingPage` features
+ case 'enable-featured-categories-on-groupings':
+ case 'enable-category-bricks-on-groupings':
+ return true;
+ case 'enable-seller-info':
+ return true;
+ case 'enable-preview-platform-for-web':
+ return false;
+ case 'enableProductPageVariants':
+ return true;
+ case 'game-center-extend-supported-features':
+ return true;
+ case 'enable-product-page-install-size':
+ return true;
+ case 'enable-icon-artwork':
+ return true;
+ default:
+ return this.provideNoValue('boolean', key);
+ }
+ }
+
+ array(key: string): Opt<unknown> {
+ switch (key) {
+ // URL patterns that are opted into the "edge" domains
+ // https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L350
+ case 'apps-media-api-edge-end-points':
+ return [
+ // Including a pattern that matches our "search" API endpoint ensures
+ // that the built URL uses the `apps-media-api-search-edge-host` host
+ // https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L352
+ '/search',
+ ];
+ case 'enabled-external-purchase-placements':
+ return ['product-page-banner', 'product-page-info-section'];
+ case 'tabs/standard':
+ return [
+ {
+ id: 'today',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Today',
+ ),
+ 'image-identifier': 'text.rectangle.page',
+ },
+ {
+ id: 'apps',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Apps',
+ ),
+ 'image-identifier': 'app.3.stack.3d.fill',
+ },
+ {
+ id: 'apps-and-games',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.AppsAndGames',
+ ),
+ 'image-identifier': 'rocket.fill',
+ },
+ {
+ id: 'arcade',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Arcade',
+ ),
+ 'image-identifier': 'joystickcontroller.fill',
+ },
+ {
+ id: 'create',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Create',
+ ),
+ 'image-identifier': 'paintbrush.fill',
+ },
+ {
+ id: 'discover',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Discover',
+ ),
+ 'image-identifier': 'star.fill',
+ },
+ {
+ id: 'games',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Games',
+ ),
+ 'image-identifier': 'rocket.fill',
+ },
+ {
+ id: 'work',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Work',
+ ),
+ 'image-identifier': 'paperplane.fill',
+ },
+ {
+ id: 'play',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Play',
+ ),
+ 'image-identifier': 'rocket.fill',
+ },
+ {
+ id: 'develop',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Develop',
+ ),
+ 'image-identifier': 'hammer.fill',
+ },
+ {
+ id: 'categories',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Categories',
+ ),
+ 'image-identifier': 'square.grid.2x2.fill',
+ },
+ {
+ id: 'search',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Search',
+ ),
+ 'image-identifier': 'magnifyingglass',
+ },
+ ];
+ default:
+ return this.provideNoValue('array', key);
+ }
+ }
+
+ dictionary(key: string): Opt<unknown> {
+ return this.provideNoValue('dictionary', key);
+ }
+
+ url(key: string): Opt<string> {
+ switch (key) {
+ case 'apps-media-api-host':
+ return 'amp-api-edge.apps.apple.com';
+ case 'apps-media-api-edge-host':
+ return 'amp-api-edge.apps.apple.com';
+ case 'apps-media-api-search-edge-host':
+ return 'amp-api-search-edge.apps.apple.com';
+
+ default:
+ return this.provideNoValue('url', key);
+ }
+ }
+
+ string(key: string): Opt<string> {
+ switch (key) {
+ case 'countryCode':
+ return this.locale.activeStorefront;
+
+ case 'language-tag':
+ return this.locale.activeLanguage;
+
+ case 'language':
+ // TODO: rdar://78159789: util for this? What about zh-Hant, etc.
+ return this.locale.activeLanguage.split('-')[0];
+
+ // Some URLs are accessed as strings
+ // TODO: fix this upstream in `ios-appstore-app` so it uses `.url()` instead
+ case 'apps-media-api-edge-host':
+ case 'apps-media-api-search-edge-host':
+ return this.url(key);
+
+ case 'game-controller-learn-more-editorial-item-id':
+ return '1687769242';
+
+ case 'familySubscriptionsLearnMoreEditorialItemId':
+ return '1563279606';
+
+ case 'external-purchase-learn-more-editorial-item-id':
+ if (this.locale.activeStorefront === 'kr') {
+ return 'id1727067165';
+ }
+
+ return 'id1760810284';
+
+ case 'appPrivacyLearnMoreEditorialItemId':
+ return 'id1538632801';
+
+ case 'ageRatingLearnMoreEditorialItemId':
+ return '1825160725';
+
+ case 'accessibility-learn-more-editorial-item-id':
+ return '1814164299';
+
+ case 'external-purchase-product-page-banner-text-variant':
+ return '2';
+ case 'external-purchase-product-page-annotation-variant':
+ return '4';
+
+ case 'transparencyLawEditorialItemId':
+ if (EU_STOREFRONTS.includes(this.locale.activeStorefront)) {
+ return 'id1620909697';
+ }
+
+ return null;
+
+ case 'appPrivacyDefinitionsEditorialItemId':
+ return '1539235847';
+
+ case 'metrics_topic':
+ return 'xp_amp_appstore_unidentified';
+
+ case 'in-app-purchases-learn-more-editorial-item-id':
+ return '1436214772';
+
+ case 'web-navigation-category-tabs-editorial-item-id':
+ return '1842456901';
+
+ default:
+ return this.provideNoValue('string', key);
+ }
+ }
+}
diff --git a/src/jet/dependencies/client.ts b/src/jet/dependencies/client.ts
new file mode 100644
index 0000000..6b8a979
--- /dev/null
+++ b/src/jet/dependencies/client.ts
@@ -0,0 +1,96 @@
+import type { Locale } from './locale';
+
+export class WebClient implements Client {
+ private readonly locale: Locale;
+
+ deviceType: DeviceType = 'web';
+
+ // Tell the App Store Client that we're *really* the "web", even if the `DeviceType`
+ // says otherwise
+ __isReallyWebClient = true as const;
+
+ // TODO: how do we define this for the "client" web, when it can change over time?
+ screenSize: { width: number; height: number } = { width: 0, height: 0 };
+
+ // TODO: how is this used? We can't have a consistent value across multiple sessions
+ guid: string = 'xxx-xx-xxx';
+
+ screenCornerRadius: number = 0;
+
+ newPaymentMethodEnabled = false;
+
+ isActivityAvailable = false;
+
+ isElectrocardiogramInstallationAllowed = false;
+
+ isScandiumInstallationAllowed = false;
+
+ isSidepackingEnabled = false;
+
+ isTinkerWatch = false;
+
+ supportsHEIF: boolean = false;
+
+ isMandrakeSupported: boolean = false;
+
+ isCharonSupported: boolean = false;
+
+ buildType: BuildType;
+
+ maxAppContentRating: number = 1000;
+
+ isIconArtworkCapable: boolean = true;
+
+ constructor(buildType: BuildType, locale: Locale) {
+ this.buildType = buildType;
+ this.locale = locale;
+ }
+
+ get storefrontIdentifier(): string {
+ return this.locale.activeStorefront;
+ }
+
+ deviceHasCapabilities(_capabilities: string[]): boolean {
+ return false;
+ }
+
+ deviceHasCapabilitiesIncludingCompatibilityCheckIsVisionOSCompatibleIOSApp(
+ _capabilities: string[],
+ _supportsVisionOSCompatibleIOSBinary: boolean,
+ ): boolean {
+ return false;
+ }
+
+ isActivePairedWatchSystemVersionAtLeastMajorVersionMinorVersionPatchVersion(
+ _majorVersion: number,
+ _minorVersion: number,
+ _patchVersion: number,
+ ): boolean {
+ return false;
+ }
+
+ canDevicePerformAppActionWithAppCapabilities(
+ _appAction: string,
+ _appCapabilities: string[] | undefined | null,
+ ): boolean {
+ return false;
+ }
+
+ isAutomaticDownloadingEnabled(): boolean {
+ return false;
+ }
+
+ isAuthorizedForUserNotifications(): boolean {
+ return false;
+ }
+
+ deletableSystemAppCanBeInstalledOnWatchWithBundleID(
+ _bundleId: string,
+ ): boolean {
+ return false;
+ }
+
+ isDeviceEligibleForDomain(_domain: string): boolean {
+ return false;
+ }
+}
diff --git a/src/jet/dependencies/console.ts b/src/jet/dependencies/console.ts
new file mode 100644
index 0000000..fe0ba64
--- /dev/null
+++ b/src/jet/dependencies/console.ts
@@ -0,0 +1,26 @@
+import type { LoggerFactory, Logger } from '@amp/web-apps-logger';
+import type { RequiredConsole } from '@jet-app/app-store/foundation/wrappers/console';
+
+export class WebConsole implements RequiredConsole {
+ private readonly logger: Logger;
+
+ constructor(loggerFactory: LoggerFactory) {
+ this.logger = loggerFactory.loggerFor('jet-console');
+ }
+
+ error(...data: unknown[]): void {
+ this.logger.error(...data);
+ }
+
+ info(...data: unknown[]): void {
+ this.logger.info(...data);
+ }
+
+ log(...data: unknown[]): void {
+ this.logger.info(...data);
+ }
+
+ warn(...data: unknown[]): void {
+ this.logger.warn(...data);
+ }
+}
diff --git a/src/jet/dependencies/feature-flags.ts b/src/jet/dependencies/feature-flags.ts
new file mode 100644
index 0000000..e745137
--- /dev/null
+++ b/src/jet/dependencies/feature-flags.ts
@@ -0,0 +1,20 @@
+const ENABLED_FEATURES = new Set([
+ // Make the `ProductPageIntentController` return a `ShelfBasedProductPage` instance
+ 'shelves_2_0_product',
+ // Enable shelf-based "Top Charts" features
+ // 'shelves_2_0_top_charts',
+ // Make the `RibbonBarShelf` contain an array of `RibbonBarItem`s
+ 'shelves_2_0_generic',
+ // Enable AX Metadata
+ 'product_accessibility_support_2025A',
+]);
+
+export class WebFeatureFlags implements FeatureFlags {
+ isEnabled(feature: string): boolean {
+ return ENABLED_FEATURES.has(feature);
+ }
+
+ isGSEUIEnabled(_feature: string): boolean {
+ return false;
+ }
+}
diff --git a/src/jet/dependencies/locale.ts b/src/jet/dependencies/locale.ts
new file mode 100644
index 0000000..e48e935
--- /dev/null
+++ b/src/jet/dependencies/locale.ts
@@ -0,0 +1,99 @@
+import type { Locale as JetLocaleDependency } from '@jet-app/app-store/foundation/dependencies/locale/locale';
+import type {
+ NormalizedLanguage,
+ NormalizedStorefront,
+ NormalizedLocale,
+ UnnormalizedLocale,
+} from '@jet-app/app-store/api/locale';
+import type I18N from '@amp/web-apps-localization';
+import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
+
+import type { Jet } from '~/jet/jet';
+import {
+ DEFAULT_STOREFRONT_CODE,
+ DEFAULT_LANGUAGE_BCP47,
+} from '~/constants/storefront';
+import {
+ type NormalizedLocaleWithDefault,
+ normalizeStorefront,
+ normalizeLanguage,
+} from '~/utils/locale';
+import type { Optional } from '@jet/environment';
+
+/**
+ * Contains information related to the locale of the request currently being
+ * made to the application.
+ *
+ * Typically, localization information is expected to be known when the Jet
+ * instance is initialized. The Web, however, will not know the current
+ * locale and langauge until after routing has already taken place.
+ *
+ * This object exists to contain that lazily-determined locale information,
+ * so that other dependencies can retreive it from here. It is to be created
+ * with the rest of the dependencies and passed to them when they are created.
+ *
+ * Localization information is set in the {@linkcode Jet#setLocale} method
+ */
+export class Locale implements JetLocaleDependency {
+ private readonly logger: Logger;
+
+ private _storefront: NormalizedStorefront | undefined;
+ private _language: NormalizedLanguage | undefined;
+
+ i18n: I18N | undefined;
+
+ constructor(loggerFactory: LoggerFactory) {
+ this.logger = loggerFactory.loggerFor('locale');
+ }
+
+ get activeStorefront(): NormalizedStorefront {
+ if (!this._storefront) {
+ this.logger.warn('`storefront` was accessed before being set');
+ return DEFAULT_STOREFRONT_CODE;
+ }
+
+ return this._storefront;
+ }
+
+ get activeLanguage(): NormalizedLanguage {
+ if (!this._language) {
+ this.logger.warn('`language` was accessed before being set');
+ return DEFAULT_LANGUAGE_BCP47;
+ }
+
+ return this._language;
+ }
+
+ setActiveLocale(locale: NormalizedLocale): void {
+ this._storefront = locale.storefront;
+ this._language = locale.language;
+ }
+
+ normalize({
+ storefront,
+ language,
+ }: UnnormalizedLocale): NormalizedLocaleWithDefault {
+ const {
+ storefront: normalizedStorefront,
+ languages,
+ defaultLanguage,
+ } = normalizeStorefront(storefront);
+
+ return {
+ storefront: normalizedStorefront,
+ ...normalizeLanguage(language || '', languages, defaultLanguage),
+ };
+ }
+
+ deriveLocaleForUrl(locale: NormalizedLocale): {
+ storefront: string;
+ language: Optional<string>;
+ } {
+ const { isDefaultLanguage } = this.normalize(locale);
+
+ return {
+ storefront: locale.storefront,
+ language: isDefaultLanguage ? undefined : locale.language,
+ };
+ }
+}
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');
+ }
+}
diff --git a/src/jet/dependencies/make-dependencies.ts b/src/jet/dependencies/make-dependencies.ts
new file mode 100644
index 0000000..f03c7ca
--- /dev/null
+++ b/src/jet/dependencies/make-dependencies.ts
@@ -0,0 +1,45 @@
+import type { LoggerFactory as AppLoggerFactory } from '@amp/web-apps-logger';
+
+import { Random } from '@amp/web-apps-common/src/jet/dependencies/random';
+import { Host } from '@amp/web-apps-common/src/jet/dependencies/host';
+import { WebBag } from './bag';
+import { WebClient } from './client';
+import { WebConsole } from './console';
+import { Locale } from './locale';
+import { WebLocalization } from './localization';
+import { makeProperties } from './properties';
+import { WebMetricsIdentifiers } from './metrics-identifiers';
+import { Net, type FeaturesCallbacks } from './net';
+import { WebStorage } from './storage';
+import { makeUnauthenticatedUser } from './user';
+import { SEO } from './seo';
+
+export type Dependencies = ReturnType<typeof makeDependencies>;
+
+export function makeDependencies(
+ loggerFactory: AppLoggerFactory,
+ fetch: typeof window.fetch,
+ featuresCallbacks?: FeaturesCallbacks,
+) {
+ const locale = new Locale(loggerFactory);
+ return {
+ bag: new WebBag(loggerFactory, locale),
+ client: new WebClient(
+ // TODO: set the right `BuildType` based on the environment where the app is running
+ 'production',
+ locale,
+ ),
+ console: new WebConsole(loggerFactory),
+ host: new Host(),
+ localization: new WebLocalization(locale, loggerFactory),
+ locale,
+ metricsIdentifiers: new WebMetricsIdentifiers(),
+ net: new Net(fetch, featuresCallbacks),
+ properties: makeProperties(),
+ random: new Random(),
+ seo: new SEO(locale),
+ storage: new WebStorage(),
+ user: makeUnauthenticatedUser(),
+ URL,
+ };
+}
diff --git a/src/jet/dependencies/media-token-service.ts b/src/jet/dependencies/media-token-service.ts
new file mode 100644
index 0000000..45cae9e
--- /dev/null
+++ b/src/jet/dependencies/media-token-service.ts
@@ -0,0 +1,11 @@
+import { MEDIA_API_JWT } from '~/config/media-api';
+
+export class WebMediaTokenService implements MediaTokenService {
+ refreshToken(): Promise<string> {
+ return Promise.resolve(MEDIA_API_JWT);
+ }
+
+ resetToken(): void {
+ // No-op; every request uses the same token for the "web" platform
+ }
+}
diff --git a/src/jet/dependencies/metrics-identifiers.ts b/src/jet/dependencies/metrics-identifiers.ts
new file mode 100644
index 0000000..e48c9d1
--- /dev/null
+++ b/src/jet/dependencies/metrics-identifiers.ts
@@ -0,0 +1,13 @@
+export class WebMetricsIdentifiers implements MetricsIdentifiers {
+ async getIdentifierForContext(
+ _metricsIdentifierKeyContext: MetricsIdentifierKeyContext,
+ ): Promise<string | undefined> {
+ return undefined;
+ }
+
+ async getMetricsFieldsForContexts(
+ _metricsIdentifierKeyContexts: MetricsIdentifierKeyContext[],
+ ): Promise<JSONData | undefined> {
+ return undefined;
+ }
+}
diff --git a/src/jet/dependencies/net.ts b/src/jet/dependencies/net.ts
new file mode 100644
index 0000000..dd7fdb9
--- /dev/null
+++ b/src/jet/dependencies/net.ts
@@ -0,0 +1,117 @@
+import type { Network, FetchRequest, FetchResponse } from '@jet/environment';
+import { fromEntries } from '@amp/web-apps-utils';
+
+import {
+ shouldUseSearchJWT,
+ makeSearchJWTAuthorizationHeader,
+} from '~/config/media-api';
+
+const CORRELATION_KEY_HEADER = 'x-apple-jingle-correlation-key';
+
+type FetchFunction = typeof window.fetch;
+
+// TODO: these URLs are also referenced in `bag` definition; we should have a single
+// source-of-truth for these domains
+const MEDIA_API_ORIGINS = [
+ 'https://amp-api.apps.apple.com',
+ 'https://amp-api-edge.apps.apple.com',
+ 'https://amp-api-search-edge.apps.apple.com',
+];
+
+export interface FeaturesCallbacks {
+ getITFEValues(): string | undefined;
+}
+
+export class Net implements Network {
+ private readonly underlyingFetch: FetchFunction;
+ private readonly getITFEValues: () => string | undefined = () => undefined;
+
+ constructor(
+ underlyingFetch: FetchFunction,
+ featuresCallbacks?: FeaturesCallbacks,
+ ) {
+ this.underlyingFetch = underlyingFetch;
+ this.getITFEValues =
+ featuresCallbacks?.getITFEValues ?? this.getITFEValues;
+ }
+
+ async fetch(request: FetchRequest): Promise<FetchResponse> {
+ const requestStartTime = getTimestampMs();
+ const requestURL = new URL(request.url);
+
+ request.headers = request.headers ?? {};
+
+ if (MEDIA_API_ORIGINS.includes(requestURL.origin)) {
+ // Need to fake this for the server due to Kong origin checks.
+ // Has no effect clientside.
+ request.headers['origin'] = 'https://apps.apple.com';
+
+ const itfe = this.getITFEValues?.();
+
+ if (itfe) {
+ // Add ITFE value as query string when set
+ requestURL.searchParams.set('itfe', itfe);
+ }
+ }
+
+ // The App Store Client will have already injected the JWT from the
+ // `media-token-service` ObjectGraph dependency into the headers. However,
+ // some endpoints need a different JWT. Here we determine if that's the
+ // case and override the existing JWT if necessary.
+ if (shouldUseSearchJWT(requestURL)) {
+ request.headers = {
+ ...request.headers,
+ ...makeSearchJWTAuthorizationHeader(),
+ };
+ }
+
+ // TODO: rdar://78158575: timeout
+ const response = await this.underlyingFetch(requestURL.toString(), {
+ ...request,
+ cache: request.cache ?? undefined,
+ credentials: 'include',
+ headers: request.headers ?? undefined,
+ method: request.method ?? undefined,
+ });
+
+ const responseStartTime = getTimestampMs();
+
+ const { ok, redirected, status, statusText, url } = response;
+
+ const headers = fromEntries(response.headers);
+ const body = await response.text();
+
+ const responseEndTime = getTimestampMs();
+
+ return {
+ ok,
+ headers,
+ redirected,
+ status,
+ statusText,
+ url,
+ body,
+ // TODO: rdar://78158575: redirect: 'manual' to get all metrics?
+ metrics: [
+ {
+ clientCorrelationKey: response.headers.get(
+ CORRELATION_KEY_HEADER,
+ ),
+ pageURL: response.url,
+ requestStartTime,
+ responseStartTime,
+ responseEndTime,
+ // TODO: rdar://78158575: responseWasCached?
+ // TODO: rdar://78158575: parseStartTime/parseEndTime
+ },
+ ],
+ };
+ }
+}
+
+/**
+ * Returns the current UTC timestamp in milliseconds.
+ */
+function getTimestampMs(): number {
+ return Date.now();
+}
diff --git a/src/jet/dependencies/object-graph.ts b/src/jet/dependencies/object-graph.ts
new file mode 100644
index 0000000..40ad0a9
--- /dev/null
+++ b/src/jet/dependencies/object-graph.ts
@@ -0,0 +1,59 @@
+import { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { ObjectGraphType } from '@jet-app/app-store/gameservicesui/src/foundation/object-graph-types';
+
+import type { Dependencies } from './make-dependencies';
+import { WebFeatureFlags } from './feature-flags';
+import { WebMediaTokenService } from './media-token-service';
+
+export { ObjectGraphType };
+
+class AppStoreWebObjectGraph extends AppStoreObjectGraph {
+ /**
+ * Configures the ObjectGraph from our `Dependencies` definition
+ *
+ * @param dependencies
+ * @returns
+ */
+ configureWithDependencies(dependencies: Dependencies) {
+ const {
+ bag,
+ client,
+ console,
+ host,
+ locale,
+ localization,
+ metricsIdentifiers,
+ net,
+ properties,
+ random,
+ seo,
+ storage,
+ user,
+ } = dependencies;
+
+ return this.addingClient(client)
+ .addingNetwork(net)
+ .addingHost(host)
+ .addingBag(bag)
+ .addingLoc(localization)
+ .addingMediaToken(new WebMediaTokenService())
+ .addingConsole(console)
+ .addingAppleSilicon(undefined)
+ .addingProperties(properties)
+ .addingLocale(locale)
+ .addingUser(user)
+ .addingFeatureFlags(new WebFeatureFlags())
+ .addingMetricsIdentifiers(metricsIdentifiers)
+ .addingSEO(seo)
+ .addingStorage(storage)
+ .addingRandom(random);
+ }
+}
+
+export function makeObjectGraph(
+ dependencies: Dependencies,
+): AppStoreObjectGraph {
+ const objectGraph = new AppStoreWebObjectGraph('app-store');
+
+ return objectGraph.configureWithDependencies(dependencies);
+}
diff --git a/src/jet/dependencies/properties.ts b/src/jet/dependencies/properties.ts
new file mode 100644
index 0000000..8956d7f
--- /dev/null
+++ b/src/jet/dependencies/properties.ts
@@ -0,0 +1,5 @@
+export function makeProperties(): PackageProperties {
+ return {
+ clientFeatures: {},
+ };
+}
diff --git a/src/jet/dependencies/seo.ts b/src/jet/dependencies/seo.ts
new file mode 100644
index 0000000..0938afa
--- /dev/null
+++ b/src/jet/dependencies/seo.ts
@@ -0,0 +1,254 @@
+import type { Opt } from '@jet/environment/types/optional';
+import type {
+ ArcadeSeeAllGamesPage,
+ ArticlePage,
+ ChartsHubPage,
+ GenericPage,
+ ReviewsPage,
+ SearchLandingPage,
+ SearchResultsPage,
+ SeeAllPage,
+ ShelfBasedProductPage,
+ TodayPage,
+ TopChartsPage,
+} from '@jet-app/app-store/api/models';
+import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+import type { SEO as SEODependency } from '@jet-app/app-store/foundation/dependencies/seo';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import type { DataContainer } from '@jet-app/app-store/foundation/media/data-structure';
+
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+import type { Locale } from './locale';
+
+import { seoDataForAnyPage, updateCanonicalURL } from '~/utils/seo/common';
+import { seoDataForArticlePage } from '~/utils/seo/article-page';
+import { seoDataForChartsPage } from '~/utils/seo/charts-page';
+import { seoDataForChartsHubPage } from '~/utils/seo/charts-hub-page';
+import { seoDataForDeveloperPage } from '~/utils/seo/developer-page';
+import { seoDataForProductPage } from '~/utils/seo/product-page';
+import { seoDataForAppEventDetailPage } from '~/utils/seo/app-event-detail-page';
+import { seoDataForReviewsPage } from '~/utils/seo/reviews-page';
+import { seoDataForSearchLandingPage } from '~/utils/seo/search-landing-page';
+import { seoDataForSearchResultsPage } from '~/utils/seo/search-results-page';
+import { seoDataForEditorialShelfCollectionPage } from '~/utils/seo/editorial-shelf-collection-page';
+import { seoDataForArcadeSeeAllPage } from '~/utils/seo/arcade-see-all-page';
+import { seoDataForSeeAllPage } from '~/utils/seo/see-all-page';
+
+export class SEO implements SEODependency {
+ private locale: Locale;
+
+ constructor(locale: Locale) {
+ this.locale = locale;
+ }
+
+ private get i18n() {
+ if (this.locale.i18n) {
+ return this.locale.i18n;
+ }
+
+ throw new Error('`i18n` not yet configured ');
+ }
+
+ private getSEODataForGenericPage(page: GenericPage): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ };
+ }
+
+ updateCanonicalURL(page: WebRenderablePage, canonicalURL: string): void {
+ updateCanonicalURL(page, canonicalURL);
+ }
+
+ /// MARK: Page SEO Data Hooks
+
+ getSEODataForAppEventPage(
+ objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForAppEventDetailPage(
+ page,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForArcadeSeeAllPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: ArcadeSeeAllGamesPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForArcadeSeeAllPage(page, this.i18n),
+ };
+ }
+
+ getSEODataForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ page: ArticlePage,
+ response: Opt<DataContainer>,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForArticlePage(
+ objectGraph,
+ this.i18n,
+ page,
+ response,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForBundlePage(
+ objectGraph: AppStoreObjectGraph,
+ page: ShelfBasedProductPage,
+ data: Opt<DataContainer>,
+ ): Opt<SeoData> {
+ return this.getSEODataForProductPage(objectGraph, page, data);
+ }
+
+ getSEODataForChartsPage(
+ objectGraph: AppStoreObjectGraph,
+ page: TopChartsPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForChartsPage(
+ page,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForChartsHubPage(
+ objectGraph: AppStoreObjectGraph,
+ page: ChartsHubPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForChartsHubPage(
+ page,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForDeveloperPage(
+ objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ response: Opt<DataContainer>,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForDeveloperPage(objectGraph, response, this.i18n),
+ };
+ }
+
+ getSEODataForEditorialPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return this.getSEODataForGenericPage(page);
+ }
+
+ getSEODataForEditorialShelfCollectionPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForEditorialShelfCollectionPage(page, this.i18n),
+ };
+ }
+
+ getSEODataForGroupingPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return this.getSEODataForGenericPage(page);
+ }
+
+ getSEODataForProductPage(
+ objectGraph: AppStoreObjectGraph,
+ page: ShelfBasedProductPage,
+ data: Opt<DataContainer>,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForProductPage(
+ objectGraph,
+ page,
+ data,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForReviewsPage(
+ objectGraph: AppStoreObjectGraph,
+ page: ReviewsPage,
+ productPage: ShelfBasedProductPage,
+ ): Opt<SeoData> {
+ return {
+ ...this.getSEODataForGenericPage(page),
+ ...seoDataForReviewsPage(this.i18n, page, productPage, objectGraph),
+ };
+ }
+
+ getSEODataForRoomPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ };
+ }
+
+ getSEODataForSearchLandingPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: SearchLandingPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForSearchLandingPage(page, this.i18n),
+ };
+ }
+
+ getSEODataForSearchResultsPage(
+ objectGraph: AppStoreObjectGraph,
+ page: SearchResultsPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForSearchResultsPage(
+ page,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForTodayPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: TodayPage,
+ ): Opt<SeoData> {
+ return seoDataForAnyPage(page, this.i18n);
+ }
+
+ getSEODataForSeeAllPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: SeeAllPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForSeeAllPage(page, this.i18n),
+ };
+ }
+}
diff --git a/src/jet/dependencies/storage.ts b/src/jet/dependencies/storage.ts
new file mode 100644
index 0000000..fe1da2c
--- /dev/null
+++ b/src/jet/dependencies/storage.ts
@@ -0,0 +1,44 @@
+/**
+ * `AppStoreKit` `Storage` implementation for the "web" client
+ *
+ * Note: The `AppStoreKit` `Storage` interface is declared as a global, which has the (presumably
+ * accidental) side-effect of implicitly being merged with the DOM library's own `Storage` interface
+ * (like `localStorage`), since interfaces declared in the same scope are merged together by TypeScript.
+ * There's no way to tell TypeScript that we only care about the `AppStoreKit` part of it, so
+ * satifying TypeScript here means that we need to implement both interfaces.
+ */
+export class WebStorage extends Map<string, string> implements Storage {
+ /* == "DOM" `Storage` Interface == */
+
+ get length() {
+ return this.size;
+ }
+
+ getItem(key: string): string | null {
+ return this.get(key) ?? null;
+ }
+
+ key(_index: number): string | null {
+ throw new Error('Method not implemented.');
+ }
+
+ removeItem(key: string): void {
+ this.delete(key);
+ }
+
+ setItem(key: string, value: string): void {
+ this.set(key, value);
+ }
+
+ /* == AppStoreKit `Storage` Interface == */
+
+ storeString(aString: string, key: string): void {
+ this.set(key, aString);
+ }
+
+ retrieveString(key: string): string {
+ // Fallback value designed based on how the ObjectGraph `StorageWrapper` handles that specific value
+ // https://github.pie.apple.com/app-store/ios-appstore-app/blob/1761d575b8dc3d7a63e7e36f3320cf9245be9f37/src/foundation/wrappers/storage.ts#L13
+ return this.get(key) ?? '<null>';
+ }
+}
diff --git a/src/jet/dependencies/user.ts b/src/jet/dependencies/user.ts
new file mode 100644
index 0000000..2dad212
--- /dev/null
+++ b/src/jet/dependencies/user.ts
@@ -0,0 +1,30 @@
+/**
+ * Create an "unauthenticated" {@linkcode User} representation
+ *
+ * The property values below match the way that `AppStoreKit` will define the `user`
+ * when the session is not authenticated.
+ */
+export function makeUnauthenticatedUser(): User {
+ return {
+ accountIdentifier: undefined,
+ dsid: undefined,
+ firstName: undefined,
+ // Note: this property is `true` for the native apps but `false` makes
+ // more sense in the context of the "web" client
+ isFitnessAppInstallationAllowed: false,
+ isManagedAppleID: false,
+ isOnDevicePersonalizationEnabled: false,
+ isUnderThirteen: false,
+ katanaId: undefined,
+ lastName: undefined,
+ treatmentGroupIdOverride: undefined,
+ userAgeIfAvailable: undefined,
+
+ onDevicePersonalizationDataContainerForAppIds(appIds) {
+ return {
+ personalizationData: {},
+ metricsData: {},
+ };
+ },
+ };
+}