init commit

This commit is contained in:
rxliuli
2025-11-04 05:03:50 +08:00
commit bce557cc2d
1396 changed files with 172991 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import type { AppPlatform } from '@jet-app/app-store/api/models';
export const PlatformToExclusivityText: Partial<Record<AppPlatform, string>> = {
watch: 'ASE.Web.AppStore.App.OnlyForWatch',
tv: 'ASE.Web.AppStore.App.OnlyForAppleTV',
messages: 'ASE.Web.AppStore.App.OnlyForiMessage',
mac: 'ASE.Web.AppStore.App.OnlyForMac',
phone: 'ASE.Web.AppStore.App.OnlyForPhone',
};
export function isPlatformSupported(
platform: AppPlatform,
appPlatforms: AppPlatform[],
) {
const dedupedPlatforms = new Set(appPlatforms);
return dedupedPlatforms.has(platform);
}
export function isPlatformExclusivelySupported(
platform: AppPlatform,
appPlatforms: AppPlatform[],
) {
const dedupedPlatforms = new Set(appPlatforms);
return dedupedPlatforms.has(platform) && dedupedPlatforms.size === 1;
}

33
src/utils/array.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Split an array into two groups based on the result {@linkcode predicate}
*
* Items for which {@linkcode predicate} returns `true` will be in the "left"
* result, and the others in the "right" one
*/
export function partition<T>(
input: Array<T>,
predicate: (element: T) => boolean,
): [Array<T>, Array<T>] {
const left: Array<T> = [];
const right: Array<T> = [];
for (const element of input) {
if (predicate(element)) {
left.push(element);
} else {
right.push(element);
}
}
return [left, right];
}
/**
* Deduplicate the elements of {@linkcode items} by their `id` property
*/
export function uniqueById<T extends { id: string }>(items: T[]): T[] {
const entries = items.map((item) => [item.id, item] as const);
const mapById = new Map<string, T>(entries);
return Array.from(mapById.values());
}

168
src/utils/color.ts Normal file
View File

