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/article-page.ts | 276 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 src/utils/seo/article-page.ts (limited to 'src/utils/seo/article-page.ts') 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, + }), + }; +} -- cgit v1.2.3