From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- src/utils/seo/app-event-detail-page.ts | 43 +++ src/utils/seo/arcade-see-all-page.ts | 40 +++ src/utils/seo/article-page.ts | 276 ++++++++++++++++++ src/utils/seo/charts-hub-page.ts | 46 +++ src/utils/seo/charts-page.ts | 58 ++++ src/utils/seo/common.ts | 75 +++++ src/utils/seo/developer-page.ts | 174 +++++++++++ src/utils/seo/editorial-shelf-collection-page.ts | 51 ++++ src/utils/seo/image-url.ts | 71 +++++ src/utils/seo/product-page.ts | 353 +++++++++++++++++++++++ src/utils/seo/reviews-page.ts | 56 ++++ src/utils/seo/search-landing-page.ts | 18 ++ src/utils/seo/search-results-page.ts | 56 ++++ src/utils/seo/see-all-page.ts | 47 +++ 14 files changed, 1364 insertions(+) create mode 100644 src/utils/seo/app-event-detail-page.ts create mode 100644 src/utils/seo/arcade-see-all-page.ts create mode 100644 src/utils/seo/article-page.ts create mode 100644 src/utils/seo/charts-hub-page.ts create mode 100644 src/utils/seo/charts-page.ts create mode 100644 src/utils/seo/common.ts create mode 100644 src/utils/seo/developer-page.ts create mode 100644 src/utils/seo/editorial-shelf-collection-page.ts create mode 100644 src/utils/seo/image-url.ts create mode 100644 src/utils/seo/product-page.ts create mode 100644 src/utils/seo/reviews-page.ts create mode 100644 src/utils/seo/search-landing-page.ts create mode 100644 src/utils/seo/search-results-page.ts create mode 100644 src/utils/seo/see-all-page.ts (limited to 'src/utils/seo') 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 { + 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
{ + 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 { + 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, + props: SeoProps, +): Partial { + 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, + 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; + + 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; + + 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; + + 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 { + 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> { + const { description } = props; + + const schemaContent: WithContext = { + '@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, + i18n: I18N, +): Partial { + 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>, + 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>, +): 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 = + { + 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>; + + 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 = + 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, +): Opt> { + 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 = { + '@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, + 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, + }; +} -- cgit v1.2.3