Browse Source

perf: 图标处理

BaiLuoYan 5 days ago
parent
commit
42a47eb849

+ 37 - 50
config/config.ts

@@ -1,12 +1,9 @@
 // https://umijs.org/config/
-// import { cleanupSVG, isEmptyColor, parseColors, runSVGO, SVG } from '@iconify/tools';
 import { defineConfig } from '@umijs/max';
-import { glob } from 'glob';
-import path, { join } from 'path';
-// import { FileSystemIconLoader } from 'unplugin-icons/loaders';
-// import Icons from 'unplugin-icons/webpack';
 import dayjs from 'dayjs';
 import fs from 'fs';
+import { glob } from 'glob';
+import path, { join } from 'path';
 import defaultSettings from './defaultSettings';
 import proxy from './proxy';
 import routes from './routes';
@@ -113,11 +110,39 @@ export default defineConfig({
       href: `/favicon-${envConfig.REACT_APP_ID}.ico`,
     },
   ],
-  // /** unplugin-icons 加载本地图标 */
-  // alias: {
-  //   '~icons': '.icons',
-  // },
+  alias: {
+    '@svgs': path.resolve(process.cwd(), 'svgs'),
+  },
   chainWebpack(memo) {
+    const svgsDir = path.resolve(process.cwd(), 'svgs');
+
+    // 将 svgs/ 目录从所有能匹配 .svg 的规则中排除(包括 asset/resource 类型规则和 svgo-loader)
+    Object.values(memo.module.rules.entries()).forEach((rule: any) => {
+      const testRegex = rule.get?.('test');
+      if (testRegex instanceof RegExp && testRegex.test('file.svg')) {
+        rule.exclude.add(svgsDir);
+        return;
+      }
+      const uses = rule.uses?.entries?.();
+      if (!uses) return;
+      Object.values(uses).forEach((use: any) => {
+        if ((use.get?.('loader') ?? '').includes('svgo-loader')) {
+          rule.exclude.add(svgsDir);
+        }
+      });
+    });
+
+    // SVG icon loader:自动处理 svgs/ 目录,无需手动运行 convert-svg-icon
+    memo.module
+      .rule('svgs-iconify')
+      .test(/\.svg$/)
+      .include.add(svgsDir)
+      .end()
+      .type('javascript/auto')
+      .use('iconify-svg-loader')
+      .loader(path.resolve(process.cwd(), 'tools/svgIconLoader.cjs'))
+      .end();
+
     // 在生产环境构建时生成 version.json 文件
     if (REACT_APP_ENV === 'prod' || REACT_APP_ENV === 'test' || REACT_APP_ENV === 'dev') {
       memo.plugin('generate-version-file').use(
@@ -125,7 +150,7 @@ export default defineConfig({
           apply(compiler: any) {
             compiler.hooks.beforeCompile.tapAsync(
               'GenerateVersionFilePlugin',
-              (params: any, callback: any) => {
+              (_params: any, callback: any) => {
                 try {
                   const publicDir = path.join(process.cwd(), 'public');
                   if (!fs.existsSync(publicDir)) {
@@ -158,46 +183,6 @@ export default defineConfig({
       );
     }
 
-    // chainWebpack(memo) {
-    //   memo.plugin('unplugin-icons').use(
-    //     Icons({
-    //       compiler: 'jsx',
-    //       jsx: 'react',
-    //       autoInstall: true,
-    //       scale: 1,
-    //       defaultClass: 'iconify-icon',
-    //       customCollections: {
-    //         // 自定义单色图标集合
-    //         'custom-sc': FileSystemIconLoader(
-    //           // 单色 svg 文件目录
-    //           './svgs/single-color',
-    //           (svg) => {
-    //             // 将 SVG 字符串转换为 SVG 实例
-    //             const svgObj = new SVG(svg);
-    //             cleanupSVG(svgObj);
-    //             parseColors(svgObj, {
-    //               defaultColor: 'currentColor',
-    //               callback: (attr, colorStr, color) => {
-    //                 return !color || isEmptyColor(color) ? colorStr : 'currentColor';
-    //               },
-    //             });
-    //             runSVGO(svgObj);
-    //             return svgObj.toMinifiedString();
-    //           },
-    //         ),
-    //         // 自定义多色图标集合
-    //         'custom-mc': FileSystemIconLoader('./svgs/multi-color', (svg) => {
-    //           // 将 SVG 字符串转换为 SVG 实例
-    //           const svgObj = new SVG(svg);
-    //           cleanupSVG(svgObj);
-    //           runSVGO(svgObj);
-    //           return svgObj.toMinifiedString();
-    //         }),
-    //       },
-    //     }),
-    //   );
-    //   return memo;
-    // },
     return memo;
   },
 
@@ -349,6 +334,8 @@ export default defineConfig({
   mfsu: {
     strategy: 'normal',
     shared: {
+      react: { singleton: true, eager: true, requiredVersion: false },
+      'react-dom': { singleton: true, eager: true, requiredVersion: false },
       'lodash-es': { singleton: true, eager: true, requiredVersion: false },
       ramda: { singleton: true, eager: true, requiredVersion: false },
     },

+ 5 - 5
package.json

@@ -4,13 +4,12 @@
   "private": true,
   "description": "An out-of-box UI solution for enterprise applications",
   "scripts": {
-    "analyze": "npm run convert-svg-icon && cross-env ANALYZE=1 REACT_APP_ENV=prod UMI_ENV=prod max build",
-    "build:generic": "npm run convert-svg-icon && cross-env PROD_ID=${npm_config_prod_id:-go-pmp} REACT_APP_ENV=${npm_config_env:-prod} UMI_ENV=${npm_config_env:-prod} max build",
+    "analyze": "cross-env ANALYZE=1 REACT_APP_ENV=prod UMI_ENV=prod max build",
+    "build:generic": "cross-env PROD_ID=${npm_config_prod_id:-go-pmp} REACT_APP_ENV=${npm_config_env:-prod} UMI_ENV=${npm_config_env:-prod} max build",
     "build:perms-system": "npm run build:perms-system:prod",
     "build:perms-system:dev": "npm run build:generic --prod-id=perms-system --env=dev",
     "build:perms-system:prod": "npm run build:generic --prod-id=perms-system --env=prod",
     "build:perms-system:test": "npm run build:generic --prod-id=perms-system --env=test",
-    "convert-svg-icon": "node tools/convertSVG.mjs",
     "dev:perms-system": "npm run dev:perms-system:no-mock",
     "dev:perms-system:no-mock": "npm run start:generic:no-mock --prod-id=perms-system --env=local",
     "i18n-remove": "pro i18n-remove --locale=zh-CN --write",
@@ -23,8 +22,8 @@
     "prettier": "prettier -c --write \"**/**.{js,jsx,tsx,ts,less,scss,md,json}\" --end-of-line auto",
     "preview": "max preview --port 8000",
     "record": "cross-env NODE_ENV=development REACT_APP_ENV=test max record --scene=login",
-    "start:generic": "npm run convert-svg-icon && cross-env PROD_ID=${npm_config_prod_id:-go-pmp} REACT_APP_ENV=${npm_config_env:-dev} UMI_ENV=dev max dev",
-    "start:generic:no-mock": "npm run convert-svg-icon && cross-env PROD_ID=${npm_config_prod_id:-go-pmp} REACT_APP_ENV=${npm_config_env:-dev} MOCK=none UMI_ENV=dev max dev",
+    "start:generic": "cross-env PROD_ID=${npm_config_prod_id:-go-pmp} REACT_APP_ENV=${npm_config_env:-dev} UMI_ENV=dev max dev",
+    "start:generic:no-mock": "cross-env PROD_ID=${npm_config_prod_id:-go-pmp} REACT_APP_ENV=${npm_config_env:-dev} MOCK=none UMI_ENV=dev max dev",
     "tsc": "tsc --noEmit"
   },
   "browserslist": [
@@ -44,6 +43,7 @@
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
+    "@iconify/react": "6.0.2",
     "@umijs/route-utils": "^2.2.2",
     "@wangeditor/editor": "^5.1.23",
     "@wangeditor/editor-for-react": "^1.0.6",

+ 13 - 0
pnpm-lock.yaml

@@ -41,6 +41,9 @@ importers:
       '@dnd-kit/utilities':
         specifier: ^3.2.2
         version: 3.2.2([email protected])
+      '@iconify/react':
+        specifier: 6.0.2
+        version: 6.0.2([email protected])
       '@umijs/route-utils':
         specifier: ^2.2.2
         version: 2.2.2
@@ -1950,6 +1953,11 @@ packages:
   '@iconify/[email protected]':
     resolution: {integrity: sha512-9ZJ4l71MOGVQa/DJxPI5XJ49D5Ax+wOvCCw4ZPcGemP34rtWWFAPsfu2QyQLjEvedbrVmEJcomam3N0SsNgWDA==}
 
+  '@iconify/[email protected]':
+    resolution: {integrity: sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==}
+    peerDependencies:
+      react: '>=16'
+
   '@iconify/[email protected]':
     resolution: {integrity: sha512-s6BcNUcCxQ3S6cvhlsoWzOuBt8qKXdVyXB9rT57uSJ/ARHD7dVM43+5ERBWn3tmkMWXeJ/s9DPVc3dUasayzeA==}
 
@@ -13730,6 +13738,11 @@ snapshots:
       '@iconify/types': 2.0.0
       pathe: 2.0.3
 
+  '@iconify/[email protected]([email protected])':
+    dependencies:
+      '@iconify/types': 2.0.0
+      react: 18.3.1
+
   '@iconify/[email protected]':
     dependencies:
       '@iconify/types': 2.0.0

+ 14 - 3
src/app.tsx

@@ -1,17 +1,19 @@
 import bg1Layout from '@/assets/images/lay-bg1.webp';
 import bg2Layout from '@/assets/images/lay-bg2.webp';
 import bg3Layout from '@/assets/images/lay-bg3.webp';
-import { AvatarDropdown, AvatarName, Footer, SelectLang } from '@/components';
-import logo from '@/icons/mc-logo.svg';
+import { AvatarDropdown, AvatarName, Footer, Icon, SelectLang } from '@/components';
 import { Settings as LayoutSettings } from '@ant-design/pro-components';
+import mcLogoSvg from '@svgs/multi-color/mc-logo.svg';
 import type { RequestConfig, RuntimeConfig, RunTimeLayoutConfig } from '@umijs/max';
 import { history } from '@umijs/max';
+import { App } from 'antd';
 import dayjs from 'dayjs';
 import utc from 'dayjs/plugin/utc';
 import React from 'react';
 import defaultSettings from '../config/defaultSettings';
 import VersionChecker from './components/VersionChecker';
 import { requestConfig } from './requestConfig';
+import { AntdAppInstanceCapture } from './utils/antdAppInstance';
 import { userKey } from './utils/authUtils';
 import { createLocalTools } from './utils/localUtils';
 import { loginPath, toLoginPage } from './utils/routerUtils';
@@ -58,7 +60,7 @@ if (waterMarkContent !== '') {
 const defSettings = {
   ...defaultSettings,
   title,
-  logo,
+  logo: <Icon icon={mcLogoSvg} width="44px" height="44px" />,
 };
 
 export async function getInitialState(): Promise<{
@@ -166,3 +168,12 @@ export const locale: RuntimeConfig['locale'] = {
     console.error(err);
   },
 };
+
+export function rootContainer(container: React.ReactNode) {
+  return (
+    <App>
+      <AntdAppInstanceCapture />
+      {container}
+    </App>
+  );
+}

+ 43 - 0
src/components/Icon/index.tsx

@@ -0,0 +1,43 @@
+import type { IconifyIcon as IconifyIconData, IconProps as IconifyProps } from '@iconify/react';
+import { Icon as IconifyReactIcon } from '@iconify/react';
+import { Icon as UmiIcon } from '@umijs/max';
+
+export interface IconProps extends Omit<IconifyProps, 'icon'> {
+  icon: IconifyIconData | string;
+  /** 鼠标悬停时显示的图标(仅字符串 icon 有效,@umijs/max 特性) */
+  hover?: string;
+  /** 旋转动画(仅字符串 icon 有效,@umijs/max 特性) */
+  spin?: boolean;
+}
+
+export const Icon = ({
+  icon,
+  hover,
+  spin,
+  mode,
+  color,
+  inline,
+  ssr,
+  fallback,
+  onLoad,
+  ...rest
+}: IconProps) => {
+  if (typeof icon !== 'string') {
+    return (
+      <IconifyReactIcon
+        icon={icon}
+        mode={mode}
+        color={color}
+        inline={inline}
+        ssr={ssr}
+        fallback={fallback}
+        onLoad={onLoad}
+        {...rest}
+      />
+    );
+  }
+
+  return <UmiIcon icon={icon as any} hover={hover} spin={spin} {...(rest as any)} />;
+};
+
+export default Icon;

+ 2 - 1
src/components/ImageUploader/index.tsx

@@ -1,6 +1,7 @@
 import { postForm } from '@/request';
+import { message } from '@/utils/antdAppInstance';
 import { DeleteOutlined, EyeOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons';
-import { message, Modal, Popconfirm, Upload } from 'antd';
+import { Modal, Popconfirm, Upload } from 'antd';
 import React, { useCallback, useEffect, useMemo, useState } from 'react';
 
 interface ImageUploadResultData {

+ 2 - 1
src/components/VersionChecker.tsx

@@ -1,4 +1,5 @@
-import { Button, notification } from 'antd';
+import { notification } from '@/utils/antdAppInstance';
+import { Button } from 'antd';
 import React, { useEffect, useRef } from 'react';
 
 interface VersionInfo {

+ 3 - 1
src/components/index.ts

@@ -1,5 +1,7 @@
 import Footer from './Footer';
+import { Icon } from './Icon';
 import { Question, SelectLang } from './RightContent';
 import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
 
-export { Footer, Question, SelectLang, AvatarDropdown, AvatarName };
+export type { IconProps } from './Icon';
+export { AvatarDropdown, AvatarName, Footer, Icon, Question, SelectLang };

+ 2 - 1
src/global.tsx

@@ -1,5 +1,6 @@
+import { message, notification } from '@/utils/antdAppInstance';
 import { getIntl } from '@umijs/max';
-import { Button, message, notification } from 'antd';
+import { Button } from 'antd';
 import defaultSettings from '../config/defaultSettings';
 
 const { pwa } = defaultSettings;

+ 0 - 1
src/icons/bg-number.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="54" height="51" viewBox="0 0 54 51"><defs><linearGradient id="svgID0" x1="33.006%" x2="68.23%" y1="33.102%" y2="73.554%"><stop offset="0%" stop-color="#4791FF"/><stop offset="100%" stop-color="#2A65EC"/></linearGradient><filter id="svgID1" width="228.1%" height="251.9%" x="-57.8%" y="-57.4%" filterUnits="objectBoundingBox"><feOffset dx="2" dy="5" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="6"/><feColorMatrix in="shadowBlurOuter1" values="0 0 0 0 0.207843137 0 0 0 0 0.447058824 0 0 0 0 0.925490196 0 0 0 0.3 0"/></filter><path id="svgID2" d="M7.492 0h21a2.91 2.91 0 012.86 3.447l-3.985 21.182A2.91 2.91 0 0124.507 27h-21a2.91 2.91 0 01-2.859-3.447L4.633 2.371A2.91 2.91 0 017.493 0"/></defs><g fill="none" fill-rule="evenodd" transform="translate(9 7)"><use fill="#000" filter="url(#svgID1)" href="#svgID2"/><use fill="url(#svgID0)" href="#svgID2"/></g></svg>

+ 0 - 1
src/icons/mc-logo.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24"><g fill="none"><path fill="url(#svgID0)" d="M17.536 5.199c-2.906-2.932-8.163-2.932-11.07 0-2.905 2.932-2.905 8.142 0 11.08l2.45 2.046v-1.877c0-1.07.58-2.875 2.226-4.341a253 253 0 013.364-2.937l.067.133-2.536 2.438c-.514.67-1.543 2.433-1.543 4.146 0 1.857.7 3.662 1.919 5.113l5.123-5.175a7.57 7.57 0 000-10.626" transform="rotate(180 13.8 13.8)scale(1.3)"/><defs><linearGradient id="svgID0" x1="12" x2="12" y1="3" y2="21" gradientUnits="userSpaceOnUse"><stop stop-color="#1890ff"/><stop offset="1" stop-color="#0188fe"/></linearGradient></defs></g></svg>

+ 0 - 1
src/icons/sc-logo.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M4.803 20.841c3.778 3.812 10.612 3.812 14.391 0 3.777-3.811 3.777-10.584 0-14.404l-3.185-2.66v2.44c0 1.392-.754 3.738-2.894 5.644a329 329 0 01-4.373 3.818l-.087-.173 3.297-3.17c.668-.87 2.006-3.162 2.006-5.39 0-2.413-.91-4.76-2.495-6.646l-6.66 6.728a9.84 9.84 0 000 13.813"/></svg>

+ 6 - 5
src/pages/Sys/Login/index.tsx

@@ -1,21 +1,22 @@
 import bgLogin from '@/assets/images/login-bg.png';
-import { Footer } from '@/components';
+import { Footer, Icon } from '@/components';
 import * as loginApi from '@/services/login';
+import { message } from '@/utils/antdAppInstance';
 import { setToken } from '@/utils/authUtils';
 import { LoadingOutlined, LockOutlined, UserOutlined } from '@ant-design/icons';
 import { LoginForm, ProFormText } from '@ant-design/pro-components';
 import '@cap.js/widget';
+import mcLogoSvg from '@svgs/multi-color/mc-logo.svg';
 import {
   FormattedMessage,
   Helmet,
-  history,
-  Icon,
   SelectLang,
+  history,
   useIntl,
   useModel,
   useRequest,
 } from '@umijs/max';
-import { Alert, message, Spin } from 'antd';
+import { Alert, Spin } from 'antd';
 import { createStyles } from 'antd-style';
 import dayjs from 'dayjs';
 import React, { useEffect, useRef, useState } from 'react';
@@ -246,7 +247,7 @@ const Login: React.FC = () => {
       <div style={{ flex: '1', padding: '32px 0' }}>
         <LoginForm
           contentStyle={{ minWidth: 280, maxWidth: '75vw' }}
-          logo={<Icon icon="local:mc-logo" width="44px" height="44px" />}
+          logo={<Icon icon={mcLogoSvg} width="44px" height="44px" />}
           title={process.env.REACT_APP_NAME}
           subTitle={intl.formatMessage(
             { id: 'pages.login.subTitle' },

+ 2 - 1
src/pages/Sys/ModifyPassword/index.tsx

@@ -1,8 +1,9 @@
 import * as api from '@/services/login';
+import { message } from '@/utils/antdAppInstance';
 import { SaveOutlined } from '@ant-design/icons';
 import { ProFormText } from '@ant-design/pro-components';
 import { useModel } from '@umijs/max';
-import { Button, Card, Form, FormInstance, message } from 'antd';
+import { Button, Card, Form, FormInstance } from 'antd';
 import React, { useRef, useState } from 'react';
 
 const ModifyPassword: React.FC = () => {

+ 2 - 1
src/pages/Sys/UserInfo/index.tsx

@@ -1,5 +1,6 @@
 import { postForm } from '@/request';
 import * as api from '@/services/login';
+import { message } from '@/utils/antdAppInstance';
 import { userKey } from '@/utils/authUtils';
 import { createLocalTools } from '@/utils/localUtils';
 import {
@@ -11,7 +12,7 @@ import {
 } from '@ant-design/icons';
 import { ProFormText } from '@ant-design/pro-components';
 import { useModel } from '@umijs/max';
-import { Avatar, Button, Card, Col, Form, message, Row, Space, Upload } from 'antd';
+import { Avatar, Button, Card, Col, Form, Row, Space, Upload } from 'antd';
 import React, { useEffect, useState } from 'react';
 
 const UserInfo: React.FC = () => {

+ 1 - 1
src/requestConfig/requestErrorConfig.ts

@@ -1,7 +1,7 @@
 import { ErrorShowType } from '@/defines';
+import { message, notification } from '@/utils/antdAppInstance';
 import { toLoginPage } from '@/utils/routerUtils';
 import type { RequestConfig } from '@umijs/max';
-import { message, notification } from 'antd';
 import axios from 'axios';
 
 /**

+ 15 - 0
src/utils/antdAppInstance.tsx

@@ -0,0 +1,15 @@
+import { App, message as defaultMessage, notification as defaultNotification } from 'antd';
+import type { MessageInstance } from 'antd/es/message/interface';
+import type { NotificationInstance } from 'antd/es/notification/interface';
+
+let message: MessageInstance = defaultMessage;
+let notification: NotificationInstance = defaultNotification;
+
+export { message, notification };
+
+export function AntdAppInstanceCapture(): null {
+  const { message: m, notification: n } = App.useApp();
+  message = m;
+  notification = n;
+  return null;
+}

+ 1 - 1
src/utils/httpUtils.ts

@@ -1,6 +1,6 @@
 import { ErrorShowType } from '@/defines';
+import { message, notification } from '@/utils/antdAppInstance';
 import { request, type RequestConfig } from '@umijs/max';
-import { message, notification } from 'antd';
 import { saveAs } from 'file-saver';
 import { toLoginPage } from './routerUtils';
 

+ 0 - 146
tools/convertSVG.mjs

@@ -1,146 +0,0 @@
-import {
-  cleanupSVG,
-  // exportJSONPackage,
-  // exportIconPackage,
-  exportToDirectory,
-  importDirectory,
-  isEmptyColor,
-  parseColors,
-  runSVGO,
-} from '@iconify/tools';
-import fs from 'fs';
-import path from 'path';
-
-(async () => {
-  const singleColorSourceDir = path.resolve(process.cwd(), 'svgs', 'single-color');
-  const multiColorSourceDir = path.resolve(process.cwd(), 'svgs', 'multi-color');
-  // const pkgOutputDir = path.resolve(process.cwd(), 'src', 'iconify');
-  const expOutputDir = path.resolve(process.cwd(), 'src', 'icons');
-
-  // Import icons
-  const singleColorIconSet = await importDirectory(singleColorSourceDir, { prefix: 'local' });
-  const multiColorIconSet = await importDirectory(multiColorSourceDir, { prefix: 'local' });
-
-  const handleIcons = async (iconSet, multiColor) => {
-    // Validate, clean up, fix palette and optimise
-    await iconSet.forEach(async (name, type) => {
-      if (type !== 'icon') {
-        return;
-      }
-
-      const svg = iconSet.toSVG(name);
-      if (!svg) {
-        // Invalid icon
-        iconSet.remove(name);
-        return;
-      }
-
-      // Clean up and optimise icons
-      try {
-        // Clean up icon code
-        await cleanupSVG(svg);
-
-        if (!multiColor) {
-          // Assume icon is monotone: replace color with currentColor, add if missing
-          // If icon is not monotone, remove this code
-          await parseColors(svg, {
-            defaultColor: 'currentColor',
-            callback: (attr, colorStr, color) => {
-              return !color || isEmptyColor(color) ? colorStr : 'currentColor';
-            },
-          });
-        }
-
-        // Optimise
-        await runSVGO(svg);
-      } catch (err) {
-        // Invalid icon
-        console.error(`Error parsing ${name}:`, err);
-        iconSet.remove(name);
-        return;
-      }
-
-      // Update icon
-      iconSet.fromSVG(name, svg);
-    });
-  };
-
-  handleIcons(singleColorIconSet, false);
-  handleIcons(multiColorIconSet, true);
-
-  const removeDir = (deletePath) => {
-    let files = [];
-    if (fs.existsSync(deletePath)) {
-      files = fs.readdirSync(deletePath);
-      files.forEach((file) => {
-        const curPath = path.join(deletePath, file);
-        if (fs.statSync(curPath).isDirectory()) {
-          removeDir(curPath);
-        } else {
-          fs.unlinkSync(curPath);
-        }
-      });
-      fs.rmdirSync(deletePath);
-    }
-  };
-
-  removeDir(expOutputDir);
-  // removeDir(pkgOutputDir);
-
-  await exportToDirectory(singleColorIconSet, {
-    target: path.join(expOutputDir, '.single-color'),
-    log: true,
-  });
-
-  // await exportIconPackage(singleColorIconSet, {
-  //   target: path.join(pkgOutputDir, '.single-color'),
-  //   cleanup: true,
-  // });
-
-  await exportToDirectory(multiColorIconSet, {
-    target: path.join(expOutputDir, '.multi-color'),
-    log: true,
-  });
-
-  // await exportIconPackage(multiColorIconSet, {
-  //   target: path.join(pkgOutputDir, '.multi-color'),
-  //   cleanup: true,
-  // });
-
-  const mergeFiles = (parentPath) => {
-    function copyFile(distPath, sourcePath) {
-      const rs = fs.createReadStream(sourcePath);
-      rs.on('error', (err) => console.log(err));
-
-      const ws = fs.createWriteStream(distPath);
-      ws.on('error', (err) => console.log(err));
-
-      rs.pipe(ws);
-    }
-
-    const copyToParent = (childName) => {
-      const childPath = path.resolve(parentPath, childName);
-      if (!fs.existsSync(childPath)) return;
-      const files = fs.readdirSync(childPath);
-      files.forEach((file) => {
-        const curFilePath = path.join(childPath, file);
-        const fileState = fs.statSync(curFilePath);
-
-        if (fileState.isFile() /*  && file != 'package.json' */) {
-          copyFile(path.join(parentPath, file), curFilePath);
-        }
-      });
-    };
-
-    copyToParent('.single-color');
-    copyToParent('.multi-color');
-
-    setTimeout(() => {
-      removeDir(path.resolve(parentPath, '.single-color'));
-      removeDir(path.resolve(parentPath, '.multi-color'));
-    }, 3000);
-  };
-
-  mergeFiles(expOutputDir);
-  // mergeFiles(pkgOutputDir);
-})();

+ 57 - 0
tools/svgIconLoader.cjs

@@ -0,0 +1,57 @@
+/**
+ * Webpack loader for SVG icons in svgs/ directory.
+ * Transforms SVG files into Iconify-compatible objects: { width, height, body }
+ * - single-color/: replaces fill/stroke colors with currentColor
+ * - multi-color/: preserves original colors
+ */
+
+let iconifyToolsPromise = null;
+
+function getIconifyTools() {
+  if (!iconifyToolsPromise) {
+    iconifyToolsPromise = import('@iconify/tools');
+  }
+  return iconifyToolsPromise;
+}
+
+module.exports = async function svgIconLoader(source) {
+  const callback = this.async();
+  const resourcePath = this.resourcePath;
+  const isSingleColor = resourcePath.includes('single-color');
+
+  try {
+    const { SVG, cleanupSVG, parseColors, isEmptyColor } = await getIconifyTools();
+
+    // Multi-color icons containing <image> tag: extract as-is
+    if (!isSingleColor && source.includes('<image')) {
+      const viewBox = source.match(/viewBox="([^"]+)"/)?.[1] || '0 0 1 1';
+      const parts = viewBox.split(' ').map(Number);
+      const width = parts[2] || 1;
+      const height = parts[3] || 1;
+      const innerContent = source.replace(/<svg[^>]*>([\s\S]*)<\/svg>/i, '$1');
+      callback(null, `export default ${JSON.stringify({ width, height, body: innerContent })}`);
+      return;
+    }
+
+    const svg = new SVG(source);
+    await cleanupSVG(svg);
+
+    if (isSingleColor) {
+      parseColors(svg, {
+        defaultColor: 'currentColor',
+        callback: (_, colorStr, color) => {
+          return !color || isEmptyColor(color) ? colorStr : 'currentColor';
+        },
+      });
+    }
+
+    const width = svg.viewBox.width;
+    const height = svg.viewBox.height;
+    const optimizedSvg = svg.toMinifiedString();
+    const innerContent = optimizedSvg.replace(/<svg[^>]*>([\s\S]*)<\/svg>/i, '$1');
+
+    callback(null, `export default ${JSON.stringify({ width, height, body: innerContent })}`);
+  } catch (err) {
+    callback(err);
+  }
+};