diff --git a/packages/core/package.json b/packages/core/package.json index 9c9bdf7..b773762 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,6 +38,10 @@ "require": "./lib/api/icons.cjs", "import": "./lib/api/icons.mjs" }, + "./lib/api/loaders": { + "require": "./lib/api/loaders.cjs", + "import": "./lib/api/loaders.mjs" + }, "./lib/api/modules": { "require": "./lib/api/modules.cjs", "import": "./lib/api/modules.mjs" diff --git a/packages/core/src/api/icons.ts b/packages/core/src/api/icons.ts index 058894d..efe7c81 100644 --- a/packages/core/src/api/icons.ts +++ b/packages/core/src/api/icons.ts @@ -1,6 +1,6 @@ import type { IconifyIcon, IconifyJSON } from '@iconify/types'; import { - IconifyIconName, + type IconifyIconName, matchIconName, stringToIcon, } from '@iconify/utils/lib/icon/name'; @@ -93,32 +93,31 @@ function parseLoaderResponse( icons: string[], data: unknown ) { - const fail = () => { + function checkMissing() { + const pending = storage.pendingIcons; icons.forEach((name) => { - storage.missing.add(name); + // Remove added icons from pending list + if (pending) { + pending.delete(name); + } + + // Mark as missing if icon is not in storage + if (!storage.icons[name]) { + storage.missing.add(name); + } }); - }; + } // Check for error - if (typeof data !== 'object' || !data) { - fail(); - } else { + if (data && typeof data === 'object') { // Add icons to storage try { const parsed = addIconSet(storage, data as IconifyJSON); if (!parsed.length) { - fail(); + checkMissing(); return; } - // Remove added icons from pending list - const pending = storage.pendingIcons; - if (pending) { - parsed.forEach((name) => { - pending.delete(name); - }); - } - // Cache API response storeInBrowserStorage(storage, data as IconifyJSON); } catch (err) { @@ -126,6 +125,9 @@ function parseLoaderResponse( } } + // Check for some icons from request were not in response: mark as missing + checkMissing(); + // Trigger update on next tick loadedNewIcons(storage); } @@ -149,15 +151,35 @@ function loadNewIcons(storage: IconStorageWithAPI, icons: string[]): void { const { provider, prefix } = storage; // Get icons and delete queue - // Icons should not be undefined, but just in case assume it can be const icons = storage.iconsToLoad; delete storage.iconsToLoad; + if (!icons || !icons.length) { + // Icons should not be undefined or empty, but just in case check it + return; + } - // TODO: check for custom loader + // Check for custom loader + if (storage.customLoader) { + const response = storage.customLoader(icons, prefix, provider); + if (response instanceof Promise) { + // Custom loader is async + response + .then((data) => { + parseLoaderResponse(storage, icons, data); + }) + .catch(() => { + parseLoaderResponse(storage, icons, null); + }); + } else { + // Sync loader + parseLoaderResponse(storage, icons, response); + } + return; + } // Using API loader // Validate icon names for API - const { valid, invalid } = checkIconNamesForAPI(icons || []); + const { valid, invalid } = checkIconNamesForAPI(icons); if (invalid.length) { // Invalid icons diff --git a/packages/core/src/api/loaders.ts b/packages/core/src/api/loaders.ts new file mode 100644 index 0000000..7c406f3 --- /dev/null +++ b/packages/core/src/api/loaders.ts @@ -0,0 +1,15 @@ +import { getStorage } from '../storage/storage.js'; +import type { IconifyCustomLoader, IconStorageWithAPI } from './types.js'; + +/** + * Set custom loader + */ +export function setCustomLoader( + loader: IconifyCustomLoader, + prefix: string, + provider?: string +): void { + // Assign loader directly to storage + (getStorage(provider || '', prefix) as IconStorageWithAPI).customLoader = + loader; +} diff --git a/packages/core/src/api/query.ts b/packages/core/src/api/query.ts index a834904..d91e505 100644 --- a/packages/core/src/api/query.ts +++ b/packages/core/src/api/query.ts @@ -7,13 +7,13 @@ import type { import { initRedundancy } from '@iconify/api-redundancy'; import { getAPIModule, - IconifyAPIQueryParams, - IconifyAPISendQuery, + type IconifyAPIQueryParams, + type IconifyAPISendQuery, } from './modules'; import { createAPIConfig, - IconifyAPIConfig, - PartialIconifyAPIConfig, + type IconifyAPIConfig, + type PartialIconifyAPIConfig, } from './config'; import { getAPIConfig } from './config'; diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts index 642e199..147abec 100644 --- a/packages/core/src/api/types.ts +++ b/packages/core/src/api/types.ts @@ -4,6 +4,16 @@ import type { } from './icons'; import type { SortedIcons } from '../icon/sort'; import type { IconStorage } from '../storage/storage'; +import { IconifyJSON } from '@iconify/types'; + +/** + * Custom icons loader + */ +export type IconifyCustomLoader = ( + icons: string[], + prefix: string, + provider: string +) => Promise | IconifyJSON | null; /** * Storage for callbacks @@ -26,6 +36,9 @@ export interface APICallbackItem { * Add custom stuff to storage */ export interface IconStorageWithAPI extends IconStorage { + // Custom loader + customLoader?: IconifyCustomLoader; + /** * List of icons that are being loaded, added to storage * diff --git a/packages/core/tests/api/custom-loader-test.ts b/packages/core/tests/api/custom-loader-test.ts new file mode 100644 index 0000000..d27840f --- /dev/null +++ b/packages/core/tests/api/custom-loader-test.ts @@ -0,0 +1,253 @@ +import { defaultIconProps } from '@iconify/utils'; +import { loadIcons, loadIcon } from '../../lib/api/icons'; +import { setCustomLoader } from '../../lib/api/loaders'; +import { listIcons } from '../../lib/storage/storage'; + +describe('Testing API loadIcons', () => { + let prefixCounter = 0; + function nextPrefix(): string { + prefixCounter++; + return `loader-test-${prefixCounter < 10 ? '0' : ''}${prefixCounter}`; + } + + it('Custom async loader with loadIcon', () => { + return new Promise((resolve, reject) => { + const provider = nextPrefix(); + const prefix = nextPrefix(); + + // Set loader + setCustomLoader( + (icons, requestedPrefix, requestedProvider) => { + return new Promise((resolve, reject) => { + try { + // Check params + expect(icons).toEqual(['icon1']); + expect(requestedPrefix).toBe(prefix); + expect(requestedProvider).toBe(provider); + } catch (err) { + reject(err); + return; + } + + // Send data + resolve({ + prefix, + icons: { + icon1: { + body: '', + }, + icon_2: { + body: '', + }, + }, + }); + }); + }, + prefix, + provider + ); + + // Load icon + loadIcon(provider + ':' + prefix + ':icon1') + .then((data) => { + try { + // Test response + expect(data).toEqual({ + ...defaultIconProps, + body: '', + }); + + // Check storage + expect(listIcons(provider, prefix)).toEqual([ + `@${provider}:${prefix}:icon1`, + `@${provider}:${prefix}:icon_2`, + ]); + } catch (err) { + reject(err); + return; + } + resolve(true); + }) + .catch(reject); + }); + }); + + it('Custom sync loader with loadIcon', () => { + return new Promise((resolve, reject) => { + const provider = nextPrefix(); + const prefix = nextPrefix(); + + // Set loader + setCustomLoader( + (icons, requestedPrefix, requestedProvider) => { + try { + // Check params + expect(icons).toEqual(['Icon_1']); + expect(requestedPrefix).toBe(prefix); + expect(requestedProvider).toBe(provider); + } catch (err) { + reject(err); + return null; + } + + // Send data + return { + prefix, + icons: { + // Use name that is not allowed in API + Icon_1: { + body: '', + }, + }, + }; + }, + prefix, + provider + ); + + // Load icon + loadIcon(`${provider}:${prefix}:Icon_1`) + .then((data) => { + try { + // Test response + expect(data).toEqual({ + ...defaultIconProps, + body: '', + }); + + // Check storage + expect(listIcons(provider, prefix)).toEqual([ + `@${provider}:${prefix}:Icon_1`, + ]); + } catch (err) { + reject(err); + return; + } + resolve(true); + }) + .catch(reject); + }); + }); + + it('Missing icons', () => { + return new Promise((resolve, reject) => { + const provider = nextPrefix(); + const prefix1 = nextPrefix(); + const prefix2 = nextPrefix(); + + // Set loaders: one is sync, one is async + setCustomLoader( + (icons, requestedPrefix, requestedProvider) => { + try { + // Check params + expect(icons).toEqual(['icon1']); + expect(requestedPrefix).toBe(prefix1); + expect(requestedProvider).toBe(provider); + } catch (err) { + reject(err); + return null; + } + + // Send data + return { + prefix: prefix1, + icons: { + icon1: { + body: '', + }, + }, + }; + }, + prefix1, + provider + ); + setCustomLoader( + (icons, requestedPrefix, requestedProvider) => { + try { + // Check params + expect(icons).toEqual(['BadIcon', 'Icon_2']); + expect(requestedPrefix).toBe(prefix2); + expect(requestedProvider).toBe(provider); + } catch (err) { + reject(err); + return null; + } + + // Send data asynchronously, without 'BadIcon' + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + prefix: prefix2, + icons: { + Icon_1: { + body: '', + }, + Icon_2: { + body: '', + }, + Icon_3: { + body: '', + }, + }, + }); + }, 150); + }); + }, + prefix2, + provider + ); + + // Load icon + loadIcons( + [ + `${provider}:${prefix1}:icon1`, + `${provider}:${prefix2}:Icon_2`, + `${provider}:${prefix2}:BadIcon`, + ], + (loaded, missing, pending) => { + if (pending.length) { + // Could be called before all icons are loaded because of async loader + return; + } + + try { + // Test response + expect(loaded).toEqual([ + { + provider, + prefix: prefix1, + name: 'icon1', + }, + { + provider, + prefix: prefix2, + name: 'Icon_2', + }, + ]); + expect(missing).toEqual([ + { + provider, + prefix: prefix2, + name: 'BadIcon', + }, + ]); + + // Check storage + expect(listIcons(provider, prefix1)).toEqual([ + `@${provider}:${prefix1}:icon1`, + ]); + expect(listIcons(provider, prefix2)).toEqual([ + `@${provider}:${prefix2}:Icon_1`, + `@${provider}:${prefix2}:Icon_2`, + `@${provider}:${prefix2}:Icon_3`, + ]); + } catch (err) { + reject(err); + return; + } + resolve(true); + } + ); + }); + }); +}); diff --git a/packages/core/tests/api/loading-test.ts b/packages/core/tests/api/loading-test.ts index 8c5d581..a08df9a 100644 --- a/packages/core/tests/api/loading-test.ts +++ b/packages/core/tests/api/loading-test.ts @@ -12,11 +12,7 @@ describe('Testing API loadIcons', () => { let prefixCounter = 0; function nextPrefix(): string { prefixCounter++; - return ( - 'api-load-test-' + - (prefixCounter < 10 ? '0' : '') + - prefixCounter.toString() - ); + return `api-load-test-${prefixCounter < 10 ? '0' : ''}${prefixCounter}`; } it('Loading few icons', () => { @@ -311,9 +307,9 @@ describe('Testing API loadIcons', () => { expect(loadedIcon).toBe(false); // Test isPending - // After change to naming convention, icon name is valid and should be pending - // Filtering invalid names is done in loader, not in API module - expect(isPending({ provider, prefix, name: 'BadIconName' })).toBe(true); + expect(isPending({ provider, prefix, name: 'BadIconName' })).toBe( + false + ); }); it('Loading one icon twice with Promise', () => { @@ -551,7 +547,6 @@ describe('Testing API loadIcons', () => { callback: QueryModuleResponse ): void => { queryCounter++; - params; switch (queryCounter) { case 1: // First call on api1