2
0
mirror of https://github.com/iconify/iconify.git synced 2024-12-12 21:57:50 +00:00

feat: split modern loader for node and browser support

This commit is contained in:
Joaquín Sánchez Jiménez 2022-02-26 15:12:13 +01:00
parent 035d2fe305
commit ef22738574
11 changed files with 313 additions and 106 deletions

View File

@ -124,6 +124,18 @@
"require": "./lib/loader/custom.js",
"import": "./lib/loader/custom.mjs"
},
"./lib/loader/fs": {
"require": "./lib/loader/fs.js",
"import": "./lib/loader/fs.mjs"
},
"./lib/loader/install-pkg": {
"require": "./lib/loader/install-pkg.js",
"import": "./lib/loader/install-pkg.mjs"
},
"./lib/loader/loader": {
"require": "./lib/loader/loader.js",
"import": "./lib/loader/loader.mjs"
},
"./lib/loader/loaders": {
"require": "./lib/loader/loaders.js",
"import": "./lib/loader/loaders.mjs"

View File

@ -50,12 +50,14 @@ export type {
CustomCollections,
IconCustomizer,
IconCustomizations,
IconifyLoaderOptions,
InlineCollection,
} from './loader/types';
export { tryInstallPkg, mergeIconProps } from './loader/utils';
export { encodeCssSvg, mergeIconProps } from './loader/utils';
export { loadIcon } from './loader/loader';
export { FileSystemIconLoader } from './loader/loaders';
export { getCustomIcon } from './loader/custom';
export { loadCollection, searchForIcon } from './loader/modern';
export { searchForIcon } from './loader/modern';
// Misc
export { camelize, camelToKebab, pascalize } from './misc/strings';

View File

@ -1,9 +1,5 @@
import createDebugger from 'debug';
import type {
CustomIconLoader,
IconCustomizations,
InlineCollection,
} from './types';
import type { CustomIconLoader, IconifyLoaderOptions, InlineCollection } from './types';
import { mergeIconProps } from './utils';
const debug = createDebugger('@iconify-loader:custom');
@ -15,7 +11,7 @@ export async function getCustomIcon(
custom: CustomIconLoader | InlineCollection,
collection: string,
icon: string,
iconsCustomizations?: IconCustomizations
options?: IconifyLoaderOptions,
): Promise<string | undefined> {
let result: string | undefined | null;
@ -29,7 +25,7 @@ export async function getCustomIcon(
}
if (result) {
if (!result.startsWith('<svg ')) {
if (!result.startsWith('<svg')) {
console.warn(
`Custom icon "${icon}" in "${collection}" is not a valid SVG`
);
@ -39,14 +35,14 @@ export async function getCustomIcon(
transform,
additionalProps = {},
iconCustomizer,
} = iconsCustomizations || {};
} = options?.customizations ?? {};
return await mergeIconProps(
transform ? await transform(result) : result,
collection,
icon,
additionalProps,
undefined,
iconCustomizer
iconCustomizer,
);
}
}

View File

@ -0,0 +1,39 @@
import { promises as fs, Stats } from 'fs';
import { isPackageExists, resolveModule } from 'local-pkg'
import type { IconifyJSON } from '@iconify/types'
import { tryInstallPkg } from './install-pkg';
const _collections: Record<string, Promise<IconifyJSON | undefined>> = {};
const isLegacyExists = isPackageExists('@iconify/json');
export async function loadCollectionFromFS(name: string, autoInstall = false): Promise<IconifyJSON | undefined> {
if (!_collections[name]) {
_collections[name] = task();
}
return _collections[name];
async function task() {
let jsonPath = resolveModule(`@iconify-json/${name}/icons.json`);
if (!jsonPath && isLegacyExists) {
jsonPath = resolveModule(`@iconify/json/json/${name}.json`);
}
if (!jsonPath && !isLegacyExists && autoInstall) {
await tryInstallPkg(`@iconify-json/${name}`);
jsonPath = resolveModule(`@iconify-json/${name}/icons.json`);
}
let stat: Stats | undefined;
try {
stat = jsonPath ? await fs.lstat(jsonPath) : undefined;
} catch (err) {
return undefined;
}
if (stat && stat.isFile()) {
return JSON.parse(await fs.readFile(jsonPath as string, 'utf8')) as IconifyJSON;
}
else {
return undefined;
}
}
}

View File

@ -0,0 +1,42 @@
import { installPackage } from '@antfu/install-pkg';
import { sleep } from '@antfu/utils';
import { cyan, yellow } from 'kolorist';
const warned = new Set<string>();
export function warnOnce(msg: string): void {
if (!warned.has(msg)) {
warned.add(msg);
console.warn(yellow(`[@iconify-loader] ${msg}`));
}
}
let pending: Promise<void> | undefined;
const tasks: Record<string, Promise<void> | undefined> = {};
export async function tryInstallPkg(name: string): Promise<void | undefined> {
if (pending) {
await pending;
}
if (!tasks[name]) {
// eslint-disable-next-line no-console
console.log(cyan(`Installing ${name}...`));
tasks[name] = pending = installPackage(name, {
dev: true,
preferOffline: true,
})
.then(() => sleep(300))
// eslint-disable-next-line
.catch((e: any) => {
warnOnce(`Failed to install ${name}`);
console.error(e);
})
.finally(() => {
pending = undefined;
});
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return tasks[name]!;
}

View File

@ -0,0 +1,65 @@
import { getCustomIcon } from './custom';
import { isNode } from './utils';
import { searchForIcon } from './modern';
import { warnOnce } from './install-pkg';
import type { IconifyLoaderOptions } from './types';
export async function loadIcon(
collection: string,
icon: string,
options?: IconifyLoaderOptions
): Promise<string | undefined> {
const custom = options?.customCollections?.[collection];
if (custom) {
const result = await getCustomIcon(custom, collection, icon, options);
if (result) {
return result;
}
}
return await loadNodeBuiltinIcon(collection, icon, options);
}
async function importFsModule(): Promise<typeof import('./fs') | undefined> {
try {
return await import('./fs');
} catch {
try {
// cjs environments
return require('./fs.js');
}
catch {
return undefined;
}
}
}
async function loadNodeBuiltinIcon(
collection: string,
icon: string,
options?: IconifyLoaderOptions,
warn = true,
): Promise<string | undefined> {
if (isNode) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { loadCollectionFromFS } = await importFsModule();
const iconSet = loadCollectionFromFS(collection, options?.autoInstall);
if (iconSet) {
// possible icon names
const ids = [
icon,
icon.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(),
icon.replace(/([a-z])(\d+)/g, '$1-$2'),
];
return await searchForIcon(iconSet, collection, ids, options);
}
if (warn) {
warnOnce(`failed to load \`@iconify-json/${collection}\`, have you installed it?`);
}
}
}

View File

@ -1,70 +1,26 @@
import { promises as fs } from 'fs';
import type { IconifyJSON } from '@iconify/types';
import type { FullIconifyIcon } from '../icon';
import { iconToSVG } from '../svg/build';
import { getIconData } from '../icon-set/get-icon';
import { mergeIconProps, tryInstallPkg } from './utils';
import { mergeIconProps } from './utils';
import createDebugger from 'debug';
import { isPackageExists, resolveModule } from 'local-pkg';
import { defaults as DefaultIconCustomizations } from '../customisations';
import type { IconCustomizations } from './types';
import type { IconifyLoaderOptions } from './types';
const debug = createDebugger('@iconify-loader:icon');
const debugModern = createDebugger('@iconify-loader:modern');
const debugLegacy = createDebugger('@iconify-loader:legacy');
const _collections: Record<string, Promise<IconifyJSON | undefined>> = {};
const isLegacyExists = isPackageExists('@iconify/json');
export async function loadCollection(
name: string,
autoInstall = false
): Promise<IconifyJSON | undefined> {
if (!_collections[name]) {
_collections[name] = task();
}
return _collections[name];
async function task(): Promise<IconifyJSON | undefined> {
let jsonPath = resolveModule(`@iconify-json/${name}/icons.json`);
if (jsonPath) {
debugModern(name);
}
if (!jsonPath && isLegacyExists) {
jsonPath = resolveModule(`@iconify/json/json/${name}.json`);
if (jsonPath) {
debugLegacy(name);
}
}
if (!jsonPath && !isLegacyExists && autoInstall) {
await tryInstallPkg(`@iconify-json/${name}`);
jsonPath = resolveModule(`@iconify-json/${name}/icons.json`);
}
if (jsonPath) {
return JSON.parse(await fs.readFile(jsonPath, 'utf8'));
} else {
debugModern(`failed to load ${name}`);
return undefined;
}
}
}
export async function searchForIcon(
iconSet: IconifyJSON,
collection: string,
ids: string[],
iconCustomizations?: IconCustomizations
options?: IconifyLoaderOptions,
): Promise<string | undefined> {
let iconData: FullIconifyIcon | null;
const {
customize,
additionalProps = {},
iconCustomizer,
} = iconCustomizations || {};
} = options?.customizations ?? {};
for (const id of ids) {
iconData = getIconData(iconSet, id, true);
if (iconData) {

View File

@ -56,7 +56,7 @@ export type IconCustomizations = {
};
/**
* List of icons as object. Key is icon name, value is icon data or callback (can be async) to get icon data
* List of icons as object. Key is the icon name, the value is the icon data or callback (can be async) to get icon data
*/
export type InlineCollection = Record<
string,
@ -64,9 +64,54 @@ export type InlineCollection = Record<
>;
/**
* Collection of custom icons. Key is collection name, value is loader or InlineCollection object
* Collection of custom icons. Key is the collection name, the value is the loader or InlineCollection object
*/
export type CustomCollections = Record<
string,
CustomIconLoader | InlineCollection
>;
/**
* Options to use with the modern loader.
*/
export type IconifyLoaderOptions = {
/**
* Scale of icons against 1em
*
* @default 1.2
*/
scale?: number
/**
* Style to apply to icons by default
*
* @default ''
*/
defaultStyle?: string
/**
* Class names to apply to icons by default
*
* @default ''
*/
defaultClass?: string
/**
* Loader for custom loaders
*/
customCollections?: Record<string, CustomIconLoader | InlineCollection>
/**
* Icon customizer
*/
customizations?: IconCustomizations
/**
* Auto install icon sources package when the usages is detected
*
* **WARNING**: only on `node` environment, on `browser` this option will be ignored
*
* @default false
*/
autoInstall?: boolean
}

View File

@ -1,19 +1,7 @@
import { installPackage } from '@antfu/install-pkg';
import { Awaitable, sleep } from '@antfu/utils';
import { cyan, yellow } from 'kolorist';
import type { Awaitable } from '@antfu/utils';
import type { IconCustomizer } from './types';
const warned = new Set<string>();
export function warnOnce(msg: string): void {
if (!warned.has(msg)) {
warned.add(msg);
console.warn(yellow(`[@iconify-loader] ${msg}`));
}
}
let pending: Promise<void> | undefined;
const tasks: Record<string, Promise<void> | undefined> = {};
export const isNode = typeof process < 'u' && typeof process.stdout < 'u'
export async function mergeIconProps(
svg: string,
@ -29,38 +17,28 @@ export async function mergeIconProps(
const v = additionalProps[p];
if (v !== undefined && v !== null) props[p] = v;
});
// add svg xmlns if missing
if (!svg.includes(' xmlns=') && !additionalProps['xmlns']) {
additionalProps['xmlns'] = 'http://www.w3.org/2000/svg';
}
// add xmlns:xlink if xlink present and the xmlns missing
if (!svg.includes('xmlns:xlink') && svg.includes('xlink:') && !additionalProps['xmlns:xlink']) {
additionalProps['xmlns:xlink'] = 'http://www.w3.org/1999/xlink';
}
const replacement = svg.startsWith('<svg ') ? '<svg ' : '<svg';
return svg.replace(
replacement,
`${replacement}${Object.keys(props)
.map((p) => `${p}="${props[p]}"`)
.join(' ')}`
`${replacement}${Object.keys(props).map((p) => `${p}="${props[p]}"`).join(' ')}`
);
}
export async function tryInstallPkg(name: string): Promise<void | undefined> {
if (pending) {
await pending;
}
if (!tasks[name]) {
// eslint-disable-next-line no-console
console.log(cyan(`Installing ${name}...`));
tasks[name] = pending = installPackage(name, {
dev: true,
preferOffline: true,
})
.then(() => sleep(300))
// eslint-disable-next-line
.catch((e: any) => {
warnOnce(`Failed to install ${name}`);
console.error(e);
})
.finally(() => {
pending = undefined;
});
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return tasks[name]!;
// https://bl.ocks.org/jennyknuth/222825e315d45a738ed9d6e04c7a88d0
export function encodeCssSvg(svg: string): string {
return svg.replace(/"/g, '\'')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
.replace(/{/g, '%7B')
.replace(/}/g, '%7D')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}

View File

@ -13,9 +13,11 @@ describe('Testing getCustomIcon', () => {
test('CustomIconLoader with transform', async () => {
const svg = await fs.readFile(fixturesDir + '/circle.svg', 'utf8');
const result = await getCustomIcon(() => svg, 'a', 'b', {
transform(icon) {
return icon.replace('<svg ', '<svg width="1em" height="1em" ');
},
customizations: {
transform(icon) {
return icon.replace('<svg ', '<svg width="1em" height="1em" ');
},
}
});
expect(result && result.indexOf('width="1em"') > -1).toBeTruthy();
expect(result && result.indexOf('height="1em"') > -1).toBeTruthy();

View File

@ -0,0 +1,70 @@
import { promises as fs } from 'fs';
import { CustomIconLoader, loadIcon } from '../lib';
const fixturesDir = __dirname + '/fixtures';
const loader: CustomIconLoader = async(name) => {
return await fs.readFile(`${fixturesDir}/${name}.svg`, 'utf8');
}
describe('Testing loadIcon', () => {
test('CustomCollection', async () => {
const svg = await loader('circle');
expect(svg).toBeTruthy()
const result = await loadIcon('a', 'circle', {
customCollections: {
'a': {
'circle': svg as string,
},
},
});
expect(result).toBeTruthy()
expect(svg).toEqual(result);
});
test('CustomCollection with transform', async () => {
const svg = await loader('circle')
expect(svg).toBeTruthy()
const result = await loadIcon('a', 'circle', {
customCollections: {
'a': {
'circle': svg as string,
},
},
customizations: {
transform(icon) {
return icon.replace('<svg ', '<svg width="1em" height="1em" ');
},
}
});
expect(result).toBeTruthy()
expect(result && result.indexOf('width="1em"') > -1).toBeTruthy();
expect(result && result.indexOf('height="1em"') > -1).toBeTruthy();
});
test('CustomCollection Icon with XML heading', async () => {
const svg = await loader('1f3eb')
expect(svg).toBeTruthy()
// Intercept console.warn
let warned = false;
const warn = console.warn;
console.warn = (/*...args*/) => {
// warn.apply(this, args);
warned = true;
};
const result = await loadIcon('a', '1f3eb', {
customCollections: {
'a': {
'1f3eb': svg as string,
},
},
});
// Restore console.warn
console.warn = warn;
expect(svg).toEqual(result);
expect(warned).toEqual(true);
});
});