diff --git a/src/components/icon/icon.ts b/src/components/icon/icon.ts index f41969602..0c68b5e8b 100644 --- a/src/components/icon/icon.ts +++ b/src/components/icon/icon.ts @@ -97,12 +97,10 @@ export default class WaIcon extends WebAwesomeElement { } private getIconSource(): string | undefined { - if (this.src) { - return this.src; - } + let ref = this.src ?? this.name; - if (this.name) { - return this.iconLibrary?.getUrl(this.name, this.family, this.variant); + if (ref) { + return this.iconLibrary?.getUrl(ref, this.family, this.variant); } return undefined; @@ -133,14 +131,17 @@ export default class WaIcon extends WebAwesomeElement { async setIcon() { this.iconLibrary ??= getIconLibrary(this.src ? 'custom' : this.library); - const url = this.getIconSource(); + let { src, name, family, variant } = this; + let ref = src ?? name; - if (!url || !this.iconLibrary) { + const url = ref ? this.iconLibrary?.getUrl(ref, family, variant) : undefined; + + if (!ref || !url || !this.iconLibrary) { this.svg = null; return; } - let iconResolver = this.iconLibrary.getElement(url); + let iconResolver = this.iconLibrary.getElement(ref, family, variant); // If we haven't rendered yet, exit early. This avoids unnecessary work due to watching multiple props. if (!this.initialRender) { diff --git a/src/components/icon/library.default.ts b/src/components/icon/library.default.ts index 679015e2d..638ef4c22 100644 --- a/src/components/icon/library.default.ts +++ b/src/components/icon/library.default.ts @@ -1,4 +1,4 @@ -import type { IconLibraryCache, UnregisteredIconLibrary } from './library.js'; +import type { IconLibraryCacheDeep, UnregisteredIconLibrary } from './library.js'; let kitCode = ''; @@ -80,7 +80,7 @@ function getIconUrl(name: string, family: string, variant: string) { } // Icons used for internal components, prefetched for speed -export const fetched: IconLibraryCache<2> = { +export const inlined: IconLibraryCacheDeep = { classic: { solid: { check: ``, @@ -110,8 +110,9 @@ export const fetched: IconLibraryCache<2> = { const library: UnregisteredIconLibrary = { name: 'default', getUrl: getIconUrl, - getKey: url => url.replace(/\?token=[^&]+/, ''), - inlined: fetched, + // Cache icons using the free URL + getCacheKey: url => url.replace(/\?token=[^&]+/, '').replace(/ka-p\./, 'ka-f.'), + inlined, }; export default library; diff --git a/src/components/icon/library.ts b/src/components/icon/library.ts index fb94b346e..dc8eeaffc 100644 --- a/src/components/icon/library.ts +++ b/src/components/icon/library.ts @@ -3,7 +3,9 @@ export const CACHEABLE_ERROR = Symbol('CACHEABLE_ERROR'); export const RETRYABLE_ERROR = Symbol('RETRYABLE_ERROR'); // 410: Gone -export const CACHEABLE_HTTP_ERRORS = [403, 404, 410]; +// NOTE: Resist the temptation to add 403 and 404 to this list. +// We may get them before a token is added, and we need to be able to retry. +export const CACHEABLE_HTTP_ERRORS = [410]; let parser: DOMParser; @@ -13,15 +15,14 @@ export default class IconLibrary { readonly name: string; readonly mutator?: IconLibraryMutator; - readonly getKey?: IconLibraryGetKey; - readonly system?: IconLibrarySystemResolver; + readonly system?: IconMapping; readonly spriteSheet?: boolean; /** Inlined markup, keyed by URL */ - inlined: IconLibraryCache<0> = {}; + inlined: IconLibraryCacheFlat = {}; /** DOM nodes, keyed by URL */ - elements: Record = {}; + cache: Record = {}; constructor(library: UnregisteredIconLibrary) { // Store library definition @@ -30,7 +31,6 @@ export default class IconLibrary { // Copy certain properties this.name = library.name; this.mutator = library.mutator; - this.getKey = library.getKey; this.system = library.system; this.spriteSheet = library.spriteSheet; @@ -42,19 +42,31 @@ export default class IconLibrary { /** * Convert an icon name, family, and variant into a URL */ - getUrl(name: string, family: string, variant: string) { + getUrl(name: string, family?: string, variant?: string) { + // console.warn('getUrl', name, family, variant); if (name.startsWith('system:')) { name = name.slice(7); if (this.system) { let resolved = this.system(name, family, variant); - name = resolved.name ?? name; - family = resolved.family ?? family; - variant = resolved.variant ?? variant; + + if (resolved) { + name = resolved.name ?? name; + family = resolved.family ?? family; + variant = resolved.variant ?? variant; + } } } - return this.spec.getUrl?.(name, family, variant); + if (this.spec.getUrl) { + return this.spec.getUrl(name, family, variant); + } + + return name; + } + + getCacheKey(url: string) { + return this.spec.getCacheKey?.(url) ?? url; } /** @@ -65,12 +77,14 @@ export default class IconLibrary { return ``; } - let cacheKey = this.getKey?.(url) ?? url; - let markup: string | typeof CACHEABLE_ERROR | undefined = this.inlined?.[cacheKey]; + let cacheKey = this.getCacheKey(url); + let markup = this.inlined[cacheKey]; if (!markup) { return fetchIcon(url).then(markup => { - if (typeof markup === 'string' || markup === CACHEABLE_ERROR) { + if (typeof markup === 'string') { + // TBD: Should we add to inlined? DOM nodes are cached anyway, perhaps that’s enough? + // Or perhaps we should go the other way and cache CACHEABLE_ERROR too? this.inlined[cacheKey] = markup; } @@ -82,19 +96,51 @@ export default class IconLibrary { } /** - * Given a URL, this function returns the resulting SVG element or an appropriate error symbol. + * Given a name, family, and variant, this function returns the resulting SVG element or an appropriate error symbol. + * If the icon library defines fallbacks, they will be tried in order. */ - async getElement(url: string): Promise { - if (this.elements[url]) { - return this.elements[url]; + async getElement( + name: string, + family?: string, + variant?: string, + ): Promise { + let url = this.getUrl(name, family, variant); + let cacheKey = this.getCacheKey(url); + + if (this.cache[cacheKey]) { + return this.cache[cacheKey]; } let markup = await this.getMarkup(url); + let result; if (markup === CACHEABLE_ERROR || markup === RETRYABLE_ERROR) { - return markup; + result = markup; + } else { + result = await this.getElementFromMarkup(markup); } + if (result === CACHEABLE_ERROR || result === RETRYABLE_ERROR) { + if (this.spec.fallback) { + // Try again with fallback + let fallback = this.spec.fallback(name, family, variant); + if (fallback) { + return this.getElement(fallback.name, fallback.family, fallback.variant); + } + } + + if (result === CACHEABLE_ERROR) { + this.cache[cacheKey] = result; + } + } + + return result; + } + + /** + * Given a URL, this function synchronously returns the resulting SVG element or an appropriate error symbol. + */ + getElementFromMarkup(markup: string): SVGElement | typeof CACHEABLE_ERROR | typeof RETRYABLE_ERROR { let svgEl; try { const div = document.createElement('div'); @@ -121,20 +167,14 @@ export default class IconLibrary { } } catch {} - const result = svgEl ?? CACHEABLE_ERROR; - this.elements[url] = result; - return result; - } - - fallback(url: string) { - // TODO implement this + return svgEl ?? CACHEABLE_ERROR; } /** * Convert the deep family → variant → icon name → markup cache that is more convenient to write out manually * to the flat URL → markup cache that icon libraries use internally **/ - inline(cache: IconLibraryFetched) { + inline(cache: IconLibraryCacheDeep) { // If no getUrl function was provided, this library does not use names, // so this should already be a flat URL → markup mapping let flatCache = cache; @@ -148,55 +188,43 @@ export default class IconLibrary { let name = path.pop()!; let [family, variant] = path; let url = this.getUrl(name as string, family as string, variant as string); - let key = this.getKey?.(url!) ?? url; - return key as string; + return this.getCacheKey(url!); }, - }) as IconLibraryCache<0>; + }) as IconLibraryCacheFlat; } Object.assign(this.inlined, flatCache); } } -export type IconLibraryResolver = (name: string, family: string, variant: string) => string; -export type IconLibrarySystemResolver = ( +export type IconLibraryResolver = (name: string, family?: string, variant?: string) => string; +export type IconMapping = ( name: string, - family: string, - variant: string, -) => { name: string; family?: string; variant?: string }; + family?: string, + variant?: string, +) => { name: string; family?: string; variant?: string; library?: string } | undefined; export type IconLibraryGetKey = (name: string) => string; export type IconLibraryMutator = (svg: SVGElement) => void; export type IconFetchedResult = string | typeof CACHEABLE_ERROR | typeof RETRYABLE_ERROR; -// This is a utility for decrementing a number up to 3 by one -// e.g., Decrement[3] yields 2 -type Decrement = [never, 0, 1, 2]; - -/** - * 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 | typeof CACHEABLE_ERROR : IconLibraryCache ->; - -export type IconLibraryFetched = IconLibraryCache<0> | IconLibraryCache<1> | IconLibraryCache<2>; +export type IconLibraryCacheFlat = Record; +export type IconLibraryCacheDeep = + | IconLibraryCacheFlat + | Record + | Record>; export interface UnregisteredIconLibrary { name: string; getUrl?: IconLibraryResolver; - system?: IconLibrarySystemResolver; + system?: IconMapping; + fallback?: IconMapping; mutator?: IconLibraryMutator; - getKey?: IconLibraryGetKey; + getCacheKey?: IconLibraryGetKey; spriteSheet?: boolean; // Max depth: family → variant → icon name → markup // but may be shallower for libraries that don't use variants or families - inlined?: IconLibraryFetched; + inlined?: IconLibraryCacheDeep; } export async function fetchIcon(url: string) { @@ -208,7 +236,7 @@ export async function fetchIcon(url: string) { } return fileData.text(); - } catch { + } catch (e) { return RETRYABLE_ERROR; } } diff --git a/src/components/icon/registry.ts b/src/components/icon/registry.ts index eb72a41f1..b2ec1e5d1 100644 --- a/src/components/icon/registry.ts +++ b/src/components/icon/registry.ts @@ -5,13 +5,13 @@ import IconLibrary, { RETRYABLE_ERROR, fetchIcon, type IconFetchedResult, - type IconLibraryCache, - type IconLibraryFetched, + type IconLibraryCacheDeep, + type IconLibraryCacheFlat, type UnregisteredIconLibrary, } from './library.js'; export { CACHEABLE_ERROR, RETRYABLE_ERROR, fetchIcon }; -export type { IconFetchedResult, IconLibrary, IconLibraryCache, IconLibraryFetched, UnregisteredIconLibrary }; +export type { IconFetchedResult, IconLibrary, IconLibraryCacheDeep, IconLibraryCacheFlat, UnregisteredIconLibrary }; let registry: IconLibrary[] = []; let watchedIcons: WaIcon[] = [];