summaryrefslogtreecommitdiff
path: root/src/utils/seo
diff options
context:
space:
mode:
Diffstat (limited to 'src/utils/seo')
-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
14 files changed, 1364 insertions, 0 deletions
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,
+ };
+}