@@ -0,0 +1,168 @@
import { isSome } from '@jet/environment/types/optional';
import type {
Artwork,
Color,
RGBColor,
NamedColor,
} from '@jet-app/app-store/api/models';
export type RGB = [number, number, number];
/**
* Represents a valid RGB color string, in the format "rgb(r, g, b)" or "rgb(r,g,b)".
* @example
* "rgb(255, 0, 128)"
* "rgb(255,0,128)"
*/
type RGBString =
| `rgb(${number},${number},${number})`
| `rgb(${number}, ${number}, ${number})`;
export const isRGBColor = (value: Color): value is RGBColor =>
value.type === 'rgb';
export const isNamedColor = (value: Color): value is NamedColor =>
value.type === 'named';
const rgbColorAsString = ({ red, green, blue }: RGBColor): string =>
`rgb(${[red, green, blue].map((color) => Math.floor(255 * color)).join()})`;
export const colorAsString = (color: Color): string => {
switch (color.type) {
case 'named':
// `ios-appstore-app` makes use of the this `placeholderBackground` named color,
// which it leaves up to the client to manage. Ideally, we could define a CSS property
// named `--placeholderBackground`, but the media-apps shared logic to determine Artwork
// background color doesn't respect CSS properties, so we are specifying the hex value.
// https://github.pie.apple.com/amp-web/media-apps/blame/main/shared/components/src/components/Artwork/utils/validateBackground.ts
if (color.name === 'placeholderBackground') {
return '#f1f1f1';
}
return `var(--${color.name})`;
case 'rgb':
return rgbColorAsString(color);
case 'dynamic':
return colorAsString(color.lightColor);
}
};
/**
* Parses an RGB string and returns an array of red, green, and blue values.
*
* This function extracts the numeric values from an RGB string (e.g., "rgb(255, 0, 128)")
* and returns them as an array of numbers.
*
* @param {RGBString} rgbString - The RGB string to parse.
* @returns {RGB} An array of three numbers representing the red, green, and blue values, each between 0 and 255.
*
* @example
* getRGBFromString("rgb(255, 0, 128)") = [255, 0, 128]
*/
export const getRGBFromString = (rgbString: RGBString): RGB => {
const rgbValues = rgbString.match(/\d+/g) ?? [];
const rgb: RGB = [0, 0, 0];
for (const [index] of rgb.entries()) {
rgb[index] = parseInt(rgbValues[index]);
}
return rgb;
};
/**
* Calculates the relative luminance for an RGB color.
*
* This function uses a standardized formula for luminance, which weights the red, green, and blue
* channels differently to account for human perception.
* @see {@link https://en.wikipedia.org/wiki/Relative_luminance|Wikipedia: Relative Luminance}
*
* @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255.
* @returns {number} The calculated luminance value, a number between 0 (darkest) and 255 (lightest).
*/
export const getLuminanceForRGB = ([r, g, b]: RGB): number => {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
export function isRGBDarkerThanThreshold([r, g, b]: RGB, threshold = 10) {
return r <= threshold && g <= threshold && b <= threshold;
}
export function isDark(rgbColor: RGBColor): boolean {
const { red, green, blue } = rgbColor;
const rgbValues = [red, green, blue].map((channel) =>
Math.floor(channel * 255),
) as RGB;
return isRGBDarkerThanThreshold(rgbValues, 127);
}
/**
* Determines whether an RGB color is approximately grey based on channel similarity.
*
* @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255.
* @param {number} [threshold=10] - Maximum allowed difference between color channels to still be considered grey-ish.
* @returns {boolean} True if the RGB values are close enough to be considered grey.
*/
function isKindOfGrey([r, g, b]: RGB, threshold = 10) {
return (
Math.abs(r - g) <= threshold &&
Math.abs(r - b) <= threshold &&
Math.abs(g - b) <= threshold
);
}
/**
* Generates CSS variables (custom properties) for a background gradient based on the background
* colors in the specified list of artworks.
*
* @param {Artwork[]} artworks - An array of Artwork, each containing a `backgroundColor` property.
* @param {Object} [options={}] - Optional configuration options.
* @param {string[]} [options.variableNames=['bottom-left', 'top-right', 'bottom-right', 'top-left']] -
* The names of the CSS variables to assign to the extracted colors. The number of colors
* used will match the length of this array.
* @param {(a: RGB, b: RGB) => number} [options.sortFn=() => 0] -
* A sorting function for ordering the colors (e.g., by luminance). Defaults to no sorting,
* which preserves input order.
*
* @returns {string} A CSS string containing custom properties, e.g.,
* "--bottom-left: rgb(255, 0, 0); --top-right: rgb(0, 255, 0);".
*/
export const getBackgroundGradientCSSVarsFromArtworks = (
artworks: Artwork[],
{
variableNames = [
'bottom-left',
'top-right',
'bottom-right',
'top-left',
],
sortFn = () => 0,
shouldRemoveGreys = false,
}: {
variableNames?: string[];
sortFn?: (a: RGB, b: RGB) => number;
shouldRemoveGreys?: boolean;
} = {},
): string => {
return artworks
.map(({ backgroundColor }) => backgroundColor)
.filter(isSome)
.filter(isRGBColor)
.map(
({ red, green, blue }): RGB => [
Math.floor(255 * red),
Math.floor(255 * green),
Math.floor(255 * blue),
],
)
.filter((rgb) => !isRGBDarkerThanThreshold(rgb, 33))
.filter((rgb) => (shouldRemoveGreys ? !isKindOfGrey(rgb, 10) : true))
.sort(sortFn)
.slice(0, variableNames.length)
.map(
([red, green, blue], index) =>
`--${variableNames[index]}: rgb(${red}, ${green}, ${blue})`,
)
.join('; ');
};

28
src/utils/error.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Tries to call {@linkcode fn} throwing an exception with the {@linkcode message}
* if an error occurs
*
* @example
* // Before
* let value;
* try {
* value = someMethod();
* } catch(e) {
* throw new Error('My specific message', { cause: e })
* }
*
* // After
* const value = mapError(
* () => someMethod(),
* 'My specific message'
* );
*/
export function mapException<T>(fn: () => T, message: string): T {
try {
return fn();
} catch (e) {
throw new Error(message, {
cause: e,
});
}
}

View File

@@ -0,0 +1,13 @@
/**
* This file is a place for defining any Feature Flag Constants used throughout the app
* In order to keep the API Interface same for Build Time vs Runtime Feature Flags
* We have ensured that all the flags have to be defined in this file
*/
// Actual Feature Flag Values have to be defined in the /apps/app-store/featureFlags.external.cjs
// BUILD BASED FEATURE FLAGS DUMMY FLAG DEFINITIONS TO FIX THE NAME OF THE FEATURE FLAGS TO BE USED
// Values of the BUILD BASED FLAGS will decide if they are evaluated to true in DEV mode
export const __FF_SHOW_RADAR = 'r01234e98765';
export const __FF_SHOW_LOC_KEYS = 'ffShowLocKeys';
export const __FF_ARYA = 'asha123e7z124';

View File

@@ -0,0 +1,44 @@
import {
buildFeatureConfig,
buildRuntimeFeatureKitConfig,
ENVIRONMENT,
loadFeatureKit,
type OnyxFeatures,
} from '@amp/web-apps-featurekit';
import type { LoggerFactory } from '@amp/web-apps-logger';
import { BUILD } from '~/config/build';
export async function setupRuntimeFeatures(
logger: LoggerFactory,
): Promise<OnyxFeatures | void> {
// load featureKit only for internal builds
if (import.meta.env.APP_SCOPE === 'internal' || import.meta.env.DEV) {
const features = await import('./consts');
// Build FeatureKit Config with overrides
const config = buildRuntimeFeatureKitConfig(features, {
[features.__FF_SHOW_RADAR]: buildFeatureConfig({
[ENVIRONMENT.DEV]: true,
}),
[features.__FF_ARYA]: {
...buildFeatureConfig({ [ENVIRONMENT.DEV]: false }),
itfe: ['y9ttlj15'],
},
});
// Load runtime featureKit
return loadFeatureKit(
'com.apple.apps',
ENVIRONMENT.DEV,
config,
logger,
{
enableToolbar: true,
radarConfig: {
component: 'ASE Web',
app: 'App Store',
build: BUILD,
},
},
);
}
}

23
src/utils/file-size.ts Normal file
View File

@@ -0,0 +1,23 @@
const ROUND_TO = 10;
const SIZE_INCREMENT = 1000;
const UNITS = ['byte', 'KB', 'MB', 'GB'];
/**
* Converts a byte count into a scaled value with a unit label (e.g. KB, MB, GB).
*
* @param {number} bytes - The number of bytes.
* @returns {{ count: number, unit: string }} Scaled value and its corresponding unit.
*/
export function getFileSizeParts(bytes: number) {
let index = 0;
while (bytes >= SIZE_INCREMENT && index < UNITS.length - 1) {
bytes /= SIZE_INCREMENT;
index++;
}
const count = Math.round(bytes * ROUND_TO) / ROUND_TO;
const unit = UNITS[index];
return { count, unit };
}

View File

@@ -0,0 +1,13 @@
import { platform } from '@amp/web-apps-utils';
const setupUrlForMac = (url: string) => {
const incomingUrl = new URL(url);
incomingUrl.searchParams.set('mt', '12');
return incomingUrl.toString();
};
export const launchAppOnMac = (url: string) => {
const appUrl = setupUrlForMac(url);
platform.launchClient(appUrl, () => {});
};

142
src/utils/locale.ts Normal file
View File

@@ -0,0 +1,142 @@
import type { Opt } from '@jet/environment';
import { DEFAULT_STOREFRONT_CODE } from '~/constants/storefront';
import type {
NormalizedLocale,
NormalizedStorefront,
NormalizedLanguage,
} from '@jet-app/app-store/api/locale';
import type { Locale } from '@jet-app/app-store/foundation/dependencies/locale/locale';
import { TEXT_DIRECTION } from '@amp/web-app-components/src/constants';
import { getLocAttributes } from '@amp/web-apps-localization';
import { regions } from '~/utils/storefront-data';
import { getJet } from '~/jet/svelte';
export type NormalizedLocaleWithDefault = NormalizedLocale & {
isDefaultLanguage: boolean;
};
type LanguageDetails = {
languages: NormalizedLanguage[];
defaultLanguage: NormalizedLanguage;
};
export function normalizeStorefront(storefront: Opt<string>): {
storefront: NormalizedStorefront;
languages: NormalizedLanguage[];
defaultLanguage: NormalizedLanguage;
} {
const storefronts: Record<NormalizedStorefront, LanguageDetails> = {};
for (const { locales } of regions) {
for (const { id, language, isDefault } of locales) {
if (isDefault) {
storefronts[id as NormalizedStorefront] = {
languages: [],
defaultLanguage: language as NormalizedLanguage,
};
}
if (id in storefronts) {
storefronts[id as NormalizedStorefront].languages.push(
language as NormalizedLanguage,
);
}
}
}
const normalizedStorefront = (storefront || '').toLowerCase();
const chosenStorefront =
normalizedStorefront in storefronts
? (normalizedStorefront as NormalizedStorefront)
: DEFAULT_STOREFRONT_CODE;
return {
storefront: chosenStorefront,
...storefronts[chosenStorefront],
};
}
export function normalizeLanguage(
language: string,
languages: NormalizedLanguage[],
defaultLanguage: NormalizedLanguage,
): { language: NormalizedLanguage; isDefaultLanguage: boolean } {
function annotateReturn(language: NormalizedLanguage): {
language: NormalizedLanguage;
isDefaultLanguage: boolean;
} {
return {
language,
isDefaultLanguage: language === defaultLanguage,
};
}
// Prefer an exact match (ex. en-US matches en-US)
const exactMatch = findMatch(language, languages, (a, b) => a === b);
if (exactMatch) {
return annotateReturn(exactMatch);
}
// Try partial match (ex. fr-CA or fr matches fr-FR)
const partialMatch = findMatch(
language,
languages,
(a, b) => a.split('-')[0] === b.split('-')[0],
);
if (partialMatch) {
return annotateReturn(partialMatch);
}
// The only remaining choice is the storefront default
return annotateReturn(defaultLanguage);
}
function findMatch<T extends string>(
needle: string,
haystack: T[],
matches: (a: string, b: string) => boolean,
): Opt<T> {
return haystack.find((possibility) =>
matches(possibility.toLowerCase(), needle.toLowerCase()),
);
}
/**
* Gets the current Locale instance from the Svelte context.
*
* @return the active {@linkcode NormalizedLocale}
*/
export function getLocale(): NormalizedLocale {
let locale: Locale | undefined;
try {
const { objectGraph } = getJet();
locale = objectGraph.locale;
} catch {
throw new Error('`getLocale` called before `Jet.load`');
}
return {
storefront: locale.activeStorefront,
language: locale.activeLanguage,
};
}
/**
* Returns whether or not the document is in RTL mode, first based on the document's direction,
* with a fallback to the storefronts default writing direction.
*/
export function isRtl() {
const { storefront } = getLocale();
const { dir } = getLocAttributes(storefront);
return (
(typeof document !== 'undefined' &&
document.dir === TEXT_DIRECTION.RTL) ||
dir === TEXT_DIRECTION.RTL
);
}

View File

@@ -0,0 +1,12 @@
import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
import { getMediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions';
import { buildMediaQueryStore } from '@amp/web-app-components/src/stores/media-query';
const { BREAKPOINTS } = ArtworkConfig.get();
const mediaQueryStore = buildMediaQueryStore(
'medium',
getMediaConditions(BREAKPOINTS, { offset: 260 }),
);
export default mediaQueryStore;

4
src/utils/metrics.ts Normal file
View File

@@ -0,0 +1,4 @@
export const APP_PRIVACY_MODAL_ID = 'ModalAppPrivacy';
export const CUSTOMER_REVIEW_MODAL_ID = 'ModalCustomerReview';
export const VERSION_HISTORY_MODAL_ID = 'ModalVersionHistory';
export const LICENSE_AGREEMENT_MODAL_ID = 'LicenseAgreement';

View File

@@ -0,0 +1,39 @@
/**
* Normalizes and makes sure we include some unicode option for number formating.
*/
function localeWithOptionsForNumbers(locale: string) {
locale = locale.toLowerCase().replace('_', '-');
if (locale === 'hi-in') {
// nu-latn makes the formatter use latin numbers.
// See BCP47 Unicode extensions for number (nu):
// http://unicode.org/repos/cldr/trunk/common/bcp47/number.xml
// TL;DR -u- means the start of unicode extension.
// nu-latn means numeric (nu) extension, latn value
return 'hi-in-u-nu-latn';
} else if (locale === 'my') {
// For the `my` locale, we want to display functional numbers as Latin numerals rather than in Burmese,
// so we are overriding the locale to give us the Latin functional numbers. See radar for more context:
// rdar://155236306 (LOC: MS-MY: ASOTW | Product Page: Functional: Numbers are not displayed in MS/EN format)
return 'my-u-nu-latn';
}
return locale;
}
/**
* Abbreviate a number into a compact shorthand
*
* @example
* const abbr = abbreviateNumber(10_000, 'en-US'); // '10K'
*/
export function abbreviateNumber(value: number, locale: string): string {
const formatter = new Intl.NumberFormat(
localeWithOptionsForNumbers(locale),
{
notation: 'compact',
},
);
return formatter.format(value);
}

34
src/utils/portal.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Svelte action to move an element to a different part of the DOM (as specified by the `targetId`
* provided), effectively creating a "portal."
*
* @param {HTMLElement} node - The element to be moved (provided by Svelte's `use:action` syntax).
* @param {string} targetId - The ID of the target element where `node` should be moved.
* @returns {{ destroy(): void } | void} - An object with a `destroy` method to remove `node` from the target when unmounted.
*
* @example
* ```svelte
* <div use:portal={'target-container'}>
* This content will be moved to the element with ID "target-container".
* </div>
* ```
*/
export default function portal(node: HTMLElement, targetId: string) {
if (typeof document === 'undefined') {
return;
}
let targetElement: HTMLElement | null = document.getElementById(targetId);
if (!targetElement) {
return;
}
targetElement.appendChild(node);
return {
destroy() {
targetElement.removeChild(node);
},
};
}

View File

@@ -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,
}),
};
}

