init commit
This commit is contained in:
71
shared/components/src/utils/cookie.ts
Normal file
71
shared/components/src/utils/cookie.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export function getCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = `${name}=`;
|
||||
const cookie = document.cookie
|
||||
.split(';')
|
||||
.map((value) => value.trimStart())
|
||||
.filter((value) => value.startsWith(prefix))[0];
|
||||
|
||||
if (!cookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cookie.substr(prefix.length);
|
||||
}
|
||||
|
||||
export function setCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
domain: string,
|
||||
expires = 0,
|
||||
path = '/',
|
||||
): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get any potential existing instances of this particular cookie
|
||||
const existingCookie = getCookie(name);
|
||||
let cookieValue = value;
|
||||
|
||||
if (existingCookie) {
|
||||
// If exisitng cookie name does not include the value we are trying to set,
|
||||
// then add it, otherwise use the existing cookie value
|
||||
cookieValue = !existingCookie.includes(value)
|
||||
? `${existingCookie}+${value}`
|
||||
: existingCookie;
|
||||
}
|
||||
|
||||
let cookieString = `${name}=${cookieValue}; path=${path}; domain=${domain};`;
|
||||
|
||||
if (expires) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + expires * 24 * 60 * 60 * 1000);
|
||||
|
||||
cookieString += ` expires=${date.toUTCString()};`;
|
||||
}
|
||||
|
||||
document.cookie = cookieString;
|
||||
|
||||
// Returning undefined because of ESLint's "consistent-return" rule
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function clearCookie(name: string, domain: string, path = '/'): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get any potential existing instances of this particular cookie
|
||||
const existingCookie = getCookie(name);
|
||||
|
||||
if (existingCookie) {
|
||||
// Set the cookie's expiration date to a past date
|
||||
setCookie(name, '', domain, -1, path);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
51
shared/components/src/utils/date.ts
Normal file
51
shared/components/src/utils/date.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Breaks duration down from milliseconds into hours/minutes/seconds
|
||||
export function getDurationParts(durationInMilliseconds: number): {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} {
|
||||
// convert ms to seconds
|
||||
const durationInSeconds = Math.floor(durationInMilliseconds / 1000);
|
||||
const duration = Math.round(durationInSeconds);
|
||||
|
||||
return {
|
||||
hours: Math.floor(duration / 3600),
|
||||
minutes: Math.floor(duration / 60) % 60,
|
||||
seconds: duration % 60,
|
||||
};
|
||||
}
|
||||
|
||||
// returns normal numeric date in YYYY-MM-DD from a date string
|
||||
// AKA getNumericDateFromReleaseDate but renamed to be more generic
|
||||
//
|
||||
// ex: getNumericDateFromDateString('2024-04-15T08:41:03Z') => '2024-04-15'
|
||||
// getNumericDateFromDateString('15 April 2024 14:48 UTC') => '2024-04-15'
|
||||
export function getNumericDateFromDateString(
|
||||
timestamp?: string,
|
||||
): string | undefined {
|
||||
if (!timestamp) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Date(timestamp).toISOString().split('T')?.[0];
|
||||
}
|
||||
|
||||
// Utility to format ISO8601 Duration Strings from raw milliseconds (ex: PT2M42S).
|
||||
export function formatISODuration(durationInMilliseconds: number): string {
|
||||
const { hours, minutes, seconds } = getDurationParts(
|
||||
durationInMilliseconds,
|
||||
);
|
||||
|
||||
if (!hours && !minutes && !seconds) {
|
||||
return 'P0D';
|
||||
}
|
||||
|
||||
return [
|
||||
'PT',
|
||||
hours && `${hours}H`,
|
||||
minutes && `${minutes}M`,
|
||||
seconds && `${seconds}S`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
40
shared/components/src/utils/debounce.ts
Normal file
40
shared/components/src/utils/debounce.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
/**
|
||||
* @name debounce
|
||||
* @description
|
||||
* Creates a debounced function that delays invoking func until
|
||||
* after delayMs milliseconds have elapsed since the last time the
|
||||
* debounced function was invoked.
|
||||
*
|
||||
* @param delayMs - delay in milliseconds
|
||||
* @param immediate - Specify invoking on the leading edge of the timeout
|
||||
* (Defaults to trailing)
|
||||
*
|
||||
*(f: F): (...args: Parameters<F>) => void
|
||||
*/
|
||||
export function debounce<F extends (...args: any[]) => any>(
|
||||
fn: F,
|
||||
delayMs: number,
|
||||
immediate = false,
|
||||
): (...args: Parameters<F>) => void {
|
||||
let timerId;
|
||||
|
||||
return function debounced(...args) {
|
||||
const shouldCallNow = immediate && !timerId;
|
||||
clearTimeout(timerId);
|
||||
|
||||
if (shouldCallNow) {
|
||||
fn.apply(this, args);
|
||||
}
|
||||
|
||||
timerId = setTimeout(() => {
|
||||
timerId = null;
|
||||
if (!immediate) {
|
||||
fn.apply(this, args);
|
||||
}
|
||||
}, delayMs);
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_MOUSE_OVER_DELAY = 300;
|
||||
117
shared/components/src/utils/getMediaConditions.ts
Normal file
117
shared/components/src/utils/getMediaConditions.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Breakpoints, Size } from '@amp/web-app-components/src/types';
|
||||
|
||||
export type MediaConditions<T extends string | number | symbol = Size> = {
|
||||
[key in T]?: string;
|
||||
};
|
||||
|
||||
type BasicBreapoints<T extends string | number | symbol> = Record<T, number>;
|
||||
|
||||
type BreakpointOptions = { offset?: number };
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function getMediaConditions<T extends string | number | symbol = Size>(
|
||||
breakpoints: Breakpoints<T>,
|
||||
options?: BreakpointOptions,
|
||||
): MediaConditions<T> {
|
||||
const viewportOrder = {
|
||||
xsmall: 0,
|
||||
small: 1,
|
||||
medium: 2,
|
||||
large: 3,
|
||||
xlarge: 4,
|
||||
};
|
||||
|
||||
const offset = options?.offset ?? 0;
|
||||
const viewportSizes = Object.keys(breakpoints).sort(
|
||||
(a, b) => viewportOrder[a] - viewportOrder[b],
|
||||
) as T[];
|
||||
|
||||
return viewportSizeToMediaConditions<T>(breakpoints, viewportSizes, offset);
|
||||
}
|
||||
|
||||
function viewportSizeToMediaConditions<T extends string | number | symbol>(
|
||||
breakpoints: Breakpoints<T>,
|
||||
viewportSizes?: T[],
|
||||
offset?: number,
|
||||
): MediaConditions<T> {
|
||||
viewportSizes ||= Object.keys(breakpoints) as T[];
|
||||
const queries: MediaConditions<T> = {};
|
||||
viewportSizes.reduce((acc, viewport) => {
|
||||
const { min, max } = {
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
...breakpoints[viewport],
|
||||
};
|
||||
|
||||
if (min && !max) {
|
||||
acc[viewport] = `(min-width:${min + offset}px)`;
|
||||
} else if (!min && max) {
|
||||
acc[viewport] = `(max-width:${max + offset}px)`;
|
||||
} else if (min && max) {
|
||||
acc[viewport] = `(min-width:${min + offset}px) and (max-width:${
|
||||
max + offset
|
||||
}px)`;
|
||||
}
|
||||
return acc;
|
||||
}, queries);
|
||||
return queries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a breakpoints object into media queries that match ranges between each breakpoint and the next.
|
||||
*
|
||||
* @param breakpoints - Object with breakpoint names as keys and pixel values as values
|
||||
* @returns Object with breakpoint names as keys and media query strings as values
|
||||
*
|
||||
* @example
|
||||
* const breakpoints = { XSM: 0, SM: 350, MD: 484, LG: 1000 };
|
||||
* const mediaQueries = breakpointsToMediaQueries(breakpoints);
|
||||
* // Returns:
|
||||
* // {
|
||||
* // XSM: '(max-width: 349px)',
|
||||
* // SM: '(min-width: 350px) and (max-width: 483px)',
|
||||
* // MD: '(min-width: 484px) and (max-width: 999px)',
|
||||
* // LG: '(min-width: 1000px)'
|
||||
* // }
|
||||
*/
|
||||
export function breakpointsToMediaQueries<T extends string>(
|
||||
breakpoints: BasicBreapoints<T>,
|
||||
): MediaConditions<T> {
|
||||
const entries = Object.entries(breakpoints) as [T, number][];
|
||||
entries.sort(([, a], [_, b]) => a - b);
|
||||
const transformedBreakpoints: Breakpoints<T> = {};
|
||||
|
||||
entries.forEach(([breakpointName, minWidth], index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === entries.length - 1;
|
||||
const nextBreakpointWidth = isLast ? null : entries[index + 1][1];
|
||||
|
||||
if (isFirst && minWidth === 0) {
|
||||
// First breakpoint starting at 0: only max-width
|
||||
if (nextBreakpointWidth !== null) {
|
||||
transformedBreakpoints[breakpointName] = {
|
||||
max: nextBreakpointWidth - 1,
|
||||
};
|
||||
} else {
|
||||
// Edge case: only one breakpoint starting at 0
|
||||
transformedBreakpoints[breakpointName] = { min: 0 };
|
||||
}
|
||||
} else if (isLast) {
|
||||
// Last breakpoint: only min-width
|
||||
transformedBreakpoints[breakpointName] = { min: minWidth };
|
||||
} else {
|
||||
// Middle breakpoints: min-width and max-width range
|
||||
transformedBreakpoints[breakpointName] = {
|
||||
min: minWidth,
|
||||
max: nextBreakpointWidth! - 1,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const viewportSizes = entries.map(([breakpointName]) => breakpointName);
|
||||
return viewportSizeToMediaConditions<T>(
|
||||
transformedBreakpoints,
|
||||
viewportSizes,
|
||||
0,
|
||||
);
|
||||
}
|
||||
29
shared/components/src/utils/getStorefrontRoute.ts
Normal file
29
shared/components/src/utils/getStorefrontRoute.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Defines a route based on a given default route and
|
||||
* otherwise falls back to the base storefront path
|
||||
*
|
||||
* @param defaultRoute - ie 'browse', 'listen-now', or empty string
|
||||
* @param storefront - storefront id ie 'us'
|
||||
* @param language - language tag ie 'en-US'
|
||||
* @returns route - ie /us/browse?l=es-MX
|
||||
*/
|
||||
export function getStorefrontRoute(
|
||||
defaultRoute: string,
|
||||
storefront: string,
|
||||
language?: string,
|
||||
): string {
|
||||
let route;
|
||||
|
||||
if (defaultRoute === '') {
|
||||
route = `/${storefront}`;
|
||||
} else {
|
||||
route = `/${storefront}/${defaultRoute}`;
|
||||
}
|
||||
|
||||
// add optional language tag if that is passed in
|
||||
if (language) {
|
||||
route = `${route}?l=${language}`;
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
25
shared/components/src/utils/getUpdatedFocusedIndex.ts
Normal file
25
shared/components/src/utils/getUpdatedFocusedIndex.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function getUpdatedFocusedIndex(
|
||||
incrementAmount: number,
|
||||
currentFocusedIndex: number | null,
|
||||
numberOfItems: number,
|
||||
): number {
|
||||
const potentialFocusedIndex = incrementAmount + currentFocusedIndex;
|
||||
|
||||
if (incrementAmount > 0) {
|
||||
if (currentFocusedIndex === null) {
|
||||
return 0;
|
||||
} else {
|
||||
return potentialFocusedIndex >= numberOfItems
|
||||
? 0
|
||||
: potentialFocusedIndex;
|
||||
}
|
||||
} else {
|
||||
if (currentFocusedIndex === null) {
|
||||
return numberOfItems - 1;
|
||||
} else {
|
||||
return potentialFocusedIndex < 0
|
||||
? numberOfItems - 1
|
||||
: potentialFocusedIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
shared/components/src/utils/internal/locale/index.ts
Normal file
17
shared/components/src/utils/internal/locale/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
//TODO rdar://93379311 (Solution for sharing context between app + shared components)
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { Locale } from '@amp/web-app-components/src/types';
|
||||
|
||||
const CONTEXT_NAME = 'shared:locale';
|
||||
|
||||
// WARNING these signatures can change after rdar://93379311
|
||||
export function setLocale(context: Map<string, unknown>, locale: Locale) {
|
||||
context.set(CONTEXT_NAME, locale);
|
||||
}
|
||||
|
||||
// WARNING these signatures can change after rdar://93379311
|
||||
export function getLocale(): Locale {
|
||||
return getContext(CONTEXT_NAME) as Locale | undefined;
|
||||
}
|
||||
64
shared/components/src/utils/makeSafeTick.ts
Normal file
64
shared/components/src/utils/makeSafeTick.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { tick as svelteTick, onDestroy } from 'svelte';
|
||||
|
||||
// Unfortantely for TS to recognize that this can be awaited
|
||||
// we need to leave `Promise<void | never>` otherwise TS hints
|
||||
// will suggest removing the await.
|
||||
// See @remarks for reason to disable `then`
|
||||
type TickType = () => Omit<Promise<string>, 'then'> | Promise<void | never>;
|
||||
|
||||
type SafeTickCallback = (tick: TickType) => Promise<void | never>;
|
||||
|
||||
class DestroyedError extends Error {
|
||||
constructor() {
|
||||
super('component was destroyed before tick resolved.');
|
||||
this.name = 'DestroyedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a safer way to use svelte's tick helper.
|
||||
*
|
||||
* This prevents code that relies on tick() from running
|
||||
* if the component is destroyed while the tick resolution
|
||||
* is inflight.
|
||||
*
|
||||
* @remarks
|
||||
* To avoid floating promises (promises with no return statements)
|
||||
* it is safer to use the `async/await` syntax.
|
||||
*
|
||||
* If this is used with the `.then()` syntax without the promise
|
||||
* being returned the DestroyedError will bubble up to sentry.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const safeTick = makeSafeTick();
|
||||
* onMount(async() => {
|
||||
* await safeTick(async (tick) => {
|
||||
* // Use tick normally
|
||||
* await tick();
|
||||
* // ...
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const makeSafeTick = (): ((
|
||||
callback: SafeTickCallback,
|
||||
) => Promise<void | never>) => {
|
||||
let destroyed = false;
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
});
|
||||
|
||||
return async (callback) => {
|
||||
try {
|
||||
await callback(async () => {
|
||||
await svelteTick();
|
||||
if (destroyed) throw new DestroyedError();
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof DestroyedError)) throw e;
|
||||
}
|
||||
};
|
||||
};
|
||||
26
shared/components/src/utils/memoize.ts
Normal file
26
shared/components/src/utils/memoize.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function memoize<T extends unknown[], S>(
|
||||
fn: (...args: T) => S,
|
||||
hashFn: (...args: unknown[]) => string = JSON.stringify,
|
||||
entryLimit = 5,
|
||||
): (...args: T) => S {
|
||||
const cache: Map<string, S> = new Map();
|
||||
|
||||
return (...args: T) => {
|
||||
const value = hashFn(args);
|
||||
if (cache.has(value)) {
|
||||
return cache.get(value);
|
||||
}
|
||||
|
||||
const returnedValue: S = fn.apply(this, args);
|
||||
|
||||
if (cache.size >= entryLimit) {
|
||||
const iterator = cache.keys();
|
||||
const firstValue = iterator.next().value;
|
||||
// remove oldest value
|
||||
cache.delete(firstValue);
|
||||
}
|
||||
cache.set(value, returnedValue);
|
||||
return returnedValue;
|
||||
};
|
||||
}
|
||||
74
shared/components/src/utils/rafQueue.ts
Normal file
74
shared/components/src/utils/rafQueue.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @name RequestAnimationFrameLimiter
|
||||
* @description
|
||||
* allows for multiple callbacks to be called
|
||||
* within a single RAF function.
|
||||
* It also spreads long running tasks across multiple
|
||||
* microtask to help keep the main thread free for user interactions
|
||||
*
|
||||
*/
|
||||
export class RequestAnimationFrameLimiter {
|
||||
private queue: Array<(timestamp?: number) => void>;
|
||||
private RAF_FN_LIMIT_MS: number;
|
||||
private requestId: number | null;
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
// ideal limit for scroll based animations: https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution#reduce_complexity_or_use_web_workers
|
||||
this.RAF_FN_LIMIT_MS = 3;
|
||||
this.requestId = null;
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
this.requestId =
|
||||
this.queue.length === 0
|
||||
? null
|
||||
: window.requestAnimationFrame((timestamp) => {
|
||||
const start = window.performance.now();
|
||||
let ellapsedTime = 0;
|
||||
const { RAF_FN_LIMIT_MS } = this;
|
||||
let count = 0;
|
||||
|
||||
while (
|
||||
count < this.queue.length &&
|
||||
ellapsedTime < RAF_FN_LIMIT_MS
|
||||
) {
|
||||
let item = this.queue[count];
|
||||
if (item) {
|
||||
item(timestamp);
|
||||
}
|
||||
const finishTime = window.performance.now();
|
||||
|
||||
count = count + 1;
|
||||
ellapsedTime = finishTime - start;
|
||||
}
|
||||
const newQueue = this.queue.slice(count);
|
||||
|
||||
this.queue = newQueue;
|
||||
this.flush();
|
||||
});
|
||||
}
|
||||
public add(callback: () => void): void {
|
||||
this.queue.push(callback);
|
||||
if (this.requestId === null) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let raf: RequestAnimationFrameLimiter | ServerSafeRAFLimiter = null;
|
||||
|
||||
type ServerSafeRAFLimiter = {
|
||||
add: (callback: () => void) => void;
|
||||
};
|
||||
|
||||
export const getRafQueue = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
// SSR safe
|
||||
raf = {
|
||||
add: (callback: () => void) => callback(),
|
||||
};
|
||||
} else if (raf === null) {
|
||||
raf = new RequestAnimationFrameLimiter();
|
||||
}
|
||||
return raf;
|
||||
};
|
||||
26
shared/components/src/utils/sanitize-html/browser.ts
Normal file
26
shared/components/src/utils/sanitize-html/browser.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Browser ONLY logic. Must have the same exports as server.ts
|
||||
// See: docs/isomorphic-imports.md
|
||||
|
||||
import { type SanitizeHtmlOptions, sanitizeDocument } from './common';
|
||||
|
||||
export { type SanitizeHtmlOptions, DEFAULT_SAFE_TAGS } from './common';
|
||||
|
||||
// Shared DOMParser instance (avoids creating a new one for each sanitization)
|
||||
let parser = null;
|
||||
|
||||
export function sanitizeHtml(
|
||||
input: string,
|
||||
options: SanitizeHtmlOptions = {},
|
||||
): string {
|
||||
if (!input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (!parser) {
|
||||
parser = new DOMParser();
|
||||
}
|
||||
|
||||
const unsafeDocument = parser.parseFromString(`${input}`, 'text/html');
|
||||
const unsafeNode = unsafeDocument.body;
|
||||
return sanitizeDocument(unsafeDocument, unsafeNode, options);
|
||||
}
|
||||
176
shared/components/src/utils/sanitize-html/common.ts
Normal file
176
shared/components/src/utils/sanitize-html/common.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
type AllowedTags = Set<string>;
|
||||
|
||||
interface AllowedAttributes {
|
||||
[tagName: string]: Set<string>;
|
||||
}
|
||||
|
||||
export interface SanitizeHtmlOptions {
|
||||
allowedTags?: string[];
|
||||
extraAllowedTags?: string[];
|
||||
keepChildrenWhenRemovingParent?: boolean;
|
||||
|
||||
/**
|
||||
* When true, replaces all entities with regular spaces
|
||||
* to prevent unwanted line breaks in the rendered HTML
|
||||
*/
|
||||
removeNbsp?: boolean;
|
||||
|
||||
/**
|
||||
* AllowedAttributes should be an object with tag name keys and array values
|
||||
* containing all of the attributes allowed for that tag:
|
||||
*
|
||||
* { 'p': ['class'], 'div': ['role', 'aria-hidden'] }
|
||||
*
|
||||
* The above allows ONLY the class attribute for <p> and ONLY the role and
|
||||
* aria-hidden attributes for <div>.
|
||||
*/
|
||||
allowedAttributes?: {
|
||||
[tagName: string]: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_SAFE_TAGS: string[] = [
|
||||
'strong',
|
||||
'em',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'br',
|
||||
];
|
||||
const DEFAULT_SAFE_ATTRS = {};
|
||||
|
||||
/**
|
||||
* Sanitizes HTML by removing all tags and attributes that aren't explicitly allowed.
|
||||
*/
|
||||
export function sanitizeDocument(
|
||||
unsafeDocument: Document,
|
||||
unsafeNode: Node | DocumentFragment,
|
||||
{
|
||||
allowedTags,
|
||||
extraAllowedTags,
|
||||
allowedAttributes = DEFAULT_SAFE_ATTRS,
|
||||
keepChildrenWhenRemovingParent,
|
||||
removeNbsp,
|
||||
}: SanitizeHtmlOptions = {},
|
||||
): string {
|
||||
if (allowedTags && extraAllowedTags) {
|
||||
throw new Error(
|
||||
'sanitizeHtml got both allowedTags and extraAllowedTags',
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTagsSet = new Set([
|
||||
...(extraAllowedTags || []),
|
||||
...(allowedTags || DEFAULT_SAFE_TAGS),
|
||||
]);
|
||||
|
||||
const allowedAttributeSets = {};
|
||||
for (const [tag, attributes] of Object.entries(allowedAttributes)) {
|
||||
allowedAttributeSets[tag] = new Set(attributes);
|
||||
}
|
||||
|
||||
const sanitizedContainer = unsafeDocument.createElement('div');
|
||||
|
||||
for (const child of [...unsafeNode.childNodes]) {
|
||||
const sanitizedChildArray = sanitizeNode(
|
||||
child as Element,
|
||||
allowedTagsSet,
|
||||
allowedAttributeSets,
|
||||
keepChildrenWhenRemovingParent,
|
||||
);
|
||||
sanitizedChildArray.forEach((node) => {
|
||||
sanitizedContainer.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
let html = sanitizedContainer.innerHTML;
|
||||
|
||||
// Replace with regular spaces if removeNbsp option is enabled
|
||||
if (removeNbsp) {
|
||||
html = html.replace(/ /g, ' ');
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function sanitizeNode(
|
||||
node: Element,
|
||||
allowedTags: AllowedTags,
|
||||
allowedAttributes: AllowedAttributes,
|
||||
keepChildrenWhenRemovingParent: boolean,
|
||||
): Node[] | Element[] {
|
||||
// Plain text is safe as is
|
||||
// NOTE: The lowercase node (instead of Node) is intentional. Node is only
|
||||
// accessible in browser. In Node.js, it depends on jsdom (which we
|
||||
// avoid importing to exclude from the clientside vendor bundle).
|
||||
// Instead of passing down window.Node or jsdom.Node depending on
|
||||
// context, we rely on the fact that instances of Node (of which node
|
||||
// will be one) will also have these constants set on them.
|
||||
if (
|
||||
([node.TEXT_NODE, node.CDATA_SECTION_NODE] as number[]).includes(
|
||||
node.nodeType,
|
||||
)
|
||||
) {
|
||||
return [node];
|
||||
}
|
||||
|
||||
// Refuse anything that isn't a tag or one of the allowed tags
|
||||
const tagName = (node.tagName || '').toLowerCase();
|
||||
|
||||
if (!allowedTags.has(tagName)) {
|
||||
// when keepChildrenWhenRemovingParent is true
|
||||
// we check children for valid nodes as well
|
||||
if (keepChildrenWhenRemovingParent) {
|
||||
return sanitizeChildren(
|
||||
node,
|
||||
allowedTags,
|
||||
allowedAttributes,
|
||||
keepChildrenWhenRemovingParent,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reconstruct node with only the allowedAttributes and sanitize its children
|
||||
const sanitized = node.ownerDocument.createElement(tagName);
|
||||
const currentlyAllowedAttributes = allowedAttributes[tagName] || new Set();
|
||||
|
||||
for (const { name, nodeValue: value } of [...node.attributes]) {
|
||||
if (currentlyAllowedAttributes.has(name)) {
|
||||
sanitized.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
const children = sanitizeChildren(
|
||||
node,
|
||||
allowedTags,
|
||||
allowedAttributes,
|
||||
keepChildrenWhenRemovingParent,
|
||||
);
|
||||
|
||||
children.forEach((child) => {
|
||||
sanitized.appendChild(child);
|
||||
});
|
||||
|
||||
return [sanitized];
|
||||
}
|
||||
|
||||
const sanitizeChildren = (
|
||||
node: Element,
|
||||
allowedTags: AllowedTags,
|
||||
allowedAttributes: AllowedAttributes,
|
||||
tagsToConvertToText: boolean,
|
||||
): Node[] => {
|
||||
const children = [...node.childNodes]
|
||||
.map((childNode) =>
|
||||
sanitizeNode(
|
||||
childNode as Element,
|
||||
allowedTags,
|
||||
allowedAttributes,
|
||||
tagsToConvertToText,
|
||||
),
|
||||
)
|
||||
.flat();
|
||||
|
||||
return children;
|
||||
};
|
||||
32
shared/components/src/utils/sanitize.ts
Normal file
32
shared/components/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Take care with < (which has special meaning inside script tags)
|
||||
// See: https://github.com/sveltejs/kit/blob/ff9a27b4/packages/kit/src/runtime/server/page/serialize_data.js#L4-L28
|
||||
const replacements = {
|
||||
'<': '\\u003C',
|
||||
'\u2028': '\\u2028',
|
||||
'\u2029': '\\u2029',
|
||||
};
|
||||
|
||||
const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g');
|
||||
|
||||
/**
|
||||
* Serializes a POJO into a HTML <script> tag that can be read clientside by
|
||||
* `deserializeServerData`.
|
||||
*
|
||||
* Use this to share data between serverside and clientside. Include the
|
||||
* returned HTML in the response to a client to allow it to read this data.
|
||||
*
|
||||
* @param data data to serialize
|
||||
* @returns serialized data (or empty string if serialization fails)
|
||||
*/
|
||||
export function serializeJSONData(data: object): string {
|
||||
try {
|
||||
return JSON.stringify(data).replace(
|
||||
pattern,
|
||||
(match) => replacements[match],
|
||||
);
|
||||
} catch (e) {
|
||||
// Don't let recursive data (or other non-serializable things) throw.
|
||||
// We'd rather just let the serialize no-op to avoid breaking consumers.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
143
shared/components/src/utils/scrollByPolyfill.ts
Normal file
143
shared/components/src/utils/scrollByPolyfill.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// COPIED FROM
|
||||
// https://github.pie.apple.com/amp-ui/ember-ui-media-shelf/blob/580ff07a546771bce8b3d85494c6268860e97215/addon/-private/scroll-by-polyfill.js
|
||||
|
||||
const SCROLL_TIME = 468;
|
||||
const Element =
|
||||
typeof window !== 'undefined' ? window.HTMLElement || window.Element : null;
|
||||
|
||||
let originalScrollBy;
|
||||
|
||||
/**
|
||||
* returns result of applying ease math function to a number
|
||||
* @method ease
|
||||
* @param {Number} k
|
||||
* @returns {Number}
|
||||
*/
|
||||
function ease(k: number): number {
|
||||
return 0.5 * (1 - Math.cos(Math.PI * k));
|
||||
}
|
||||
|
||||
// define timing method
|
||||
const now: () => number =
|
||||
typeof window !== 'undefined' && window?.performance?.now
|
||||
? window.performance.now.bind(window.performance)
|
||||
: Date.now;
|
||||
|
||||
/**
|
||||
* changes scroll position inside an element
|
||||
* @method scrollElement
|
||||
* @param {Number} x
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function scrollElement(x: number): void {
|
||||
this.scrollLeft = x;
|
||||
}
|
||||
|
||||
/**
|
||||
* self invoked function that, given a context, steps through scrolling
|
||||
* @method step
|
||||
* @param {Object} context
|
||||
* @returns {undefined}
|
||||
*/
|
||||
type Context = {
|
||||
startTime: number;
|
||||
startX: number;
|
||||
x: number;
|
||||
method: (x: number) => void;
|
||||
scrollable: HTMLElement;
|
||||
};
|
||||
function step(context: Context): void {
|
||||
const time = now();
|
||||
let elapsed = (time - context.startTime) / SCROLL_TIME;
|
||||
|
||||
// avoid elapsed times higher than one
|
||||
elapsed = Math.min(1, elapsed);
|
||||
|
||||
// apply easing to elapsed time
|
||||
const value = ease(elapsed);
|
||||
|
||||
const currentX = context.startX + (context.x - context.startX) * value;
|
||||
|
||||
context.method.call(context.scrollable, currentX);
|
||||
|
||||
// scroll more if we have not reached our destination
|
||||
if (currentX !== context.x) {
|
||||
window.requestAnimationFrame(step.bind(window, context));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* scrolls window or element with a smooth behavior
|
||||
* @method smoothScroll
|
||||
* @param {Object|Node} el
|
||||
* @param {Number} x
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function smoothScroll(el: HTMLElement, x: number): void {
|
||||
const startTime = now();
|
||||
// define scroll context
|
||||
const startX = el.scrollLeft;
|
||||
const method = scrollElement;
|
||||
|
||||
// scroll looping over a frame
|
||||
step({
|
||||
scrollable: el,
|
||||
method,
|
||||
startTime,
|
||||
startX,
|
||||
x,
|
||||
});
|
||||
}
|
||||
|
||||
let polyfillHasRun = false;
|
||||
/**
|
||||
* ripped partially from https://github.com/iamdustan/smoothscroll/blob/master/src/smoothscroll.js
|
||||
* Only polyfill horizontal scroll space to avoid unexpected behaviour in parent apps
|
||||
*
|
||||
* @method scrollByPolyfill
|
||||
*/
|
||||
export default function scrollByPolyfill(): void {
|
||||
// return if scroll behavior is supported
|
||||
if ('scrollBehavior' in document.documentElement.style || polyfillHasRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if prefers-reduce-motion && need polyfill, navigate shelf immediately without easing
|
||||
const motionMediaQuery = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
);
|
||||
function addScrollByToProto() {
|
||||
if (motionMediaQuery.matches) {
|
||||
if (originalScrollBy) {
|
||||
Element.prototype.scrollBy = originalScrollBy;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function scrollByPoly(options: ScrollToOptions): void;
|
||||
function scrollByPoly(x: number, _y: number): void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function scrollByPoly(
|
||||
paramOne: number | ScrollToOptions,
|
||||
_paramTwo?: number,
|
||||
): void {
|
||||
let xValue = 0;
|
||||
if (typeof paramOne === 'number') {
|
||||
xValue = paramOne;
|
||||
} else if (typeof paramOne === 'object') {
|
||||
xValue = paramOne.left || 0;
|
||||
}
|
||||
|
||||
const moveByX = this.scrollLeft + xValue;
|
||||
smoothScroll(this, moveByX);
|
||||
}
|
||||
|
||||
originalScrollBy = Element.prototype.scrollBy;
|
||||
Element.prototype.scrollBy = scrollByPoly;
|
||||
}
|
||||
|
||||
motionMediaQuery.addListener(addScrollByToProto);
|
||||
|
||||
addScrollByToProto();
|
||||
polyfillHasRun = true;
|
||||
}
|
||||
75
shared/components/src/utils/shelfAspectRatio.ts
Normal file
75
shared/components/src/utils/shelfAspectRatio.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { getAspectRatio } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
|
||||
import { setContext, getContext, hasContext } from 'svelte';
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
|
||||
import type { AspectRatioOverrideConfig } from '@amp/web-app-components/src/components/Shelf/types';
|
||||
|
||||
const SHELF_ASPECT_RATIO_KEY = 'shelf-aspect-ratio';
|
||||
|
||||
export const getShelfAspectRatioContext = (): {
|
||||
shelfAspectRatio: Readable<string>;
|
||||
addProfile: (profile: string | Profile) => void;
|
||||
} => {
|
||||
return getContext(SHELF_ASPECT_RATIO_KEY);
|
||||
};
|
||||
|
||||
export const hasShelfAspectRatioContext = () =>
|
||||
hasContext(SHELF_ASPECT_RATIO_KEY);
|
||||
|
||||
const createShelfAspectRatioStore = (config: AspectRatioOverrideConfig) => {
|
||||
const { subscribe, update } = writable(new Map() as Map<string, number>);
|
||||
|
||||
const addProfile = (profile: string) => {
|
||||
const ratio = getAspectRatio(profile).toFixed(2);
|
||||
|
||||
update((ratiosCount) => {
|
||||
const currentCount = ratiosCount.get(ratio);
|
||||
const newCount = ratiosCount.has(ratio) ? currentCount + 1 : 0;
|
||||
ratiosCount.set(ratio, newCount);
|
||||
return ratiosCount;
|
||||
});
|
||||
};
|
||||
|
||||
const aspectRatioStore = {
|
||||
subscribe,
|
||||
addProfile,
|
||||
};
|
||||
|
||||
const shelfAspectRatio = derived(aspectRatioStore, ($store) => {
|
||||
let aspectRatio: string = null;
|
||||
|
||||
// Don't set shelf aspect ratio when only 1 ratio is found
|
||||
//
|
||||
// This allows e.g. a shelf with only tall artwork Powerswooshes to use
|
||||
// their native 3:4 aspect ratio, even when the shelf is set to use the
|
||||
// fixed 1:1 aspect ratio or a dominant aspect ratio.
|
||||
if ($store.size > 1) {
|
||||
if (config.type === 'fixed') {
|
||||
aspectRatio = config.aspectRatio;
|
||||
} else if (config.type === 'dominant') {
|
||||
let highestCount = 0;
|
||||
for (const [ratio, count] of $store.entries()) {
|
||||
if (highestCount < count) {
|
||||
aspectRatio = ratio;
|
||||
highestCount = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aspectRatio;
|
||||
});
|
||||
|
||||
return {
|
||||
shelfAspectRatio,
|
||||
addProfile,
|
||||
};
|
||||
};
|
||||
|
||||
export const createShelfAspectRatioContext = (
|
||||
config: AspectRatioOverrideConfig,
|
||||
) => {
|
||||
setContext(SHELF_ASPECT_RATIO_KEY, createShelfAspectRatioStore(config));
|
||||
return getShelfAspectRatioContext();
|
||||
};
|
||||
25
shared/components/src/utils/should-show-navigation-item.ts
Normal file
25
shared/components/src/utils/should-show-navigation-item.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function shouldShowNavigationItem(
|
||||
visibilityPreferencesKey: string | null,
|
||||
isEditing: boolean,
|
||||
data: Record<string, boolean> | null,
|
||||
itemVisibilityPreferenceKey: string,
|
||||
): boolean {
|
||||
// If there are no visibility preferences,
|
||||
// the item should always be shown.
|
||||
if (!visibilityPreferencesKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the visibility preference of an item
|
||||
// is in an editing state, it should be shown.
|
||||
if (isEditing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show the item if the visibility preference is to show it.
|
||||
if (data && data[itemVisibilityPreferenceKey]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
49
shared/components/src/utils/throttle.ts
Normal file
49
shared/components/src/utils/throttle.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
/**
|
||||
* @name throttle
|
||||
* @description
|
||||
* Creates a throttled function that only invokes func at most once per every limit time (ms).
|
||||
*
|
||||
* *NOTE: this does not capture or recall all functions that were triggered.
|
||||
* This will drop function calls that happen during the throttle time*
|
||||
* @param limit - time to wait between calls in ms
|
||||
* @example
|
||||
* Normal event
|
||||
* event | | | |
|
||||
* time ----------------
|
||||
* callback | | | |
|
||||
*
|
||||
* Throttled event [300ms]
|
||||
* event | | | |
|
||||
* time ----------------
|
||||
* callback | | |
|
||||
* [300] [300]
|
||||
*/
|
||||
|
||||
export function throttle<T extends []>(
|
||||
func: (..._: T) => unknown,
|
||||
limit: number,
|
||||
): (..._: T) => void {
|
||||
let lastTimeoutId;
|
||||
let lastCallTime: number;
|
||||
|
||||
return function throttled(...args) {
|
||||
const nextCall = () => {
|
||||
func.apply(this, args);
|
||||
lastCallTime = Date.now();
|
||||
};
|
||||
|
||||
if (!lastCallTime) {
|
||||
nextCall();
|
||||
} else {
|
||||
clearTimeout(lastTimeoutId);
|
||||
const timeBetweenCalls = Date.now() - lastCallTime;
|
||||
const waitTime = Math.max(0, limit - timeBetweenCalls);
|
||||
lastTimeoutId = setTimeout(() => {
|
||||
if (timeBetweenCalls >= limit) {
|
||||
nextCall();
|
||||
}
|
||||
}, waitTime);
|
||||
}
|
||||
};
|
||||
}
|
||||
71
shared/components/src/utils/uniqueId.ts
Normal file
71
shared/components/src/utils/uniqueId.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
export const UNIQUE_ID_CONTEXT_NAME = 'amp-web-unique-id';
|
||||
|
||||
interface UniqueContext {
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
// TODO: rdar://84029606 (Extract logger into shared util)
|
||||
interface Logger {
|
||||
warn(...args: any[]): string;
|
||||
}
|
||||
interface LoggerFactory {
|
||||
loggerFor(name: string): Logger;
|
||||
}
|
||||
|
||||
export function initializeUniqueIdContext(
|
||||
context: Map<string, unknown>,
|
||||
loggerFactory: LoggerFactory,
|
||||
): void {
|
||||
const logger = loggerFactory.loggerFor('uniqueIdContext');
|
||||
|
||||
if (context.has(UNIQUE_ID_CONTEXT_NAME)) {
|
||||
logger.warn(
|
||||
`${UNIQUE_ID_CONTEXT_NAME} context has already been created. Cannot be created more than once`,
|
||||
);
|
||||
} else {
|
||||
const INITAL_STATE: UniqueContext = { nextId: 0 };
|
||||
context.set(UNIQUE_ID_CONTEXT_NAME, INITAL_STATE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique Id string based on string provided
|
||||
*
|
||||
* @returns unique id string
|
||||
*/
|
||||
export type UniqueIdGenerator = () => string;
|
||||
|
||||
// Custom elements most likely will not be used in an environment has that initialized the Svelte
|
||||
// context. Components that are later wrapped by a custom element should use this function so that
|
||||
// they can generate unique ids automatically when used inside a Svelte app, but not throw an error
|
||||
// when used in other contexts.
|
||||
//
|
||||
export function maybeGetUniqueIdGenerator(): UniqueIdGenerator | undefined {
|
||||
const UNIQUE_ID_PREFIX = 'uid-';
|
||||
const state: UniqueContext = getContext(UNIQUE_ID_CONTEXT_NAME);
|
||||
const isNextIdANumber = typeof state?.nextId === 'number';
|
||||
|
||||
if (!isNextIdANumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
return () => {
|
||||
const id = `${UNIQUE_ID_PREFIX}${state.nextId}`;
|
||||
state.nextId += 1;
|
||||
return id;
|
||||
};
|
||||
}
|
||||
|
||||
export function getUniqueIdGenerator(): UniqueIdGenerator {
|
||||
const uniqueIdGenerator = maybeGetUniqueIdGenerator();
|
||||
|
||||
if (!uniqueIdGenerator) {
|
||||
throw new Error(
|
||||
`${UNIQUE_ID_CONTEXT_NAME} context has not been initialized. Initialize at application bootstrap.`,
|
||||
);
|
||||
}
|
||||
|
||||
return uniqueIdGenerator;
|
||||
}
|
||||
Reference in New Issue
Block a user