From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- shared/components/src/utils/cookie.ts | 71 +++++++++ shared/components/src/utils/date.ts | 51 ++++++ shared/components/src/utils/debounce.ts | 40 +++++ shared/components/src/utils/getMediaConditions.ts | 117 ++++++++++++++ shared/components/src/utils/getStorefrontRoute.ts | 29 ++++ .../components/src/utils/getUpdatedFocusedIndex.ts | 25 +++ .../components/src/utils/internal/locale/index.ts | 17 ++ shared/components/src/utils/makeSafeTick.ts | 64 ++++++++ shared/components/src/utils/memoize.ts | 26 +++ shared/components/src/utils/rafQueue.ts | 74 +++++++++ .../components/src/utils/sanitize-html/browser.ts | 26 +++ .../components/src/utils/sanitize-html/common.ts | 176 +++++++++++++++++++++ shared/components/src/utils/sanitize.ts | 32 ++++ shared/components/src/utils/scrollByPolyfill.ts | 143 +++++++++++++++++ shared/components/src/utils/shelfAspectRatio.ts | 75 +++++++++ .../src/utils/should-show-navigation-item.ts | 25 +++ shared/components/src/utils/throttle.ts | 49 ++++++ shared/components/src/utils/uniqueId.ts | 71 +++++++++ 18 files changed, 1111 insertions(+) create mode 100644 shared/components/src/utils/cookie.ts create mode 100644 shared/components/src/utils/date.ts create mode 100644 shared/components/src/utils/debounce.ts create mode 100644 shared/components/src/utils/getMediaConditions.ts create mode 100644 shared/components/src/utils/getStorefrontRoute.ts create mode 100644 shared/components/src/utils/getUpdatedFocusedIndex.ts create mode 100644 shared/components/src/utils/internal/locale/index.ts create mode 100644 shared/components/src/utils/makeSafeTick.ts create mode 100644 shared/components/src/utils/memoize.ts create mode 100644 shared/components/src/utils/rafQueue.ts create mode 100644 shared/components/src/utils/sanitize-html/browser.ts create mode 100644 shared/components/src/utils/sanitize-html/common.ts create mode 100644 shared/components/src/utils/sanitize.ts create mode 100644 shared/components/src/utils/scrollByPolyfill.ts create mode 100644 shared/components/src/utils/shelfAspectRatio.ts create mode 100644 shared/components/src/utils/should-show-navigation-item.ts create mode 100644 shared/components/src/utils/throttle.ts create mode 100644 shared/components/src/utils/uniqueId.ts (limited to 'shared/components/src/utils') diff --git a/shared/components/src/utils/cookie.ts b/shared/components/src/utils/cookie.ts new file mode 100644 index 0000000..112733f --- /dev/null +++ b/shared/components/src/utils/cookie.ts @@ -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; +} diff --git a/shared/components/src/utils/date.ts b/shared/components/src/utils/date.ts new file mode 100644 index 0000000..f128de7 --- /dev/null +++ b/shared/components/src/utils/date.ts @@ -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(''); +} diff --git a/shared/components/src/utils/debounce.ts b/shared/components/src/utils/debounce.ts new file mode 100644 index 0000000..fcadbef --- /dev/null +++ b/shared/components/src/utils/debounce.ts @@ -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) => void + */ +export function debounce any>( + fn: F, + delayMs: number, + immediate = false, +): (...args: Parameters) => 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; diff --git a/shared/components/src/utils/getMediaConditions.ts b/shared/components/src/utils/getMediaConditions.ts new file mode 100644 index 0000000..2d5028b --- /dev/null +++ b/shared/components/src/utils/getMediaConditions.ts @@ -0,0 +1,117 @@ +import type { Breakpoints, Size } from '@amp/web-app-components/src/types'; + +export type MediaConditions = { + [key in T]?: string; +}; + +type BasicBreapoints = Record; + +type BreakpointOptions = { offset?: number }; + +// eslint-disable-next-line import/prefer-default-export +export function getMediaConditions( + breakpoints: Breakpoints, + options?: BreakpointOptions, +): MediaConditions { + 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(breakpoints, viewportSizes, offset); +} + +function viewportSizeToMediaConditions( + breakpoints: Breakpoints, + viewportSizes?: T[], + offset?: number, +): MediaConditions { + viewportSizes ||= Object.keys(breakpoints) as T[]; + const queries: MediaConditions = {}; + 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( + breakpoints: BasicBreapoints, +): MediaConditions { + const entries = Object.entries(breakpoints) as [T, number][]; + entries.sort(([, a], [_, b]) => a - b); + const transformedBreakpoints: Breakpoints = {}; + + 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( + transformedBreakpoints, + viewportSizes, + 0, + ); +} diff --git a/shared/components/src/utils/getStorefrontRoute.ts b/shared/components/src/utils/getStorefrontRoute.ts new file mode 100644 index 0000000..2aaaace --- /dev/null +++ b/shared/components/src/utils/getStorefrontRoute.ts @@ -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; +} diff --git a/shared/components/src/utils/getUpdatedFocusedIndex.ts b/shared/components/src/utils/getUpdatedFocusedIndex.ts new file mode 100644 index 0000000..ca2c765 --- /dev/null +++ b/shared/components/src/utils/getUpdatedFocusedIndex.ts @@ -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; + } + } +} diff --git a/shared/components/src/utils/internal/locale/index.ts b/shared/components/src/utils/internal/locale/index.ts new file mode 100644 index 0000000..e4165a9 --- /dev/null +++ b/shared/components/src/utils/internal/locale/index.ts @@ -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, 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; +} diff --git a/shared/components/src/utils/makeSafeTick.ts b/shared/components/src/utils/makeSafeTick.ts new file mode 100644 index 0000000..f9ea8c2 --- /dev/null +++ b/shared/components/src/utils/makeSafeTick.ts @@ -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` otherwise TS hints +// will suggest removing the await. +// See @remarks for reason to disable `then` +type TickType = () => Omit, 'then'> | Promise; + +type SafeTickCallback = (tick: TickType) => Promise; + +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) => { + 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; + } + }; +}; diff --git a/shared/components/src/utils/memoize.ts b/shared/components/src/utils/memoize.ts new file mode 100644 index 0000000..a5e07ef --- /dev/null +++ b/shared/components/src/utils/memoize.ts @@ -0,0 +1,26 @@ +// eslint-disable-next-line import/prefer-default-export +export function memoize( + fn: (...args: T) => S, + hashFn: (...args: unknown[]) => string = JSON.stringify, + entryLimit = 5, +): (...args: T) => S { + const cache: Map = 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; + }; +} diff --git a/shared/components/src/utils/rafQueue.ts b/shared/components/src/utils/rafQueue.ts new file mode 100644 index 0000000..a56d9a7 --- /dev/null +++ b/shared/components/src/utils/rafQueue.ts @@ -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; +}; diff --git a/shared/components/src/utils/sanitize-html/browser.ts b/shared/components/src/utils/sanitize-html/browser.ts new file mode 100644 index 0000000..ad8b804 --- /dev/null +++ b/shared/components/src/utils/sanitize-html/browser.ts @@ -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); +} diff --git a/shared/components/src/utils/sanitize-html/common.ts b/shared/components/src/utils/sanitize-html/common.ts new file mode 100644 index 0000000..38b3b2e --- /dev/null +++ b/shared/components/src/utils/sanitize-html/common.ts @@ -0,0 +1,176 @@ +type AllowedTags = Set; + +interface AllowedAttributes { + [tagName: string]: Set; +} + +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

and ONLY the role and + * aria-hidden attributes for

. + */ + 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; +}; diff --git a/shared/components/src/utils/sanitize.ts b/shared/components/src/utils/sanitize.ts new file mode 100644 index 0000000..107a543 --- /dev/null +++ b/shared/components/src/utils/sanitize.ts @@ -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