diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /src/utils | |
init commit
Diffstat (limited to 'src/utils')
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, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +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', + }, + {}, + ); +}; |
