summaryrefslogtreecommitdiff
path: root/src/utils
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/utils
init commit
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/app-platforms.ts25
-rw-r--r--src/utils/array.ts33
-rw-r--r--src/utils/color.ts168
-rw-r--r--src/utils/error.ts28
-rw-r--r--src/utils/features/consts.ts13
-rw-r--r--src/utils/features/runtime.ts44
-rw-r--r--src/utils/file-size.ts23
-rw-r--r--src/utils/launch-client.ts13
-rw-r--r--src/utils/locale.ts142
-rw-r--r--src/utils/media-queries.ts12
-rw-r--r--src/utils/metrics.ts4
-rw-r--r--src/utils/number-formatting.ts39
-rw-r--r--src/utils/portal.ts34
-rw-r--r--src/utils/seo/app-event-detail-page.ts43
-rw-r--r--src/utils/seo/arcade-see-all-page.ts40
-rw-r--r--src/utils/seo/article-page.ts276
-rw-r--r--src/utils/seo/charts-hub-page.ts46
-rw-r--r--src/utils/seo/charts-page.ts58
-rw-r--r--src/utils/seo/common.ts75
-rw-r--r--src/utils/seo/developer-page.ts174
-rw-r--r--src/utils/seo/editorial-shelf-collection-page.ts51
-rw-r--r--src/utils/seo/image-url.ts71
-rw-r--r--src/utils/seo/product-page.ts353
-rw-r--r--src/utils/seo/reviews-page.ts56
-rw-r--r--src/utils/seo/search-landing-page.ts18
-rw-r--r--src/utils/seo/search-results-page.ts56
-rw-r--r--src/utils/seo/see-all-page.ts47
-rw-r--r--src/utils/shelves.ts56
-rw-r--r--src/utils/storefront-data.ts15
-rw-r--r--src/utils/string-formatting.ts126
-rw-r--r--src/utils/transition.ts45
-rw-r--r--src/utils/types.ts17
-rw-r--r--src/utils/url.ts13
-rw-r--r--src/utils/video-poster.ts27
34 files changed, 2241 insertions, 0 deletions
diff --git a/src/utils/app-platforms.ts b/src/utils/app-platforms.ts
new file mode 100644
index 0000000..1adb840
--- /dev/null
+++ b/src/utils/app-platforms.ts
@@ -0,0 +1,25 @@
+import type { AppPlatform } from '@jet-app/app-store/api/models';
+
+export const PlatformToExclusivityText: Partial<Record<AppPlatform, string>> = {
+ watch: 'ASE.Web.AppStore.App.OnlyForWatch',
+ tv: 'ASE.Web.AppStore.App.OnlyForAppleTV',
+ messages: 'ASE.Web.AppStore.App.OnlyForiMessage',
+ mac: 'ASE.Web.AppStore.App.OnlyForMac',
+ phone: 'ASE.Web.AppStore.App.OnlyForPhone',
+};
+
+export function isPlatformSupported(
+ platform: AppPlatform,
+ appPlatforms: AppPlatform[],
+) {
+ const dedupedPlatforms = new Set(appPlatforms);
+ return dedupedPlatforms.has(platform);
+}
+
+export function isPlatformExclusivelySupported(
+ platform: AppPlatform,
+ appPlatforms: AppPlatform[],
+) {
+ const dedupedPlatforms = new Set(appPlatforms);
+ return dedupedPlatforms.has(platform) && dedupedPlatforms.size === 1;
+}
diff --git a/src/utils/array.ts b/src/utils/array.ts
new file mode 100644
index 0000000..de9ef96
--- /dev/null
+++ b/src/utils/array.ts
@@ -0,0 +1,33 @@
+/**
+ * Split an array into two groups based on the result {@linkcode predicate}
+ *
+ * Items for which {@linkcode predicate} returns `true` will be in the "left"
+ * result, and the others in the "right" one
+ */
+export function partition<T>(
+ input: Array<T>,
+ predicate: (element: T) => boolean,
+): [Array<T>, Array<T>] {
+ const left: Array<T> = [];
+ const right: Array<T> = [];
+
+ for (const element of input) {
+ if (predicate(element)) {
+ left.push(element);
+ } else {
+ right.push(element);
+ }
+ }
+
+ return [left, right];
+}
+
+/**
+ * Deduplicate the elements of {@linkcode items} by their `id` property
+ */
+export function uniqueById<T extends { id: string }>(items: T[]): T[] {
+ const entries = items.map((item) => [item.id, item] as const);
+ const mapById = new Map<string, T>(entries);
+
+ return Array.from(mapById.values());
+}
diff --git a/src/utils/color.ts b/src/utils/color.ts
new file mode 100644
index 0000000..1d1c334
--- /dev/null
+++ b/src/utils/color.ts
@@ -0,0 +1,168 @@
+import { isSome } from '@jet/environment/types/optional';
+import type {
+ Artwork,
+ Color,
+ RGBColor,
+ NamedColor,
+} from '@jet-app/app-store/api/models';
+
+export type RGB = [number, number, number];
+
+/**
+ * Represents a valid RGB color string, in the format "rgb(r, g, b)" or "rgb(r,g,b)".
+ * @example
+ * "rgb(255, 0, 128)"
+ * "rgb(255,0,128)"
+ */
+type RGBString =
+ | `rgb(${number},${number},${number})`
+ | `rgb(${number}, ${number}, ${number})`;
+
+export const isRGBColor = (value: Color): value is RGBColor =>
+ value.type === 'rgb';
+
+export const isNamedColor = (value: Color): value is NamedColor =>
+ value.type === 'named';
+
+const rgbColorAsString = ({ red, green, blue }: RGBColor): string =>
+ `rgb(${[red, green, blue].map((color) => Math.floor(255 * color)).join()})`;
+
+export const colorAsString = (color: Color): string => {
+ switch (color.type) {
+ case 'named':
+ // `ios-appstore-app` makes use of the this `placeholderBackground` named color,
+ // which it leaves up to the client to manage. Ideally, we could define a CSS property
+ // named `--placeholderBackground`, but the media-apps shared logic to determine Artwork
+ // background color doesn't respect CSS properties, so we are specifying the hex value.
+ // https://github.pie.apple.com/amp-web/media-apps/blame/main/shared/components/src/components/Artwork/utils/validateBackground.ts
+ if (color.name === 'placeholderBackground') {
+ return '#f1f1f1';
+ }
+
+ return `var(--${color.name})`;
+ case 'rgb':
+ return rgbColorAsString(color);
+ case 'dynamic':
+ return colorAsString(color.lightColor);
+ }
+};
+
+/**
+ * Parses an RGB string and returns an array of red, green, and blue values.
+ *
+ * This function extracts the numeric values from an RGB string (e.g., "rgb(255, 0, 128)")
+ * and returns them as an array of numbers.
+ *
+ * @param {RGBString} rgbString - The RGB string to parse.
+ * @returns {RGB} An array of three numbers representing the red, green, and blue values, each between 0 and 255.
+ *
+ * @example
+ * getRGBFromString("rgb(255, 0, 128)") = [255, 0, 128]
+ */
+export const getRGBFromString = (rgbString: RGBString): RGB => {
+ const rgbValues = rgbString.match(/\d+/g) ?? [];
+ const rgb: RGB = [0, 0, 0];
+
+ for (const [index] of rgb.entries()) {
+ rgb[index] = parseInt(rgbValues[index]);
+ }
+
+ return rgb;
+};
+
+/**
+ * Calculates the relative luminance for an RGB color.
+ *
+ * This function uses a standardized formula for luminance, which weights the red, green, and blue
+ * channels differently to account for human perception.
+ * @see {@link https://en.wikipedia.org/wiki/Relative_luminance|Wikipedia: Relative Luminance}
+ *
+ * @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255.
+ * @returns {number} The calculated luminance value, a number between 0 (darkest) and 255 (lightest).
+ */
+export const getLuminanceForRGB = ([r, g, b]: RGB): number => {
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+
+export function isRGBDarkerThanThreshold([r, g, b]: RGB, threshold = 10) {
+ return r <= threshold && g <= threshold && b <= threshold;
+}
+
+export function isDark(rgbColor: RGBColor): boolean {
+ const { red, green, blue } = rgbColor;
+ const rgbValues = [red, green, blue].map((channel) =>
+ Math.floor(channel * 255),
+ ) as RGB;
+
+ return isRGBDarkerThanThreshold(rgbValues, 127);
+}
+
+/**
+ * Determines whether an RGB color is approximately grey based on channel similarity.
+ *
+ * @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255.
+ * @param {number} [threshold=10] - Maximum allowed difference between color channels to still be considered grey-ish.
+ * @returns {boolean} True if the RGB values are close enough to be considered grey.
+ */
+function isKindOfGrey([r, g, b]: RGB, threshold = 10) {
+ return (
+ Math.abs(r - g) <= threshold &&
+ Math.abs(r - b) <= threshold &&
+ Math.abs(g - b) <= threshold
+ );
+}
+
+/**
+ * Generates CSS variables (custom properties) for a background gradient based on the background
+ * colors in the specified list of artworks.
+ *
+ * @param {Artwork[]} artworks - An array of Artwork, each containing a `backgroundColor` property.
+ * @param {Object} [options={}] - Optional configuration options.
+ * @param {string[]} [options.variableNames=['bottom-left', 'top-right', 'bottom-right', 'top-left']] -
+ * The names of the CSS variables to assign to the extracted colors. The number of colors
+ * used will match the length of this array.
+ * @param {(a: RGB, b: RGB) => number} [options.sortFn=() => 0] -
+ * A sorting function for ordering the colors (e.g., by luminance). Defaults to no sorting,
+ * which preserves input order.
+ *
+ * @returns {string} A CSS string containing custom properties, e.g.,
+ * "--bottom-left: rgb(255, 0, 0); --top-right: rgb(0, 255, 0);".
+ */
+export const getBackgroundGradientCSSVarsFromArtworks = (
+ artworks: Artwork[],
+ {
+ variableNames = [
+ 'bottom-left',
+ 'top-right',
+ 'bottom-right',
+ 'top-left',
+ ],
+ sortFn = () => 0,
+ shouldRemoveGreys = false,
+ }: {
+ variableNames?: string[];
+ sortFn?: (a: RGB, b: RGB) => number;
+ shouldRemoveGreys?: boolean;
+ } = {},
+): string => {
+ return artworks
+ .map(({ backgroundColor }) => backgroundColor)
+ .filter(isSome)
+ .filter(isRGBColor)
+ .map(
+ ({ red, green, blue }): RGB => [
+ Math.floor(255 * red),
+ Math.floor(255 * green),
+ Math.floor(255 * blue),
+ ],
+ )
+ .filter((rgb) => !isRGBDarkerThanThreshold(rgb, 33))
+ .filter((rgb) => (shouldRemoveGreys ? !isKindOfGrey(rgb, 10) : true))
+ .sort(sortFn)
+ .slice(0, variableNames.length)
+ .map(
+ ([red, green, blue], index) =>
+ `--${variableNames[index]}: rgb(${red}, ${green}, ${blue})`,
+ )
+ .join('; ');
+};
diff --git a/src/utils/error.ts b/src/utils/error.ts
new file mode 100644
index 0000000..40f0fc0
--- /dev/null
+++ b/src/utils/error.ts
@@ -0,0 +1,28 @@
+/**
+ * Tries to call {@linkcode fn} throwing an exception with the {@linkcode message}
+ * if an error occurs
+ *
+ * @example
+ * // Before
+ * let value;
+ * try {
+ * value = someMethod();
+ * } catch(e) {
+ * throw new Error('My specific message', { cause: e })
+ * }
+ *
+ * // After
+ * const value = mapError(
+ * () => someMethod(),
+ * 'My specific message'
+ * );
+ */
+export function mapException<T>(fn: () => T, message: string): T {
+ try {
+ return fn();
+ } catch (e) {
+ throw new Error(message, {
+ cause: e,
+ });
+ }
+}
diff --git a/src/utils/features/consts.ts b/src/utils/features/consts.ts
new file mode 100644
index 0000000..393fea9
--- /dev/null
+++ b/src/utils/features/consts.ts
@@ -0,0 +1,13 @@
+/**
+ * This file is a place for defining any Feature Flag Constants used throughout the app
+ * In order to keep the API Interface same for Build Time vs Runtime Feature Flags
+ * We have ensured that all the flags have to be defined in this file
+ */
+// Actual Feature Flag Values have to be defined in the /apps/app-store/featureFlags.external.cjs
+// BUILD BASED FEATURE FLAGS DUMMY FLAG DEFINITIONS TO FIX THE NAME OF THE FEATURE FLAGS TO BE USED
+// Values of the BUILD BASED FLAGS will decide if they are evaluated to true in DEV mode
+export const __FF_SHOW_RADAR = 'r01234e98765';
+
+export const __FF_SHOW_LOC_KEYS = 'ffShowLocKeys';
+
+export const __FF_ARYA = 'asha123e7z124';
diff --git a/src/utils/features/runtime.ts b/src/utils/features/runtime.ts
new file mode 100644
index 0000000..ebb83ad
--- /dev/null
+++ b/src/utils/features/runtime.ts
@@ -0,0 +1,44 @@
+import {
+ buildFeatureConfig,
+ buildRuntimeFeatureKitConfig,
+ ENVIRONMENT,
+ loadFeatureKit,
+ type OnyxFeatures,
+} from '@amp/web-apps-featurekit';
+import type { LoggerFactory } from '@amp/web-apps-logger';
+import { BUILD } from '~/config/build';
+
+export async function setupRuntimeFeatures(
+ logger: LoggerFactory,
+): Promise<OnyxFeatures | void> {
+ // load featureKit only for internal builds
+ if (import.meta.env.APP_SCOPE === 'internal' || import.meta.env.DEV) {
+ const features = await import('./consts');
+
+ // Build FeatureKit Config with overrides
+ const config = buildRuntimeFeatureKitConfig(features, {
+ [features.__FF_SHOW_RADAR]: buildFeatureConfig({
+ [ENVIRONMENT.DEV]: true,
+ }),
+ [features.__FF_ARYA]: {
+ ...buildFeatureConfig({ [ENVIRONMENT.DEV]: false }),
+ itfe: ['y9ttlj15'],
+ },
+ });
+ // Load runtime featureKit
+ return loadFeatureKit(
+ 'com.apple.apps',
+ ENVIRONMENT.DEV,
+ config,
+ logger,
+ {
+ enableToolbar: true,
+ radarConfig: {
+ component: 'ASE Web',
+ app: 'App Store',
+ build: BUILD,
+ },
+ },
+ );
+ }
+}
diff --git a/src/utils/file-size.ts b/src/utils/file-size.ts
new file mode 100644
index 0000000..f71c4f4
--- /dev/null
+++ b/src/utils/file-size.ts
@@ -0,0 +1,23 @@
+const ROUND_TO = 10;
+const SIZE_INCREMENT = 1000;
+const UNITS = ['byte', 'KB', 'MB', 'GB'];
+
+/**
+ * Converts a byte count into a scaled value with a unit label (e.g. KB, MB, GB).
+ *
+ * @param {number} bytes - The number of bytes.
+ * @returns {{ count: number, unit: string }} Scaled value and its corresponding unit.
+ */
+export function getFileSizeParts(bytes: number) {
+ let index = 0;
+
+ while (bytes >= SIZE_INCREMENT && index < UNITS.length - 1) {
+ bytes /= SIZE_INCREMENT;
+ index++;
+ }
+
+ const count = Math.round(bytes * ROUND_TO) / ROUND_TO;
+ const unit = UNITS[index];
+
+ return { count, unit };
+}
diff --git a/src/utils/launch-client.ts b/src/utils/launch-client.ts
new file mode 100644
index 0000000..5202726
--- /dev/null
+++ b/src/utils/launch-client.ts
@@ -0,0 +1,13 @@
+import { platform } from '@amp/web-apps-utils';
+
+const setupUrlForMac = (url: string) => {
+ const incomingUrl = new URL(url);
+ incomingUrl.searchParams.set('mt', '12');
+ return incomingUrl.toString();
+};
+
+export const launchAppOnMac = (url: string) => {
+ const appUrl = setupUrlForMac(url);
+
+ platform.launchClient(appUrl, () => {});
+};
diff --git a/src/utils/locale.ts b/src/utils/locale.ts
new file mode 100644
index 0000000..cd1151a
--- /dev/null
+++ b/src/utils/locale.ts
@@ -0,0 +1,142 @@
+import type { Opt } from '@jet/environment';
+import { DEFAULT_STOREFRONT_CODE } from '~/constants/storefront';
+
+import type {
+ NormalizedLocale,
+ NormalizedStorefront,
+ NormalizedLanguage,
+} from '@jet-app/app-store/api/locale';
+import type { Locale } from '@jet-app/app-store/foundation/dependencies/locale/locale';
+
+import { TEXT_DIRECTION } from '@amp/web-app-components/src/constants';
+import { getLocAttributes } from '@amp/web-apps-localization';
+
+import { regions } from '~/utils/storefront-data';
+import { getJet } from '~/jet/svelte';
+
+export type NormalizedLocaleWithDefault = NormalizedLocale & {
+ isDefaultLanguage: boolean;
+};
+
+type LanguageDetails = {
+ languages: NormalizedLanguage[];
+ defaultLanguage: NormalizedLanguage;
+};
+
+export function normalizeStorefront(storefront: Opt<string>): {
+ storefront: NormalizedStorefront;
+ languages: NormalizedLanguage[];
+ defaultLanguage: NormalizedLanguage;
+} {
+ const storefronts: Record<NormalizedStorefront, LanguageDetails> = {};
+
+ for (const { locales } of regions) {
+ for (const { id, language, isDefault } of locales) {
+ if (isDefault) {
+ storefronts[id as NormalizedStorefront] = {
+ languages: [],
+ defaultLanguage: language as NormalizedLanguage,
+ };
+ }
+
+ if (id in storefronts) {
+ storefronts[id as NormalizedStorefront].languages.push(
+ language as NormalizedLanguage,
+ );
+ }
+ }
+ }
+
+ const normalizedStorefront = (storefront || '').toLowerCase();
+ const chosenStorefront =
+ normalizedStorefront in storefronts
+ ? (normalizedStorefront as NormalizedStorefront)
+ : DEFAULT_STOREFRONT_CODE;
+
+ return {
+ storefront: chosenStorefront,
+ ...storefronts[chosenStorefront],
+ };
+}
+
+export function normalizeLanguage(
+ language: string,
+ languages: NormalizedLanguage[],
+ defaultLanguage: NormalizedLanguage,
+): { language: NormalizedLanguage; isDefaultLanguage: boolean } {
+ function annotateReturn(language: NormalizedLanguage): {
+ language: NormalizedLanguage;
+ isDefaultLanguage: boolean;
+ } {
+ return {
+ language,
+ isDefaultLanguage: language === defaultLanguage,
+ };
+ }
+
+ // Prefer an exact match (ex. en-US matches en-US)
+ const exactMatch = findMatch(language, languages, (a, b) => a === b);
+ if (exactMatch) {
+ return annotateReturn(exactMatch);
+ }
+
+ // Try partial match (ex. fr-CA or fr matches fr-FR)
+ const partialMatch = findMatch(
+ language,
+ languages,
+ (a, b) => a.split('-')[0] === b.split('-')[0],
+ );
+ if (partialMatch) {
+ return annotateReturn(partialMatch);
+ }
+
+ // The only remaining choice is the storefront default
+ return annotateReturn(defaultLanguage);
+}
+
+function findMatch<T extends string>(
+ needle: string,
+ haystack: T[],
+ matches: (a: string, b: string) => boolean,
+): Opt<T> {
+ return haystack.find((possibility) =>
+ matches(possibility.toLowerCase(), needle.toLowerCase()),
+ );
+}
+
+/**
+ * Gets the current Locale instance from the Svelte context.
+ *
+ * @return the active {@linkcode NormalizedLocale}
+ */
+export function getLocale(): NormalizedLocale {
+ let locale: Locale | undefined;
+
+ try {
+ const { objectGraph } = getJet();
+
+ locale = objectGraph.locale;
+ } catch {
+ throw new Error('`getLocale` called before `Jet.load`');
+ }
+
+ return {
+ storefront: locale.activeStorefront,
+ language: locale.activeLanguage,
+ };
+}
+
+/**
+ * Returns whether or not the document is in RTL mode, first based on the document's direction,
+ * with a fallback to the storefronts default writing direction.
+ */
+export function isRtl() {
+ const { storefront } = getLocale();
+ const { dir } = getLocAttributes(storefront);
+
+ return (
+ (typeof document !== 'undefined' &&
+ document.dir === TEXT_DIRECTION.RTL) ||
+ dir === TEXT_DIRECTION.RTL
+ );
+}
diff --git a/src/utils/media-queries.ts b/src/utils/media-queries.ts
new file mode 100644
index 0000000..d189b94
--- /dev/null
+++ b/src/utils/media-queries.ts
@@ -0,0 +1,12 @@
+import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
+import { getMediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions';
+import { buildMediaQueryStore } from '@amp/web-app-components/src/stores/media-query';
+
+const { BREAKPOINTS } = ArtworkConfig.get();
+
+const mediaQueryStore = buildMediaQueryStore(
+ 'medium',
+ getMediaConditions(BREAKPOINTS, { offset: 260 }),
+);
+
+export default mediaQueryStore;
diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts
new file mode 100644
index 0000000..9e3b015
--- /dev/null
+++ b/src/utils/metrics.ts
@@ -0,0 +1,4 @@
+export const APP_PRIVACY_MODAL_ID = 'ModalAppPrivacy';
+export const CUSTOMER_REVIEW_MODAL_ID = 'ModalCustomerReview';
+export const VERSION_HISTORY_MODAL_ID = 'ModalVersionHistory';
+export const LICENSE_AGREEMENT_MODAL_ID = 'LicenseAgreement';
diff --git a/src/utils/number-formatting.ts b/src/utils/number-formatting.ts
new file mode 100644
index 0000000..8e9ef71
--- /dev/null
+++ b/src/utils/number-formatting.ts
@@ -0,0 +1,39 @@
+/**
+ * Normalizes and makes sure we include some unicode option for number formating.
+ */
+function localeWithOptionsForNumbers(locale: string) {
+ locale = locale.toLowerCase().replace('_', '-');
+
+ if (locale === 'hi-in') {
+ // nu-latn makes the formatter use latin numbers.
+ // See BCP47 Unicode extensions for number (nu):
+ // http://unicode.org/repos/cldr/trunk/common/bcp47/number.xml
+ // TL;DR -u- means the start of unicode extension.
+ // nu-latn means numeric (nu) extension, latn value
+ return 'hi-in-u-nu-latn';
+ } else if (locale === 'my') {
+ // For the `my` locale, we want to display functional numbers as Latin numerals rather than in Burmese,
+ // so we are overriding the locale to give us the Latin functional numbers. See radar for more context:
+ // rdar://155236306 (LOC: MS-MY: ASOTW | Product Page: Functional: Numbers are not displayed in MS/EN format)
+ return 'my-u-nu-latn';
+ }
+
+ return locale;
+}
+
+/**
+ * Abbreviate a number into a compact shorthand
+ *
+ * @example
+ * const abbr = abbreviateNumber(10_000, 'en-US'); // '10K'
+ */
+export function abbreviateNumber(value: number, locale: string): string {
+ const formatter = new Intl.NumberFormat(
+ localeWithOptionsForNumbers(locale),
+ {
+ notation: 'compact',
+ },
+ );
+
+ return formatter.format(value);
+}
diff --git a/src/utils/portal.ts b/src/utils/portal.ts
new file mode 100644
index 0000000..0c61ed0
--- /dev/null
+++ b/src/utils/portal.ts
@@ -0,0 +1,34 @@
+/**
+ * Svelte action to move an element to a different part of the DOM (as specified by the `targetId`
+ * provided), effectively creating a "portal."
+ *
+ * @param {HTMLElement} node - The element to be moved (provided by Svelte's `use:action` syntax).
+ * @param {string} targetId - The ID of the target element where `node` should be moved.
+ * @returns {{ destroy(): void } | void} - An object with a `destroy` method to remove `node` from the target when unmounted.
+ *
+ * @example
+ * ```svelte
+ * <div use:portal={'target-container'}>
+ * This content will be moved to the element with ID "target-container".
+ * </div>
+ * ```
+ */
+export default function portal(node: HTMLElement, targetId: string) {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ let targetElement: HTMLElement | null = document.getElementById(targetId);
+
+ if (!targetElement) {
+ return;
+ }
+
+ targetElement.appendChild(node);
+
+ return {
+ destroy() {
+ targetElement.removeChild(node);
+ },
+ };
+}
diff --git a/src/utils/seo/app-event-detail-page.ts b/src/utils/seo/app-event-detail-page.ts
new file mode 100644
index 0000000..7b6c270
--- /dev/null
+++ b/src/utils/seo/app-event-detail-page.ts
@@ -0,0 +1,43 @@
+import type { GenericPage } from '@jet-app/app-store/api/models';
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+import { isAppEventDetailShelf } from '~/components/jet/shelf/AppEventDetailShelf.svelte';
+import { truncateAroundLimit } from '~/utils/string-formatting';
+import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common';
+
+export function seoDataForAppEventDetailPage(
+ page: GenericPage,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ const appEventDetailShelf = page.shelves.find(isAppEventDetailShelf);
+
+ const { appEvent } = appEventDetailShelf?.items[0] || {};
+
+ if (!appEvent) {
+ return {};
+ }
+
+ const title = appEvent.title;
+ const description = truncateAroundLimit(
+ appEvent.detail,
+ MAX_DESCRIPTION_LENGTH,
+ language,
+ );
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ crop: 'fo',
+ twitterCropCode: 'fo',
+ artworkUrl: appEvent?.moduleArtwork?.template,
+ imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', {
+ title: title,
+ }),
+ };
+}
diff --git a/src/utils/seo/arcade-see-all-page.ts b/src/utils/seo/arcade-see-all-page.ts
new file mode 100644
index 0000000..14d1474
--- /dev/null
+++ b/src/utils/seo/arcade-see-all-page.ts
@@ -0,0 +1,40 @@
+import type I18N from '@amp/web-apps-localization';
+import type { GenericPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import { isAppTrailerLockupShelf } from '~/components/jet/shelf/AppTrailerLockupShelf.svelte';
+
+export function seoDataForArcadeSeeAllPage(
+ page: GenericPage,
+ i18n: I18N,
+): SeoData {
+ const titleWithSiteName = i18n.t(
+ 'ASE.Web.AppStore.Meta.TitleWithSiteName',
+ {
+ title: i18n.t('ASE.Web.AppStore.ArcadeSeeAll.Meta.Title'),
+ },
+ );
+
+ const appNames = page.shelves
+ .filter(isAppTrailerLockupShelf)
+ .flatMap((shelf) => shelf.items)
+ .slice(0, 3)
+ .map((item) => item.title);
+
+ const description = i18n.t(
+ 'ASE.Web.AppStore.ArcadeSeeAll.Meta.Description',
+ {
+ listing1: appNames[0],
+ listing2: appNames[1],
+ listing3: appNames[2],
+ },
+ );
+
+ return {
+ pageTitle: titleWithSiteName,
+ socialTitle: titleWithSiteName,
+ appleTitle: titleWithSiteName,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/article-page.ts b/src/utils/seo/article-page.ts
new file mode 100644
index 0000000..371e63e
--- /dev/null
+++ b/src/utils/seo/article-page.ts
@@ -0,0 +1,276 @@
+import type { Opt } from '@jet/environment/types/optional';
+import type {
+ Article,
+ CollectionPage,
+ CreativeWork,
+ WithContext,
+} from 'schema-dts';
+
+import type { ArticlePage } from '@jet-app/app-store/api/models';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import {
+ type DataContainer,
+ type Data,
+ dataFromDataContainer,
+} from '@jet-app/app-store/foundation/media/data-structure';
+import {
+ attributeAsDictionary,
+ attributeAsString,
+} from '@jet-app/app-store/foundation/media/attributes';
+import { relationshipCollection } from '@jet-app/app-store/foundation/media/relationships';
+
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+
+import { isSmallLockupShelf } from '~/components/jet/shelf/SmallLockupShelf.svelte';
+import { isLockupOverlay } from '~/components/jet/today-card/TodayCardOverlay.svelte';
+import { isLockupListOverlay } from '~/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte';
+import { isTodayCardMediaWithArtwork } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+import { isTodayCardMediaVideo } from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+import { isTodayCardMediaRiver } from '~/components/jet/today-card/media/TodayCardMediaRiver.svelte';
+import { isTodayCardMediaBrandedSingleApp } from '~/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte';
+import { isTodayCardMediaAppEvent } from '~/components/jet/today-card/media/TodayCardMediaAppEvent.svelte';
+
+import { AppleOrganization } from './common';
+import { buildOpenGraphImageURL } from './image-url';
+import { basicSoftwareApplicationSchema } from './product-page';
+import { stripTags, truncateAroundLimit } from '~/utils/string-formatting';
+
+/// MARK: Schema Data
+
+/**
+ * SEO-related props that have already been computed, and will be re-used within the schema
+ */
+interface SeoProps {
+ title: string;
+ description: string | undefined;
+}
+
+function commonSchemaForArticlePage(
+ data: Data,
+ { title, description }: SeoProps,
+): WithContext<CreativeWork> {
+ const artwork =
+ attributeAsDictionary(
+ data,
+ 'editorialArtwork.storyCenteredStatic16x9',
+ ) ?? undefined;
+ const lastPublishedDate =
+ attributeAsString(data, 'lastPublishedDate') ?? undefined;
+
+ return {
+ '@type': 'CreativeWork',
+ '@context': 'https://schema.org',
+
+ description,
+ headline: title,
+ name: title,
+
+ dateModified: lastPublishedDate,
+ datePublished: lastPublishedDate,
+ image: artwork ? buildOpenGraphImageURL(artwork) : undefined,
+
+ author: AppleOrganization,
+ publisher: AppleOrganization,
+ };
+}
+
+function articleSchemaForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ data: Data,
+): WithContext<Article> {
+ const cardContents = relationshipCollection(data, 'card-contents') ?? [];
+ const [app] = cardContents;
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+
+ mainEntityOfPage: app
+ ? basicSoftwareApplicationSchema(objectGraph, app)
+ : undefined,
+ };
+}
+
+function collectionPageSchemaForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ data: Data,
+): WithContext<CollectionPage> {
+ const cardContents = relationshipCollection(data, 'card-contents') ?? [];
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'CollectionPage',
+
+ mentions: cardContents.map((app) =>
+ basicSoftwareApplicationSchema(objectGraph, app),
+ ),
+ };
+}
+
+/**
+ *
+ * @param objectGraph
+ * @param response the API response for the Article page
+ * @param props SEO-related props that have already been derrived for the page
+ */
+export function schemaDataForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ response: Opt<DataContainer>,
+ props: SeoProps,
+): Partial<SeoData> {
+ if (!response) {
+ return {};
+ }
+
+ const articleData = dataFromDataContainer(objectGraph, response);
+ if (!articleData) {
+ return {};
+ }
+
+ let schemaContent = commonSchemaForArticlePage(articleData, props);
+
+ const kind = attributeAsString(articleData, 'kind');
+
+ if (kind === 'Collection') {
+ schemaContent = {
+ ...schemaContent,
+ ...collectionPageSchemaForArticlePage(objectGraph, articleData),
+ };
+ } else {
+ schemaContent = {
+ ...schemaContent,
+ ...articleSchemaForArticlePage(objectGraph, articleData),
+ };
+ }
+
+ return {
+ schemaName: 'article-page',
+ schemaContent,
+ };
+}
+
+/// MARK: Full SEO Data
+
+export function seoDataForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ i18n: I18N,
+ page: ArticlePage,
+ response: Opt<DataContainer>,
+ language: string,
+): SeoData {
+ const { card } = page;
+
+ if (!card) {
+ return {};
+ }
+
+ const storyTitle = stripTags(card.title);
+ const pageTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: storyTitle,
+ });
+
+ let artwork = '';
+ let crop: CropCode = 'fo';
+ let appNames = [];
+
+ if (card.overlay && isLockupListOverlay(card.overlay)) {
+ appNames = card.overlay.lockups.slice(0, 3).map((item) => item.title);
+ } else {
+ appNames = page.shelves
+ .filter(isSmallLockupShelf)
+ .flatMap((shelf) => shelf.items)
+ .slice(0, 3)
+ .map((item) => item.title);
+ }
+
+ const firstParagraphShelf = page.shelves.find(
+ (shelf) => shelf.contentType === 'paragraph',
+ );
+ let description;
+
+ // If an article has a paragraph shelf, we use that to populate the meta description,
+ // otherwise, we build a list of app names for the description.
+ if (page.shelves.length > 1 && firstParagraphShelf?.items) {
+ // The article paragraphs can contain HTML tags, so we strip them out here
+ const text = stripTags(firstParagraphShelf.items[0].text);
+
+ const articleContent = truncateAroundLimit(text, 110, language);
+
+ description = i18n.t(
+ 'ASE.Web.AppStore.Meta.Story.Description.WithArticleContent',
+ { articleContent },
+ );
+ } else if (appNames.length === 1) {
+ description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.One', {
+ storyTitle,
+ featuredAppName: appNames[0],
+ });
+ } else if (appNames.length === 2) {
+ description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.Two', {
+ storyTitle,
+ featuredAppName: appNames[0],
+ featuredAppName2: appNames[1],
+ });
+ } else if (appNames.length >= 3) {
+ description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.Three', {
+ storyTitle,
+ featuredAppName: appNames[0],
+ featuredAppName2: appNames[1],
+ featuredAppName3: appNames[2],
+ });
+ } else if (card.overlay && isLockupOverlay(card.overlay)) {
+ const featuredAppName = card.overlay.lockup.title;
+
+ description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.One', {
+ storyTitle,
+ featuredAppName,
+ });
+ }
+
+ if (card.media && isTodayCardMediaWithArtwork(card.media)) {
+ artwork = card.media.artworks[0].template;
+ } else if (card.media && isTodayCardMediaVideo(card.media)) {
+ artwork = card.media.videos[0].preview.template;
+ } else if (card.media && isTodayCardMediaRiver(card.media)) {
+ artwork = card.media.lockups[0].icon.template;
+ crop = 'wa';
+ } else if (
+ card.media &&
+ (isTodayCardMediaBrandedSingleApp(card.media) ||
+ isTodayCardMediaAppEvent(card.media))
+ ) {
+ if (card.media.artworks.length > 0) {
+ artwork = card.media.artworks[0].template;
+ } else if (card.media.videos.length > 0) {
+ artwork = card.media.videos[0].preview.template;
+ }
+ }
+
+ // We are setting the `link rel="canonical"` tag for iPad, Watch and TV story pages to point to
+ // the iPhone page.
+ let canonicalUrl = page.canonicalURL?.replace(
+ /\/([a-z]{2})\/(ipad|watch|tv)\/story\//,
+ '/$1/iphone/story/',
+ );
+
+ return {
+ pageTitle,
+ crop,
+ canonicalUrl,
+ socialTitle: pageTitle,
+ description: description,
+ socialDescription: description,
+ appleDescription: description,
+ artworkUrl: artwork,
+ twitterCropCode: crop,
+ imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', {
+ title: storyTitle,
+ }),
+ ...schemaDataForArticlePage(objectGraph, response, {
+ title: pageTitle,
+ description,
+ }),
+ };
+}
diff --git a/src/utils/seo/charts-hub-page.ts b/src/utils/seo/charts-hub-page.ts
new file mode 100644
index 0000000..1b670ad
--- /dev/null
+++ b/src/utils/seo/charts-hub-page.ts
@@ -0,0 +1,46 @@
+import type { ChartsHubPage, Lockup } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type I18N from '@amp/web-apps-localization';
+import { getPlatformFromPage } from '~/utils/seo/common';
+import { truncateAroundLimit } from '~/utils/string-formatting';
+
+export function seoDataForChartsHubPage(
+ page: ChartsHubPage,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ const platform = getPlatformFromPage(page);
+ const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.ChartsHub.Title', {
+ platform,
+ }),
+ });
+
+ let description;
+ const items = page.charts[0].segments[0].shelves[0].items as Array<Lockup>;
+
+ if (items) {
+ const appsTitles = items.map(({ title }) => title);
+
+ description = truncateAroundLimit(
+ i18n.t('ASE.Web.AppStore.Meta.ChartsHub.Description', {
+ platform,
+ listing1: appsTitles[0],
+ listing2: appsTitles[1],
+ listing3: appsTitles[2],
+ listing4: appsTitles[3],
+ }),
+ 160,
+ language,
+ );
+ }
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/charts-page.ts b/src/utils/seo/charts-page.ts
new file mode 100644
index 0000000..14de925
--- /dev/null
+++ b/src/utils/seo/charts-page.ts
@@ -0,0 +1,58 @@
+import type { TopChartsPage, Lockup } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type I18N from '@amp/web-apps-localization';
+import { getPlatformFromPage } from '~/utils/seo/common';
+import {
+ commaSeparatedList,
+ truncateAroundLimit,
+} from '~/utils/string-formatting';
+
+export function seoDataForChartsPage(
+ page: TopChartsPage,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ // Genre 36 and 6014 are the "All Apps" and "All Games" genres, which we do not want to
+ // include in the page title, since it would then read as "Best All Games Apps - App Store".
+ const category = page.categoriesButtonTitle;
+ const isAllAppsOrGames = ['36', '6014'].includes(page.genreId);
+ const titleLocKey =
+ isAllAppsOrGames || !category
+ ? 'ASE.Web.AppStore.Meta.ChartsHub.Title'
+ : 'ASE.Web.AppStore.Meta.Charts.Title';
+ const platform = getPlatformFromPage(page);
+
+ const title = i18n.t(titleLocKey, {
+ category,
+ platform,
+ });
+
+ let description;
+ const items = page.segments[0].shelves[0].items as Array<Lockup>;
+
+ if (items) {
+ const appTitles = items.map(({ title }) => title).slice(0, 3);
+ const locKey =
+ category && !isAllAppsOrGames
+ ? 'ASE.Web.AppStore.Meta.Charts.Description'
+ : 'ASE.Web.AppStore.Meta.Charts.DescriptionWithoutCategory';
+
+ description = truncateAroundLimit(
+ i18n.t(locKey, {
+ category,
+ platform,
+ listOfApps: commaSeparatedList(appTitles, language),
+ }),
+ 160,
+ );
+ }
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/common.ts b/src/utils/seo/common.ts
new file mode 100644
index 0000000..8873dbd
--- /dev/null
+++ b/src/utils/seo/common.ts
@@ -0,0 +1,75 @@
+import type { Opt } from '@jet/environment/types/optional';
+import type { Organization } from 'schema-dts';
+import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+export const MAX_DESCRIPTION_LENGTH = 160;
+
+export const AppleOrganization: Organization = {
+ '@type': 'Organization',
+ name: 'Apple Inc',
+ url: 'http://www.apple.com',
+ logo: {
+ '@type': 'ImageObject',
+ url: 'https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png',
+ },
+};
+
+export function updateCanonicalURL(
+ page: WebRenderablePage,
+ canonicalURL: string,
+): void {
+ const seoData = page.seoData as Opt<SeoData>;
+
+ if (!seoData) {
+ return;
+ }
+
+ seoData.url = canonicalURL;
+}
+
+export function seoDataForAnyPage(
+ page: WebRenderablePage,
+ i18n: I18N,
+): SeoData {
+ const pageTitle =
+ 'title' in page
+ ? i18n.t('ASE.Web.AppStore.Meta.TitleWithPlatformAndSiteName', {
+ title: page.title,
+ platform: getPlatformFromPage(page),
+ })
+ : i18n.t('ASE.Web.AppStore.Meta.SiteName');
+
+ const description = i18n.t('ASE.Web.AppStore.Meta.Description');
+
+ return {
+ url: page.canonicalURL ?? '',
+ siteName: i18n.t('ASE.Web.AppStore.Meta.SiteName'),
+
+ pageTitle,
+ socialTitle: pageTitle,
+ appleTitle: pageTitle,
+
+ description,
+ socialDescription: description,
+ appleDescription: description,
+
+ width: 1200,
+ height: 630,
+ twitterWidth: 1200,
+ twitterHeight: 630,
+ twitterCropCode: 'wa',
+ crop: 'wa',
+ fileType: 'jpg',
+ artworkUrl: '/assets/images/share/app-store.png',
+
+ twitterSite: '@AppStore',
+ };
+}
+
+export function getPlatformFromPage(page: WebRenderablePage): Opt<string> {
+ return page.webNavigation?.platforms.find((platform) => platform.isActive)
+ ?.action.title;
+}
diff --git a/src/utils/seo/developer-page.ts b/src/utils/seo/developer-page.ts
new file mode 100644
index 0000000..914dd08
--- /dev/null
+++ b/src/utils/seo/developer-page.ts
@@ -0,0 +1,174 @@
+import {
+ type Opt,
+ unwrapOptional as unwrap,
+} from '@jet/environment/types/optional';
+import type { Organization, WithContext } from 'schema-dts';
+
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import {
+ type Data,
+ type DataContainer,
+ dataFromDataContainer,
+} from '@jet-app/app-store/foundation/media/data-structure';
+import { attributeAsString } from '@jet-app/app-store/foundation/media/attributes';
+import { relationshipCollection } from '@jet-app/app-store/foundation/media/relationships';
+
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+import { uniqueById } from '~/utils/array';
+import { basicSoftwareApplicationSchema } from '~/utils/seo/product-page';
+
+/**
+ * Generate a basic {@linkcode Person} schema for a "developer" page
+ *
+ * Note: this is appropriate to be embedded into another schema that
+ * needs to reference the developer
+ */
+export function basicDeveloperSchema(data: Data) {
+ return {
+ '@type': 'Organization',
+ name: attributeAsString(data, 'name') ?? undefined,
+ url: attributeAsString(data, 'url') ?? undefined,
+ } satisfies Organization;
+}
+
+export function buildDeveloperDescription(
+ props: {
+ name: string;
+ },
+ appData: Data[],
+ i18n: I18N,
+) {
+ const { name: developerName } = props;
+
+ switch (appData.length) {
+ case 0:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.ZeroApps',
+ {
+ developerName,
+ },
+ );
+ case 1:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.OneApp',
+ {
+ developerName,
+ listing1: attributeAsString(appData[0], 'name'),
+ },
+ );
+ case 2:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.TwoApps',
+ {
+ developerName,
+ listing1: attributeAsString(appData[0], 'name'),
+ listing2: attributeAsString(appData[1], 'name'),
+ },
+ );
+ case 3:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.ThreeApps',
+ {
+ developerName,
+ listing1: attributeAsString(appData[0], 'name'),
+ listing2: attributeAsString(appData[1], 'name'),
+ listing3: attributeAsString(appData[2], 'name'),
+ },
+ );
+ default:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.ManyApps',
+ {
+ developerName,
+ listing1: attributeAsString(appData[0], 'name'),
+ listing2: attributeAsString(appData[1], 'name'),
+ listing3: attributeAsString(appData[2], 'name'),
+ },
+ );
+ }
+}
+
+/**
+ * Builds the Schema.org meta-data for a "Developer" page
+ *
+ * @param objectGraph The Object Graph
+ * @param developerPageData The `Data` for the Developer page
+ * @param appData The `Data` for all apps related to the Developer apge
+ * @param props Pre-formatted properties also used outside of the Schema
+ * @returns
+ */
+function developerOrganizationSchemaSeoData(
+ objectGraph: AppStoreObjectGraph,
+ developerPageData: Data,
+ appData: Data[],
+ props: {
+ description: string;
+ },
+): Opt<Partial<SeoData>> {
+ const { description } = props;
+
+ const schemaContent: WithContext<Organization> = {
+ '@context': 'https://schema.org',
+
+ ...basicDeveloperSchema(developerPageData),
+
+ description,
+ hasOfferCatalog: {
+ '@type': 'OfferCatalog',
+ itemListElement: appData.map((app) =>
+ basicSoftwareApplicationSchema(objectGraph, app),
+ ),
+ },
+ };
+
+ return {
+ schemaName: 'developer',
+ schemaContent,
+ };
+}
+
+/**
+ * Builds the full `SeoData` requirements for a "Developer" page
+ */
+export function seoDataForDeveloperPage(
+ objectGraph: AppStoreObjectGraph,
+ container: Opt<DataContainer>,
+ i18n: I18N,
+): Partial<SeoData> {
+ if (!container) {
+ return {};
+ }
+
+ const developerPageData = dataFromDataContainer(objectGraph, container);
+ if (!developerPageData) {
+ return {};
+ }
+
+ const allApps = uniqueById([
+ ...unwrap(relationshipCollection(developerPageData, 'atv-apps')),
+ ...unwrap(relationshipCollection(developerPageData, 'app-bundles')),
+ ...unwrap(relationshipCollection(developerPageData, 'imessage-apps')),
+ ...unwrap(relationshipCollection(developerPageData, 'ios-apps')),
+ ...unwrap(relationshipCollection(developerPageData, 'mac-apps')),
+ ...unwrap(relationshipCollection(developerPageData, 'watch-apps')),
+ ]);
+
+ const name = unwrap(attributeAsString(developerPageData, 'name'));
+ const description = buildDeveloperDescription({ name }, allApps, i18n);
+
+ return {
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ ...developerOrganizationSchemaSeoData(
+ objectGraph,
+ developerPageData,
+ allApps,
+ {
+ description,
+ },
+ ),
+ };
+}
diff --git a/src/utils/seo/editorial-shelf-collection-page.ts b/src/utils/seo/editorial-shelf-collection-page.ts
new file mode 100644
index 0000000..dd152df
--- /dev/null
+++ b/src/utils/seo/editorial-shelf-collection-page.ts
@@ -0,0 +1,51 @@
+import type I18N from '@amp/web-apps-localization';
+import type { GenericPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import { isPageHeaderShelf } from '~/components/jet/shelf/PageHeaderShelf.svelte';
+import { getPlatformFromPage } from '~/utils/seo/common';
+import { commaSeparatedList } from '../string-formatting';
+
+export function seoDataForEditorialShelfCollectionPage(
+ page: GenericPage,
+ i18n: I18N,
+): SeoData {
+ let title = page.title;
+ let description;
+ const headerShelf = page.shelves.find(isPageHeaderShelf);
+
+ if (headerShelf) {
+ title = headerShelf.items[0].title;
+ description = headerShelf.items[0].subtitle;
+ }
+
+ if (!description) {
+ const platform = getPlatformFromPage(page);
+ const titles = page.shelves
+ .filter((shelf) => !isPageHeaderShelf(shelf))
+ .flatMap(({ items }) => items)
+ .slice(0, 3)
+ .map((item) => item.title);
+
+ description = i18n.t(
+ 'ASE.Web.AppStore.Meta.EditorialShelfCollection.Description',
+ {
+ platform,
+ listOfApps: commaSeparatedList(titles),
+ },
+ );
+ }
+
+ const titleWithSiteName = i18n.t(
+ 'ASE.Web.AppStore.Meta.TitleWithSiteName',
+ { title },
+ );
+
+ return {
+ pageTitle: titleWithSiteName,
+ socialTitle: titleWithSiteName,
+ appleTitle: titleWithSiteName,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/image-url.ts b/src/utils/seo/image-url.ts
new file mode 100644
index 0000000..b2295f7
--- /dev/null
+++ b/src/utils/seo/image-url.ts
@@ -0,0 +1,71 @@
+import type { URL } from 'schema-dts';
+import type { Opt } from '@jet/environment/types/optional';
+
+import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+import { buildSrcSeo } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+
+const RECOMMENDED_OPEN_GRAPH_IMAGE_WIDTH = 1200;
+const RECOMMENDED_OPEN_GRAPH_IMAGE_HEIGHT = 630;
+
+const DEFAULT_OPEN_GRAPH_IMAGE_CROP = 'bb';
+const DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE = 'png';
+
+/**
+ * Generate an OpenGraph image URL from a Media API artwork definition
+ *
+ * This overrides the default size of the image with the recommendations
+ * from the Open Graph documentation
+ */
+export function buildOpenGraphImageURL(
+ artworkDefinition: Opt<MapLike<JSONValue>>,
+ crop: CropCode = DEFAULT_OPEN_GRAPH_IMAGE_CROP,
+): URL | undefined {
+ if (!artworkDefinition) {
+ return undefined;
+ }
+
+ const { url } = artworkDefinition;
+
+ if (typeof url !== 'string') {
+ return undefined;
+ }
+
+ return (
+ buildSrcSeo(url, {
+ crop,
+ width: RECOMMENDED_OPEN_GRAPH_IMAGE_WIDTH,
+ height: RECOMMENDED_OPEN_GRAPH_IMAGE_HEIGHT,
+ fileType: DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE,
+ }) ?? undefined
+ );
+}
+
+/**
+ * Construct a metadata-friendly URL for some Media API-provided artwork
+ */
+export function buildImageURL(
+ artworkDefinition: Opt<MapLike<JSONValue>>,
+): URL | undefined {
+ if (!artworkDefinition) {
+ return undefined;
+ }
+
+ const { url, width, height } = artworkDefinition;
+
+ if (
+ typeof url !== 'string' ||
+ typeof width !== 'number' ||
+ typeof height !== 'number'
+ ) {
+ return undefined;
+ }
+
+ return (
+ buildSrcSeo(url, {
+ crop: DEFAULT_OPEN_GRAPH_IMAGE_CROP,
+ width,
+ height,
+ fileType: DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE,
+ }) ?? undefined
+ );
+}
diff --git a/src/utils/seo/product-page.ts b/src/utils/seo/product-page.ts
new file mode 100644
index 0000000..bc518ea
--- /dev/null
+++ b/src/utils/seo/product-page.ts
@@ -0,0 +1,353 @@
+import type { Offer, SoftwareApplication, WithContext } from 'schema-dts';
+
+import {
+ type Opt,
+ unwrapOptional as unwrap,
+} from '@jet/environment/types/optional';
+import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models';
+import type { PreviewPlatform } from '@jet-app/app-store/api/models/preview-platform';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import {
+ type AttributePlatform,
+ type Data,
+ type DataContainer,
+ dataFromDataContainer,
+} from '@jet-app/app-store/foundation/media/data-structure';
+import {
+ attributeAsArrayOrEmpty,
+ attributeAsDictionary,
+ attributeAsNumber,
+ attributeAsString,
+} from '@jet-app/app-store/foundation/media/attributes';
+import {
+ platformAttributeAsBooleanOrFalse,
+ platformAttributeAsDictionary,
+ platformAttributeAsString,
+} from '@jet-app/app-store/foundation/media/platform-attributes';
+import {
+ relationship,
+ relationshipCollection,
+} from '@jet-app/app-store/foundation/media/relationships';
+import {
+ asString,
+ asNumber,
+} from '@jet-app/app-store/foundation/json-parsing/server-data';
+import { bestAttributePlatformFromData } from '@jet-app/app-store/common/content/attributes';
+import { offerDataFromData } from '@jet-app/app-store/common/offers/offers';
+
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+
+import { basicDeveloperSchema } from './developer-page';
+import { buildOpenGraphImageURL, buildImageURL } from './image-url';
+import { truncateAroundLimit } from '~/utils/string-formatting';
+import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common';
+import { isProductBadgeShelf } from '~/components/jet/shelf/ProductBadgeShelf.svelte';
+
+/// MARK: Primary Image
+
+/**
+ * Determine if the data for a product represents an app that **only** supports iMessage
+ */
+function isMessagesOnly(data: Data, attributePlatform: AttributePlatform) {
+ const hasMessagesExtension = platformAttributeAsBooleanOrFalse(
+ data,
+ attributePlatform,
+ 'hasMessagesExtension',
+ );
+ const isHiddenFromSpringboard = platformAttributeAsBooleanOrFalse(
+ data,
+ attributePlatform,
+ 'isHiddenFromSpringboard',
+ );
+
+ return hasMessagesExtension && isHiddenFromSpringboard;
+}
+
+function buildProductArtworkImage(
+ data: Data,
+ attributePlatform: AttributePlatform,
+) {
+ let iconCropCode: CropCode | undefined = undefined;
+
+ if (isMessagesOnly(data, attributePlatform)) {
+ iconCropCode = 'wb';
+ }
+
+ const deviceFamilies = attributeAsArrayOrEmpty(data, 'deviceFamilies');
+ const hasIOSApp = deviceFamilies.includes('iphone');
+
+ if (hasIOSApp) {
+ iconCropCode = 'wa';
+ }
+
+ const artworkDefinition =
+ platformAttributeAsDictionary(data, attributePlatform, 'artwork') ??
+ attributeAsDictionary(data, 'artwork');
+
+ return buildOpenGraphImageURL(artworkDefinition, iconCropCode);
+}
+
+/// MARK: Screenshots
+
+const PREFERRED_SCREENSHOT_TYPE_BY_PLATFORM: Record<PreviewPlatform, string[]> =
+ {
+ iphone: [
+ 'iphone_d74',
+ 'iphone_d73',
+ 'iphone_6_5',
+ 'iphone_5_8',
+ 'iphone6+',
+ 'iphone6',
+ 'iphone5',
+ 'iphone',
+ ],
+ ipad: ['ipadPro_2018', 'ipad_11', 'ipad', 'ipad_10_5', 'ipadPro'],
+ watch: [
+ 'appleWatch_2024',
+ 'appleWatch_2022',
+ 'appleWatch_2021',
+ 'appleWatch_2018',
+ 'appleWatch',
+ ],
+ tv: ['appletv', 'appleTV'],
+ mac: [],
+ vision: [],
+ };
+
+function buildProductScreenshots(
+ data: Data,
+ attributePlatform: AttributePlatform,
+ previewPlatform: PreviewPlatform,
+) {
+ const screenshotsByType = platformAttributeAsDictionary(
+ data,
+ attributePlatform,
+ 'screenshotsByType',
+ );
+ if (!screenshotsByType) {
+ return undefined;
+ }
+
+ const preferredScreenshotType = PREFERRED_SCREENSHOT_TYPE_BY_PLATFORM[
+ previewPlatform
+ ]?.find((preferredType) => preferredType in screenshotsByType);
+ if (!preferredScreenshotType) {
+ return undefined;
+ }
+
+ const screenshotArtworkDefinitions = screenshotsByType[
+ preferredScreenshotType
+ ] as Array<MapLike<JSONValue>>;
+
+ return screenshotArtworkDefinitions
+ .map((screenshotArtworkDefinition) =>
+ buildImageURL(screenshotArtworkDefinition),
+ )
+ .filter((screenshot) => typeof screenshot !== 'undefined');
+}
+
+function buildOffer(
+ objectGraph: AppStoreObjectGraph,
+ data: Data,
+ attributePlatform: AttributePlatform,
+): Offer | undefined {
+ const offer = offerDataFromData(objectGraph, data, attributePlatform);
+ if (!offer) {
+ return undefined;
+ }
+
+ const price = asNumber(offer, 'price') ?? undefined;
+ const priceCurrency = asString(offer, 'currencyCode') ?? undefined;
+ const category = !price || price === 0 ? 'free' : undefined;
+
+ return {
+ '@type': 'Offer',
+ price,
+ priceCurrency,
+ category,
+ };
+}
+
+function buildAvailableDevices(data: Data): string | undefined {
+ const deviceFamilies = attributeAsArrayOrEmpty(data, 'deviceFamilies');
+ if (!deviceFamilies) {
+ return undefined;
+ }
+
+ return deviceFamilies
+ .filter((device) => typeof device === 'string')
+ .map((device) => {
+ if (device === 'mac') {
+ return 'Mac';
+ } else if (device.indexOf('ip') === 0) {
+ return device.replace(/^.{2}/g, 'iP');
+ } else if (device === 'tvos') {
+ return 'Apple TV';
+ } else if (device === 'watch') {
+ return 'Apple Watch';
+ }
+
+ return undefined;
+ })
+ .filter((device) => !!device)
+ .join(', ');
+}
+
+/**
+ * Produces a minimal {@linkcode SoftwareApplication} definition from a Media API `app` response
+ *
+ * Appropriate for embedding within another schema
+ */
+export function basicSoftwareApplicationSchema(
+ objectGraph: AppStoreObjectGraph,
+ data: Data,
+) {
+ const allGenreData = relationshipCollection(data, 'genres');
+ const firstGenreData = (allGenreData && allGenreData[0]) ?? undefined;
+
+ const attributePlatformFromData: Opt<AttributePlatform> =
+ bestAttributePlatformFromData(objectGraph, data);
+
+ if (!attributePlatformFromData) {
+ return null;
+ }
+
+ const attributePlatform = unwrap(attributePlatformFromData);
+
+ return {
+ '@type': 'SoftwareApplication',
+
+ name: attributeAsString(data, 'name') ?? undefined,
+ description:
+ platformAttributeAsString(
+ data,
+ attributePlatform,
+ 'description.standard',
+ ) ?? undefined,
+ image: buildProductArtworkImage(data, attributePlatform),
+ availableOnDevice: buildAvailableDevices(data),
+ operatingSystem:
+ platformAttributeAsString(
+ data,
+ attributePlatform,
+ 'requirementsString',
+ ) ?? undefined,
+ offers: buildOffer(objectGraph, data, attributePlatform),
+ applicationCategory: firstGenreData
+ ? attributeAsString(firstGenreData, 'name') ?? undefined
+ : undefined,
+
+ aggregateRating: {
+ '@type': 'AggregateRating',
+ ratingValue:
+ attributeAsNumber(data, 'userRating.value') ?? undefined,
+ reviewCount:
+ attributeAsNumber(data, 'userRating.ratingCount') ?? undefined,
+ },
+ } satisfies SoftwareApplication;
+}
+
+/// MARK: Schema Definition
+
+function softwareApplicationSchemaSeoData(
+ objectGraph: AppStoreObjectGraph,
+ container: Opt<DataContainer>,
+): Opt<Partial<SeoData>> {
+ if (!container) {
+ return null;
+ }
+
+ const productPageData = dataFromDataContainer(objectGraph, container);
+ if (!productPageData) {
+ return null;
+ }
+
+ const developerDataContainer = relationship(productPageData, 'developer');
+ const developerData = dataFromDataContainer(
+ objectGraph,
+ developerDataContainer,
+ );
+
+ const attributePlatform = unwrap(
+ bestAttributePlatformFromData(objectGraph, productPageData),
+ );
+
+ const schemaContent: WithContext<SoftwareApplication> = {
+ '@context': 'https://schema.org',
+
+ ...basicSoftwareApplicationSchema(objectGraph, productPageData),
+
+ author: developerData ? basicDeveloperSchema(developerData) : undefined,
+ screenshot: buildProductScreenshots(
+ productPageData,
+ attributePlatform,
+ unwrap(objectGraph.activeIntent?.previewPlatform),
+ ),
+ };
+
+ return {
+ schemaName: 'software-application',
+ schemaContent,
+ };
+}
+
+export function seoDataForProductPage(
+ objectGraph: AppStoreObjectGraph,
+ page: ShelfBasedProductPage,
+ data: Opt<DataContainer>,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ const artworkUrl = page.lockup.icon?.template;
+ const badgeShelf = Object.values(page.shelfMapping).find(
+ isProductBadgeShelf,
+ );
+ const developerName = badgeShelf?.items.find(
+ ({ key }) => key === 'developer',
+ )?.caption;
+
+ const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.Product.Title', {
+ appName: page.lockup.title,
+ }),
+ });
+
+ const descriptionLocKey = developerName
+ ? 'ASE.Web.AppStore.Meta.Product.Description'
+ : 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName';
+
+ const description = truncateAroundLimit(
+ i18n.t(descriptionLocKey, {
+ appName: page.lockup.title,
+ developerName,
+ }),
+ MAX_DESCRIPTION_LENGTH,
+ language,
+ );
+
+ // Removes all query parameters (including `platform=*`) to form the canonical version
+ // of the URL for the `link rel="canonical"` tag.
+ let url = page.canonicalURL;
+ if (url) {
+ const cleanCanonicalUrl = new URL(url);
+ cleanCanonicalUrl.search = '';
+ url = cleanCanonicalUrl.toString();
+ }
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ canonicalUrl: url,
+ artworkUrl,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', {
+ title: page.title,
+ }),
+ ...softwareApplicationSchemaSeoData(objectGraph, data),
+ };
+}
diff --git a/src/utils/seo/reviews-page.ts b/src/utils/seo/reviews-page.ts
new file mode 100644
index 0000000..041d7b8
--- /dev/null
+++ b/src/utils/seo/reviews-page.ts
@@ -0,0 +1,56 @@
+import type {
+ ReviewsPage,
+ ShelfBasedProductPage,
+} from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import type I18N from '@amp/web-apps-localization';
+
+import { truncateAroundLimit } from '~/utils/string-formatting';
+import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common';
+import { isProductBadgeShelf } from '~/components/jet/shelf/ProductBadgeShelf.svelte';
+
+export function seoDataForReviewsPage(
+ i18n: I18N,
+ page: ReviewsPage,
+ productPage: ShelfBasedProductPage,
+ objectGraph: AppStoreObjectGraph,
+): SeoData {
+ const appName = productPage.lockup.title;
+ const artworkUrl = productPage.lockup.icon?.template;
+ const badgeShelf = Object.values(productPage.shelfMapping).find(
+ isProductBadgeShelf,
+ );
+ const developerName = badgeShelf?.items.find(
+ ({ key }) => key === 'developer',
+ )?.caption;
+
+ const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.Reviews.Title', {
+ appName,
+ }),
+ });
+
+ const descriptionLocKey = developerName
+ ? 'ASE.Web.AppStore.Meta.Product.Description'
+ : 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName';
+
+ const description = truncateAroundLimit(
+ i18n.t(descriptionLocKey, {
+ appName,
+ developerName,
+ }),
+ MAX_DESCRIPTION_LENGTH,
+ objectGraph.locale.activeLanguage,
+ );
+
+ return {
+ artworkUrl,
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/search-landing-page.ts b/src/utils/seo/search-landing-page.ts
new file mode 100644
index 0000000..70a8bd4
--- /dev/null
+++ b/src/utils/seo/search-landing-page.ts
@@ -0,0 +1,18 @@
+import type { SearchResultsPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type I18N from '@amp/web-apps-localization';
+
+export function seoDataForSearchLandingPage(
+ page: SearchResultsPage,
+ i18n: I18N,
+): SeoData {
+ const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.SearchLanding.Title'),
+ });
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ };
+}
diff --git a/src/utils/seo/search-results-page.ts b/src/utils/seo/search-results-page.ts
new file mode 100644
index 0000000..48bcdce
--- /dev/null
+++ b/src/utils/seo/search-results-page.ts
@@ -0,0 +1,56 @@
+import type { SearchResultsPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type I18N from '@amp/web-apps-localization';
+import {
+ isSearchResultShelf,
+ isRenderableInSearchResultsShelf,
+} from '~/components/jet/shelf/SearchResultShelf.svelte';
+import { commaSeparatedList } from '../string-formatting';
+
+export function seoDataForSearchResultsPage(
+ page: SearchResultsPage,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ const term = page?.searchTermContext?.term;
+ const pageTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: page?.searchTermContext?.term,
+ });
+ const shareTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.SearchResults.Title', {
+ term: page?.searchTermContext?.term,
+ }),
+ });
+
+ const resultsShelf = page?.shelves?.find(isSearchResultShelf) ?? null;
+
+ const renderableItems = (resultsShelf?.items ?? []).filter(
+ isRenderableInSearchResultsShelf,
+ );
+
+ const appNames = renderableItems
+ .slice(0, 3)
+ .map((item) => item.lockup.title);
+
+ let description;
+ if (appNames.length) {
+ description = i18n.t(
+ 'ASE.Web.AppStore.Meta.SearchResults.Description',
+ {
+ term,
+ listOfApps: commaSeparatedList(appNames, language),
+ },
+ );
+ }
+
+ return term
+ ? {
+ pageTitle,
+ socialTitle: shareTitle,
+ appleTitle: shareTitle,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ }
+ : {};
+}
diff --git a/src/utils/seo/see-all-page.ts b/src/utils/seo/see-all-page.ts
new file mode 100644
index 0000000..bbdf369
--- /dev/null
+++ b/src/utils/seo/see-all-page.ts
@@ -0,0 +1,47 @@
+import type I18N from '@amp/web-apps-localization';
+import type { SeeAllPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+export function seoDataForSeeAllPage(page: SeeAllPage, i18n: I18N): SeoData {
+ let title = i18n.t('ASE.Web.AppStore.Meta.Product.Title');
+ const shelfName = {
+ reviews: 'productRatings',
+ 'customers-also-bought-apps': 'similarItems',
+ 'developer-other-apps': 'moreByDeveloper',
+ }[page.seeAllType];
+
+ if (shelfName) {
+ const shelf = page.shelfMapping[shelfName];
+ title = `${page.title} - ${shelf.title}`;
+ }
+
+ const titleWithSiteName = i18n.t(
+ 'ASE.Web.AppStore.Meta.TitleWithSiteName',
+ { title },
+ );
+
+ const descriptionLocKey =
+ {
+ reviews: 'ASE.Web.AppStore.SeeAll.Reviews.Meta.Description',
+ 'customers-also-bought-apps':
+ 'ASE.Web.AppStore.SeeAll.CustomersAlsoBoughtApps.Meta.Description',
+ 'developer-other-apps':
+ 'ASE.Web.AppStore.SeeAll.DeveloperOtherApps.Meta.Description',
+ }[page.seeAllType] ||
+ 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName';
+ const description = i18n.t(descriptionLocKey, {
+ appName: page.title,
+ });
+
+ const artworkUrl = page.lockup.icon?.template;
+
+ return {
+ pageTitle: titleWithSiteName,
+ socialTitle: titleWithSiteName,
+ appleTitle: titleWithSiteName,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ artworkUrl,
+ };
+}
diff --git a/src/utils/shelves.ts b/src/utils/shelves.ts
new file mode 100644
index 0000000..e144f4b
--- /dev/null
+++ b/src/utils/shelves.ts
@@ -0,0 +1,56 @@
+import type {
+ ShelfBasedProductPage,
+ Shelf,
+} from '@jet-app/app-store/api/models';
+import { isProductMediaShelf } from '~/components/jet/shelf/ProductMediaShelf.svelte';
+
+type ShelfWithExpandedMedia = Shelf & {
+ expandedMedia?: ShelfWithExpandedMedia[];
+};
+
+export const getProductPageShelvesForOrdering = (
+ page: ShelfBasedProductPage,
+ shelfOrder: string,
+): Shelf[] => {
+ return (
+ page.shelfOrderings[shelfOrder]
+ ?.map((shelfIdentifier) => page.shelfMapping[shelfIdentifier])
+ // The type system doesn't reflect this, but ordering identifier may be provided for
+ // shelves that do not exist. We should probably filter those out
+ .filter((shelf): shelf is Shelf => !!shelf)
+ );
+};
+
+export const getProductPageShelvesWithExpandedMedia = (
+ page: ShelfBasedProductPage,
+): ShelfWithExpandedMedia[] => {
+ const { defaultShelfOrdering = 'notPurchasedOrdering' } = page;
+
+ const shelves = getProductPageShelvesForOrdering(
+ page,
+ defaultShelfOrdering,
+ ) as ShelfWithExpandedMedia[];
+
+ // find the location of the product media of selected platform in shelves
+ const mainMediaShelfIndex = shelves.findIndex((shelf) =>
+ isProductMediaShelf(shelf),
+ );
+
+ let expandedMedia: ShelfWithExpandedMedia[] | undefined;
+
+ if (mainMediaShelfIndex !== -1) {
+ expandedMedia = getProductPageShelvesForOrdering(
+ page,
+ 'notPurchasedOrdering_ExpandedMedia',
+ )
+ .filter((shelf) => isProductMediaShelf(shelf))
+ // filter out the product media shelf of selected platform to avoid duplicate shelves
+ .filter(({ id }) => id !== shelves[mainMediaShelfIndex].id);
+ }
+
+ if (expandedMedia) {
+ shelves[mainMediaShelfIndex].expandedMedia = expandedMedia;
+ }
+
+ return shelves;
+};
diff --git a/src/utils/storefront-data.ts b/src/utils/storefront-data.ts
new file mode 100644
index 0000000..9e2d848
--- /dev/null
+++ b/src/utils/storefront-data.ts
@@ -0,0 +1,15 @@
+import type {
+ Region,
+ Languages,
+} from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/types';
+import type { StorefrontNames } from '@amp/web-app-components/src/components/banners/types';
+import {
+ regions as outputtedRegions,
+ languages as outputtedLanguages,
+} from 'virtual:storefronts';
+import { getFormattedStorefrontNameTranslations } from '@amp/web-app-storefronts';
+
+export const regions: Region[] = outputtedRegions;
+export const languages: Languages = outputtedLanguages;
+export const storefrontNameTranslations: StorefrontNames =
+ getFormattedStorefrontNameTranslations(regions);
diff --git a/src/utils/string-formatting.ts b/src/utils/string-formatting.ts
new file mode 100644
index 0000000..ff9f8bb
--- /dev/null
+++ b/src/utils/string-formatting.ts
@@ -0,0 +1,126 @@
+import type I18N from '@amp/web-apps-localization';
+import he from 'he';
+
+export function isString(string: unknown): string is string {
+ return typeof string === 'string';
+}
+
+export function concatWithMiddot(pieces: string[], i18n: I18N): string {
+ if (!pieces.length) {
+ return '';
+ }
+
+ return (
+ pieces.reduce((memo, current) => {
+ return i18n.t('ASE.Web.AppStore.ContentA.Middot.ContentB', {
+ contentA: memo,
+ contentB: current,
+ });
+ }) || ''
+ );
+}
+
+/**
+ * Truncates a block of text to fit within a character limit, with a bias towards ending on a
+ * full sentence. If no complete sentence fits within the limit, it falls back to a word-based
+ * truncation with an ellipsis.
+ *
+ * @param {string} text - The text to truncate.
+ * @param {number} limit - The maximum number of characters allowed before truncation.
+ * @param {string} [locale=en_US] - The locale to use when breaking the text into segments.
+ * @returns {string} Truncated text clipped to the limit, ideally ending on a natural stopping point.
+ */
+export function truncateAroundLimit(
+ text: string,
+ limit: number,
+ locale: string = 'en-US',
+): string {
+ // If the text is shorter than the limit, return all the text, unaltered.
+ if (text.length <= limit) {
+ return text;
+ }
+
+ const decodedText = he.decode(text);
+
+ const isSegemnterSupported = typeof Intl.Segmenter === 'function';
+ const terminatingPunctuation = '…';
+
+ // A very naive fallback if the browser doesn't support `Segementer`,
+ // which just truncates the text to the last space before the `limit`.
+ if (!isSegemnterSupported) {
+ const truncatedText = decodedText.slice(0, limit);
+ const indexOfLastSpace = truncatedText.lastIndexOf(' ');
+ if (indexOfLastSpace) {
+ return (
+ truncatedText.slice(0, indexOfLastSpace).trim() +
+ terminatingPunctuation
+ );
+ } else {
+ // If the text is an _exteremly_ long word or block of text, like a URL
+ return truncatedText.trim() + terminatingPunctuation;
+ }
+ }
+
+ const sentences = Array.from(
+ new Intl.Segmenter(locale, { granularity: 'sentence' }).segment(text),
+ (s) => s.segment,
+ );
+
+ let result = '';
+ for (const sentence of sentences) {
+ // If there is still room to add another sentence without going over the limit, add it.
+ if (result.length + sentence.length <= limit) {
+ result += sentence;
+ } else {
+ break;
+ }
+ }
+
+ result = result.trim();
+
+ // If the result we built based on full sentences is close-enough to the desired limit
+ // (e.g. within the threshold of 75% of 160), we can use it.
+ if (result.length >= limit * 0.75) {
+ return result;
+ }
+
+ // Otherwise, fallback to building up single words until we approach the limit.
+ const segments = Array.from(
+ new Intl.Segmenter(locale, { granularity: 'word' }).segment(
+ decodedText,
+ ),
+ );
+
+ result = '';
+ for (const { segment } of segments) {
+ if (result.length + segment.length <= limit) {
+ result += segment;
+ } else {
+ break;
+ }
+ }
+
+ return result.trim() + terminatingPunctuation;
+}
+
+export function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
+
+export function commaSeparatedList(items: Array<string>, locale = 'en') {
+ return new Intl.ListFormat(locale, {
+ style: 'long',
+ type: 'conjunction',
+ }).format(items);
+}
+
+export function stripTags(text: string) {
+ return text.replace(/(<([^>]+)>)/gi, '');
+}
+
+export function stripUnicodeWhitespace(text: string) {
+ return text.replace(/[\u0000-\u001F]/g, '');
+}
diff --git a/src/utils/transition.ts b/src/utils/transition.ts
new file mode 100644
index 0000000..e89b038
--- /dev/null
+++ b/src/utils/transition.ts
@@ -0,0 +1,45 @@
+import { cubicOut } from 'svelte/easing';
+import type { EasingFunction, TransitionConfig } from 'svelte/transition';
+
+interface FlyAndBlurParams {
+ // Time (ms) before the animation starts.
+ delay?: number;
+ // Total animation time (ms).
+ duration?: number;
+ // Easing function (defaults to cubicOut).
+ easing?: EasingFunction;
+ // Horizontal offset in pixels at start (like `fly`).
+ x?: number;
+ // Vertical offset in pixels at start (like `fly`).
+ y?: number;
+ // Initial blur radius in pixels.
+ blur?: number;
+}
+
+export function flyAndBlur(
+ node: Element,
+ {
+ delay = 0,
+ duration = 420,
+ easing = cubicOut,
+ x = 0,
+ y = 0,
+ blur = 3,
+ }: FlyAndBlurParams = {},
+): TransitionConfig {
+ const style = getComputedStyle(node);
+ const initialOpacity = +style.opacity;
+
+ return {
+ delay,
+ duration,
+ easing,
+ css: (t: number, u: number) => {
+ return `
+ transform: translate(${x * u}px, ${y * u}px);
+ opacity: ${initialOpacity * t};
+ filter: blur(${blur * u}px);
+ `;
+ },
+ };
+}
diff --git a/src/utils/types.ts b/src/utils/types.ts
new file mode 100644
index 0000000..4cb85aa
--- /dev/null
+++ b/src/utils/types.ts
@@ -0,0 +1,17 @@
+/**
+ * Determine if {@linkcode input} matches the `"object"` type
+ */
+export function isObject(input: unknown): input is object {
+ return typeof input === 'object' && !!input;
+}
+
+type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
+
+/**
+ * Helper type for creating an exclusive union between two types
+ *
+ * @see {@link https://stackoverflow.com/a/53229567/2250435 | StackOverflow Post}
+ */
+export type XOR<T, U> = T | U extends object
+ ? (Without<T, U> & U) | (Without<U, T> & T)
+ : T | U;
diff --git a/src/utils/url.ts b/src/utils/url.ts
new file mode 100644
index 0000000..8596d89
--- /dev/null
+++ b/src/utils/url.ts
@@ -0,0 +1,13 @@
+/**
+ * Removes the protcol, host and port from a URL, returning
+ * just the path and search portions
+ *
+ * This is useful for taking a URL that points to the production site
+ * and removing anything specific to the location that it is deployed,
+ * creating a partial URL that works both locally or when deployed
+ */
+export function stripHost(input: string): string {
+ const url = new URL(input);
+
+ return url.pathname + url.search;
+}
diff --git a/src/utils/video-poster.ts b/src/utils/video-poster.ts
new file mode 100644
index 0000000..e2e32ed
--- /dev/null
+++ b/src/utils/video-poster.ts
@@ -0,0 +1,27 @@
+import type { Artwork } from '@jet-app/app-store/api/models';
+import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
+import type { Size } from '@amp/web-app-components/src/types';
+import type { NamedProfile } from 'src/config/components/artwork';
+import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+import { getDataFromProfile } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
+
+export const buildPoster = (
+ preview: Artwork,
+ profile: NamedProfile | Profile,
+ mediaQuery: string,
+): ReturnType<typeof buildSrc> => {
+ const profileData = getDataFromProfile(profile);
+ const imageAttributes = profileData[mediaQuery as Size] || preview;
+ const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 2;
+
+ return buildSrc(
+ preview.template,
+ {
+ crop: 'sr',
+ width: imageAttributes.width * dpr,
+ height: imageAttributes.height * dpr,
+ fileType: 'webp',
+ },
+ {},
+ );
+};