init commit
This commit is contained in:
25
src/utils/app-platforms.ts
Normal file
25
src/utils/app-platforms.ts
Normal 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
33
src/utils/array.ts
Normal 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
168
src/utils/color.ts
Normal 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
28
src/utils/error.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
13
src/utils/features/consts.ts
Normal file
13
src/utils/features/consts.ts
Normal 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';
|
||||
44
src/utils/features/runtime.ts
Normal file
44
src/utils/features/runtime.ts
Normal 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
23
src/utils/file-size.ts
Normal 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 };
|
||||
}
|
||||
13
src/utils/launch-client.ts
Normal file
13
src/utils/launch-client.ts
Normal 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
142
src/utils/locale.ts
Normal 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
|
||||
);
|
||||
}
|
||||
12
src/utils/media-queries.ts
Normal file
12
src/utils/media-queries.ts
Normal 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
4
src/utils/metrics.ts
Normal 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';
|
||||
39
src/utils/number-formatting.ts
Normal file
39
src/utils/number-formatting.ts
Normal 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
34
src/utils/portal.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
43
src/utils/seo/app-event-detail-page.ts
Normal file
43
src/utils/seo/app-event-detail-page.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
40
src/utils/seo/arcade-see-all-page.ts
Normal file
40
src/utils/seo/arcade-see-all-page.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
276
src/utils/seo/article-page.ts
Normal file
276
src/utils/seo/article-page.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
46
src/utils/seo/charts-hub-page.ts
Normal file
46
src/utils/seo/charts-hub-page.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
58
src/utils/seo/charts-page.ts
Normal file
58
src/utils/seo/charts-page.ts
Normal 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
75
src/utils/seo/common.ts
Normal 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;
|
||||
}
|
||||
174
src/utils/seo/developer-page.ts
Normal file
174
src/utils/seo/developer-page.ts
Normal 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,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
51
src/utils/seo/editorial-shelf-collection-page.ts
Normal file
51
src/utils/seo/editorial-shelf-collection-page.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
71
src/utils/seo/image-url.ts
Normal file
71
src/utils/seo/image-url.ts
Normal 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
|
||||
);
|
||||
}
|
||||
353
src/utils/seo/product-page.ts
Normal file
353
src/utils/seo/product-page.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
56
src/utils/seo/reviews-page.ts
Normal file
56
src/utils/seo/reviews-page.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
src/utils/seo/search-landing-page.ts
Normal file
18
src/utils/seo/search-landing-page.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
src/utils/seo/search-results-page.ts
Normal file
56
src/utils/seo/search-results-page.ts
Normal 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,
|
||||
}
|
||||
: {};
|
||||
}
|
||||
47
src/utils/seo/see-all-page.ts
Normal file
47
src/utils/seo/see-all-page.ts
Normal 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
56
src/utils/shelves.ts
Normal 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;
|
||||
};
|
||||
15
src/utils/storefront-data.ts
Normal file
15
src/utils/storefront-data.ts
Normal 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);
|
||||
126
src/utils/string-formatting.ts
Normal file
126
src/utils/string-formatting.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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
45
src/utils/transition.ts
Normal 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
17
src/utils/types.ts
Normal 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
13
src/utils/url.ts
Normal 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
27
src/utils/video-poster.ts
Normal 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',
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user