diff --git a/packages/utils/package-lock.json b/packages/utils/package-lock.json index 9da4729..50d5b6d 100644 --- a/packages/utils/package-lock.json +++ b/packages/utils/package-lock.json @@ -17,6 +17,7 @@ "local-pkg": "^0.4.0" }, "devDependencies": { + "@iconify-json/flat-color-icons": "^1.0.2", "@iconify/library-builder": "^1.0.5", "@types/debug": "^4.1.7", "@types/jest": "^27.0.1", @@ -740,6 +741,15 @@ "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==", "dev": true }, + "node_modules/@iconify-json/flat-color-icons": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@iconify-json/flat-color-icons/-/flat-color-icons-1.1.0.tgz", + "integrity": "sha512-UWbgi+4T0wkOQHNKxfxfGiDevGPrgD369kzC/dHGaz6WJxlwqxxRQvb6L8tTVCLdwIcmcNCV1Wy0SKgX0R/pHw==", + "dev": true, + "dependencies": { + "@iconify/types": "^1.0.12" + } + }, "node_modules/@iconify/library-builder": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@iconify/library-builder/-/library-builder-1.0.5.tgz", @@ -5951,6 +5961,15 @@ "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==", "dev": true }, + "@iconify-json/flat-color-icons": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@iconify-json/flat-color-icons/-/flat-color-icons-1.1.0.tgz", + "integrity": "sha512-UWbgi+4T0wkOQHNKxfxfGiDevGPrgD369kzC/dHGaz6WJxlwqxxRQvb6L8tTVCLdwIcmcNCV1Wy0SKgX0R/pHw==", + "dev": true, + "requires": { + "@iconify/types": "^1.0.12" + } + }, "@iconify/library-builder": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@iconify/library-builder/-/library-builder-1.0.5.tgz", diff --git a/packages/utils/package.json b/packages/utils/package.json index b3bfbe3..0ced1a3 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -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" @@ -148,6 +160,10 @@ "require": "./lib/svg/build.js", "import": "./lib/svg/build.mjs" }, + "./lib/svg/encode-svg-for-css": { + "require": "./lib/svg/encode-svg-for-css.js", + "import": "./lib/svg/encode-svg-for-css.mjs" + }, "./lib/svg/id": { "require": "./lib/svg/id.js", "import": "./lib/svg/id.mjs" @@ -166,6 +182,7 @@ "local-pkg": "^0.4.0" }, "devDependencies": { + "@iconify-json/flat-color-icons": "^1.0.2", "@iconify/library-builder": "^1.0.5", "@types/debug": "^4.1.7", "@types/jest": "^27.0.1", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 495ae24..badee3e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -39,6 +39,7 @@ export { convertIconSetInfo } from './icon-set/convert-info'; export { iconToSVG } from './svg/build'; export { replaceIDs } from './svg/id'; export { calculateSize } from './svg/size'; +export { encodeSvgForCss } from './svg/encode-svg-for-css'; // Colors export { colorKeywords } from './colors/keywords'; @@ -50,12 +51,14 @@ export type { CustomCollections, IconCustomizer, IconCustomizations, + IconifyLoaderOptions, InlineCollection, } from './loader/types'; -export { tryInstallPkg, mergeIconProps } from './loader/utils'; +export { mergeIconProps } from './loader/utils'; +export { isNode, 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'; diff --git a/packages/utils/src/loader/custom.ts b/packages/utils/src/loader/custom.ts index 84e3ed4..321a6c2 100644 --- a/packages/utils/src/loader/custom.ts +++ b/packages/utils/src/loader/custom.ts @@ -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 { let result: string | undefined | null; @@ -29,24 +25,19 @@ export async function getCustomIcon( } if (result) { - if (!result.startsWith('> = {}; +const isLegacyExists = isPackageExists('@iconify/json'); + +export async function loadCollectionFromFS(name: string, autoInstall = false): Promise { + 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; + } + } +} diff --git a/packages/utils/src/loader/install-pkg.ts b/packages/utils/src/loader/install-pkg.ts new file mode 100644 index 0000000..282b1e9 --- /dev/null +++ b/packages/utils/src/loader/install-pkg.ts @@ -0,0 +1,42 @@ +import { installPackage } from '@antfu/install-pkg'; +import { sleep } from '@antfu/utils'; +import { cyan, yellow } from 'kolorist'; + +const warned = new Set(); + +export function warnOnce(msg: string): void { + if (!warned.has(msg)) { + warned.add(msg); + console.warn(yellow(`[@iconify-loader] ${msg}`)); + } +} + +let pending: Promise | undefined; +const tasks: Record | undefined> = {}; + +export async function tryInstallPkg(name: string): Promise { + 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]!; +} diff --git a/packages/utils/src/loader/loader.ts b/packages/utils/src/loader/loader.ts new file mode 100644 index 0000000..830a799 --- /dev/null +++ b/packages/utils/src/loader/loader.ts @@ -0,0 +1,67 @@ +import { getCustomIcon } from './custom'; +import { searchForIcon } from './modern'; +import { warnOnce } from './install-pkg'; +import type { IconifyLoaderOptions } from './types'; + +export const isNode = typeof process < 'u' && typeof process.stdout < 'u' + +export async function loadIcon( + collection: string, + icon: string, + options?: IconifyLoaderOptions +): Promise { + const custom = options?.customCollections?.[collection]; + + if (custom) { + const result = await getCustomIcon(custom, collection, icon, options); + if (result) { + return result; + } + } + + if (!isNode) { + return undefined; + } + + return await loadNodeBuiltinIcon(collection, icon, options); +} + +async function importFsModule(): Promise { + 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 { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { loadCollectionFromFS } = await importFsModule(); + const iconSet = await 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?`); + } +} + diff --git a/packages/utils/src/loader/modern.ts b/packages/utils/src/loader/modern.ts index de9983a..a5b5f51 100644 --- a/packages/utils/src/loader/modern.ts +++ b/packages/utils/src/loader/modern.ts @@ -1,70 +1,22 @@ -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> = {}; -const isLegacyExists = isPackageExists('@iconify/json'); - -export async function loadCollection( - name: string, - autoInstall = false -): Promise { - if (!_collections[name]) { - _collections[name] = task(); - } - - return _collections[name]; - - async function task(): Promise { - 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 { let iconData: FullIconifyIcon | null; - const { - customize, - additionalProps = {}, - iconCustomizer, - } = iconCustomizations || {}; + const { customize } = options?.customizations ?? {}; for (const id of ids) { iconData = getIconData(iconSet, id, true); if (iconData) { @@ -77,12 +29,12 @@ export async function searchForIcon( : defaultCustomizations ); return await mergeIconProps( - `${body}`, + // DON'T remove space on + `${body}`, collection, id, - additionalProps, + options, () => attributes, - iconCustomizer ); } } diff --git a/packages/utils/src/loader/types.ts b/packages/utils/src/loader/types.ts index 5784f3d..ce0018c 100644 --- a/packages/utils/src/loader/types.ts +++ b/packages/utils/src/loader/types.ts @@ -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,59 @@ 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 = { + /** + * Add svg and xlink xml namespace when necessary. + * + * @default false + */ + addXmlNs?: boolean + + /** + * Scale of icons against 1em + */ + 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 + + /** + * 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 +} diff --git a/packages/utils/src/loader/utils.ts b/packages/utils/src/loader/utils.ts index 8caf7aa..fec837b 100644 --- a/packages/utils/src/loader/utils.ts +++ b/packages/utils/src/loader/utils.ts @@ -1,66 +1,59 @@ -import { installPackage } from '@antfu/install-pkg'; -import { Awaitable, sleep } from '@antfu/utils'; -import { cyan, yellow } from 'kolorist'; -import type { IconCustomizer } from './types'; - -const warned = new Set(); - -export function warnOnce(msg: string): void { - if (!warned.has(msg)) { - warned.add(msg); - console.warn(yellow(`[@iconify-loader] ${msg}`)); - } -} - -let pending: Promise | undefined; -const tasks: Record | undefined> = {}; +import type { Awaitable } from '@antfu/utils'; +import type { IconifyLoaderOptions } from './types'; export async function mergeIconProps( svg: string, collection: string, icon: string, - additionalProps: Record, + options?: IconifyLoaderOptions, propsProvider?: () => Awaitable>, - iconCustomizer?: IconCustomizer ): Promise { + const { scale, addXmlNs = false } = options ?? {} + const { + additionalProps = {}, + iconCustomizer, + } = options?.customizations ?? {}; const props: Record = (await propsProvider?.()) ?? {}; + if (!svg.includes(" width=") && !svg.includes(" height=") && typeof scale === 'number') { + if ((typeof props.width === 'undefined' || props.width === null) && (typeof props.height === 'undefined' || props.height === null)) { + props.width = `${scale}em`; + props.height = `${scale}em`; + } + } + await iconCustomizer?.(collection, icon, props); Object.keys(additionalProps).forEach((p) => { const v = additionalProps[p]; if (v !== undefined && v !== null) props[p] = v; }); - const replacement = svg.startsWith(' `${p}="${props[p]}"`) - .join(' ')}` + // add xml namespaces if necessary + if (addXmlNs) { + // add svg xmlns if missing + if (!svg.includes(' xmlns=') && !props['xmlns']) { + props['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:') && !props['xmlns:xlink']) { + props['xmlns:xlink'] = 'http://www.w3.org/1999/xlink'; + } + } + + svg = svg.replace( + ' `${p}="${props[p]}"`).join(' ')}` ); -} -export async function tryInstallPkg(name: string): Promise { - if (pending) { - await pending; + if (svg && options) { + const { defaultStyle, defaultClass } = options + // additional props and iconCustomizer takes precedence + if (defaultClass && !svg.includes(' class=')) { + svg = svg.replace(' 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]!; + return svg; } diff --git a/packages/utils/src/svg/encode-svg-for-css.ts b/packages/utils/src/svg/encode-svg-for-css.ts new file mode 100644 index 0000000..845e549 --- /dev/null +++ b/packages/utils/src/svg/encode-svg-for-css.ts @@ -0,0 +1,17 @@ +// https://bl.ocks.org/jennyknuth/222825e315d45a738ed9d6e04c7a88d0 +export function encodeSvgForCss(svg: string): string { + let useSvg = svg.startsWith('') ? svg.replace('', '') : svg; + if (!useSvg.includes(' xmlns:xlink=') && useSvg.includes(' xlink:')) { + useSvg = useSvg.replace('/g, '%3E'); +} diff --git a/packages/utils/tests/get-custom-icon-test.ts b/packages/utils/tests/get-custom-icon-test.ts index f2ae0fe..bbf43cb 100644 --- a/packages/utils/tests/get-custom-icon-test.ts +++ b/packages/utils/tests/get-custom-icon-test.ts @@ -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(' -1).toBeTruthy(); expect(result && result.indexOf('height="1em"') > -1).toBeTruthy(); diff --git a/packages/utils/tests/iconify-icon-test.ts b/packages/utils/tests/iconify-icon-test.ts new file mode 100644 index 0000000..75a284a --- /dev/null +++ b/packages/utils/tests/iconify-icon-test.ts @@ -0,0 +1,59 @@ +import { loadIcon } from '../lib'; + +describe('Testing loadIcon with @iconify-json/flat-color-icons>', () => { + + test('loadIcon works', async () => { + const result = await loadIcon('flat-color-icons', 'up-right'); + expect(result).toBeTruthy(); + }); + + test('loadIcon adds xmlns:xlink', async () => { + const result = await loadIcon('flat-color-icons', 'up-right', { addXmlNs: true }); + expect(result).toBeTruthy(); + expect(result && result.indexOf('xmlns:xlink=') > - 1).toBeTruthy(); + }); + + test('loadIcon with customize with default style and class', async () => { + const result = await loadIcon('flat-color-icons', 'up-right', { + defaultStyle: 'margin-top: 1rem;', + defaultClass: 'clazz', + customizations: { + customize(props) { + props.width = '2em'; + props.height = '2em'; + return props; + }, + } + }); + expect(result).toBeTruthy(); + expect(result && result.indexOf('margin-top: 1rem;') > - 1).toBeTruthy(); + expect(result && result.indexOf('class="clazz"') > - 1).toBeTruthy(); + expect(result && result.indexOf('width="2em"') > - 1).toBeTruthy(); + expect(result && result.indexOf('height="2em"') > - 1).toBeTruthy(); + }); + + test('loadIcon preserves customizations order', async () => { + const result = await loadIcon('flat-color-icons', 'up-right', { + scale: 1, + defaultStyle: 'color: red;', + defaultClass: 'clazz1', + customizations: { + additionalProps: { + 'width': '2em', + 'height': '2em', + 'style': 'color: blue;', + 'class': 'clazz2', + }, + // it will never be called, it is not a custom icon + transform(icon) { + return icon.replace(' { + 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(' -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); + }); +});