- Signature
- `getKey` from URL
- Fix bugs with `deepEntries()` / `flatten()`
This commit is contained in:
Lea Verou
2025-05-02 17:32:44 -04:00
parent cff9d56b3f
commit 6306955c74
8 changed files with 140 additions and 91 deletions

View File

@@ -196,7 +196,8 @@ Here's an example that registers an icon library located in the `/assets/icons`
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('my-icons', {
registerIconLibrary({
name: 'my-icons',
resolver: (name, family, variant) => `/assets/icons/${name}.svg`,
mutator: svg => svg.setAttribute('fill', 'currentColor')
});
@@ -224,7 +225,8 @@ Icons in this library are licensed under the [MIT License](https://github.com/tw
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('default', {
registerIconLibrary({
name: 'default',
resolver: (name, family) => {
const suffix = family === 'filled' ? '-fill' : '';
return `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/${name}${suffix}.svg`
@@ -243,7 +245,8 @@ Icons in this library are licensed under the [Creative Commons 4.0 License](http
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('boxicons', {
registerIconLibrary({
name: 'boxicons',
resolver: name => {
let folder = 'regular';
if (name.substring(0, 4) === 'bxs-') folder = 'solid';
@@ -297,7 +300,8 @@ Icons in this library are licensed under the [MIT License](https://github.com/lu
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('lucide', {
registerIconLibrary({
name: 'lucide',
resolver: name => `https://cdn.jsdelivr.net/npm/lucide-static@0.16.29/icons/${name}.svg`
});
</script>
@@ -313,7 +317,8 @@ Icons in this library are licensed under the [MIT License](https://github.com/ta
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('heroicons', {
registerIconLibrary({
name: 'heroicons',
resolver: name => `https://cdn.jsdelivr.net/npm/heroicons@2.0.1/24/outline/${name}.svg`
});
</script>
@@ -338,7 +343,8 @@ Icons in this library are licensed under the [MIT License](https://github.com/lu
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('iconoir', {
registerIconLibrary({
name: 'iconoir',
resolver: name => `https://cdn.jsdelivr.net/gh/lucaburgio/iconoir@latest/icons/${name}.svg`
});
</script>
@@ -363,7 +369,8 @@ Icons in this library are licensed under the [MIT License](https://github.com/io
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('ionicons', {
registerIconLibrary({
name: 'ionicons',
resolver: name => `https://cdn.jsdelivr.net/npm/ionicons@5.1.2/dist/ionicons/svg/${name}.svg`,
mutator: svg => {
svg.setAttribute('fill', 'currentColor');
@@ -408,7 +415,8 @@ Icons in this library are licensed under the [MIT License](https://github.com/mi
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('jam', {
registerIconLibrary({
name: 'jam',
resolver: name => `https://cdn.jsdelivr.net/npm/jam-icons@2.0.0/svg/${name}.svg`,
mutator: svg => svg.setAttribute('fill', 'currentColor')
});
@@ -441,7 +449,8 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('material', {
registerIconLibrary({
name: 'material',
resolver: name => {
const match = name.match(/^(.*?)(_(round|sharp))?$/);
return `https://cdn.jsdelivr.net/npm/@material-icons/svg@1.0.5/svg/${match[1]}/${match[3] || 'outline'}.svg`;
@@ -484,7 +493,8 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('remixicon', {
registerIconLibrary({
name: 'remixicon',
resolver: name => {
const match = name.match(/^(.*?)\/(.*?)?$/);
match[1] = match[1].charAt(0).toUpperCase() + match[1].slice(1);
@@ -521,7 +531,8 @@ Icons in this library are licensed under the [MIT License](https://github.com/ta
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('tabler', {
registerIconLibrary({
name: 'tabler',
resolver: name => `https://cdn.jsdelivr.net/npm/@tabler/icons@1.68.0/icons/${name}.svg`,
mutator: svg => {
svg.style.fill = 'none';
@@ -557,7 +568,8 @@ Icons in this library are licensed under the [Apache 2.0 License](https://github
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('unicons', {
registerIconLibrary({
name: 'unicons',
resolver: name => {
const match = name.match(/^(.*?)(-s)?$/);
return `https://cdn.jsdelivr.net/npm/@iconscout/unicons@3.0.3/svg/${match[2] === '-s' ? 'solid' : 'line'}/${
@@ -595,7 +607,8 @@ For example, this will change the default icon library to use [Bootstrap Icons](
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('default', {
registerIconLibrary({
name: 'default',
resolver: (name, family) => {
const suffix = family === 'filled' ? '-fill' : '';
return `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/icons/${name}${suffix}.svg`
@@ -620,7 +633,8 @@ For security reasons, browsers may apply the same-origin policy on `<use>` eleme
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('sprite', {
registerIconLibrary({
name: 'sprite',
resolver: name => `/assets/images/sprite.svg#${name}`,
mutator: svg => svg.setAttribute('fill', 'currentColor'),
spriteSheet: true
@@ -642,7 +656,8 @@ This creates a mapping of `(name, library, variant)` to SVG markup:
<script type="module">
import { registerIconLibrary } from '/dist/webawesome.js';
registerIconLibrary('default', {
registerIconLibrary({
name: 'default',
resolver: name => `/path/to/custom/icons/${name}.svg`,
fetched: {
check: '<svg xmlns="http://www.w3.org/2000/svg">...</svg>',

View File

@@ -1412,7 +1412,8 @@ hasOutline: false
import { registerIconLibrary } from '/dist/webawesome.js';
// Ensure regular icons are always available for the knobs
registerIconLibrary('fa-classic-regular', {
registerIconLibrary({
name: 'fa-classic-regular',
resolver: name => `https://ka-f.fontawesome.com/releases/v6.5.1/svgs/regular/${name}.svg`
});
@@ -1453,36 +1454,47 @@ hasOutline: false
break;
case 'premium':
iconFamily.value = 'custom';
registerIconLibrary('default', {
registerIconLibrary({
name: 'default',
resolver: name => `/assets/icons/chunk/${name}.svg`,
mutator: svg => {[...svg.querySelectorAll('[fill="black"]')].map(el => el.setAttribute('fill', 'currentColor'));}
});
registerIconLibrary('system', {
registerIconLibrary({
name: 'system',
resolver: name => `/assets/icons/chunk/${name}.svg`,
mutator: svg => {[...svg.querySelectorAll('[fill="black"]')].map(el => el.setAttribute('fill', 'currentColor'));}
});
break;
case 'playful':
iconFamily.value = 'custom';
registerIconLibrary('default', {
registerIconLibrary({
name: 'default',
resolver: name => `/assets/icons/jelly/${name}.svg`,
mutator: svg => {[...svg.querySelectorAll('[fill="black"]')].map(el => el.setAttribute('fill', 'currentColor'));}
});
registerIconLibrary('system', {
registerIconLibrary({
name: 'system',
name: 'system',
name: 'system',
resolver: name => `/assets/icons/jelly/${name}.svg`,
mutator: svg => {[...svg.querySelectorAll('[fill="black"]')].map(el => el.setAttribute('fill', 'currentColor'));}
});
break;
case 'brutalist':
caregisterIconLibrary({
registerIconLibrary({
registerIconLibrary({
name: 'default',
iconFamily.value = 'custom';
registerIconLibrary('default', {
registerIconLibrary({
name: 'default',
resolver: name => `/assets/icons/utility/${name}.svg`,
mutator: svg => {
[...svg.querySelectorAll('[fill="black"]')].map(el => el.setAttribute('fill', 'currentColor'));
[...svg.querySelectorAll('[stroke="black"]')].map(el => el.setAttribute('stroke', 'currentColor'));
}
});
registerIconLibrary('system', {
registerIconLibrary({
name: 'system',
resolver: name => `/assets/icons/utility/${name}.svg`,
mutator: svg => {
[...svg.querySelectorAll('[fill="black"]')].map(el => el.setAttribute('fill', 'currentColor'));
@@ -1497,10 +1509,12 @@ hasOutline: false
break;
case 'classic':
iconFamily.value = 'custom';
registerIconLibrary('default', {
registerIconLibrary({
name: 'default',
resolver: name => `/assets/icons/bootstrap/${name}.svg`,
});
registerIconLibrary('system', {
registerIconLibrary({
name: 'system',
resolver: name => `/assets/icons/bootstrap/${name}.svg`,
});
break;
@@ -1531,7 +1545,8 @@ hasOutline: false
iconLibrary = 'sharp-solid';
}
// Ensures sharp-solid variations are available for ratings, etc.
registerIconLibrary('always-solid', {
registerIconLibrary({
name: 'always-solid',
resolver: name => `https://ka-f.fontawesome.com/releases/v6.5.1/svgs/sharp-solid/${name}.svg`
});
solidifyRatingStars();
@@ -1557,15 +1572,18 @@ hasOutline: false
iconLibrary = 'solid';
}
// Ensures solid variations are available for radios, ratings, etc.
registerIconLibrary('always-solid', {
registerIconLibrary({
name: 'always-solid',
resolver: name => `https://ka-f.fontawesome.com/releases/v6.5.1/svgs/solid/${name}.svg`
});
solidifyRatingStars();
}
registerIconLibrary('default', {
registerIconLibrary({
name: 'default',
resolver: name => `https://ka-f.fontawesome.com/releases/v6.5.1/svgs/${iconLibrary}/${name}.svg`
});
registerIconLibrary('system', {
registerIconLibrary({
name: 'system',
resolver: name => `https://ka-f.fontawesome.com/releases/v6.5.1/svgs/${iconLibrary}/${name}.svg`
});
};

View File

@@ -24,7 +24,8 @@ const testLibraryIcons = {
describe('<wa-icon>', () => {
before(() => {
registerIconLibrary('test-library', {
registerIconLibrary({
name: 'test-library',
resolver: (name: keyof typeof testLibraryIcons) => {
// only for testing a bad request
if (name === ('bad-request' as keyof typeof testLibraryIcons)) {
@@ -170,7 +171,8 @@ describe('<wa-icon>', () => {
describe('svg sprite sheets', () => {
// TODO: this test is skipped because Bootstrap sprite.svg doesn't seem to be available in CI. Will fix in a future PR. [Konnor]
it.skip('Should properly grab an SVG and render it from bootstrap icons', async () => {
registerIconLibrary('sprite', {
registerIconLibrary({
name: 'sprite',
resolver: name => `/docs/assets/images/sprite.svg#${name}`,
mutator: svg => svg.setAttribute('fill', 'currentColor'),
spriteSheet: true,
@@ -197,7 +199,8 @@ describe('<wa-icon>', () => {
});
it('Should render nothing if the sprite hash is wrong', async () => {
registerIconLibrary('sprite', {
registerIconLibrary({
name: 'sprite',
resolver: name => `/docs/assets/images/sprite.svg#${name}`,
mutator: svg => svg.setAttribute('fill', 'currentColor'),
spriteSheet: true,
@@ -224,7 +227,8 @@ describe('<wa-icon>', () => {
// TODO: <use> svg icons don't emit a "load" or "error" event...if we can figure out how to get the event to emit errors.
// Once we figure out how to emit errors / loading perhaps we can actually test this?
it.skip("Should produce an error if the icon doesn't exist.", async () => {
registerIconLibrary('sprite', {
registerIconLibrary({
name: 'sprite',
resolver(name) {
return `/docs/assets/images/sprite.svg#${name}`;
},

View File

@@ -136,15 +136,22 @@ export default class WaIcon extends WebAwesomeElement {
return this.svg;
}
let markup: string;
let cacheKey = library?.getKey?.(url) ?? url;
let markup: string | undefined = library?.fetched?.[cacheKey];
if (library?.fetched?.[url]) {
markup = library.fetched[url] as string;
} else {
if (!markup) {
try {
fileData = await fetch(url, { mode: 'cors' });
if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
markup = await fileData.text();
if (fileData.ok) {
markup = await fileData.text();
if (library) {
library.fetched![cacheKey] = markup;
}
} else {
return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR;
}
} catch {
return RETRYABLE_ERROR;
}

View File

@@ -1,5 +1,5 @@
import { getKitCode } from '../../utilities/base-path.js';
import type { IconLibrary, IconLibraryCache } from './types.d.ts';
import type { IconLibraryCache, UnregisteredIconLibrary } from './types.d.ts';
function getIconUrl(name: string, family: string, variant: string) {
const kitCode = getKitCode();
@@ -66,12 +66,10 @@ export const fetched: IconLibraryCache<2> = {
},
};
const library: IconLibrary = {
const library: UnregisteredIconLibrary = {
name: 'default',
resolver: (name: string, family = 'classic', variant = 'solid') => {
return getIconUrl(name, family, variant);
},
// @ts-expect-error
resolver: getIconUrl,
getKey: url => url.replace(/\?token=[^&]+/, ''),
fetched,
};

View File

@@ -14,7 +14,7 @@ export type { IconLibrary, IconLibraryCache, IconLibraryFetched, UnregisteredIco
let registry: IconLibrary[] = [];
let watchedIcons: WaIcon[] = [];
registerIconLibrary('default', defaultLibrary);
registerIconLibrary(defaultLibrary);
/** Adds an icon to the list of watched icons. */
export function watchIcon(icon: WaIcon) {
@@ -32,26 +32,26 @@ export function getIconLibrary(name?: string) {
}
/** Adds an icon library to the registry, or overrides an existing one. */
export function registerIconLibrary(name: string, options: UnregisteredIconLibrary) {
unregisterIconLibrary(name);
export function registerIconLibrary(library: UnregisteredIconLibrary) {
unregisterIconLibrary(library.name);
registry.push({
name,
resolver: options.resolver,
mutator: options.mutator,
spriteSheet: options.spriteSheet,
fetched: options.fetched ? flattenIconLibraryCache(options.fetched, options.resolver) : {},
let registeredLibrary: IconLibrary = {
...library,
fetched: {},
addFetched(cache: IconLibraryFetched) {
// Convert flat URL → markup cache to deep family → variant → icon name → markup cache
let flatCache = flattenIconLibraryCache(cache, this.resolver);
this.fetched ??= {};
Object.assign(this.fetched, flatCache);
return addFetched.call(this, cache);
},
});
};
if (library.fetched) {
registeredLibrary.addFetched(library.fetched);
}
registry.push(registeredLibrary);
// Redraw watched icons
watchedIcons.forEach(icon => {
if (icon.library === name) {
if (icon.library === library.name) {
icon.setIcon();
}
});
@@ -61,15 +61,23 @@ export function registerIconLibrary(name: string, options: UnregisteredIconLibra
* 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, {
function addFetched(this: IconLibrary | UnregisteredIconLibrary, cache: IconLibraryFetched) {
let { resolver, getKey } = this;
// Convert flat URL → markup cache to deep family → variant → icon name → markup cache
let flatCache = 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);
let url = resolver(name, family, variant);
let key = getKey?.(url) ?? url;
return key;
},
}) as IconLibraryCache<0>;
this.fetched ??= {};
Object.assign(this.fetched, flatCache);
}
/** Removes an icon library from the registry. */

View File

@@ -1,4 +1,5 @@
export type IconLibraryResolver = (name: string, family: string, variant: string) => string;
export type IconLibraryGetKey = (name: string) => string;
export type IconLibraryMutator = (svg: SVGElement) => void;
// This is a utility for decrementing a number up to 3 by one
@@ -20,8 +21,10 @@ export type IconLibraryCache<N extends number = 0> = Record<
export type IconLibraryFetched = IconLibraryCache<0> | IconLibraryCache<1> | IconLibraryCache<2>;
export interface UnregisteredIconLibrary {
name: string;
resolver: IconLibraryResolver;
mutator?: IconLibraryMutator;
getKey?: IconLibraryGetKey;
spriteSheet?: boolean;
// Max depth: family → variant → icon name → markup
@@ -30,12 +33,7 @@ export interface UnregisteredIconLibrary {
}
// Registered icon library
export interface IconLibrary {
name: string;
resolver: IconLibraryResolver;
mutator?: IconLibraryMutator;
spriteSheet?: boolean;
export interface IconLibrary extends UnregisteredIconLibrary {
// One level only: URL → markup
fetched?: IconLibraryCache<0>;
addFetched: (cache: IconLibraryFetched) => void;

View File

@@ -95,10 +95,6 @@ function _deepEach(value: any, callback: EachCallback, options: EachOptions, pat
}
}
export type DeepEntriesOptions = {
filter?: EachCallback;
};
/**
* Like Object.entries, but for deeply nested objects.
* For shallow objects the output is the same as Object.entries.
@@ -109,17 +105,20 @@ export type DeepEntriesOptions = {
* @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 } = options;
export function deepEntries(obj: any, options?: EachOptions): any[][] {
let entries: any[][] = [];
deepEach(
obj,
(value, path) => {
if (Array.isArray(value) || isPlainObject(value)) {
// We only want leaf values
return;
}
deepEach(obj, (value, path, parent) => {
let included = filter?.(value, path, parent) ?? true;
if (included) {
entries.push([...path, value]);
}
});
},
options,
);
return entries;
}
@@ -144,16 +143,18 @@ export function flatten<T = unknown>(
return {} as Record<keyof any, T>;
}
let entries = deepEntries(obj).map(pathAndValue => {
if (pathAndValue.length < 2) {
return null;
}
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][];
let value = pathAndValue.pop() as T;
let path = pathAndValue as Property[];
let key = getKey(path, value);
return [key, value];
})
.filter(Boolean) as [string, T][];
return Object.fromEntries(entries.filter(Boolean)) as Record<keyof any, T>;
return Object.fromEntries(entries) as Record<keyof any, T>;
}