diff --git a/.gitignore b/.gitignore index 49f145962..343f9357c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ packages/**/src/icons/*.js packages/**/src/icons/*.ts packages/**/src/icons/*.tsx packages/**/src/aliases.ts +packages/**/src/dynamicIconImports.ts packages/**/LICENSE categories.json tags.json diff --git a/docs/guide/packages/lucide-react.md b/docs/guide/packages/lucide-react.md index 969e54311..b049abcc4 100644 --- a/docs/guide/packages/lucide-react.md +++ b/docs/guide/packages/lucide-react.md @@ -67,6 +67,8 @@ It is possible to create one generic icon component to load icons. It's not reco ::: danger Example below importing all ES Modules, caution using this example. All icons will be imported. When using bundlers like: `Webpack`, `Rollup` or `Vite` the application build size will grow strongly and harming the performance the application. + +This is not the case for the latest NextJS, because it uses server side rendering. The icons will be streamed to the client when needed. For NextJS with Dynamic Imports, see [dynamic imports](#nextjs-example) section for more information. ::: ### Icon Component Example @@ -94,3 +96,59 @@ const App = () => { export default App; ``` + +#### With Dynamic Imports + +> :warning: This is experimental and only works with bundlers that support dynamic imports. + +Lucide react exports a dynamic import map `dynamicIconImports`. Useful for applications that want to show icons dynamically by icon name. For example when using a content management system with where icon names are stored in a database. + +When using client side rendering, it will fetch the icon component when it's needed. This will reduce the initial bundle size. + +The keys of the dynamic import map are the lucide original icon names (kebab case). + +Example with React suspense: + +```tsx +import React, { lazy, Suspense } from 'react'; +import { dynamicIconImports, LucideProps } from 'lucide-react'; + +const fallback =
+ +interface IconProps extends Omit { + name: keyof typeof dynamicIconImports; +} + +const Icon = ({ name, ...props }: IconProps) => { + const LucideIcon = lazy(dynamicIconImports[name]); + + return ( + + + + ); +} + +export default Icon +``` + +##### NextJS Example + +In NextJS [the dynamic function](https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading#nextdynamic) can be used to load the icon component dynamically. + +```tsx +import dynamic from 'next/dynamic' +import { dynamicIconImports, LucideProps } from 'lucide-react'; + +interface IconProps extends LucideProps { + name: keyof typeof dynamicIconImports; +} + +const Icon = ({ name, ...props }: IconProps) => { + const LucideIcon = dynamic(dynamicIconImports[name]) + + return ; +}; + +export default Icon; +``` diff --git a/packages/lucide-react/README.md b/packages/lucide-react/README.md index 4c3229d5a..557801446 100644 --- a/packages/lucide-react/README.md +++ b/packages/lucide-react/README.md @@ -60,7 +60,7 @@ It is possible to create a generic icon component to load icons. > :warning: The example below is importing all ES modules. This is **not** recommended when you using a bundler since your application build size will grow substantially. ```js -import * as icons from 'lucide-react'; +import { icons } from 'lucide-react'; const Icon = ({ name, color, size }) => { const LucideIcon = icons[name]; @@ -70,3 +70,59 @@ const Icon = ({ name, color, size }) => { export default Icon; ``` + +#### With Dynamic Imports + +> :warning: This is experimental and only works with bundlers that support dynamic imports. + +Lucide react exports a dynamic import map `dynamicIconImports`. Useful for applications that want to show icons dynamically by icon name. For example when using a content management system with where icon names are stored in a database. + +When using client side rendering, it will fetch the icon component when it's needed. This will reduce the initial bundle size. + +The keys of the dynamic import map are the lucide original icon names. + +Example with React suspense: + +```tsx +import React, { lazy, Suspense } from 'react'; +import { dynamicIconImports, LucideProps } from 'lucide-react'; + +const fallback =
+ +interface IconProps extends Omit { + name: keyof typeof dynamicIconImports; +} + +const Icon = ({ name, ...props }: IconProps) => { + const LucideIcon = lazy(dynamicIconImports[name]); + + return ( + + + + ); +} + +export default Icon +``` + +##### NextJS Example + +In NextJS [the dynamic function](https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading#nextdynamic) can be used to load the icon component dynamically. + +```tsx +import dynamic from 'next/dynamic' +import { dynamicIconImports, LucideProps } from 'lucide-react'; + +interface IconProps extends LucideProps { + name: keyof typeof dynamicIconImports; +} + +const Icon = ({ name, ...props }: IconProps) => { + const LucideIcon = dynamic(dynamicIconImports[name]) + + return ; +}; + +export default Icon; +``` diff --git a/packages/lucide-react/package.json b/packages/lucide-react/package.json index 2caa41aaa..7d13398dc 100644 --- a/packages/lucide-react/package.json +++ b/packages/lucide-react/package.json @@ -22,10 +22,10 @@ "dist" ], "scripts": { - "build": "pnpm clean && pnpm copy:license && pnpm build:icons && pnpm typecheck && pnpm build:bundles && pnpm build:types", + "build": "pnpm clean && pnpm copy:license && pnpm build:icons && pnpm typecheck && pnpm build:bundles", "copy:license": "cp ../../LICENSE ./LICENSE", "clean": "rm -rf dist && rm -rf stats && rm -rf ./src/icons/*.ts", - "build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mjs --renderUniqueKey --withAliases --aliasesFileExtension=.ts --iconFileExtension=.ts --exportFileName=index.ts", + "build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mjs --renderUniqueKey --withAliases --withDynamicImports --aliasesFileExtension=.ts --iconFileExtension=.ts --exportFileName=index.ts", "build:types": "node ./scripts/buildTypes.mjs", "build:bundles": "rollup -c ./rollup.config.mjs", "typecheck": "tsc", @@ -44,6 +44,7 @@ "react": "17.0.2", "react-dom": "17.0.2", "rollup": "^3.5.1", + "rollup-plugin-dts": "^5.0.0", "typescript": "^4.8.4", "vite": "^4.3.9", "vitest": "^0.32.2" diff --git a/packages/lucide-react/rollup.config.mjs b/packages/lucide-react/rollup.config.mjs index c96372808..3294d258a 100644 --- a/packages/lucide-react/rollup.config.mjs +++ b/packages/lucide-react/rollup.config.mjs @@ -1,5 +1,6 @@ import plugins, { replace } from '@lucide/rollup-plugins'; import pkg from './package.json' assert { type: 'json' }; +import dts from "rollup-plugin-dts"; const packageName = 'LucideReact'; const outputFileName = 'lucide-react'; @@ -21,19 +22,21 @@ const bundles = [ format: 'cjs', inputs, outputDir, - aliasesSupport: true + aliasesSupport: true, + withDynamicImports: true, }, { format: 'esm', inputs, outputDir, preserveModules: true, - aliasesSupport: true + aliasesSupport: true, + withDynamicImports: true, }, ]; const configs = bundles - .map(({ inputs, outputDir, format, minify, preserveModules, aliasesSupport }) => + .map(({ inputs, outputDir, format, minify, preserveModules, aliasesSupport, withDynamicImports }) => inputs.map(input => ({ input, plugins: [ @@ -47,6 +50,15 @@ const configs = bundles }), ] : [] ), + ...( + !withDynamicImports ? [ + replace({ + "export { default as dynamicIconImports } from './dynamicIconImports';": '', + delimiters: ['', ''], + preventAssignment: false, + }), + ] : [] + ), ...plugins(pkg, minify) ], external: ['react', 'prop-types'], @@ -71,4 +83,13 @@ const configs = bundles ) .flat(); -export default configs; +export default [ + { + input: inputs[0], + output: [{ + file: `dist/${outputFileName}.d.ts`, format: "es" + }], + plugins: [dts()], + }, + ...configs +]; diff --git a/packages/lucide-react/scripts/buildTypes.mjs b/packages/lucide-react/scripts/buildTypes.mjs deleted file mode 100644 index 86d8afa7f..000000000 --- a/packages/lucide-react/scripts/buildTypes.mjs +++ /dev/null @@ -1,92 +0,0 @@ -import path from 'path'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { getAliases } from '@lucide/build-icons'; -import { - readSvgDirectory, - resetFile, - toPascalCase, - writeFile, - getCurrentDirPath, -} from '../../../scripts/helpers.mjs'; - -const currentDir = getCurrentDirPath(import.meta.url); -const srcDirectory = path.join(currentDir, '../dist'); - -const writeDeclarationFile = (typesFile, directory, content) => { - resetFile(typesFile, directory); - writeFile(content, typesFile, directory); -}; - -const ICONS_DIR = path.resolve(currentDir, '../../../icons'); -const TYPES_FILE = 'lucide-react.d.ts'; - -let declarationFileContent = `\ -/// -import { SVGAttributes, FC, SVGProps, ReactSVG } from 'react' - -declare module 'lucide-react' - -// Create interface extending SVGProps -export interface LucideProps extends Partial> { - size?: string | number - absoluteStrokeWidth?: boolean -} - -export type LucideIcon = (props: LucideProps) => JSX.Element; - -export type IconNode = [elementName: keyof ReactSVG, attrs: Record][] - -export declare const createLucideIcon: (iconName: string, iconNode: IconNode) => LucideIcon; - -export type Icon = FC; - -// Generated icon -`; - -const svgFiles = readSvgDirectory(ICONS_DIR); - -svgFiles.forEach((svgFile) => { - const iconName = path.basename(svgFile, '.svg'); - const componentName = toPascalCase(iconName); - - declarationFileContent += `export declare const ${componentName}: LucideIcon;\n`; -}); - -const aliases = await getAliases(ICONS_DIR); - -declarationFileContent += `\n - -// Generated icon aliases -`; - -let aliasesCount = 0; - -svgFiles.forEach((svgFile) => { - const iconName = path.basename(svgFile, '.svg'); - const componentName = toPascalCase(iconName); - const iconAliases = aliases[iconName]?.aliases; - - declarationFileContent += `// ${componentName} aliases\n`; - declarationFileContent += `export declare const ${componentName}Icon: LucideIcon;\n`; - declarationFileContent += `export declare const Lucide${componentName}: LucideIcon;\n`; - aliasesCount += 1; - if (iconAliases != null && Array.isArray(iconAliases)) { - iconAliases.forEach((alias) => { - const componentNameAlias = toPascalCase(alias); - declarationFileContent += `export declare const ${componentNameAlias}: LucideIcon;\n`; - - aliasesCount += 1; - }); - } - - declarationFileContent += '\n'; -}); - -writeDeclarationFile(TYPES_FILE, srcDirectory, declarationFileContent); -console.log( - `Generated ${TYPES_FILE} file with`, - svgFiles.length, - 'icons and with', - aliasesCount, - 'aliases', -); diff --git a/packages/lucide-react/scripts/exportTemplate.mjs b/packages/lucide-react/scripts/exportTemplate.mjs index ee09feaab..b9c3d35af 100644 --- a/packages/lucide-react/scripts/exportTemplate.mjs +++ b/packages/lucide-react/scripts/exportTemplate.mjs @@ -1,7 +1,33 @@ -export default ({ componentName, children }) => ` +export default ({ componentName, iconName, children, getSvg }) => { + const svgContents = getSvg(); + + const svgBase64 = Buffer.from( + svgContents + .replace('\n', '') + .replace( + 'stroke="currentColor"', + 'stroke="#000" style="background-color: #fff; border-radius: 2px"', + ), + ).toString('base64'); + + // declarationFileContent += `\ + + return ` import createLucideIcon from '../createLucideIcon'; +/** + * @component @name ${componentName} + * @description Lucide SVG icon component, renders SVG Element with children. + * + * @preview ![img](data:image/svg+xml;base64,${svgBase64}) - https://lucide.dev/icons/${iconName} + * @see https://lucide.dev/guide/packages/lucide-react - Documentation + * + * @param {Object} props - Lucide icons props and any valid SVG attribute + * @returns {JSX.Element} JSX Element + * + */ const ${componentName} = createLucideIcon('${componentName}', ${JSON.stringify(children)}); export default ${componentName}; `; +}; diff --git a/packages/lucide-react/src/createLucideIcon.ts b/packages/lucide-react/src/createLucideIcon.ts index 1ad356927..f0e4258ea 100644 --- a/packages/lucide-react/src/createLucideIcon.ts +++ b/packages/lucide-react/src/createLucideIcon.ts @@ -1,4 +1,4 @@ -import { forwardRef, createElement, ReactSVG, SVGProps } from 'react'; +import { forwardRef, createElement, ReactSVG, SVGProps, ForwardRefExoticComponent, RefAttributes } from 'react'; import defaultAttributes from './defaultAttributes'; export type IconNode = [elementName: keyof ReactSVG, attrs: Record][] @@ -9,6 +9,8 @@ export interface LucideProps extends SVGAttributes { size?: string | number absoluteStrokeWidth?: boolean } + +export type LucideIcon = ForwardRefExoticComponent> /** * Converts string to KebabCase * Copied from scripts/helper. If anyone knows how to properly import it here @@ -19,7 +21,7 @@ export interface LucideProps extends SVGAttributes { */ export const toKebabCase = (string: string) => string.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); -const createLucideIcon = (iconName: string, iconNode: IconNode) => { +const createLucideIcon = (iconName: string, iconNode: IconNode): LucideIcon => { const Component = forwardRef( ({ color = 'currentColor', size = 24, strokeWidth = 2, absoluteStrokeWidth, children, ...rest }, ref) => createElement( diff --git a/packages/lucide-react/src/lucide-react.ts b/packages/lucide-react/src/lucide-react.ts index 2ddd5eb31..25abd4159 100644 --- a/packages/lucide-react/src/lucide-react.ts +++ b/packages/lucide-react/src/lucide-react.ts @@ -1,4 +1,10 @@ export * from './icons'; export * as icons from './icons'; export * from './aliases'; -export { default as createLucideIcon } from './createLucideIcon'; +export { default as dynamicIconImports } from './dynamicIconImports'; +export { + default as createLucideIcon, + type IconNode, + type LucideProps, + type LucideIcon, +} from './createLucideIcon'; diff --git a/packages/lucide-react/tests/__snapshots__/lucide-react.spec.tsx.snap b/packages/lucide-react/tests/__snapshots__/lucide-react.spec.tsx.snap index c438c752f..9879bcd60 100644 --- a/packages/lucide-react/tests/__snapshots__/lucide-react.spec.tsx.snap +++ b/packages/lucide-react/tests/__snapshots__/lucide-react.spec.tsx.snap @@ -5,3 +5,5 @@ exports[`Using lucide icon components > should adjust the size, stroke color and exports[`Using lucide icon components > should not scale the strokeWidth when absoluteStrokeWidth is set 1`] = `""`; exports[`Using lucide icon components > should render an component 1`] = `""`; + +exports[`Using lucide icon components > should render icons dynamically by using the dynamicIconImports module 1`] = `""`; diff --git a/packages/lucide-react/tests/lucide-react.spec.tsx b/packages/lucide-react/tests/lucide-react.spec.tsx index 438560dc4..b9524245f 100644 --- a/packages/lucide-react/tests/lucide-react.spec.tsx +++ b/packages/lucide-react/tests/lucide-react.spec.tsx @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { render, cleanup } from '@testing-library/react' -import { Pen, Edit2, Grid } from '../src/lucide-react'; +import { render, cleanup, waitFor } from '@testing-library/react' +import { Pen, Edit2, Grid, dynamicIconImports, LucideProps } from '../src/lucide-react'; +import { Suspense, lazy } from 'react'; describe('Using lucide icon components', () => { it('should render an component', () => { @@ -73,4 +74,37 @@ describe('Using lucide icon components', () => { expect( container.innerHTML ).toMatchSnapshot(); }); + + it('should render icons dynamically by using the dynamicIconImports module', async () => { + interface IconProps extends Omit { + name: keyof typeof dynamicIconImports; + } + + const Icon = ({ name, ...props }: IconProps) => { + const LucideIcon = lazy(dynamicIconImports[name]); + + return ( + + + + ); + } + + const { container, getByLabelText } = render( + , + ); + + await waitFor(() => getByLabelText('smile')) + + expect( container.innerHTML ).toMatchSnapshot(); + + }); + + }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0a0f5ac8..97bc6caa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,6 +384,9 @@ importers: rollup: specifier: ^3.5.1 version: 3.23.0 + rollup-plugin-dts: + specifier: ^5.0.0 + version: 5.0.0(rollup@3.23.0)(typescript@4.9.5) typescript: specifier: ^4.8.4 version: 4.9.5 @@ -1828,10 +1831,6 @@ packages: resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} engines: {node: '>=6.9.0'} - /@babel/helper-validator-identifier@7.19.1: - resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} - engines: {node: '>=6.9.0'} - /@babel/helper-validator-identifier@7.22.5: resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} engines: {node: '>=6.9.0'} @@ -4872,7 +4871,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/helper-string-parser': 7.21.5 - '@babel/helper-validator-identifier': 7.19.1 + '@babel/helper-validator-identifier': 7.22.5 to-fast-properties: 2.0.0 /@babel/types@7.22.5: diff --git a/tools/build-icons/building/generateDynamicImports.mjs b/tools/build-icons/building/generateDynamicImports.mjs new file mode 100644 index 000000000..62d35afb4 --- /dev/null +++ b/tools/build-icons/building/generateDynamicImports.mjs @@ -0,0 +1,30 @@ +import path from 'path'; +import { resetFile, appendFile } from '../../../scripts/helpers.mjs'; + +export default function generateDynamicImports({ + iconNodes, + outputDirectory, + fileExtension, + showLog = true, +}) { + const fileName = path.basename(`dynamicIconImports${fileExtension}`); + const icons = Object.keys(iconNodes); + + // Reset file + resetFile(fileName, outputDirectory); + + let importString = `const dynamicIconImports = {\n`; + + // Generate Import for Icon VNodes + icons.forEach((iconName) => { + importString += ` '${iconName}': () => import('./icons/${iconName}'),\n`; + }); + + importString += '};\nexport default dynamicIconImports;\n'; + + appendFile(importString, fileName, outputDirectory); + + if (showLog) { + console.log(`Successfully generated ${fileName} file`); + } +} diff --git a/tools/build-icons/building/generateIconFiles.mjs b/tools/build-icons/building/generateIconFiles.mjs index 24e76d3ac..44247f7e3 100644 --- a/tools/build-icons/building/generateIconFiles.mjs +++ b/tools/build-icons/building/generateIconFiles.mjs @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import prettier from 'prettier'; -import { toPascalCase } from '../../../scripts/helpers.mjs'; +import { readSvg, toPascalCase } from '../../../scripts/helpers.mjs'; export default ({ iconNodes, @@ -10,6 +10,7 @@ export default ({ showLog = true, iconFileExtension = '.js', pretty = true, + iconsDir, }) => { const icons = Object.keys(iconNodes); const iconsDistDirectory = path.join(outputDirectory, `icons`); @@ -25,7 +26,9 @@ export default ({ let { children } = iconNodes[iconName]; children = children.map(({ name, attributes }) => [name, attributes]); - const elementTemplate = template({ componentName, iconName, children }); + const getSvg = () => readSvg(`${iconName}.svg`, iconsDir); + + const elementTemplate = template({ componentName, iconName, children, getSvg }); const output = pretty ? prettier.format(elementTemplate, { singleQuote: true, diff --git a/tools/build-icons/main.mjs b/tools/build-icons/main.mjs index 9c85ab314..24bc94585 100755 --- a/tools/build-icons/main.mjs +++ b/tools/build-icons/main.mjs @@ -10,6 +10,7 @@ import generateExportsFile from './building/generateExportsFile.mjs'; import { readSvgDirectory, getCurrentDirPath } from '../../scripts/helpers.mjs'; import generateAliasesFile from './building/generateAliasesFile.mjs'; import getAliases from './utils/getAliases.mjs'; +import generateDynamicImports from './building/generateDynamicImports.mjs'; const cliArguments = getArgumentOptions(process.argv.slice(2)); @@ -30,6 +31,7 @@ const { importImportFileExtension = '', exportFileName = 'index.js', withAliases = false, + withDynamicImports = false, aliasesFileExtension = '.js', aliasImportFileExtension = '', pretty = true, @@ -54,6 +56,7 @@ async function buildIcons() { showLog: !silent, iconFileExtension, pretty: JSON.parse(pretty), + iconsDir: ICONS_DIR, }); if (withAliases) { @@ -69,6 +72,15 @@ async function buildIcons() { }); } + if (withDynamicImports) { + generateDynamicImports({ + iconNodes: icons, + outputDirectory: OUTPUT_DIR, + fileExtension: aliasesFileExtension, + showLog: !silent, + }); + } + // Generates entry files for the compiler filled with icons exports generateExportsFile( path.join(OUTPUT_DIR, 'icons', exportFileName),