View File

@@ -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,
};
}

View File

@@ -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,
}),
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

75
src/utils/seo/common.ts Normal file
View File

@@ -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;
}

View File

@@ -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,
},
),
};
}

View File

@@ -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,
};
}

View File

@@ -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
);
}

View File

@@ -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),
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
}
: {};
}

View File

@@ -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,
};
}

56
src/utils/shelves.ts Normal file
View File

@@ -0,0 +1,56 @@
import type {
ShelfBasedProductPage,
Shelf,
} from '@jet-app/app-store/api/models';
import { isProductMediaShelf } from '~/components/jet/shelf/ProductMediaShelf.svelte';
type ShelfWithExpandedMedia = Shelf & {
expandedMedia?: ShelfWithExpandedMedia[];
};
export const getProductPageShelvesForOrdering = (
page: ShelfBasedProductPage,
shelfOrder: string,
): Shelf[] => {
return (
page.shelfOrderings[shelfOrder]
?.map((shelfIdentifier) => page.shelfMapping[shelfIdentifier])
// The type system doesn't reflect this, but ordering identifier may be provided for
// shelves that do not exist. We should probably filter those out
.filter((shelf): shelf is Shelf => !!shelf)
);
};
export const getProductPageShelvesWithExpandedMedia = (
page: ShelfBasedProductPage,
): ShelfWithExpandedMedia[] => {
const { defaultShelfOrdering = 'notPurchasedOrdering' } = page;
const shelves = getProductPageShelvesForOrdering(
page,
defaultShelfOrdering,
) as ShelfWithExpandedMedia[];
// find the location of the product media of selected platform in shelves
const mainMediaShelfIndex = shelves.findIndex((shelf) =>
isProductMediaShelf(shelf),
);
let expandedMedia: ShelfWithExpandedMedia[] | undefined;
if (mainMediaShelfIndex !== -1) {
expandedMedia = getProductPageShelvesForOrdering(
page,
'notPurchasedOrdering_ExpandedMedia',
)
.filter((shelf) => isProductMediaShelf(shelf))
// filter out the product media shelf of selected platform to avoid duplicate shelves
.filter(({ id }) => id !== shelves[mainMediaShelfIndex].id);
}
if (expandedMedia) {
shelves[mainMediaShelfIndex].expandedMedia = expandedMedia;
}
return shelves;
};

