diff --git a/package.json b/package.json index 0775691e..79ac627e 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "husky": "9.1.7", "jsdom": "25.0.1", "lint-staged": "15.2.10", + "lucide": "0.460.0", "postcss": "8.4.49", "remixicon": "4.5.0", "sass": "1.81.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81dcc415..a96c207e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 2.11.8 '@rotki/eslint-config': specifier: 3.5.0 - version: 3.5.0(@types/eslint@9.6.0)(@typescript-eslint/utils@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(@vue/compiler-sfc@3.5.13)(eslint-plugin-cypress@4.1.0(eslint@9.15.0(jiti@1.21.6)))(eslint-plugin-storybook@0.11.1(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5) + version: 3.5.0(@types/eslint@9.6.0)(@typescript-eslint/utils@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(@vue/compiler-sfc@3.5.13)(eslint-plugin-cypress@4.1.0(eslint@9.15.0(jiti@1.21.6)))(eslint-plugin-storybook@0.11.1(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5(@types/node@20.17.6)(@vitest/ui@2.1.5)(jsdom@25.0.1)(sass@1.81.0)(terser@5.17.7)) '@storybook/addon-docs': specifier: 8.4.4 version: 8.4.4(@types/react@18.2.8)(storybook@8.4.4(prettier@3.3.3))(webpack-sources@3.2.3) @@ -70,7 +70,7 @@ importers: version: 5.2.0(vite@5.4.11(@types/node@20.17.6)(sass@1.81.0)(terser@5.17.7))(vue@3.5.13(typescript@5.6.3)) '@vitest/coverage-v8': specifier: 2.1.5 - version: 2.1.5(vitest@2.1.5) + version: 2.1.5(vitest@2.1.5(@types/node@20.17.6)(@vitest/ui@2.1.5)(jsdom@25.0.1)(sass@1.81.0)(terser@5.17.7)) '@vitest/ui': specifier: 2.1.5 version: 2.1.5(vitest@2.1.5) @@ -134,6 +134,9 @@ importers: lint-staged: specifier: 15.2.10 version: 15.2.10 + lucide: + specifier: 0.460.0 + version: 0.460.0 postcss: specifier: 8.4.49 version: 8.4.49 @@ -3554,6 +3557,9 @@ packages: resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==} engines: {node: '>=16.14'} + lucide@0.460.0: + resolution: {integrity: sha512-kEqx3yHU+q4S0k7RH183QFaDy6xepEcN3yVjiBhxM1qX/tSMrmOSuUQJCobiIYzB1q9m8RmAN0efPiXZq79JSQ==} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -5931,7 +5937,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.27.3': optional: true - '@rotki/eslint-config@3.5.0(@types/eslint@9.6.0)(@typescript-eslint/utils@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(@vue/compiler-sfc@3.5.13)(eslint-plugin-cypress@4.1.0(eslint@9.15.0(jiti@1.21.6)))(eslint-plugin-storybook@0.11.1(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5)': + '@rotki/eslint-config@3.5.0(@types/eslint@9.6.0)(@typescript-eslint/utils@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(@vue/compiler-sfc@3.5.13)(eslint-plugin-cypress@4.1.0(eslint@9.15.0(jiti@1.21.6)))(eslint-plugin-storybook@0.11.1(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5(@types/node@20.17.6)(@vitest/ui@2.1.5)(jsdom@25.0.1)(sass@1.81.0)(terser@5.17.7))': dependencies: '@antfu/eslint-define-config': 1.23.0-2 '@antfu/install-pkg': 0.4.1 @@ -5943,7 +5949,7 @@ snapshots: '@stylistic/eslint-plugin': 2.11.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3) '@typescript-eslint/eslint-plugin': 8.15.0(@typescript-eslint/parser@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3) '@typescript-eslint/parser': 8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3) - '@vitest/eslint-plugin': 1.1.10(@typescript-eslint/utils@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5) + '@vitest/eslint-plugin': 1.1.10(@typescript-eslint/utils@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5(@types/node@20.17.6)(@vitest/ui@2.1.5)(jsdom@25.0.1)(sass@1.81.0)(terser@5.17.7)) eslint: 9.15.0(jiti@1.21.6) eslint-config-flat-gitignore: 0.3.0(eslint@9.15.0(jiti@1.21.6)) eslint-config-prettier: 9.1.0(eslint@9.15.0(jiti@1.21.6)) @@ -6465,7 +6471,7 @@ snapshots: vite: 5.4.11(@types/node@20.17.6)(sass@1.81.0)(terser@5.17.7) vue: 3.5.13(typescript@5.6.3) - '@vitest/coverage-v8@2.1.5(vitest@2.1.5)': + '@vitest/coverage-v8@2.1.5(vitest@2.1.5(@types/node@20.17.6)(@vitest/ui@2.1.5)(jsdom@25.0.1)(sass@1.81.0)(terser@5.17.7))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -6483,7 +6489,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.1.10(@typescript-eslint/utils@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5)': + '@vitest/eslint-plugin@1.1.10(@typescript-eslint/utils@8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)(vitest@2.1.5(@types/node@20.17.6)(@vitest/ui@2.1.5)(jsdom@25.0.1)(sass@1.81.0)(terser@5.17.7))': dependencies: '@typescript-eslint/utils': 8.15.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3) eslint: 9.15.0(jiti@1.21.6) @@ -8750,6 +8756,8 @@ snapshots: lru-cache@8.0.5: {} + lucide@0.460.0: {} + lz-string@1.5.0: {} magic-string@0.30.11: diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 33cbf456..da23da55 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -5,7 +5,8 @@ import fs from 'fs-extra'; import { pascalCase } from 'scule'; import { XMLParser } from 'fast-xml-parser'; -const PREFIX = 'ri-'; +const REMIX_PREFIX = 'ri-'; +const LUCIDE_PREFIX = 'lu-'; const TARGET = 'src/icons/'; const CHUNK_SIZE = 500; @@ -17,6 +18,10 @@ function resolveRemixIconDir() { return resolveRoot('node_modules', 'remixicon', 'icons'); } +function resolveLucideIconDir() { + return resolveRoot('node_modules', 'lucide', 'dist', 'esm', 'icons'); +} + function resolveCustomIconDir() { return resolveRoot('src', 'custom-icons'); } @@ -43,7 +48,17 @@ function getPathFromSvgString(svg) { ignoreAttributes: false, }); const obj = parser.parse(svg); - return obj.svg.path['@_d']; + + function findFirstPath(node) { + if (node.path) + return node.path; + if (node.g) + return findFirstPath(node.g); + return null; + } + + const path = findFirstPath(obj.svg); + return path['@_d']; } async function getAllSvgDataFromPath(pathDir) { @@ -57,25 +72,58 @@ async function getAllSvgDataFromPath(pathDir) { }); return res; } - else if (type.isFile()) { - try { - const name = PREFIX + path.basename(pathDir).replace('.svg', ''); - const generatedName = pascalCase(name); - const svg = await readFile(pathDir, 'utf8'); - const svgPath = getPathFromSvgString(svg); - - return [ - { - name, - generatedName, - svgPath, - }, - ]; - } - catch (error) { - consola.warn(`Error while processing ${pathDir}`, error); - return []; - } + + try { + const name = REMIX_PREFIX + path.basename(pathDir).replace('.svg', ''); + const generatedName = pascalCase(name); + const svg = await readFile(pathDir, 'utf8'); + const svgPath = getPathFromSvgString(svg); + + return [ + { + name, + generatedName, + components: [ + ['path', { d: svgPath }], + ], + }, + ]; + } + catch (error) { + consola.warn(`Error while processing ${pathDir}`, error); + return []; + } +} + +async function getLucideSvgDataFromPath(pathDir) { + const type = await lstat(pathDir); + if (type.isDirectory()) { + const res = []; + const dirs = await readdir(pathDir); + await loop(dirs, async (child) => { + if (child.endsWith('.js')) { + res.push(...(await getLucideSvgDataFromPath(`${pathDir}/${child}`))); + } + }); + return res; + } + + try { + const filePath = path.basename(pathDir).replace('.js', ''); + const name = LUCIDE_PREFIX + filePath; + const generatedName = pascalCase(name); + const iconModule = await import(`${pathDir}`); + const components = iconModule.default[2]; + + return [{ + name, + generatedName, + components, + }]; + } + catch (error) { + consola.warn(`Error while processing ${pathDir}`, error); + return []; } } @@ -87,6 +135,8 @@ async function collectAllIconMetas() { res.push(...(await getAllSvgDataFromPath(dir))); }); + res.push(...(await getLucideSvgDataFromPath(resolveLucideIconDir()))); + return res; } @@ -114,7 +164,7 @@ import { type GeneratedIcon } from '@/types/icons';\n await loop(chunk, (icon) => { chunkFileContent += `export const ${icon.generatedName}: GeneratedIcon = { name: '${icon.name}', - path: '${icon.svgPath}', + components: ${JSON.stringify(icon.components)}, };\n`; names.push(icon.name); diff --git a/src/components/icons/RuiIcon.stories.ts b/src/components/icons/RuiIcon.stories.ts index 822353c6..a316f7c6 100644 --- a/src/components/icons/RuiIcon.stories.ts +++ b/src/components/icons/RuiIcon.stories.ts @@ -27,7 +27,7 @@ const meta: Meta = { docs: { description: { component: - 'All icons can be seen here: https://remixicon.com/. Use it without prefix `ri-`', + 'We provide icons from Remix icons and Lucide icons. For remix icons use it without prefix `ri-` (eg: arrow-down-circle-fill), while for lucide icon, you need to add prefix `lu-` (eg: lu-arrow-down).', }, }, }, @@ -70,4 +70,20 @@ export const SecondaryTiny: Story = { }, }; +export const LucideIconPrimary: Story = { + args: { + color: 'primary', + name: 'lu-arrow-down', + size: 24, + }, +}; + +export const LucideIconPrimaryLarge: Story = { + args: { + color: 'primary', + name: 'lu-arrow-down', + size: 48, + }, +}; + export default meta; diff --git a/src/components/icons/RuiIcon.vue b/src/components/icons/RuiIcon.vue index 30caf71f..493d82a4 100644 --- a/src/components/icons/RuiIcon.vue +++ b/src/components/icons/RuiIcon.vue @@ -19,12 +19,15 @@ const props = withDefaults(defineProps(), { const { registeredIcons } = useIcons(); -const path: ComputedRef = computed(() => { +const isLucide = computed(() => props.name.startsWith('lu-')); + +const components: ComputedRef<[string, Record][] | undefined> = computed(() => { const name = props.name; if (!isRuiIcon(name)) { console.warn(`icon ${name} must be a valid RuiIcon`); } - const iconName = `ri-${name}`; + const prefix = get(isLucide) ? '' : 'ri-'; + const iconName = `${prefix}${name}`; const found = registeredIcons[iconName]; if (!found) { @@ -42,9 +45,16 @@ const path: ComputedRef = computed(() => { viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" > - diff --git a/src/composables/icons.ts b/src/composables/icons.ts index df34338b..f2d7e80f 100644 --- a/src/composables/icons.ts +++ b/src/composables/icons.ts @@ -73,7 +73,7 @@ export interface IconsOptions { } export interface UseIconsReturn { - registeredIcons: Readonly>; + registeredIcons: Readonly][]>>; } export const IconsSymbol: InjectionKey = Symbol.for('rui:icons'); @@ -86,7 +86,7 @@ export function createIconDefaults(options?: Partial): UseIconsRet [ ...requiredIcons, ...iconsToAdd, - ].map(({ name, path }) => [name, path]), + ].map(({ components, name }) => [name, components]), ), }, }; diff --git a/src/types/icons.ts b/src/types/icons.ts index f76da4df..ea5c440b 100644 --- a/src/types/icons.ts +++ b/src/types/icons.ts @@ -1,4 +1,4 @@ export interface GeneratedIcon { name: string; - path: string; + components: [string, Record][]; }