vite-plugin-cus-svg-icon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # vite-plugin-svg-icon
2
+
3
+ 一个轻量的 Vite 插件,只处理本地 SVG 图标,并尽量对齐 UnoCSS `preset-icons` 的使用方式与输出风格。
4
+
5
+ ## 特性
6
+
7
+ - 仅处理本地 SVG,不依赖 UnoCSS
8
+ - 支持 UnoCSS 风格的 `prefix`、`collections`、`FileSystemIconLoader`、`addCurrentFill`
9
+ - 内部已集成 `@iconify/utils`,无需在业务项目中额外单独安装和导入
10
+ - 默认类名前缀为 `i-`,支持自定义和多前缀
11
+ - 支持 `safelist`
12
+ - 支持 `extraProperties`
13
+ - 支持 `scale`、`unit`
14
+ - 支持 `mode: 'mask' | 'bg' | 'auto'`
15
+ - 支持 SVG 大小阈值控制,默认超过 `10KB` 不注入 CSS,并输出警告
16
+ - 开发环境按 Vite 已加载模块增量扫描
17
+ - 构建阶段按配置范围全量扫描源码文本,不做 AST 解析
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ npm install vite-plugin-svg-icon
23
+ ```
24
+
25
+ ## 使用方式
26
+
27
+ ```ts
28
+ import { defineConfig } from "vite";
29
+ import { svgIconPlugin } from "vite-plugin-svg-icon";
30
+
31
+ export default defineConfig({
32
+ plugins: [
33
+ svgIconPlugin({
34
+ // 必填:定义本地图标集合
35
+ collections: {
36
+ // 简洁写法:仅配置 path 即可
37
+ svg: {
38
+ path: "src/assets/icons",
39
+ },
40
+ // 目录级 safelist,适合动态拼接类名的目录
41
+ svgDy: {
42
+ path: "src/assets/icons-dynamic",
43
+ safelist: true,
44
+ },
45
+ },
46
+ }),
47
+ ],
48
+ });
49
+ ```
50
+
51
+ 在应用入口引入虚拟样式:
52
+
53
+ ```ts
54
+ import "virtual:svg-icon.css";
55
+ ```
56
+
57
+ 然后在模板或字符串中直接使用类名:
58
+
59
+ ```html
60
+ <span class="i-svg:home"></span>
61
+ <span class="i-svg:user-add"></span>
62
+ <span class="i-svgDy:logo"></span>
63
+ <span class="i-svgDy:rainbow"></span>
64
+ <span class="i-svgDy:rainbow?mask"></span>
65
+ ```
66
+
67
+ 如果图标目录下存在 `home.svg` 与 `user/add.svg`,就会分别生成对应的 CSS。
68
+
69
+ ## 本地 Collections
70
+
71
+ ### 推荐简洁的 collection 对象写法:
72
+
73
+ ```ts
74
+ import { svgIconPlugin } from "vite-plugin-svg-icon";
75
+
76
+ svgIconPlugin({
77
+ collections: {
78
+ svg: {
79
+ path: "src/assets/icons",
80
+ },
81
+ svgDy: {
82
+ path: "src/assets/icons-dynamic",
83
+ safelist: true,
84
+ },
85
+ },
86
+ });
87
+ ```
88
+
89
+ - `path`:本地图标目录
90
+ - `transform` / `fn`:可选 SVG 处理函数
91
+ - `autoFillCurrentColor`:默认 `true`,会自动在根 `<svg>` 上补 `fill="currentColor"`,若已有 `fill` 则保持不变
92
+ - `safelist: true`:自动保留该目录下所有图标,适合动态图标目录
93
+
94
+ ### 也支持与 UnoCSS 接近的写法:
95
+
96
+ ```ts
97
+ import {
98
+ FileSystemIconLoader,
99
+ addCurrentFill,
100
+ svgIconPlugin,
101
+ } from "vite-plugin-svg-icon";
102
+
103
+ svgIconPlugin({
104
+ collections: {
105
+ svg: FileSystemIconLoader("src/assets/icons", addCurrentFill),
106
+ svgDy: FileSystemIconLoader("src/assets/icons-dynamic", addCurrentFill),
107
+ },
108
+ });
109
+ ```
110
+
111
+ - `FileSystemIconLoader(dir, transform?)`:从本地目录懒加载 SVG
112
+ - `addCurrentFill(svg)`:给未声明 `fill` 的根 `<svg>` 注入 `fill="currentColor"`,适合单色图标
113
+ - 目录中的 `user/add.svg` 会映射为 `user-add`
114
+
115
+ ## Demo
116
+
117
+ 仓库根目录提供了一个基于最新 `create-vite` 生成的 `demo` 项目,用于真实验证插件行为:
118
+
119
+ ```bash
120
+ cd demo
121
+ npm install
122
+ npm run dev
123
+ ```
124
+
125
+ 如果你希望直接在仓库根目录执行完整验证,可以运行:
126
+
127
+ ```bash
128
+ npm run verify
129
+ ```
130
+
131
+ 这个命令会自动完成:
132
+
133
+ - 插件源码类型检查
134
+ - 插件单元测试
135
+ - 插件构建
136
+ - `demo` 构建验证
137
+ - `demo` 开发服务器启动与虚拟 CSS 内容校验
138
+ - 超过 `10KB` 的 SVG 警告校验
139
+
140
+ ## 配置项
141
+
142
+ ```ts
143
+ interface SvgIconPluginOptions {
144
+ collections: Record<string, IconCollection>; // 必填:图标集合定义
145
+ prefix?: string | string[]; // 类名前缀,默认 'i-'
146
+ virtualModuleId?: string; // 虚拟 CSS 模块 ID,默认 'virtual:svg-icon.css'
147
+ scale?: number; // 图标尺寸缩放,默认 1
148
+ unit?: string; // 尺寸单位,默认 'em'
149
+ mode?: "mask" | "bg" | "auto"; // 默认渲染模式,默认 'auto'
150
+ maxSvgSize?: number; // SVG 大小上限(字节),默认 10 * 1024
151
+ contentInclude?: string[]; // 构建期扫描包含范围(glob)
152
+ contentExclude?: string[]; // 构建期扫描排除范围(glob)
153
+ safelist?: string[]; // 顶层保留类名(仍然有效)
154
+ extraProperties?: Record<string, string>; // 追加到所有图标规则的公共 CSS
155
+ }
156
+ ```
157
+
158
+ 其中 `collections` 支持三种形式:
159
+
160
+ ```ts
161
+ collections: {
162
+ svg: FileSystemIconLoader('src/icons', addCurrentFill),
163
+ svgDy: {
164
+ path: 'src/icons-dynamic',
165
+ safelist: true,
166
+ },
167
+ inline: {
168
+ home: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="..." /></svg>',
169
+ },
170
+ }
171
+ ```
172
+
173
+ ### `scale`
174
+
175
+ - 类型:`number`
176
+ - 默认值:`1`
177
+ - 含义:按当前字体大小 `1em` 缩放图标
178
+ - 输出方式:直接作用于生成 CSS 的 `width` 和 `height`,不会额外引入尺寸变量
179
+
180
+ ### `mode`
181
+
182
+ - `mask`:单色图标,使用 `mask` / `-webkit-mask`
183
+ - `bg`:背景图模式,保留 SVG 原始颜色
184
+ - `auto`:默认值;检测到多色、渐变、pattern、image 等复杂着色时自动退化为背景图模式
185
+
186
+ ### `extraProperties`
187
+
188
+ 与 UnoCSS 一样,可以给所有图标附加公共 CSS 属性,例如:
189
+
190
+ ```ts
191
+ extraProperties: {
192
+ display: 'inline-block',
193
+ 'vertical-align': 'middle',
194
+ }
195
+ ```
196
+
197
+ ## 扫描策略
198
+
199
+ - 开发环境:在 `transform` / `transformIndexHtml` 中扫描当前被 Vite 处理的源码文本,只更新受影响文件的图标集合
200
+ - 生产构建:在虚拟 CSS 模块加载时,对 `contentInclude` 范围进行并发全量扫描
201
+ - 提取方式:直接把源码当纯文本处理,通过前缀和 collection 解析候选类名,不解析 AST
202
+ - 兼容类似 `hover:i-svg:home`、`dark:i-svgDy:bell?bg` 的变体写法
203
+ - 对 `safelist: true` 的文件系统 collection,插件会直接基于目录索引生成保留项,不需要用户手写 `readdirSync()`
204
+ - 开发环境会监听文件系统 collection 目录中的 SVG 新增、删除与修改,并自动刷新虚拟 CSS
205
+
206
+ ## 性能设计
207
+
208
+ - 增量扫描:开发环境只处理当前被 Vite 触达或变更的源码文件,不在启动时扫描整个项目
209
+ - 轻量指纹:源码变更判断使用内容指纹而不是热路径上的 `fs.stat()`,这不仅在超大项目里有意义,在中小项目的高频 HMR 场景下同样能减少系统调用
210
+ - 快速失败:源码文本在进入正则提取前,会先检查是否包含任一图标前缀,避免大量无关文件进入完整扫描
211
+ - 并发去重:文件系统 collection 使用 `refreshPromise` 合并并发刷新;单个图标加载也会做 in-flight 去重,避免重复读取同一个 SVG
212
+ - 原子替换:collection 刷新时先构建新索引,再一次性替换旧索引,避免并发读取时看到 `clear()` 后的中间态
213
+ - 增量 usage 索引:源码图标使用关系按文件差量维护,虚拟 CSS 重建时直接消费当前活跃 usage,而不是每次重新合并全部扫描结果
214
+
215
+ ## 生成样式
216
+
217
+ 生成结果参考 UnoCSS 的图标输出结构:
218
+
219
+ ```css
220
+ .i-svg\:home {
221
+ --svg-c-icon: url("data:image/svg+xml;utf8,...");
222
+ }
223
+
224
+ .i-svg\:home {
225
+ -webkit-mask: var(--svg-c-icon) no-repeat;
226
+ mask: var(--svg-c-icon) no-repeat;
227
+ -webkit-mask-size: 100% 100%;
228
+ mask-size: 100% 100%;
229
+ background-color: currentColor;
230
+ color: inherit;
231
+ width: 1em;
232
+ height: 1em;
233
+ }
234
+ ```
235
+
236
+ ## 限制
237
+
238
+ - 当前只处理本地 collections,不处理第三方图标集下载
239
+ - 嵌套目录会被展开为连字符,例如 `user/add.svg -> i-svg:user-add`
240
+ - `auto` 模式采用启发式判断多色 SVG,极少数边缘 SVG 仍建议手动使用 `?mask` / `?bg`
package/dist/core.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { CustomIconLoader, IconMode, IconRecord, LoadedIconSource, ParsedIconUsage, ResolvedSvgIconPluginOptions, SvgIconPluginOptions } from './types';
2
+ /** 解析并规范化插件配置,包括 collections 与各项默认值。 */
3
+ export declare function resolveOptions(options: SvgIconPluginOptions, root: string): Promise<ResolvedSvgIconPluginOptions>;
4
+ /** 二次暴露 Iconify 的文件系统 loader,并附加本插件内部需要的元信息。 */
5
+ export declare function FileSystemIconLoader(dir: string, transform?: (svg: string) => string | Promise<string>): CustomIconLoader;
6
+ /** 当根 svg 尚未声明 fill 时,为其补上 fill="currentColor"。 */
7
+ export declare function addCurrentFill(svg: string): string;
8
+ /** 将相对 svg 文件路径转换为类名匹配阶段使用的规范化图标名。 */
9
+ export declare function normalizeIconName(relativeFilePath: string): string;
10
+ /** 将 collection 级别的自动 safelist 扩展为标准图标类使用记录。 */
11
+ export declare function extractCollectionSafelistUsages(options: Pick<ResolvedSvgIconPluginOptions, 'collections' | 'prefixes'>): Map<string, ParsedIconUsage>;
12
+ /** 直接从任意源码文本中提取图标使用记录,不依赖 AST 解析。 */
13
+ export declare function extractIconUsagesFromCode(code: string, options: Pick<ResolvedSvgIconPluginOptions, 'prefixes' | 'collections' | 'mode'>): Map<string, ParsedIconUsage>;
14
+ /** 从配置中的 safelist 条目里提取图标使用记录。 */
15
+ export declare function extractIconUsagesFromSafelist(entries: string[], options: Pick<ResolvedSvgIconPluginOptions, 'prefixes' | 'collections' | 'mode'>): Map<string, ParsedIconUsage>;
16
+ /** 构建规范化的图标记录,供大小判断、mode 决策与 CSS 生成复用。 */
17
+ export declare function createIconRecord(collection: string, icon: string, source: LoadedIconSource, maxSvgSize: number): IconRecord;
18
+ /** 按 mask/bg 渲染模式分组,为已解析的图标使用记录生成 CSS 规则。 */
19
+ export declare function generateCss(entries: Array<{
20
+ usage: ParsedIconUsage;
21
+ icon: IconRecord;
22
+ mode: Exclude<IconMode, 'auto'>;
23
+ }>, options: Pick<ResolvedSvgIconPluginOptions, 'scale' | 'unit' | 'extraProperties'>): string;
24
+ /** 解析最终渲染模式;当 mode 为 auto 时会自动判断是否应退化为 bg。 */
25
+ export declare function resolveIconRenderMode(svg: string, mode: IconMode): Exclude<IconMode, 'auto'>;
26
+ /** 判断文件路径是否位于给定目录集合中的任意目录下。 */
27
+ export declare function isFileInDirectories(filePath: string, directories: string[]): boolean;
28
+ /** 使用 Iconify 的序列化器将 SVG 转为优化过的 data URL。 */
29
+ export declare function toSvgDataUri(svg: string): string;
30
+ /** 将 Windows 路径统一转换为斜杠形式,便于内部稳定比较。 */
31
+ export declare function normalizeSlashes(value: string): string;
32
+ /** 去掉 Vite 模块 id 上附带的 query 与 hash 后缀。 */
33
+ export declare function stripQuery(id: string): string;
34
+ export type { IconMode, SvgIconPluginOptions } from './types';
package/dist/core.js ADDED
@@ -0,0 +1,425 @@
1
+ import path from 'node:path';
2
+ import { glob } from 'tinyglobby';
3
+ import { FileSystemIconLoader as IconifyFileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders';
4
+ import { svgToData as iconifySvgToData } from '@iconify/utils/lib/svg/url';
5
+ const DEFAULT_CONTENT_INCLUDE = ['**/*.{html,js,ts,jsx,tsx,vue,md,mdx,astro,svelte}'];
6
+ const DEFAULT_CONTENT_EXCLUDE = [
7
+ '**/node_modules/**',
8
+ '**/.git/**',
9
+ '**/dist/**',
10
+ '**/*.svg',
11
+ '**/*.{png,jpg,jpeg,gif,webp,avif,ico,woff,woff2,ttf,eot,mp4,mp3,pdf,zip}',
12
+ ];
13
+ const SVG_MODE_SUFFIX = /(?:\?(mask|bg|auto))$/;
14
+ const TOKEN_MATCHER = /[A-Za-z0-9_:?-]+/g;
15
+ /** 解析并规范化插件配置,包括 collections 与各项默认值。 */
16
+ export async function resolveOptions(options, root) {
17
+ const prefixes = toArray(options.prefix ?? 'i-')
18
+ .map((prefix) => prefix.trim())
19
+ .filter(Boolean);
20
+ const collections = new Map();
21
+ for (const [name, collection] of Object.entries(options.collections)) {
22
+ collections.set(name, await resolveCollection(name, collection, root));
23
+ }
24
+ return {
25
+ collections,
26
+ prefixes,
27
+ virtualModuleId: options.virtualModuleId?.trim() || 'virtual:svg-icon.css',
28
+ resolvedVirtualModuleId: `\0${options.virtualModuleId?.trim() || 'virtual:svg-icon.css'}`,
29
+ scale: options.scale ?? 1,
30
+ unit: options.unit?.trim() || 'em',
31
+ mode: options.mode ?? 'auto',
32
+ maxSvgSize: options.maxSvgSize ?? 10 * 1024,
33
+ contentInclude: options.contentInclude?.length
34
+ ? options.contentInclude
35
+ : DEFAULT_CONTENT_INCLUDE,
36
+ contentExclude: options.contentExclude?.length
37
+ ? options.contentExclude
38
+ : DEFAULT_CONTENT_EXCLUDE,
39
+ safelist: options.safelist ?? [],
40
+ extraProperties: options.extraProperties ?? {},
41
+ root,
42
+ };
43
+ }
44
+ /** 二次暴露 Iconify 的文件系统 loader,并附加本插件内部需要的元信息。 */
45
+ export function FileSystemIconLoader(dir, transform) {
46
+ const loader = IconifyFileSystemIconLoader(dir, transform);
47
+ loader.__svgIconLoaderMeta = {
48
+ kind: 'fs',
49
+ dir,
50
+ transform,
51
+ };
52
+ return loader;
53
+ }
54
+ /** 当根 svg 尚未声明 fill 时,为其补上 fill="currentColor"。 */
55
+ export function addCurrentFill(svg) {
56
+ if (/fill\s*=\s*['"]currentColor['"]/i.test(svg)) {
57
+ return svg;
58
+ }
59
+ // 对齐 UnoCSS 本地 collection 的用法,让单色图标默认继承文本颜色。
60
+ return svg.replace(/<svg\b([^>]*)>/i, (match, attrs) => {
61
+ if (/\sfill\s*=/.test(attrs)) {
62
+ return match;
63
+ }
64
+ return `<svg${attrs} fill="currentColor">`;
65
+ });
66
+ }
67
+ /** 将相对 svg 文件路径转换为类名匹配阶段使用的规范化图标名。 */
68
+ export function normalizeIconName(relativeFilePath) {
69
+ return stripExtension(relativeFilePath)
70
+ .split(/[\\/]+/)
71
+ .map((segment) => segment.trim().replace(/[^a-zA-Z0-9_-]+/g, '-'))
72
+ .filter(Boolean)
73
+ .join('-');
74
+ }
75
+ /** 将 collection 级别的自动 safelist 扩展为标准图标类使用记录。 */
76
+ export function extractCollectionSafelistUsages(options) {
77
+ const usages = new Map();
78
+ const prefix = options.prefixes[0];
79
+ for (const [collectionName, collection] of options.collections) {
80
+ if (!collection.autoSafelist || !collection.sortedIconNames)
81
+ continue;
82
+ for (const iconName of collection.sortedIconNames) {
83
+ const className = `${prefix}${collectionName}:${iconName}`;
84
+ usages.set(className, {
85
+ className,
86
+ collection: collectionName,
87
+ icon: iconName,
88
+ mode: 'auto',
89
+ });
90
+ }
91
+ }
92
+ return usages;
93
+ }
94
+ /** 直接从任意源码文本中提取图标使用记录,不依赖 AST 解析。 */
95
+ export function extractIconUsagesFromCode(code, options) {
96
+ const usages = new Map();
97
+ if (!options.prefixes.some((prefix) => code.includes(prefix))) {
98
+ return usages;
99
+ }
100
+ for (const token of code.match(TOKEN_MATCHER) ?? []) {
101
+ const usage = parseIconUsage(token, options);
102
+ if (usage) {
103
+ usages.set(usage.className, usage);
104
+ }
105
+ }
106
+ return usages;
107
+ }
108
+ /** 从配置中的 safelist 条目里提取图标使用记录。 */
109
+ export function extractIconUsagesFromSafelist(entries, options) {
110
+ const usages = new Map();
111
+ for (const entry of entries) {
112
+ const value = entry.trim();
113
+ if (!value)
114
+ continue;
115
+ const usage = parseIconUsage(value, options) ||
116
+ parseIconUsage(`${options.prefixes[0]}${value}`, options);
117
+ if (usage) {
118
+ usages.set(usage.className, usage);
119
+ }
120
+ }
121
+ return usages;
122
+ }
123
+ /** 构建规范化的图标记录,供大小判断、mode 决策与 CSS 生成复用。 */
124
+ export function createIconRecord(collection, icon, source, maxSvgSize) {
125
+ const cleanedSvg = cleanupSvg(source.svg);
126
+ const size = Buffer.byteLength(cleanedSvg);
127
+ return {
128
+ collection,
129
+ icon,
130
+ filePath: source.filePath,
131
+ svg: cleanedSvg,
132
+ size,
133
+ oversize: size > maxSvgSize,
134
+ dataUri: size > maxSvgSize ? undefined : toSvgDataUri(cleanedSvg),
135
+ };
136
+ }
137
+ /** 按 mask/bg 渲染模式分组,为已解析的图标使用记录生成 CSS 规则。 */
138
+ export function generateCss(entries, options) {
139
+ const maskSelectors = [];
140
+ const bgSelectors = [];
141
+ const variableRules = [];
142
+ for (const entry of entries) {
143
+ const selector = `.${escapeClassName(entry.usage.className)}`;
144
+ // 每个类只保存一次 SVG 数据,再由共享规则通过 --svg-c-icon 引用。
145
+ variableRules.push(`${selector}{--svg-c-icon:url("${entry.icon.dataUri}");}`);
146
+ if (entry.mode === 'mask') {
147
+ maskSelectors.push(selector);
148
+ }
149
+ else {
150
+ bgSelectors.push(selector);
151
+ }
152
+ }
153
+ const rules = [];
154
+ if (maskSelectors.length) {
155
+ rules.push(createSharedRule(maskSelectors, 'mask', options));
156
+ }
157
+ if (bgSelectors.length) {
158
+ rules.push(createSharedRule(bgSelectors, 'bg', options));
159
+ }
160
+ rules.push(...variableRules);
161
+ return rules.join('\n');
162
+ }
163
+ /** 解析最终渲染模式;当 mode 为 auto 时会自动判断是否应退化为 bg。 */
164
+ export function resolveIconRenderMode(svg, mode) {
165
+ if (mode !== 'auto') {
166
+ return mode;
167
+ }
168
+ // 多色或带渐变的 SVG 使用背景图模式更安全,避免 mask 丢失颜色信息。
169
+ if (hasComplexPaint(svg)) {
170
+ return 'bg';
171
+ }
172
+ return 'mask';
173
+ }
174
+ /** 判断文件路径是否位于给定目录集合中的任意目录下。 */
175
+ export function isFileInDirectories(filePath, directories) {
176
+ const normalizedFile = normalizeSlashes(path.resolve(filePath));
177
+ return directories.some((dir) => normalizedFile.startsWith(normalizeSlashes(path.resolve(dir)) + '/'));
178
+ }
179
+ /** 使用 Iconify 的序列化器将 SVG 转为优化过的 data URL。 */
180
+ export function toSvgDataUri(svg) {
181
+ // 直接复用 Iconify 的优化实现,使输出更接近其生态行为。
182
+ return iconifySvgToData(svg);
183
+ }
184
+ /** 将 Windows 路径统一转换为斜杠形式,便于内部稳定比较。 */
185
+ export function normalizeSlashes(value) {
186
+ return value.replace(/\\/g, '/');
187
+ }
188
+ /** 去掉 Vite 模块 id 上附带的 query 与 hash 后缀。 */
189
+ export function stripQuery(id) {
190
+ return id.replace(/[?#].*$/, '');
191
+ }
192
+ /** 将单个 token 解析为具体的图标使用记录,兼容连接符与冒号两种写法。 */
193
+ function parseIconUsage(token, options) {
194
+ const className = token.trim();
195
+ if (!className)
196
+ return null;
197
+ const collectionNames = [...options.collections.keys()].sort((left, right) => right.length - left.length);
198
+ const candidates = [className, ...getVariantCandidates(className)];
199
+ for (const candidate of candidates) {
200
+ let requestedMode = options.mode;
201
+ let baseName = candidate;
202
+ const modeMatch = baseName.match(SVG_MODE_SUFFIX);
203
+ if (modeMatch?.[1]) {
204
+ requestedMode = modeMatch[1];
205
+ baseName = baseName.slice(0, -modeMatch[0].length);
206
+ }
207
+ for (const prefix of options.prefixes) {
208
+ if (!baseName.startsWith(prefix))
209
+ continue;
210
+ const rest = baseName.slice(prefix.length);
211
+ for (const collection of collectionNames) {
212
+ if (rest.startsWith(`${collection}:`)) {
213
+ const icon = rest.slice(collection.length + 1);
214
+ if (icon) {
215
+ return { className: candidate, collection, icon, mode: requestedMode };
216
+ }
217
+ }
218
+ if (rest.startsWith(`${collection}-`)) {
219
+ const icon = rest.slice(collection.length + 1);
220
+ if (icon) {
221
+ return { className: candidate, collection, icon, mode: requestedMode };
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
227
+ return null;
228
+ }
229
+ /** 将任意支持的 collection 输入形式统一解析为内部 loader 抽象。 */
230
+ async function resolveCollection(name, collection, root) {
231
+ if (isFileSystemCollectionConfig(collection)) {
232
+ const transform = composeTransforms(collection.autoFillCurrentColor === false ? undefined : addCurrentFill, collection.transform ?? collection.fn);
233
+ return resolveFileSystemCollection(name, collection.path, root, transform, collection.safelist ?? false);
234
+ }
235
+ if (typeof collection === 'function' && collection.__svgIconLoaderMeta?.kind === 'fs') {
236
+ return resolveFileSystemCollection(name, collection.__svgIconLoaderMeta.dir, root, collection.__svgIconLoaderMeta.transform, false, collection);
237
+ }
238
+ if (typeof collection === 'function') {
239
+ return {
240
+ name,
241
+ load: async (iconName) => {
242
+ const svg = await collection(iconName);
243
+ return svg ? { svg } : null;
244
+ },
245
+ };
246
+ }
247
+ return {
248
+ name,
249
+ load: async (iconName) => {
250
+ const source = collection[iconName];
251
+ if (!source)
252
+ return null;
253
+ return {
254
+ svg: typeof source === 'function' ? await source() : source,
255
+ };
256
+ },
257
+ };
258
+ }
259
+ /** 构建可刷新的文件系统 collection 索引,并委托 Iconify loader 读取 SVG。 */
260
+ async function resolveFileSystemCollection(name, dir, root, transform, autoSafelist = false, loader = FileSystemIconLoader(path.resolve(root, dir), transform)) {
261
+ const fsDir = path.resolve(root, dir);
262
+ let iconToFile = new Map();
263
+ let refreshPromise = null;
264
+ const collection = {
265
+ name,
266
+ autoSafelist,
267
+ fsDir,
268
+ normalizedFsDir: normalizeSlashes(fsDir),
269
+ fileToIconName: new Map(),
270
+ sortedIconNames: [],
271
+ refresh: async () => {
272
+ if (refreshPromise) {
273
+ return refreshPromise;
274
+ }
275
+ refreshPromise = (async () => {
276
+ const files = await glob('**/*.svg', {
277
+ cwd: fsDir,
278
+ absolute: true,
279
+ });
280
+ const nextIconToFile = new Map();
281
+ const nextFileToIconName = new Map();
282
+ for (const file of files) {
283
+ const relativeFilePath = normalizeSlashes(path.relative(fsDir, file));
284
+ const iconName = normalizeIconName(relativeFilePath);
285
+ nextIconToFile.set(iconName, file);
286
+ nextFileToIconName.set(normalizeSlashes(path.resolve(file)), iconName);
287
+ }
288
+ iconToFile = nextIconToFile;
289
+ collection.fileToIconName = nextFileToIconName;
290
+ collection.sortedIconNames = [...nextIconToFile.keys()].sort();
291
+ })().finally(() => {
292
+ refreshPromise = null;
293
+ });
294
+ return refreshPromise;
295
+ },
296
+ load: async (iconName) => {
297
+ let filePath = iconToFile.get(iconName);
298
+ if (!filePath) {
299
+ await collection.refresh?.();
300
+ filePath = iconToFile.get(iconName);
301
+ }
302
+ if (!filePath)
303
+ return null;
304
+ const svg = await loader(iconName);
305
+ if (!svg)
306
+ return null;
307
+ return {
308
+ svg,
309
+ filePath: normalizeSlashes(path.resolve(filePath)),
310
+ };
311
+ },
312
+ };
313
+ await collection.refresh?.();
314
+ return collection;
315
+ }
316
+ /** 在 mode 判断与 data-url 序列化前,对 SVG 做轻量清理。 */
317
+ function cleanupSvg(svg) {
318
+ return svg
319
+ .replace(/<\?xml[\s\S]*?\?>/g, '')
320
+ .replace(/<!DOCTYPE[\s\S]*?>/gi, '')
321
+ .replace(/<!--[\s\S]*?-->/g, '')
322
+ .replace(/\r?\n|\r/g, ' ')
323
+ .replace(/\s{2,}/g, ' ')
324
+ .trim();
325
+ }
326
+ /** 为 mask 模式或 bg 模式的选择器生成共享 CSS 规则。 */
327
+ function createSharedRule(selectors, mode, options) {
328
+ const size = `${formatScale(options.scale)}${options.unit}`;
329
+ const declarations = mode === 'mask'
330
+ ? [
331
+ '-webkit-mask:var(--svg-c-icon) no-repeat;',
332
+ 'mask:var(--svg-c-icon) no-repeat;',
333
+ '-webkit-mask-size:100% 100%;',
334
+ 'mask-size:100% 100%;',
335
+ 'background-color:currentColor;',
336
+ 'color:inherit;',
337
+ `width:${size};`,
338
+ `height:${size};`,
339
+ ]
340
+ : [
341
+ 'background:var(--svg-c-icon) no-repeat;',
342
+ 'background-size:100% 100%;',
343
+ 'background-color:transparent;',
344
+ 'color:inherit;',
345
+ `width:${size};`,
346
+ `height:${size};`,
347
+ ];
348
+ for (const [property, value] of Object.entries(options.extraProperties)) {
349
+ declarations.push(`${property}:${value};`);
350
+ }
351
+ return `${selectors.join(',')}{${declarations.join('')}}`;
352
+ }
353
+ /** 判断 SVG 是否包含多色或复杂着色,从而应使用 bg 模式。 */
354
+ function hasComplexPaint(svg) {
355
+ if (/<(linearGradient|radialGradient|pattern|image)\b/i.test(svg)) {
356
+ return true;
357
+ }
358
+ const values = new Set();
359
+ const attrMatcher = /\b(?:fill|stroke|stop-color|color)\s*=\s*['"]([^'"]+)['"]/gi;
360
+ for (const match of svg.matchAll(attrMatcher)) {
361
+ const normalized = normalizePaintValue(match[1]);
362
+ if (!normalized)
363
+ continue;
364
+ if (normalized.startsWith('url('))
365
+ return true;
366
+ values.add(normalized);
367
+ }
368
+ const styleMatcher = /\b(?:fill|stroke|stop-color|color)\s*:\s*([^;"]+)/gi;
369
+ for (const match of svg.matchAll(styleMatcher)) {
370
+ const normalized = normalizePaintValue(match[1]);
371
+ if (!normalized)
372
+ continue;
373
+ if (normalized.startsWith('url('))
374
+ return true;
375
+ values.add(normalized);
376
+ }
377
+ return values.size > 1;
378
+ }
379
+ /** 规范化颜色值,便于 auto 模式稳定比较着色特征。 */
380
+ function normalizePaintValue(value) {
381
+ const normalized = value.trim().replace(/\s+/g, '').toLowerCase();
382
+ if (!normalized)
383
+ return null;
384
+ if (['none', 'currentcolor', 'inherit', 'transparent', 'context-fill', 'context-stroke'].includes(normalized)) {
385
+ return null;
386
+ }
387
+ return normalized;
388
+ }
389
+ /** 对生成的 CSS 选择器做转义,避免特殊字符影响解析。 */
390
+ function escapeClassName(value) {
391
+ return value.replace(/([^a-zA-Z0-9_-])/g, '\\$1');
392
+ }
393
+ /** 移除文件路径中的最后一个扩展名。 */
394
+ function stripExtension(filePath) {
395
+ return filePath.replace(/\.[^.]+$/, '');
396
+ }
397
+ /** 将单值或数组统一规范为数组。 */
398
+ function toArray(value) {
399
+ return Array.isArray(value) ? value : [value];
400
+ }
401
+ /** 将 scale 数值格式化为更紧凑、适合 CSS 输出的字符串。 */
402
+ function formatScale(scale) {
403
+ const text = String(scale);
404
+ return text.endsWith('.0') ? text.slice(0, -2) : text;
405
+ }
406
+ /** 返回去掉变体前缀后的候选值,便于解析 hover:i-svg:home 这类类名。 */
407
+ function getVariantCandidates(token) {
408
+ const indices = [];
409
+ for (let index = token.indexOf(':'); index !== -1; index = token.indexOf(':', index + 1)) {
410
+ indices.push(index);
411
+ }
412
+ return indices.map((index) => token.slice(index + 1));
413
+ }
414
+ /** 将两个可选的 SVG transform 组合为一条串行处理管线。 */
415
+ function composeTransforms(first, second) {
416
+ if (!first)
417
+ return second;
418
+ if (!second)
419
+ return first;
420
+ return async (svg) => second(await first(svg));
421
+ }
422
+ /** 判断当前输入是否为本地文件系统 collection 的对象简写形式。 */
423
+ function isFileSystemCollectionConfig(value) {
424
+ return typeof value === 'object' && value !== null && 'path' in value;
425
+ }
@@ -0,0 +1,2 @@
1
+ export { FileSystemIconLoader, addCurrentFill, type IconMode, type SvgIconPluginOptions, } from './core';
2
+ export { svgIconPlugin } from './plugin';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { FileSystemIconLoader, addCurrentFill, } from './core';
2
+ export { svgIconPlugin } from './plugin';
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from 'vite';
2
+ import { type SvgIconPluginOptions } from './core';
3
+ /** 创建插件实例,并维护开发态与构建态共享的运行时状态。 */
4
+ export declare function svgIconPlugin(userOptions: SvgIconPluginOptions): Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,346 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { glob } from 'tinyglobby';
4
+ import { createFilter, normalizePath } from 'vite';
5
+ import { createIconRecord, extractCollectionSafelistUsages, extractIconUsagesFromCode, extractIconUsagesFromSafelist, generateCss, isFileInDirectories, resolveOptions, resolveIconRenderMode, stripQuery, } from './core';
6
+ /** 创建插件实例,并维护开发态与构建态共享的运行时状态。 */
7
+ export function svgIconPlugin(userOptions) {
8
+ const state = {
9
+ iconRecords: new Map(),
10
+ iconRecordPromises: new Map(),
11
+ sourceRecords: new Map(),
12
+ usageOwners: new Map(),
13
+ sourceUsages: new Map(),
14
+ staticUsages: new Map(),
15
+ activeUsages: new Map(),
16
+ warnedOversizeIcons: new Set(),
17
+ buildScanned: false,
18
+ };
19
+ return {
20
+ name: 'vite-plugin-svg-icon',
21
+ enforce: 'pre',
22
+ // 在 Vite 配置最终确定后,统一解析用户配置。
23
+ async configResolved(config) {
24
+ state.config = config;
25
+ state.options = await resolveOptions(userOptions, config.root);
26
+ state.filter = createFilter(state.options.contentInclude, state.options.contentExclude);
27
+ syncStaticUsages(state);
28
+ },
29
+ // 在开发模式下为文件系统 collection 注册新增、修改、删除监听。
30
+ configureServer(server) {
31
+ state.server = server;
32
+ registerCollectionWatchers(state, server);
33
+ },
34
+ // 将对外暴露的虚拟 CSS id 映射为内部解析 id。
35
+ resolveId(id) {
36
+ if (id === state.options?.virtualModuleId) {
37
+ return state.options.resolvedVirtualModuleId;
38
+ }
39
+ return null;
40
+ },
41
+ // 根据已扫描源码与 safelist 图标生成当前虚拟 CSS 内容。
42
+ async load(id) {
43
+ if (!state.options || id !== state.options.resolvedVirtualModuleId) {
44
+ return null;
45
+ }
46
+ // 构建模式下会一次性扫描完整源码集合,开发模式下则保持增量更新。
47
+ if (state.config?.command === 'build' && !state.buildScanned) {
48
+ await scanAllContentFiles(state);
49
+ state.buildScanned = true;
50
+ }
51
+ return getCss(state);
52
+ },
53
+ // 对通过内容过滤器的源码模块做增量扫描。
54
+ async transform(code, id) {
55
+ if (!state.options || !state.filter)
56
+ return null;
57
+ const filePath = cleanFileId(id);
58
+ if (!state.filter(filePath))
59
+ return null;
60
+ await updateSourceRecord(state, filePath, code);
61
+ return null;
62
+ },
63
+ // 扫描 HTML 入口内容,确保只出现在 index.html 中的图标类也能被发现。
64
+ async transformIndexHtml(html, ctx) {
65
+ if (!state.options)
66
+ return html;
67
+ const filePath = getHtmlFilePath(ctx, state.options.root);
68
+ await updateSourceRecord(state, filePath, html);
69
+ return html;
70
+ },
71
+ // 开发模式下,当源码文件或本地 SVG 变化时使图标 CSS 缓存失效。
72
+ async handleHotUpdate(ctx) {
73
+ if (!state.options || !state.filter)
74
+ return;
75
+ const filePath = cleanFileId(ctx.file);
76
+ if (filePath.endsWith('.svg') && isCollectionSvgFile(state, filePath)) {
77
+ await invalidateIconCacheByFile(state, filePath);
78
+ return invalidateVirtualCss(state);
79
+ }
80
+ if (!state.filter(filePath)) {
81
+ return;
82
+ }
83
+ let code;
84
+ try {
85
+ code = await ctx.read();
86
+ }
87
+ catch {
88
+ removeSourceRecord(state, filePath);
89
+ return invalidateVirtualCss(state);
90
+ }
91
+ await updateSourceRecord(state, filePath, code);
92
+ return invalidateVirtualCss(state);
93
+ },
94
+ };
95
+ }
96
+ /** 在构建模式下扫描所有配置命中的源码文件。 */
97
+ async function scanAllContentFiles(state) {
98
+ if (!state.options)
99
+ return;
100
+ const files = await glob(state.options.contentInclude, {
101
+ cwd: state.options.root,
102
+ absolute: true,
103
+ ignore: state.options.contentExclude,
104
+ });
105
+ await Promise.all(files.map((file) => scanSourceFile(state, file)));
106
+ }
107
+ /** 从磁盘读取单个源码文件,并更新其中提取出的图标使用记录。 */
108
+ async function scanSourceFile(state, filePath) {
109
+ try {
110
+ const contents = await fs.readFile(filePath, 'utf8');
111
+ await updateSourceRecord(state, filePath, contents);
112
+ }
113
+ catch {
114
+ removeSourceRecord(state, filePath);
115
+ }
116
+ }
117
+ /** 基于源码内容指纹缓存,保存某个源码文件最新提取出的图标使用记录。 */
118
+ async function updateSourceRecord(state, filePath, code) {
119
+ if (!state.options)
120
+ return;
121
+ const normalizedPath = normalizePath(filePath);
122
+ const fingerprint = createCodeFingerprint(code);
123
+ const cached = state.sourceRecords.get(normalizedPath);
124
+ if (cached && cached.fingerprint === fingerprint) {
125
+ return;
126
+ }
127
+ const nextUsages = extractIconUsagesFromCode(code, state.options);
128
+ applySourceUsagesDelta(state, normalizedPath, cached?.usages ?? new Map(), nextUsages);
129
+ // 只保留当前文件最新内容提取出的结果,便于低成本处理 HMR 更新。
130
+ state.sourceRecords.set(normalizedPath, {
131
+ filePath: normalizedPath,
132
+ fingerprint,
133
+ usages: nextUsages,
134
+ });
135
+ }
136
+ /** 将当前已使用的所有图标类解析为虚拟模块需要输出的 CSS 规则。 */
137
+ async function getCss(state) {
138
+ if (!state.options)
139
+ return '';
140
+ const entries = [];
141
+ for (const usage of state.activeUsages.values()) {
142
+ const icon = await loadIconRecord(state, usage.collection, usage.icon);
143
+ if (!icon)
144
+ continue;
145
+ if (icon.oversize || !icon.dataUri) {
146
+ warnOversizeIcon(state, usage, icon);
147
+ continue;
148
+ }
149
+ entries.push({
150
+ usage,
151
+ icon,
152
+ mode: resolveIconRenderMode(icon.svg, usage.mode),
153
+ });
154
+ }
155
+ return generateCss(entries, state.options);
156
+ }
157
+ /** 让 Vite 模块图中的虚拟 CSS 模块失效,以触发重新生成。 */
158
+ function invalidateVirtualCss(state) {
159
+ if (!state.server || !state.options) {
160
+ return;
161
+ }
162
+ const module = state.server.moduleGraph.getModuleById(state.options.resolvedVirtualModuleId);
163
+ if (!module) {
164
+ return;
165
+ }
166
+ state.server.moduleGraph.invalidateModule(module);
167
+ return [module];
168
+ }
169
+ /** 将 Vite 模块 id 规范化为不带 query/hash 的普通文件路径。 */
170
+ function cleanFileId(id) {
171
+ return normalizePath(stripQuery(id));
172
+ }
173
+ /** 返回 transformIndexHtml 阶段应使用的实际 HTML 文件路径。 */
174
+ function getHtmlFilePath(ctx, root) {
175
+ if (ctx && 'filename' in ctx && typeof ctx.filename === 'string') {
176
+ return normalizePath(ctx.filename);
177
+ }
178
+ return normalizePath(path.join(root, 'index.html'));
179
+ }
180
+ /** 从已解析的 collection 中加载单个图标,并按 collection/icon 维度做缓存。 */
181
+ async function loadIconRecord(state, collection, icon) {
182
+ if (!state.options)
183
+ return null;
184
+ const cacheKey = `${collection}:${icon}`;
185
+ if (state.iconRecords.has(cacheKey)) {
186
+ return state.iconRecords.get(cacheKey) ?? null;
187
+ }
188
+ if (state.iconRecordPromises.has(cacheKey)) {
189
+ return state.iconRecordPromises.get(cacheKey) ?? null;
190
+ }
191
+ const loader = state.options.collections.get(collection);
192
+ if (!loader) {
193
+ state.iconRecords.set(cacheKey, null);
194
+ return null;
195
+ }
196
+ // 缓存进行中的加载任务,避免并发场景下重复读取同一个 SVG。
197
+ const promise = (async () => {
198
+ const source = await loader.load(icon);
199
+ if (!source) {
200
+ state.iconRecords.set(cacheKey, null);
201
+ return null;
202
+ }
203
+ const record = createIconRecord(collection, icon, source, state.options.maxSvgSize);
204
+ state.iconRecords.set(cacheKey, record);
205
+ return record;
206
+ })();
207
+ state.iconRecordPromises.set(cacheKey, promise);
208
+ try {
209
+ return await promise;
210
+ }
211
+ finally {
212
+ state.iconRecordPromises.delete(cacheKey);
213
+ }
214
+ }
215
+ /** 当 SVG 超过 maxSvgSize 被跳过时,仅输出一次告警。 */
216
+ function warnOversizeIcon(state, usage, icon) {
217
+ const warnKey = icon.filePath ?? `${icon.collection}:${icon.icon}`;
218
+ if (state.warnedOversizeIcons.has(warnKey))
219
+ return;
220
+ state.warnedOversizeIcons.add(warnKey);
221
+ state.config?.logger.warn(`[vite-plugin-svg-icon] Skipped "${usage.className}" because "${icon.filePath ?? `${icon.collection}:${icon.icon}`}" is larger than ${state.options.maxSvgSize} bytes.`);
222
+ }
223
+ /** 刷新文件系统 collection 索引,并清理变化 SVG 对应的旧缓存。 */
224
+ async function invalidateIconCacheByFile(state, filePath) {
225
+ if (!state.options)
226
+ return;
227
+ const normalizedPath = normalizePath(filePath);
228
+ for (const [collectionName, collection] of state.options.collections) {
229
+ if (!collection.fsDir || !collection.fileToIconName)
230
+ continue;
231
+ if (!isFileInDirectories(normalizedPath, [collection.fsDir]))
232
+ continue;
233
+ const previousIconName = collection.fileToIconName.get(normalizedPath);
234
+ await collection.refresh?.();
235
+ if (previousIconName) {
236
+ state.iconRecords.delete(`${collectionName}:${previousIconName}`);
237
+ state.iconRecordPromises.delete(`${collectionName}:${previousIconName}`);
238
+ }
239
+ const nextIconName = collection.fileToIconName.get(normalizedPath);
240
+ if (nextIconName && nextIconName !== previousIconName) {
241
+ state.iconRecords.delete(`${collectionName}:${nextIconName}`);
242
+ state.iconRecordPromises.delete(`${collectionName}:${nextIconName}`);
243
+ }
244
+ }
245
+ syncStaticUsages(state);
246
+ }
247
+ /** 判断某个文件路径是否属于任一已配置的文件系统图标 collection。 */
248
+ function isCollectionSvgFile(state, filePath) {
249
+ if (!state.options)
250
+ return false;
251
+ const normalizedPath = normalizePath(filePath);
252
+ return [...state.options.collections.values()].some((collection) => Boolean(collection.normalizedFsDir && normalizedPath.startsWith(collection.normalizedFsDir + '/')));
253
+ }
254
+ /** 注册开发态监听器,使 SVG 的新增、修改、删除都能刷新 collection 输出。 */
255
+ function registerCollectionWatchers(state, server) {
256
+ if (!state.options)
257
+ return;
258
+ const directories = [...state.options.collections.values()]
259
+ .map((collection) => collection.fsDir)
260
+ .filter((dir) => Boolean(dir));
261
+ if (!directories.length)
262
+ return;
263
+ server.watcher.add(directories);
264
+ const handleCollectionEvent = async (filePath) => {
265
+ if (!filePath.endsWith('.svg'))
266
+ return;
267
+ if (!isCollectionSvgFile(state, normalizePath(filePath)))
268
+ return;
269
+ await invalidateIconCacheByFile(state, filePath);
270
+ await reloadVirtualCss(state, server);
271
+ };
272
+ server.watcher.on('add', handleCollectionEvent);
273
+ server.watcher.on('unlink', handleCollectionEvent);
274
+ }
275
+ /** 根据静态 safelist 与 collection 自动 safelist 重新同步当前活跃图标使用集合。 */
276
+ function syncStaticUsages(state) {
277
+ if (!state.options)
278
+ return;
279
+ state.staticUsages = new Map([
280
+ ...extractIconUsagesFromSafelist(state.options.safelist, state.options),
281
+ ...extractCollectionSafelistUsages(state.options),
282
+ ]);
283
+ rebuildActiveUsages(state);
284
+ }
285
+ /** 依据静态使用集合与源码使用集合,重建当前活跃的图标使用索引。 */
286
+ function rebuildActiveUsages(state) {
287
+ state.activeUsages = new Map(state.staticUsages);
288
+ for (const [className, usage] of state.sourceUsages) {
289
+ state.activeUsages.set(className, usage);
290
+ }
291
+ }
292
+ /** 将单个源码文件的新旧提取结果做差量合并,维护 usage 拥有者与活跃索引。 */
293
+ function applySourceUsagesDelta(state, filePath, previousUsages, nextUsages) {
294
+ for (const [className] of previousUsages) {
295
+ if (nextUsages.has(className))
296
+ continue;
297
+ const owners = state.usageOwners.get(className);
298
+ owners?.delete(filePath);
299
+ if (owners && owners.size > 0) {
300
+ continue;
301
+ }
302
+ state.usageOwners.delete(className);
303
+ state.sourceUsages.delete(className);
304
+ if (!state.staticUsages.has(className)) {
305
+ state.activeUsages.delete(className);
306
+ }
307
+ }
308
+ for (const [className, usage] of nextUsages) {
309
+ let owners = state.usageOwners.get(className);
310
+ if (!owners) {
311
+ owners = new Set();
312
+ state.usageOwners.set(className, owners);
313
+ }
314
+ owners.add(filePath);
315
+ state.sourceUsages.set(className, usage);
316
+ state.activeUsages.set(className, usage);
317
+ }
318
+ }
319
+ /** 删除源码文件对应的使用记录,并同步释放它贡献的活跃图标引用。 */
320
+ function removeSourceRecord(state, filePath) {
321
+ const normalizedPath = normalizePath(filePath);
322
+ const record = state.sourceRecords.get(normalizedPath);
323
+ if (!record)
324
+ return;
325
+ applySourceUsagesDelta(state, normalizedPath, record.usages, new Map());
326
+ state.sourceRecords.delete(normalizedPath);
327
+ }
328
+ /** 使用轻量内容指纹判断源码内容是否真的变化,避免热路径上的 stat 调用。 */
329
+ function createCodeFingerprint(code) {
330
+ // FNV-1a 32-bit 在实现复杂度、速度和分布稳定性之间比较均衡,
331
+ // 适合作为开发态热路径上的轻量内容指纹。
332
+ let hash = 0x811c9dc5;
333
+ for (let index = 0; index < code.length; index += 1) {
334
+ hash ^= code.charCodeAt(index);
335
+ hash = Math.imul(hash, 0x01000193) >>> 0;
336
+ }
337
+ return `${code.length}:${hash.toString(36)}`;
338
+ }
339
+ /** 失效并触发虚拟 CSS 模块热更新,避免对整页执行 full reload。 */
340
+ async function reloadVirtualCss(state, server) {
341
+ const modules = invalidateVirtualCss(state);
342
+ const module = modules?.[0];
343
+ if (!module)
344
+ return;
345
+ await server.reloadModule(module);
346
+ }
@@ -0,0 +1,84 @@
1
+ export type Awaitable<T> = T | Promise<T>;
2
+ export type IconMode = 'mask' | 'bg' | 'auto';
3
+ export type IconTransform = (svg: string) => Awaitable<string>;
4
+ export type IconSource = string | (() => Awaitable<string>);
5
+ export type InlineIconCollection = Record<string, IconSource>;
6
+ export interface FileSystemCollectionConfig {
7
+ path: string;
8
+ fn?: IconTransform;
9
+ transform?: IconTransform;
10
+ autoFillCurrentColor?: boolean;
11
+ safelist?: boolean;
12
+ }
13
+ export interface FileSystemIconLoaderMeta {
14
+ kind: 'fs';
15
+ dir: string;
16
+ transform?: IconTransform;
17
+ }
18
+ export interface CustomIconLoader {
19
+ (iconName: string): Awaitable<string | undefined>;
20
+ __svgIconLoaderMeta?: FileSystemIconLoaderMeta;
21
+ }
22
+ export type IconCollection = InlineIconCollection | CustomIconLoader | FileSystemCollectionConfig;
23
+ export interface SvgIconPluginOptions {
24
+ collections: Record<string, IconCollection>;
25
+ prefix?: string | string[];
26
+ virtualModuleId?: string;
27
+ scale?: number;
28
+ unit?: string;
29
+ mode?: IconMode;
30
+ maxSvgSize?: number;
31
+ contentInclude?: string[];
32
+ contentExclude?: string[];
33
+ safelist?: string[];
34
+ extraProperties?: Record<string, string>;
35
+ }
36
+ export interface ResolvedCollection {
37
+ name: string;
38
+ load: (iconName: string) => Promise<LoadedIconSource | null>;
39
+ refresh?: () => Promise<void>;
40
+ autoSafelist?: boolean;
41
+ fsDir?: string;
42
+ normalizedFsDir?: string;
43
+ fileToIconName?: Map<string, string>;
44
+ sortedIconNames?: string[];
45
+ }
46
+ export interface ResolvedSvgIconPluginOptions {
47
+ collections: Map<string, ResolvedCollection>;
48
+ prefixes: string[];
49
+ virtualModuleId: string;
50
+ resolvedVirtualModuleId: string;
51
+ scale: number;
52
+ unit: string;
53
+ mode: IconMode;
54
+ maxSvgSize: number;
55
+ contentInclude: string[];
56
+ contentExclude: string[];
57
+ safelist: string[];
58
+ extraProperties: Record<string, string>;
59
+ root: string;
60
+ }
61
+ export interface ParsedIconUsage {
62
+ className: string;
63
+ collection: string;
64
+ icon: string;
65
+ mode: IconMode;
66
+ }
67
+ export interface LoadedIconSource {
68
+ svg: string;
69
+ filePath?: string;
70
+ }
71
+ export interface IconRecord {
72
+ collection: string;
73
+ icon: string;
74
+ filePath?: string;
75
+ svg: string;
76
+ size: number;
77
+ oversize: boolean;
78
+ dataUri?: string;
79
+ }
80
+ export interface SourceScanRecord {
81
+ filePath: string;
82
+ fingerprint: string;
83
+ usages: Map<string, ParsedIconUsage>;
84
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "vite-plugin-cus-svg-icon",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight Vite plugin for generating CSS icons from local SVG files only.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "sideEffects": false,
11
+ "main": "./dist/index.js",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "engines": {
21
+ "node": "^20.19.0 || >=22.12.0"
22
+ },
23
+ "keywords": [
24
+ "vite-plugin",
25
+ "vite",
26
+ "svg",
27
+ "icons",
28
+ "local-icons"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.build.json",
32
+ "check": "tsc -p tsconfig.json --noEmit",
33
+ "test": "vitest run",
34
+ "verify:demo": "node ./scripts/verify-demo.mjs",
35
+ "verify": "npm run check && npm run test && npm run build && npm run verify:demo"
36
+ },
37
+ "peerDependencies": {
38
+ "vite": "^8.0.0"
39
+ },
40
+ "dependencies": {
41
+ "@iconify/utils": "^3.1.0",
42
+ "tinyglobby": "^0.2.15"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.5.0",
46
+ "typescript": "^6.0.2",
47
+ "vite": "^8.0.3",
48
+ "vitest": "^4.1.2"
49
+ }
50
+ }