View File

@@ -0,0 +1,15 @@
import type {
Region,
Languages,
} from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/types';
import type { StorefrontNames } from '@amp/web-app-components/src/components/banners/types';
import {
regions as outputtedRegions,
languages as outputtedLanguages,
} from 'virtual:storefronts';
import { getFormattedStorefrontNameTranslations } from '@amp/web-app-storefronts';
export const regions: Region[] = outputtedRegions;
export const languages: Languages = outputtedLanguages;
export const storefrontNameTranslations: StorefrontNames =
getFormattedStorefrontNameTranslations(regions);

View File

@@ -0,0 +1,126 @@
import type I18N from '@amp/web-apps-localization';
import he from 'he';
export function isString(string: unknown): string is string {
return typeof string === 'string';
}
export function concatWithMiddot(pieces: string[], i18n: I18N): string {
if (!pieces.length) {
return '';
}
return (
pieces.reduce((memo, current) => {
return i18n.t('ASE.Web.AppStore.ContentA.Middot.ContentB', {
contentA: memo,
contentB: current,
});
}) || ''
);
}
/**
* Truncates a block of text to fit within a character limit, with a bias towards ending on a
* full sentence. If no complete sentence fits within the limit, it falls back to a word-based
* truncation with an ellipsis.
*
* @param {string} text - The text to truncate.
* @param {number} limit - The maximum number of characters allowed before truncation.
* @param {string} [locale=en_US] - The locale to use when breaking the text into segments.
* @returns {string} Truncated text clipped to the limit, ideally ending on a natural stopping point.
*/
export function truncateAroundLimit(
text: string,
limit: number,
locale: string = 'en-US',
): string {
// If the text is shorter than the limit, return all the text, unaltered.
if (text.length <= limit) {
return text;
}
const decodedText = he.decode(text);
const isSegemnterSupported = typeof Intl.Segmenter === 'function';
const terminatingPunctuation = '…';
// A very naive fallback if the browser doesn't support `Segementer`,
// which just truncates the text to the last space before the `limit`.
if (!isSegemnterSupported) {
const truncatedText = decodedText.slice(0, limit);
const indexOfLastSpace = truncatedText.lastIndexOf(' ');
if (indexOfLastSpace) {
return (
truncatedText.slice(0, indexOfLastSpace).trim() +
terminatingPunctuation
);
} else {
// If the text is an _exteremly_ long word or block of text, like a URL
return truncatedText.trim() + terminatingPunctuation;
}
}
const sentences = Array.from(
new Intl.Segmenter(locale, { granularity: 'sentence' }).segment(text),
(s) => s.segment,
);
let result = '';
for (const sentence of sentences) {
// If there is still room to add another sentence without going over the limit, add it.
if (result.length + sentence.length <= limit) {
result += sentence;
} else {
break;
}
}
result = result.trim();
// If the result we built based on full sentences is close-enough to the desired limit
// (e.g. within the threshold of 75% of 160), we can use it.
if (result.length >= limit * 0.75) {
return result;
}
// Otherwise, fallback to building up single words until we approach the limit.
const segments = Array.from(
new Intl.Segmenter(locale, { granularity: 'word' }).segment(
decodedText,
),
);
result = '';
for (const { segment } of segments) {
if (result.length + segment.length <= limit) {
result += segment;
} else {
break;
}
}
return result.trim() + terminatingPunctuation;
}
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export function commaSeparatedList(items: Array<string>, locale = 'en') {
return new Intl.ListFormat(locale, {
style: 'long',
type: 'conjunction',
}).format(items);
}
export function stripTags(text: string) {
return text.replace(/(<([^>]+)>)/gi, '');
}
export function stripUnicodeWhitespace(text: string) {
return text.replace(/[\u0000-\u001F]/g, '');
}

