summaryrefslogtreecommitdiff
path: root/src/utils/seo/product-page.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/utils/seo/product-page.ts')
-rw-r--r--src/utils/seo/product-page.ts353
1 files changed, 353 insertions, 0 deletions
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),
+ };
+}