diff --git a/src/components/icon/library.default.ts b/src/components/icon/library.default.ts index 81d20b9bc..aac2eb1c7 100644 --- a/src/components/icon/library.default.ts +++ b/src/components/icon/library.default.ts @@ -39,28 +39,30 @@ function getIconUrl(name: string, family: string, variant: string) { } // Icons used for internal components, prefetched for speed -export const fetched: IconLibraryCache<1, 3> = { - solid: { - check: ``, - 'chevron-down': ``, - 'chevron-left': ``, - 'chevron-right': ``, - circle: ``, - 'eye-dropper': ``, - 'grip-vertical': ``, - indeterminate: ``, - minus: ``, - pause: ``, - play: ``, - star: ``, - user: ``, - xmark: ``, - }, - regular: { - 'circle-xmark': ``, - copy: ``, - eye: ``, - 'eye-slash': ``, +export const fetched: IconLibraryCache<2> = { + classic: { + solid: { + check: ``, + 'chevron-down': ``, + 'chevron-left': ``, + 'chevron-right': ``, + circle: ``, + 'eye-dropper': ``, + 'grip-vertical': ``, + indeterminate: ``, + minus: ``, + pause: ``, + play: ``, + star: ``, + user: ``, + xmark: ``, + }, + regular: { + 'circle-xmark': ``, + copy: ``, + eye: ``, + 'eye-slash': ``, + }, }, }; diff --git a/src/components/icon/library.ts b/src/components/icon/library.ts index 8a74ec508..fbdcc7665 100644 --- a/src/components/icon/library.ts +++ b/src/components/icon/library.ts @@ -1,9 +1,15 @@ -import { deepEntries } from '../../utilities/deep.js'; +import { flatten } from '../../utilities/deep.js'; import type WaIcon from '../icon/icon.js'; import defaultLibrary from './library.default.js'; -import type { IconLibrary, IconLibraryCache, IconLibraryResolver, UnregisteredIconLibrary } from './types.d.ts'; +import type { + IconLibrary, + IconLibraryCache, + IconLibraryFetched, + IconLibraryResolver, + UnregisteredIconLibrary, +} from './types.d.ts'; -export type { IconLibrary, IconLibraryCache, UnregisteredIconLibrary } from './types.d.ts'; +export type { IconLibrary, IconLibraryCache, IconLibraryFetched, UnregisteredIconLibrary } from './types.d.ts'; let registry: IconLibrary[] = []; let watchedIcons: WaIcon[] = []; @@ -34,10 +40,10 @@ export function registerIconLibrary(name: string, options: UnregisteredIconLibra resolver: options.resolver, mutator: options.mutator, spriteSheet: options.spriteSheet, - fetched: flattenIconLibraryCache(options.resolver, options.fetched), - addFetched(cache: IconLibraryCache<1, 3>) { + fetched: options.fetched ? flattenIconLibraryCache(options.fetched, options.resolver) : {}, + addFetched(cache: IconLibraryFetched) { // Convert flat URL → markup cache to deep family → variant → icon name → markup cache - let flatCache = flattenIconLibraryCache(this.resolver, cache); + let flatCache = flattenIconLibraryCache(cache, this.resolver); this.fetched ??= {}; Object.assign(this.fetched, flatCache); }, @@ -51,21 +57,19 @@ export function registerIconLibrary(name: string, options: UnregisteredIconLibra }); } -/** Convert deep family → variant → icon name → markup cache to flat URL → markup cache */ -function flattenIconLibraryCache(resolver: IconLibraryResolver, cache?: IconLibraryCache<1, 3>): IconLibraryCache<1> { - if (!cache) { - return {}; - } - - return Object.fromEntries( - deepEntries(cache, { - transformPath: (path: string[]) => { - let name = path.pop()!; - let [family, variant] = path; - return [resolver(name, family, variant)]; - }, - }), - ); +/** + * Convert the deep family → variant → icon name → markup cache that is more convenient to provide + * to the flat URL → markup cache that icon libraries use internally + **/ +function flattenIconLibraryCache(cache: IconLibraryFetched, resolver: IconLibraryResolver): IconLibraryCache<0> { + return flatten(cache, { + getKey(path: string[]) { + // name is always the last value no matter the depth + let name = path.pop()!; + let [family, variant] = path; + return resolver(name, family, variant); + }, + }) as IconLibraryCache<0>; } /** Removes an icon library from the registry. */ diff --git a/src/components/icon/types.d.ts b/src/components/icon/types.d.ts index f0c9621ad..e250880e2 100644 --- a/src/components/icon/types.d.ts +++ b/src/components/icon/types.d.ts @@ -5,10 +5,19 @@ export type IconLibraryMutator = (svg: SVGElement) => void; // e.g., Decrement[3] yields 2 type Decrement = [never, 0, 1, 2]; -// Record of string → string or nested Record -export type IconLibraryCache = Min extends 0 - ? string | (Max extends 0 ? never : Record>) - : Record>; +/** + * Record of string → string or nested string → Record + * The number indicates how many params we have; none (0), just family (1), or family and style (2). + * IconLibraryCache<0> is Record (name → markup) + * IconLibraryCache<1> is Record> (family → name → markup) + * IconLibraryCache<2> is Record>> (family → variant → name → markup) + */ +export type IconLibraryCache = Record< + string, + N extends 0 ? string : IconLibraryCache +>; + +export type IconLibraryFetched = IconLibraryCache<0> | IconLibraryCache<1> | IconLibraryCache<2>; export interface UnregisteredIconLibrary { resolver: IconLibraryResolver; @@ -17,7 +26,7 @@ export interface UnregisteredIconLibrary { // Max depth: family → variant → icon name → markup // but may be shallower for libraries that don't use variants or families - fetched?: IconLibraryCache<1, 3>; + fetched?: IconLibraryFetched; } // Registered icon library @@ -28,6 +37,6 @@ export interface IconLibrary { spriteSheet?: boolean; // One level only: URL → markup - fetched?: IconLibraryCache<1>; - addFetched: (cache: IconLibraryCache<1, 3>) => void; + fetched?: IconLibraryCache<0>; + addFetched: (cache: IconLibraryFetched) => void; } diff --git a/src/utilities/deep.ts b/src/utilities/deep.ts index cfc9b3b06..e0320dbb4 100644 --- a/src/utilities/deep.ts +++ b/src/utilities/deep.ts @@ -1,6 +1,3 @@ -type Property = keyof any; -export type EachCallback = (value: any, key: Property, parent: object, path: Property[]) => any; - export function isPlainObject(obj: any) { return isObject(obj, 'Object'); } @@ -14,70 +11,149 @@ export function isObject(obj: any, type: string) { return proto.constructor?.name === type; } -/** - * Iterate over a deep array, recursively for plain objects - * @param obj The object to iterate over. Can be an array or a plain object, or even a primitive value. - * @param callback. value is === parent[key] - * @param parentObj The parent object of the current value Mainly used internally to facilitate recursion. - * @param key The key of the current value. Mainly used internally to facilitate recursion. - * @param path Any existing path (not including the key). Mainly used internally to facilitate recursion. - */ -export function deepEach(obj: any, callback: EachCallback, parentObj?: object, key?: Property, path: Property[] = []) { - if (key !== undefined) { - let ret = callback(obj, key, parentObj!, path); +type Property = keyof any; +export type EachCallback = (value: any, path: Property[], parent?: object) => T; +export type EachOptions = { + filter?: EachCallback; + descend?: EachCallback; +}; - if (ret !== undefined) { - if (ret === false) { - // Do not descend further - return; +/** + * Iterate over a deep object or array recursively + * + * @param obj The object to iterate over. Can be a plain object,or array, or even a primitive value. + * @param callback. The callback to execute for each value. Will not be called on circular references. + * Returning `false` will stop the iteration and a non-undefined value will overwrite the value. + */ +export function deepEach(obj: any, callback: EachCallback, options: EachOptions = {}): any { + // Used to track circular references + // WeakSet is used to avoid memory leaks + let visited = new WeakSet(); + + return _deepEach( + obj, + (value, path, parent) => { + if (value && typeof value === 'object') { + if (visited.has(value)) { + // Abort mission + return false; + } + + visited.add(value); } - // Overwrite value - // @ts-expect-error - parentObj[key] = ret; - obj = ret; + return callback(value, path, parent); + }, + options, + ); +} + +/** + * Private recursive function to support `deepEach()`. + * @param value Same as {@link deepEach} + * @param callback Same as {@link deepEach} + * @param path The path to `value` from the root object as an array of keys. + * @param parent The parent object of the current value. `value === parent[path.at(-1)]` + */ +function _deepEach(value: any, callback: EachCallback, options: EachOptions, path: Property[] = [], parent?: object) { + if (path.length > 0) { + let included = options.filter?.(value, path, parent) ?? true; + + if (included) { + let ret = callback(value, path, parent!); + + if (ret !== undefined) { + // Overwrite value + let key = path.at(-1); + // @ts-expect-error TS doesn't know that if path.length > 0, parent is an object + value = parent[key] = ret; + } } } - let newPath = key !== undefined ? [...path, key] : path; + let isArray = Array.isArray(value); + let isObject = !isArray && isPlainObject(value); + let isContainer = isArray || isObject; - if (Array.isArray(obj)) { - for (let i = 0; i < obj.length; i++) { - deepEach(obj[i], callback, obj, i, newPath); + if (isContainer) { + let descend = options.descend?.(value, path, parent) ?? true; + + if (!descend) { + // Do not descend further + return; } - } else if (isPlainObject(obj)) { - for (let key in obj) { - deepEach(obj[key], callback, obj, key, newPath); + + if (isArray) { + for (let i = 0; i < value.length; i++) { + _deepEach(value[i], callback, options, [...path, i], value); + } + } else if (isObject) { + for (let key in value) { + _deepEach(value[key], callback, options, [...path, key], value); + } } } } export type DeepEntriesOptions = { filter?: EachCallback; - transformPath?: (path: Property[]) => Property[]; }; /** * Like Object.entries, but for deeply nested objects. * For shallow objects the output is the same as Object.entries. + * Circular references will not be included. * @param obj The object to iterate over. * @param options.filter - If this returns false, the entry is not added to the result. - * @param options.transformPath - Transform the path, e.g. to serialize it to a single key. - * @returns Array of arrays. In each array the last value is the value, and all values before that are the keys. + * @param options.transformPath - Transform the path, e.g. to serialize it to a single key or reduce levels. + * @returns Array of arrays. In each item, the last value is the value, and all values before that are the keys. + * So for an object with N levels, the result will be an array of arrays with N+1 items each. */ export function deepEntries(obj: any, options: DeepEntriesOptions = {}): any[][] { - let { filter, transformPath } = options; + let { filter } = options; let entries: any[][] = []; - deepEach(obj, (value, key, parent, path) => { - let included = filter?.(value, key, parent, path) ?? true; + deepEach(obj, (value, path, parent) => { + let included = filter?.(value, path, parent) ?? true; if (included) { - let fullPath = [...path, key]; - path = transformPath?.(fullPath) ?? fullPath; - entries.push([...fullPath, value]); + entries.push([...path, value]); } }); return entries; } + +export type FlattenOptions = { + getKey?: (path: Property[], value?: any) => Property; +}; + +/** + * Convert a potentially deeply nested object to a flat object. + * Circular references will not be included. + * @param obj + * @param options.getKey - A function to transform the path to a key. The default is to join the path with '.'. + */ +export function flatten( + obj: Record, + options: FlattenOptions = {}, +): Record { + let { getKey = path => path.join('.') } = options; + + if (!obj || typeof obj !== 'object') { + return {} as Record; + } + + let entries = deepEntries(obj).map(pathAndValue => { + if (pathAndValue.length < 2) { + return null; + } + + let value = pathAndValue.pop() as T; + let path = pathAndValue as Property[]; + let key = getKey(path, value); + return [key, value]; + }) as [string, T][]; + + return Object.fromEntries(entries.filter(Boolean)) as Record; +}