45
src/utils/transition.ts Normal file
View File

@@ -0,0 +1,45 @@
import { cubicOut } from 'svelte/easing';
import type { EasingFunction, TransitionConfig } from 'svelte/transition';
interface FlyAndBlurParams {
// Time (ms) before the animation starts.
delay?: number;
// Total animation time (ms).
duration?: number;
// Easing function (defaults to cubicOut).
easing?: EasingFunction;
// Horizontal offset in pixels at start (like `fly`).
x?: number;
// Vertical offset in pixels at start (like `fly`).
y?: number;
// Initial blur radius in pixels.
blur?: number;
}
export function flyAndBlur(
node: Element,
{
delay = 0,
duration = 420,
easing = cubicOut,
x = 0,
y = 0,
blur = 3,
}: FlyAndBlurParams = {},
): TransitionConfig {
const style = getComputedStyle(node);
const initialOpacity = +style.opacity;
return {
delay,
duration,
easing,
css: (t: number, u: number) => {
return `
transform: translate(${x * u}px, ${y * u}px);
opacity: ${initialOpacity * t};
filter: blur(${blur * u}px);
`;
},
};
}

17
src/utils/types.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Determine if {@linkcode input} matches the `"object"` type
*/
export function isObject(input: unknown): input is object {
return typeof input === 'object' && !!input;
}
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
/**
* Helper type for creating an exclusive union between two types
*
* @see {@link https://stackoverflow.com/a/53229567/2250435 | StackOverflow Post}
*/
export type XOR<T, U> = T | U extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;

13
src/utils/url.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Removes the protcol, host and port from a URL, returning
* just the path and search portions
*
* This is useful for taking a URL that points to the production site
* and removing anything specific to the location that it is deployed,
* creating a partial URL that works both locally or when deployed
*/
export function stripHost(input: string): string {
const url = new URL(input);
return url.pathname + url.search;
}

27
src/utils/video-poster.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { Artwork } from '@jet-app/app-store/api/models';
import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
import type { Size } from '@amp/web-app-components/src/types';
import type { NamedProfile } from 'src/config/components/artwork';
import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
import { getDataFromProfile } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
export const buildPoster = (
preview: Artwork,
profile: NamedProfile | Profile,
mediaQuery: string,
): ReturnType<typeof buildSrc> => {
const profileData = getDataFromProfile(profile);
const imageAttributes = profileData[mediaQuery as Size] || preview;
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 2;
return buildSrc(
preview.template,
{
crop: 'sr',
width: imageAttributes.width * dpr,
height: imageAttributes.height * dpr,
fileType: 'webp',
},
{},
);
};