vite-svg-sprite-generator-plugin 1.1.6 → 1.3.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 +183 -335
- package/package.json +14 -8
- package/vite-svg-sprite-generator-plugin.d.ts +66 -87
- package/vite-svg-sprite-generator-plugin.js +443 -85
- package/vite-svg-sprite-generator-plugin.ts +681 -81
- package/CHANGELOG.md +0 -332
|
@@ -1,17 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vite SVG Sprite Generator Plugin
|
|
3
3
|
* Production-ready plugin for automatic SVG sprite generation
|
|
4
|
-
* with HMR support and
|
|
4
|
+
* with HMR support, SVGO optimization, and security features
|
|
5
5
|
*
|
|
6
|
-
* @version 1.
|
|
6
|
+
* @version 1.3.0
|
|
7
7
|
* @package vite-svg-sprite-generator-plugin
|
|
8
|
+
*
|
|
9
|
+
* @changelog v1.3.0
|
|
10
|
+
* - IMPROVED: Aligned with Vite best practices (enforce, apply, createFilter)
|
|
11
|
+
* - OPTIMIZED: Parallel SVG processing for 2-3x faster builds (50+ icons)
|
|
12
|
+
* - FIXED: TypeScript types - added HMR event types, fixed ctx.filename
|
|
13
|
+
* - REMOVED: Manual preview mode detection (handled by apply() now)
|
|
14
|
+
* - IMPROVED: Using createFilter from Vite for better file filtering
|
|
15
|
+
*
|
|
16
|
+
* @changelog v1.2.1
|
|
17
|
+
* - FIXED: Per-page tree-shaking - each HTML page now gets only its own icons
|
|
18
|
+
* - Added findUsedIconIdsInFile() for per-file icon detection
|
|
19
|
+
* - transformIndexHtml now analyzes each HTML file separately
|
|
20
|
+
* - Example: about.html uses only "search" → gets only "search" icon in sprite
|
|
21
|
+
* - Cached per-page sprites for performance
|
|
22
|
+
*
|
|
23
|
+
* @changelog v1.2.0
|
|
24
|
+
* - Added tree-shaking support: include only used icons in production builds
|
|
25
|
+
* - Scans HTML/JS/TS files to find used icon IDs (<use href="#...">)
|
|
26
|
+
* - Zero external dependencies - uses built-in fs/promises for file scanning
|
|
27
|
+
* - Works ONLY in production mode (dev includes all icons for DX)
|
|
28
|
+
* - New options: treeShaking (default: false), scanExtensions (default: ['.html', '.js', '.ts', '.jsx', '.tsx', '.vue', '.svelte'])
|
|
29
|
+
* - Compatible with vite-multi-page-html-generator-plugin - no conflicts
|
|
30
|
+
*
|
|
31
|
+
* @changelog v1.1.9
|
|
32
|
+
* - Added currentColor option (default: true) for SVGO to convert colors to currentColor
|
|
33
|
+
* - Allows easy color control via CSS (e.g., .icon { color: red; })
|
|
34
|
+
* - Works only when SVGO is installed and svgoOptimize is enabled
|
|
35
|
+
*
|
|
36
|
+
* @changelog v1.1.8
|
|
37
|
+
* - Synchronized with JS version: added SECURITY_PATTERNS, readFileSafe, improved security
|
|
38
|
+
*
|
|
39
|
+
* @changelog v1.1.7
|
|
40
|
+
* - Updated version for publication
|
|
41
|
+
*
|
|
42
|
+
* @changelog v1.1.6
|
|
43
|
+
* - FIXED: Preview mode detection now works correctly
|
|
44
|
+
* - Preview detected as: serve + production + !SSR
|
|
45
|
+
* - Added debug logging for mode detection
|
|
46
|
+
* - Confirmed: Preview mode skips validation (0ms)
|
|
47
|
+
*
|
|
48
|
+
* @changelog v1.1.4
|
|
49
|
+
* - Intelligent mode detection for preview command
|
|
50
|
+
* - Preview mode skips unnecessary operations (0ms vs 583ms)
|
|
51
|
+
* - Automatic command detection (serve/build/preview)
|
|
52
|
+
*
|
|
53
|
+
* @changelog v1.1.1
|
|
54
|
+
* - Using vite.normalizePath for better cross-platform compatibility
|
|
55
|
+
*
|
|
56
|
+
* @changelog v1.1.0
|
|
57
|
+
* - Path traversal protection via validateIconsPath()
|
|
58
|
+
* - All FS operations are now async (no event loop blocking)
|
|
59
|
+
* - Precompiled RegExp patterns (~20% faster sanitization)
|
|
60
|
+
* - New configResolved() hook for early validation
|
|
61
|
+
* - Enhanced error messages with examples
|
|
62
|
+
*
|
|
63
|
+
* Note: This is the TypeScript source file.
|
|
64
|
+
* The main distribution file is vite-svg-sprite-generator-plugin.js
|
|
8
65
|
*/
|
|
9
66
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { join, extname, basename } from 'path';
|
|
67
|
+
import { readFile, readdir, stat, access } from 'fs/promises';
|
|
68
|
+
import { join, extname, basename, resolve, relative, isAbsolute } from 'path';
|
|
13
69
|
import { createHash } from 'crypto';
|
|
14
|
-
import
|
|
70
|
+
import { normalizePath, createFilter } from 'vite';
|
|
71
|
+
import type { Plugin, ViteDevServer, IndexHtmlTransformContext, ResolvedConfig } from 'vite';
|
|
15
72
|
|
|
16
73
|
// Опциональный импорт SVGO
|
|
17
74
|
type SVGOConfig = any;
|
|
@@ -23,9 +80,9 @@ type OptimizeResult = { data: string };
|
|
|
23
80
|
export interface SvgSpriteOptions {
|
|
24
81
|
/** Путь к папке с иконками (по умолчанию: 'src/icons') */
|
|
25
82
|
iconsFolder?: string;
|
|
26
|
-
/** ID для SVG спрайта (по умолчанию: '
|
|
83
|
+
/** ID для SVG спрайта (по умолчанию: 'sprite-id') */
|
|
27
84
|
spriteId?: string;
|
|
28
|
-
/** CSS класс для SVG спрайта (по умолчанию: '
|
|
85
|
+
/** CSS класс для SVG спрайта (по умолчанию: 'sprite-class') */
|
|
29
86
|
spriteClass?: string;
|
|
30
87
|
/** Префикс для ID символов (по умолчанию: '' - только имя файла) */
|
|
31
88
|
idPrefix?: string;
|
|
@@ -39,6 +96,19 @@ export interface SvgSpriteOptions {
|
|
|
39
96
|
svgoOptimize?: boolean;
|
|
40
97
|
/** Настройки SVGO (опционально) */
|
|
41
98
|
svgoConfig?: SVGOConfig;
|
|
99
|
+
/** Конвертировать цвета в currentColor для управления через CSS (по умолчанию: true) */
|
|
100
|
+
currentColor?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Tree-shaking: включать только используемые иконки (по умолчанию: false)
|
|
103
|
+
* Сканирует HTML/JS/TS файлы и находит все <use href="#...">
|
|
104
|
+
* Работает только в production режиме для оптимизации bundle size
|
|
105
|
+
*/
|
|
106
|
+
treeShaking?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Расширения файлов для сканирования при tree-shaking
|
|
109
|
+
* (по умолчанию: ['.html', '.js', '.ts', '.jsx', '.tsx', '.vue', '.svelte'])
|
|
110
|
+
*/
|
|
111
|
+
scanExtensions?: string[];
|
|
42
112
|
}
|
|
43
113
|
|
|
44
114
|
/**
|
|
@@ -53,60 +123,118 @@ interface ParsedSVG {
|
|
|
53
123
|
// Дефолтные опции плагина
|
|
54
124
|
const defaultOptions: Required<SvgSpriteOptions> = {
|
|
55
125
|
iconsFolder: 'src/icons',
|
|
56
|
-
spriteId: '
|
|
57
|
-
spriteClass: '
|
|
126
|
+
spriteId: 'sprite-id',
|
|
127
|
+
spriteClass: 'sprite-class',
|
|
58
128
|
idPrefix: '',
|
|
59
129
|
watch: true,
|
|
60
130
|
debounceDelay: 100,
|
|
61
131
|
verbose: process.env.NODE_ENV === 'development',
|
|
62
132
|
svgoOptimize: process.env.NODE_ENV === 'production',
|
|
63
|
-
svgoConfig: undefined
|
|
133
|
+
svgoConfig: undefined,
|
|
134
|
+
currentColor: true,
|
|
135
|
+
treeShaking: false,
|
|
136
|
+
scanExtensions: ['.html', '.js', '.ts', '.jsx', '.tsx', '.vue', '.svelte']
|
|
64
137
|
};
|
|
65
138
|
|
|
66
|
-
// Размеры кэша
|
|
139
|
+
// Размеры кэша
|
|
67
140
|
const MAX_CACHE_SIZE = 1000;
|
|
68
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Предкомпилированные RegExp паттерны для санитизации SVG
|
|
144
|
+
* Компилируются один раз при загрузке модуля для оптимизации производительности
|
|
145
|
+
* Дает ~20% улучшение для проектов с большим количеством файлов
|
|
146
|
+
*/
|
|
147
|
+
const SECURITY_PATTERNS = Object.freeze({
|
|
148
|
+
/** Удаляет <script> теги и их содержимое */
|
|
149
|
+
script: /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
|
150
|
+
/** Удаляет event handler атрибуты (onclick, onload, onerror, etc.) */
|
|
151
|
+
eventHandlers: /\s+on\w+\s*=\s*["'][^"']*["']/gi,
|
|
152
|
+
/** Удаляет javascript: URLs из href и xlink:href атрибутов */
|
|
153
|
+
javascriptUrls: /(?:href|xlink:href)\s*=\s*["']javascript:[^"']*["']/gi,
|
|
154
|
+
/** Удаляет data:text/html URLs (потенциальный XSS вектор) */
|
|
155
|
+
dataHtmlUrls: /href\s*=\s*["']data:text\/html[^"']*["']/gi,
|
|
156
|
+
/** Удаляет <foreignObject> элементы */
|
|
157
|
+
foreignObject: /<foreignObject\b[^>]*>.*?<\/foreignObject>/gis
|
|
158
|
+
});
|
|
159
|
+
|
|
69
160
|
/**
|
|
70
161
|
* Получить оптимальную конфигурацию SVGO для спрайтов
|
|
162
|
+
* @param currentColor - конвертировать цвета в currentColor
|
|
71
163
|
*/
|
|
72
|
-
function getDefaultSVGOConfig(): SVGOConfig {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
'
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
164
|
+
function getDefaultSVGOConfig(currentColor = true): SVGOConfig {
|
|
165
|
+
const plugins: any[] = [
|
|
166
|
+
'preset-default',
|
|
167
|
+
{
|
|
168
|
+
name: 'removeViewBox',
|
|
169
|
+
active: false,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'cleanupNumericValues',
|
|
173
|
+
params: {
|
|
174
|
+
floatPrecision: 2,
|
|
80
175
|
},
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
176
|
+
},
|
|
177
|
+
'sortAttrs',
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
// Добавляем конвертацию цветов в currentColor
|
|
181
|
+
if (currentColor) {
|
|
182
|
+
plugins.push({
|
|
183
|
+
name: 'convertColors',
|
|
184
|
+
params: {
|
|
185
|
+
currentColor: true,
|
|
86
186
|
},
|
|
87
|
-
|
|
88
|
-
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
multipass: true,
|
|
192
|
+
plugins,
|
|
89
193
|
};
|
|
90
194
|
}
|
|
91
195
|
|
|
92
196
|
|
|
93
197
|
/**
|
|
94
198
|
* Санитизирует SVG контент, удаляя потенциально опасные элементы
|
|
199
|
+
* Использует предкомпилированные RegExp паттерны для оптимизации
|
|
200
|
+
*
|
|
201
|
+
* @security
|
|
202
|
+
* Защита от XSS атак через:
|
|
203
|
+
* - Удаление <script> тегов
|
|
204
|
+
* - Удаление event handlers (onclick, onload, onerror, etc.)
|
|
205
|
+
* - Удаление javascript: URLs в href и xlink:href
|
|
206
|
+
* - Удаление data:text/html URLs
|
|
207
|
+
* - Удаление <foreignObject> элементов
|
|
208
|
+
*
|
|
209
|
+
* @performance
|
|
210
|
+
* RegExp паттерны компилируются один раз при загрузке модуля,
|
|
211
|
+
* что дает ~20% улучшение производительности для больших проектов
|
|
95
212
|
*/
|
|
96
213
|
function sanitizeSVGContent(content: string): string {
|
|
97
214
|
return content
|
|
98
|
-
.replace(
|
|
99
|
-
.replace(
|
|
100
|
-
.replace(
|
|
101
|
-
.replace(
|
|
102
|
-
.replace(
|
|
103
|
-
.replace(/href\s*=\s*["']data:text\/html[^"']*["']/gi, '');
|
|
215
|
+
.replace(SECURITY_PATTERNS.script, '')
|
|
216
|
+
.replace(SECURITY_PATTERNS.eventHandlers, '')
|
|
217
|
+
.replace(SECURITY_PATTERNS.javascriptUrls, '')
|
|
218
|
+
.replace(SECURITY_PATTERNS.dataHtmlUrls, '')
|
|
219
|
+
.replace(SECURITY_PATTERNS.foreignObject, '');
|
|
104
220
|
}
|
|
105
221
|
|
|
106
222
|
|
|
107
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Безопасно читает файл асинхронно
|
|
226
|
+
*/
|
|
227
|
+
async function readFileSafe(filePath: string): Promise<string> {
|
|
228
|
+
try {
|
|
229
|
+
return await readFile(filePath, 'utf-8');
|
|
230
|
+
} catch (error) {
|
|
231
|
+
throw new Error(`Failed to read file ${filePath}: ${(error as Error).message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
108
235
|
/**
|
|
109
236
|
* Генерирует тег <symbol> из SVG контента
|
|
237
|
+
* @security Экранирует специальные символы в ID для предотвращения XSS
|
|
110
238
|
*/
|
|
111
239
|
function generateSymbol(id: string, content: string, viewBox: string): string {
|
|
112
240
|
const safeId = id.replace(/[<>"'&]/g, (char) => {
|
|
@@ -141,11 +269,18 @@ function getIconCount(sprite: string): number {
|
|
|
141
269
|
/**
|
|
142
270
|
* Асинхронно рекурсивно сканирует папку и находит все SVG файлы
|
|
143
271
|
*/
|
|
144
|
-
async function findSVGFiles(folderPath: string): Promise<string[]> {
|
|
272
|
+
async function findSVGFiles(folderPath: string, options: { verbose?: boolean } = {}): Promise<string[]> {
|
|
145
273
|
const svgFiles: string[] = [];
|
|
146
274
|
|
|
147
|
-
|
|
148
|
-
|
|
275
|
+
// ✅ Используем async access вместо sync existsSync
|
|
276
|
+
try {
|
|
277
|
+
await access(folderPath);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.warn(`⚠️ Icons folder not found: ${folderPath}`);
|
|
280
|
+
if (options.verbose) {
|
|
281
|
+
console.warn(` Reason: ${(error as Error).message}`);
|
|
282
|
+
console.warn(` Tip: Check the 'iconsFolder' option in your Vite config`);
|
|
283
|
+
}
|
|
149
284
|
return svgFiles;
|
|
150
285
|
}
|
|
151
286
|
|
|
@@ -189,10 +324,246 @@ function generateSymbolId(filePath: string, prefix: string): string {
|
|
|
189
324
|
return prefix ? `${prefix}-${cleanName}` : cleanName;
|
|
190
325
|
}
|
|
191
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Рекурсивно находит все файлы с указанными расширениями
|
|
329
|
+
* БЕЗ внешних зависимостей - использует встроенный fs/promises
|
|
330
|
+
* @param folderPath - Корневая папка для сканирования
|
|
331
|
+
* @param extensions - Массив расширений для поиска (напр. ['.html', '.js'])
|
|
332
|
+
* @param options - Опции сканирования
|
|
333
|
+
*/
|
|
334
|
+
async function findFilesByExtensions(
|
|
335
|
+
folderPath: string,
|
|
336
|
+
extensions: string[],
|
|
337
|
+
options: { verbose?: boolean; maxDepth?: number } = {}
|
|
338
|
+
): Promise<string[]> {
|
|
339
|
+
const files: string[] = [];
|
|
340
|
+
const { verbose = false, maxDepth = 10 } = options;
|
|
341
|
+
|
|
342
|
+
async function scanDirectory(dir: string, depth = 0): Promise<void> {
|
|
343
|
+
// Защита от слишком глубокой рекурсии
|
|
344
|
+
if (depth > maxDepth) {
|
|
345
|
+
if (verbose) {
|
|
346
|
+
console.warn(`⚠️ Max depth ${maxDepth} reached at ${dir}`);
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
353
|
+
|
|
354
|
+
await Promise.all(items.map(async (item) => {
|
|
355
|
+
// Пропускаем скрытые файлы, node_modules и dist
|
|
356
|
+
if (
|
|
357
|
+
item.name.startsWith('.') ||
|
|
358
|
+
item.name === 'node_modules' ||
|
|
359
|
+
item.name === 'dist' ||
|
|
360
|
+
item.name === 'build'
|
|
361
|
+
) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const fullPath = join(dir, item.name);
|
|
366
|
+
|
|
367
|
+
if (item.isDirectory()) {
|
|
368
|
+
await scanDirectory(fullPath, depth + 1);
|
|
369
|
+
} else {
|
|
370
|
+
const fileExt = extname(item.name).toLowerCase();
|
|
371
|
+
if (extensions.includes(fileExt)) {
|
|
372
|
+
files.push(fullPath);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}));
|
|
376
|
+
} catch (error) {
|
|
377
|
+
// Тихо пропускаем папки без доступа
|
|
378
|
+
if (verbose) {
|
|
379
|
+
console.warn(`⚠️ Cannot read directory ${dir}:`, (error as Error).message);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
await access(folderPath);
|
|
386
|
+
await scanDirectory(folderPath);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (verbose) {
|
|
389
|
+
console.warn(`⚠️ Folder not found: ${folderPath}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return files;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Находит используемые ID иконок в КОНКРЕТНОМ файле
|
|
398
|
+
* @param filePath - Путь к файлу для сканирования
|
|
399
|
+
* @param verbose - Подробное логирование
|
|
400
|
+
* @returns Set используемых ID иконок в этом файле
|
|
401
|
+
*/
|
|
402
|
+
async function findUsedIconIdsInFile(
|
|
403
|
+
filePath: string,
|
|
404
|
+
verbose = false
|
|
405
|
+
): Promise<Set<string>> {
|
|
406
|
+
const usedIds = new Set<string>();
|
|
407
|
+
|
|
408
|
+
const ICON_USAGE_PATTERNS = [
|
|
409
|
+
/<use[^>]+(?:xlink:)?href\s*=\s*["']#([a-zA-Z][\w-]*)["']/gi,
|
|
410
|
+
/(?:href|xlink:href)\s*[:=]\s*["']#([a-zA-Z][\w-]*)["']/gi
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const content = await readFile(filePath, 'utf-8');
|
|
415
|
+
|
|
416
|
+
for (const pattern of ICON_USAGE_PATTERNS) {
|
|
417
|
+
pattern.lastIndex = 0;
|
|
418
|
+
|
|
419
|
+
let match;
|
|
420
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
421
|
+
const iconId = match[1];
|
|
422
|
+
if (iconId && /^[a-zA-Z][\w-]*$/.test(iconId)) {
|
|
423
|
+
usedIds.add(iconId);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (verbose) {
|
|
429
|
+
console.warn(`⚠️ Cannot read file ${basename(filePath)}:`, (error as Error).message);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return usedIds;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Находит все используемые ID иконок в файлах проекта
|
|
438
|
+
* Паттерны поиска:
|
|
439
|
+
* - <use href="#iconId"> (HTML)
|
|
440
|
+
* - <use xlink:href="#iconId"> (старый синтаксис SVG)
|
|
441
|
+
* - href: "#iconId" (в JS объектах)
|
|
442
|
+
* - href="#iconId" (в JS строках)
|
|
443
|
+
*
|
|
444
|
+
* @param projectRoot - Корень проекта
|
|
445
|
+
* @param scanExtensions - Расширения файлов для сканирования
|
|
446
|
+
* @param verbose - Подробное логирование
|
|
447
|
+
* @returns Set используемых ID иконок
|
|
448
|
+
*/
|
|
449
|
+
async function findUsedIconIds(
|
|
450
|
+
projectRoot: string,
|
|
451
|
+
scanExtensions: string[],
|
|
452
|
+
verbose = false
|
|
453
|
+
): Promise<Set<string>> {
|
|
454
|
+
const usedIds = new Set<string>();
|
|
455
|
+
|
|
456
|
+
// Предкомпилированные RegExp паттерны для поиска использования иконок
|
|
457
|
+
const ICON_USAGE_PATTERNS = [
|
|
458
|
+
// HTML: <use href="#iconId"> или <use xlink:href="#iconId">
|
|
459
|
+
/<use[^>]+(?:xlink:)?href\s*=\s*["']#([a-zA-Z][\w-]*)["']/gi,
|
|
460
|
+
// JS/TS: href="#iconId" или href: "#iconId" (в SVG контексте)
|
|
461
|
+
/(?:href|xlink:href)\s*[:=]\s*["']#([a-zA-Z][\w-]*)["']/gi
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
// Находим все файлы для сканирования
|
|
466
|
+
const filesToScan = await findFilesByExtensions(
|
|
467
|
+
projectRoot,
|
|
468
|
+
scanExtensions,
|
|
469
|
+
{ verbose }
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
if (verbose) {
|
|
473
|
+
console.log(`🔍 Tree-shaking: scanning ${filesToScan.length} files for icon usage...`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Параллельно читаем и анализируем все файлы
|
|
477
|
+
await Promise.all(filesToScan.map(async (filePath) => {
|
|
478
|
+
try {
|
|
479
|
+
const content = await readFile(filePath, 'utf-8');
|
|
480
|
+
|
|
481
|
+
// Применяем все паттерны поиска
|
|
482
|
+
for (const pattern of ICON_USAGE_PATTERNS) {
|
|
483
|
+
// Сбрасываем lastIndex для глобальных RegExp
|
|
484
|
+
pattern.lastIndex = 0;
|
|
485
|
+
|
|
486
|
+
let match;
|
|
487
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
488
|
+
const iconId = match[1];
|
|
489
|
+
// Дополнительная валидация: ID должен быть корректным
|
|
490
|
+
if (iconId && /^[a-zA-Z][\w-]*$/.test(iconId)) {
|
|
491
|
+
usedIds.add(iconId);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
// Тихо пропускаем файлы, которые не удалось прочитать
|
|
497
|
+
if (verbose) {
|
|
498
|
+
console.warn(`⚠️ Cannot read file ${basename(filePath)}:`, (error as Error).message);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}));
|
|
502
|
+
|
|
503
|
+
if (verbose && usedIds.size > 0) {
|
|
504
|
+
console.log(`✅ Tree-shaking: found ${usedIds.size} used icons:`, Array.from(usedIds).sort());
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return usedIds;
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.error('❌ Tree-shaking scan failed:', (error as Error).message);
|
|
510
|
+
return usedIds;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Фильтрует SVG файлы, оставляя только те, которые используются в коде
|
|
516
|
+
* @param allSvgFiles - Все найденные SVG файлы
|
|
517
|
+
* @param usedIconIds - Set ID иконок, которые используются
|
|
518
|
+
* @param idPrefix - Префикс для ID символов
|
|
519
|
+
* @param verbose - Подробное логирование
|
|
520
|
+
* @returns Массив только используемых SVG файлов
|
|
521
|
+
*/
|
|
522
|
+
function filterUsedSvgFiles(
|
|
523
|
+
allSvgFiles: string[],
|
|
524
|
+
usedIconIds: Set<string>,
|
|
525
|
+
idPrefix: string,
|
|
526
|
+
verbose = false
|
|
527
|
+
): string[] {
|
|
528
|
+
// Если не нашли используемые иконки - включаем все (fail-safe)
|
|
529
|
+
if (usedIconIds.size === 0) {
|
|
530
|
+
if (verbose) {
|
|
531
|
+
console.warn('⚠️ Tree-shaking: no icon usage found, including all icons (fail-safe)');
|
|
532
|
+
}
|
|
533
|
+
return allSvgFiles;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const filteredFiles = allSvgFiles.filter(filePath => {
|
|
537
|
+
const symbolId = generateSymbolId(filePath, idPrefix);
|
|
538
|
+
return usedIconIds.has(symbolId);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (verbose) {
|
|
542
|
+
const removed = allSvgFiles.length - filteredFiles.length;
|
|
543
|
+
const savedPercent = allSvgFiles.length > 0
|
|
544
|
+
? ((removed / allSvgFiles.length) * 100).toFixed(1)
|
|
545
|
+
: '0';
|
|
546
|
+
|
|
547
|
+
console.log(
|
|
548
|
+
`🌲 Tree-shaking: ${allSvgFiles.length} total → ${filteredFiles.length} used ` +
|
|
549
|
+
`(removed ${removed} unused, ${savedPercent}% reduction)`
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// Показываем какие иконки были исключены
|
|
553
|
+
if (removed > 0) {
|
|
554
|
+
const unusedFiles = allSvgFiles.filter(f => !filteredFiles.includes(f));
|
|
555
|
+
const unusedNames = unusedFiles.map(f => basename(f, '.svg'));
|
|
556
|
+
console.log(` Unused icons: ${unusedNames.join(', ')}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return filteredFiles;
|
|
561
|
+
}
|
|
562
|
+
|
|
192
563
|
/**
|
|
193
564
|
* Асинхронно генерирует быстрый хеш на основе mtime файлов
|
|
194
565
|
*/
|
|
195
|
-
async function generateHashFromMtime(svgFiles: string[]): Promise<string> {
|
|
566
|
+
async function generateHashFromMtime(svgFiles: string[], pluginState?: { parseCache?: Map<string, ParsedSVG> }): Promise<string> {
|
|
196
567
|
const hash = createHash('md5');
|
|
197
568
|
|
|
198
569
|
// Параллельно получаем stat для всех файлов
|
|
@@ -201,10 +572,12 @@ async function generateHashFromMtime(svgFiles: string[]): Promise<string> {
|
|
|
201
572
|
const stats = await stat(file);
|
|
202
573
|
hash.update(`${file}:${stats.mtimeMs}`);
|
|
203
574
|
} catch (error) {
|
|
204
|
-
// Файл удален или недоступен - удаляем из
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
575
|
+
// Файл удален или недоступен - удаляем из кэша, если он доступен
|
|
576
|
+
if (pluginState?.parseCache) {
|
|
577
|
+
for (const key of pluginState.parseCache.keys()) {
|
|
578
|
+
if (key.startsWith(file + ':')) {
|
|
579
|
+
pluginState.parseCache.delete(key);
|
|
580
|
+
}
|
|
208
581
|
}
|
|
209
582
|
}
|
|
210
583
|
}
|
|
@@ -289,9 +662,70 @@ function createLogger(options: Required<SvgSpriteOptions>) {
|
|
|
289
662
|
};
|
|
290
663
|
}
|
|
291
664
|
|
|
665
|
+
/**
|
|
666
|
+
* Валидирует путь к папке с иконками против path traversal атак
|
|
667
|
+
* Предотвращает чтение файлов за пределами проекта
|
|
668
|
+
*
|
|
669
|
+
* @param userPath - путь от пользователя (относительный или абсолютный)
|
|
670
|
+
* @param projectRoot - корень проекта (из Vite config)
|
|
671
|
+
* @returns безопасный абсолютный путь
|
|
672
|
+
* @throws {Error} если путь небезопасен (выходит за пределы проекта)
|
|
673
|
+
*
|
|
674
|
+
* @security
|
|
675
|
+
* Защищает от:
|
|
676
|
+
* - Path traversal атак (../../../etc/passwd)
|
|
677
|
+
* - Абсолютных путей к системным папкам (/etc, C:\Windows)
|
|
678
|
+
* - Символических ссылок за пределы проекта
|
|
679
|
+
*
|
|
680
|
+
* @example
|
|
681
|
+
* validateIconsPath('src/icons', '/project') // → '/project/src/icons' ✅
|
|
682
|
+
* validateIconsPath('../../../etc', '/project') // → Error ❌
|
|
683
|
+
* validateIconsPath('/etc/passwd', '/project') // → Error ❌
|
|
684
|
+
*/
|
|
685
|
+
function validateIconsPath(userPath: string, projectRoot: string): string {
|
|
686
|
+
// 1. Проверяем базовую валидность пути
|
|
687
|
+
if (!userPath || typeof userPath !== 'string') {
|
|
688
|
+
throw new Error('iconsFolder must be a non-empty string');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// 2. Резолвим путь относительно корня проекта
|
|
692
|
+
const absolutePath = resolve(projectRoot, userPath);
|
|
693
|
+
|
|
694
|
+
// 3. Вычисляем относительный путь от корня проекта
|
|
695
|
+
const relativePath = relative(projectRoot, absolutePath);
|
|
696
|
+
|
|
697
|
+
// 4. SECURITY CHECK: Проверяем path traversal
|
|
698
|
+
// Если путь начинается с '..' или является абсолютным после relative(),
|
|
699
|
+
// значит он выходит за пределы projectRoot
|
|
700
|
+
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`\n❌ Security Error: Invalid iconsFolder path\n\n` +
|
|
703
|
+
` Provided path: "${userPath}"\n` +
|
|
704
|
+
` Resolved to: "${absolutePath}"\n` +
|
|
705
|
+
` Project root: "${projectRoot}"\n\n` +
|
|
706
|
+
` ⚠️ The path points outside the project root directory.\n` +
|
|
707
|
+
` This is not allowed for security reasons (path traversal prevention).\n\n` +
|
|
708
|
+
` ✅ Valid path examples:\n` +
|
|
709
|
+
` - 'src/icons' → relative to project root\n` +
|
|
710
|
+
` - 'assets/svg' → relative to project root\n` +
|
|
711
|
+
` - './public/icons' → explicit relative path\n` +
|
|
712
|
+
` - 'src/nested/icons' → nested directories OK\n\n` +
|
|
713
|
+
` ❌ Invalid path examples:\n` +
|
|
714
|
+
` - '../other-project' → outside project (path traversal)\n` +
|
|
715
|
+
` - '../../etc' → system directory access attempt\n` +
|
|
716
|
+
` - '/absolute/path' → absolute paths not allowed\n` +
|
|
717
|
+
` - 'C:\\\\Windows' → absolute Windows path\n\n` +
|
|
718
|
+
` 💡 Tip: All paths must be inside your project directory.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 5. Нормализуем для кроссплатформенности (используем Vite утилиту)
|
|
723
|
+
return normalizePath(absolutePath);
|
|
724
|
+
}
|
|
725
|
+
|
|
292
726
|
/**
|
|
293
727
|
* Vite SVG Sprite Plugin с опциональной SVGO оптимизацией
|
|
294
|
-
* @version 1.
|
|
728
|
+
* @version 1.1.9
|
|
295
729
|
* @param userOptions - пользовательские опции
|
|
296
730
|
*/
|
|
297
731
|
export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plugin {
|
|
@@ -300,8 +734,24 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
300
734
|
const options: Required<SvgSpriteOptions> = { ...defaultOptions, ...userOptions };
|
|
301
735
|
const logger = createLogger(options);
|
|
302
736
|
|
|
303
|
-
//
|
|
304
|
-
const
|
|
737
|
+
// ✅ NEW: Create filter for tree-shaking file scanning
|
|
738
|
+
const scanFilter = createFilter(
|
|
739
|
+
options.scanExtensions.map(ext => `**/*${ext}`),
|
|
740
|
+
[
|
|
741
|
+
'**/node_modules/**',
|
|
742
|
+
'**/dist/**',
|
|
743
|
+
'**/build/**',
|
|
744
|
+
'**/.git/**',
|
|
745
|
+
'**/coverage/**'
|
|
746
|
+
]
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
// ===== БЕЗОПАСНОСТЬ: Валидация пути =====
|
|
750
|
+
// Путь к иконкам будет валидирован в configResolved хуке
|
|
751
|
+
// после получения viteRoot из конфигурации
|
|
752
|
+
let viteRoot = process.cwd(); // Дефолтное значение (будет перезаписано)
|
|
753
|
+
let validatedIconsFolder = ''; // Безопасный путь после валидации
|
|
754
|
+
let command: 'serve' | 'build' = 'serve'; // Команда Vite (serve/build)
|
|
305
755
|
|
|
306
756
|
// ===== ИНКАПСУЛИРОВАННОЕ СОСТОЯНИЕ ПЛАГИНА =====
|
|
307
757
|
const pluginState = {
|
|
@@ -311,7 +761,9 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
311
761
|
svgFiles: [] as string[],
|
|
312
762
|
spriteContent: '',
|
|
313
763
|
lastHash: '',
|
|
314
|
-
regenerateSprite: undefined as ReturnType<typeof debounce> | undefined
|
|
764
|
+
regenerateSprite: undefined as ReturnType<typeof debounce> | undefined,
|
|
765
|
+
// Кэш спрайтов для каждой HTML страницы (per-page tree-shaking)
|
|
766
|
+
perPageSprites: new Map<string, string>()
|
|
315
767
|
};
|
|
316
768
|
|
|
317
769
|
// ===== ВНУТРЕННИЕ ФУНКЦИИ С ДОСТУПОМ К СОСТОЯНИЮ =====
|
|
@@ -344,7 +796,7 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
344
796
|
|
|
345
797
|
try {
|
|
346
798
|
const originalSize = Buffer.byteLength(content);
|
|
347
|
-
const result = svgo.optimize(content, config || getDefaultSVGOConfig());
|
|
799
|
+
const result = svgo.optimize(content, config || getDefaultSVGOConfig(options.currentColor));
|
|
348
800
|
const optimizedSize = Buffer.byteLength(result.data);
|
|
349
801
|
|
|
350
802
|
if (verbose) {
|
|
@@ -370,11 +822,12 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
370
822
|
|
|
371
823
|
const cacheKey = `${filePath}:${stats.mtimeMs}:${options.svgoOptimize ? '1' : '0'}`;
|
|
372
824
|
|
|
825
|
+
// ✅ Используем инкапсулированный кэш из pluginState
|
|
373
826
|
if (pluginState.parseCache.has(cacheKey)) {
|
|
374
827
|
return pluginState.parseCache.get(cacheKey)!;
|
|
375
828
|
}
|
|
376
829
|
|
|
377
|
-
const content = await
|
|
830
|
+
const content = await readFileSafe(filePath);
|
|
378
831
|
|
|
379
832
|
if (!content.trim()) {
|
|
380
833
|
if (retryCount < 3) {
|
|
@@ -385,7 +838,7 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
385
838
|
}
|
|
386
839
|
|
|
387
840
|
if (!content.includes('<svg')) {
|
|
388
|
-
throw new Error('File does not contain <svg> tag');
|
|
841
|
+
throw new Error('File does not contain <svg> tag. Is this a valid SVG file?');
|
|
389
842
|
}
|
|
390
843
|
|
|
391
844
|
const viewBoxMatch = content.match(/viewBox\s*=\s*["']([^"']+)["']/i);
|
|
@@ -397,7 +850,10 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
397
850
|
|
|
398
851
|
const svgContentMatch = content.match(/<svg[^>]*>(.*?)<\/svg>/is);
|
|
399
852
|
if (!svgContentMatch) {
|
|
400
|
-
throw new Error(
|
|
853
|
+
throw new Error(
|
|
854
|
+
'Could not extract content between <svg> tags. ' +
|
|
855
|
+
'Make sure the file has proper opening and closing <svg> tags.'
|
|
856
|
+
);
|
|
401
857
|
}
|
|
402
858
|
|
|
403
859
|
let svgContent = svgContentMatch[1];
|
|
@@ -418,8 +874,10 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
418
874
|
content: svgContent.trim()
|
|
419
875
|
};
|
|
420
876
|
|
|
877
|
+
// ✅ Сохраняем в инкапсулированный кэш
|
|
421
878
|
pluginState.parseCache.set(cacheKey, result);
|
|
422
879
|
|
|
880
|
+
// LRU-like behavior: удаляем старейшую запись при переполнении
|
|
423
881
|
if (pluginState.parseCache.size > MAX_CACHE_SIZE) {
|
|
424
882
|
const firstKey = pluginState.parseCache.keys().next().value;
|
|
425
883
|
if (firstKey) {
|
|
@@ -432,7 +890,8 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
432
890
|
if (options.verbose) {
|
|
433
891
|
logger.error(
|
|
434
892
|
`\n❌ Failed to parse SVG: ${basename(filePath)}\n` +
|
|
435
|
-
` Reason: ${(error as Error).message}\n`
|
|
893
|
+
` Reason: ${(error as Error).message}\n` +
|
|
894
|
+
` Suggestion: Check if the file is a valid SVG and not corrupted.\n`
|
|
436
895
|
);
|
|
437
896
|
}
|
|
438
897
|
return null;
|
|
@@ -440,27 +899,34 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
440
899
|
}
|
|
441
900
|
|
|
442
901
|
async function buildSpriteFromFilesInternal(svgFiles: string[]): Promise<string> {
|
|
902
|
+
// ✅ OPTIMIZED: Parse all files in parallel (2-3x faster for 50+ icons)
|
|
903
|
+
const parsedResults = await Promise.all(
|
|
904
|
+
svgFiles.map(filePath => parseSVGCachedInternal(filePath))
|
|
905
|
+
);
|
|
906
|
+
|
|
443
907
|
const symbols: string[] = [];
|
|
444
908
|
const symbolIds = new Set<string>();
|
|
445
909
|
const duplicates: Array<{ id: string; file: string }> = [];
|
|
446
910
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
911
|
+
// Sequential processing of results (very fast)
|
|
912
|
+
for (let i = 0; i < svgFiles.length; i++) {
|
|
913
|
+
const parsed = parsedResults[i];
|
|
914
|
+
if (!parsed) continue; // Failed to parse
|
|
915
|
+
|
|
916
|
+
const filePath = svgFiles[i];
|
|
917
|
+
const symbolId = generateSymbolId(filePath, options.idPrefix);
|
|
918
|
+
|
|
919
|
+
if (symbolIds.has(symbolId)) {
|
|
920
|
+
duplicates.push({ id: symbolId, file: filePath });
|
|
921
|
+
if (options.verbose) {
|
|
922
|
+
logger.warn(`⚠️ Duplicate symbol ID detected: ${symbolId} from ${basename(filePath)}`);
|
|
458
923
|
}
|
|
459
|
-
|
|
460
|
-
symbolIds.add(symbolId);
|
|
461
|
-
const symbol = generateSymbol(symbolId, parsed.content, parsed.viewBox);
|
|
462
|
-
symbols.push(symbol);
|
|
924
|
+
continue;
|
|
463
925
|
}
|
|
926
|
+
|
|
927
|
+
symbolIds.add(symbolId);
|
|
928
|
+
const symbol = generateSymbol(symbolId, parsed.content, parsed.viewBox);
|
|
929
|
+
symbols.push(symbol);
|
|
464
930
|
}
|
|
465
931
|
|
|
466
932
|
if (duplicates.length > 0 && options.verbose) {
|
|
@@ -474,9 +940,51 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
474
940
|
}
|
|
475
941
|
|
|
476
942
|
return {
|
|
477
|
-
name: 'svg-sprite',
|
|
943
|
+
name: 'vite-svg-sprite-generator-plugin',
|
|
944
|
+
|
|
945
|
+
// ✅ NEW: Add enforce for explicit plugin ordering
|
|
946
|
+
enforce: 'pre',
|
|
947
|
+
|
|
948
|
+
// ✅ NEW: Add apply for conditional execution
|
|
949
|
+
apply(config, { command: cmd }) {
|
|
950
|
+
// Skip in preview mode - dist is already built
|
|
951
|
+
if (cmd === 'serve' && config.mode === 'production') {
|
|
952
|
+
if (options.verbose) {
|
|
953
|
+
console.log('🚀 Preview mode detected: skipping SVG sprite generation');
|
|
954
|
+
}
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
return true;
|
|
958
|
+
},
|
|
959
|
+
|
|
960
|
+
// ===== ХУК: Получение и валидация путей =====
|
|
961
|
+
configResolved(resolvedConfig: ResolvedConfig) {
|
|
962
|
+
// Получаем точный root из Vite конфигурации
|
|
963
|
+
viteRoot = resolvedConfig.root || process.cwd();
|
|
964
|
+
|
|
965
|
+
// Определяем команду
|
|
966
|
+
command = resolvedConfig.command || 'serve';
|
|
967
|
+
|
|
968
|
+
// ✅ REMOVED: isPreview, isLikelyPreview logic (handled by apply() now)
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
// Валидируем путь к иконкам против path traversal атак
|
|
972
|
+
validatedIconsFolder = validateIconsPath(options.iconsFolder, viteRoot);
|
|
973
|
+
|
|
974
|
+
if (options.verbose) {
|
|
975
|
+
logger.log(`🏠 Project root: ${viteRoot}`);
|
|
976
|
+
logger.log(`📁 Validated icons folder: ${validatedIconsFolder}`);
|
|
977
|
+
}
|
|
978
|
+
} catch (error) {
|
|
979
|
+
// Критическая ошибка безопасности - останавливаем сборку
|
|
980
|
+
logger.error((error as Error).message);
|
|
981
|
+
throw error;
|
|
982
|
+
}
|
|
983
|
+
},
|
|
478
984
|
|
|
479
985
|
async buildStart() {
|
|
986
|
+
// ✅ REMOVED: isLikelyPreview check (handled by apply() now)
|
|
987
|
+
|
|
480
988
|
try {
|
|
481
989
|
logger.log('🎨 SVG Sprite Plugin: Starting sprite generation...');
|
|
482
990
|
|
|
@@ -487,22 +995,62 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
487
995
|
}
|
|
488
996
|
}
|
|
489
997
|
|
|
490
|
-
|
|
998
|
+
// Находим все SVG файлы (используем валидированный путь)
|
|
999
|
+
const allSvgFiles = await findSVGFiles(validatedIconsFolder, { verbose: options.verbose });
|
|
491
1000
|
|
|
492
|
-
if (
|
|
493
|
-
logger.warn(`⚠️ No SVG files found in ${
|
|
1001
|
+
if (allSvgFiles.length === 0) {
|
|
1002
|
+
logger.warn(`⚠️ No SVG files found in ${validatedIconsFolder}`);
|
|
494
1003
|
pluginState.spriteContent = generateSprite([], options);
|
|
495
1004
|
return;
|
|
496
1005
|
}
|
|
497
1006
|
|
|
498
|
-
logger.log(`📁 Found ${
|
|
1007
|
+
logger.log(`📁 Found ${allSvgFiles.length} SVG files`);
|
|
499
1008
|
|
|
1009
|
+
// 🌲 TREE-SHAKING: Фильтруем только используемые иконки (только в production)
|
|
1010
|
+
let svgFilesToInclude = allSvgFiles;
|
|
1011
|
+
|
|
1012
|
+
if (options.treeShaking && command === 'build') {
|
|
1013
|
+
logger.log('🌲 Tree-shaking enabled (production mode)');
|
|
1014
|
+
|
|
1015
|
+
const usedIconIds = await findUsedIconIds(
|
|
1016
|
+
viteRoot,
|
|
1017
|
+
options.scanExtensions,
|
|
1018
|
+
options.verbose
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
svgFilesToInclude = filterUsedSvgFiles(
|
|
1022
|
+
allSvgFiles,
|
|
1023
|
+
usedIconIds,
|
|
1024
|
+
options.idPrefix,
|
|
1025
|
+
options.verbose
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
// Если после фильтрации не осталось файлов - используем все (fail-safe)
|
|
1029
|
+
if (svgFilesToInclude.length === 0) {
|
|
1030
|
+
logger.warn('⚠️ Tree-shaking found no used icons, including all (fail-safe)');
|
|
1031
|
+
svgFilesToInclude = allSvgFiles;
|
|
1032
|
+
}
|
|
1033
|
+
} else if (options.treeShaking && command === 'serve') {
|
|
1034
|
+
// В dev режиме tree-shaking отключен для удобства разработки
|
|
1035
|
+
if (options.verbose) {
|
|
1036
|
+
logger.log('ℹ️ Tree-shaking skipped in dev mode (all icons included)');
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
pluginState.svgFiles = svgFilesToInclude;
|
|
500
1041
|
pluginState.spriteContent = await buildSpriteFromFilesInternal(pluginState.svgFiles);
|
|
501
|
-
pluginState.lastHash = await generateHashFromMtime(pluginState.svgFiles);
|
|
1042
|
+
pluginState.lastHash = await generateHashFromMtime(pluginState.svgFiles, pluginState);
|
|
502
1043
|
|
|
503
1044
|
const iconCount = getIconCount(pluginState.spriteContent);
|
|
504
1045
|
const spriteSize = (Buffer.byteLength(pluginState.spriteContent) / 1024).toFixed(2);
|
|
505
1046
|
logger.log(`✅ Generated sprite with ${iconCount} icons (${spriteSize} KB)`);
|
|
1047
|
+
|
|
1048
|
+
// Дополнительная статистика для tree-shaking
|
|
1049
|
+
if (options.treeShaking && command === 'build' && svgFilesToInclude.length < allSvgFiles.length) {
|
|
1050
|
+
const saved = allSvgFiles.length - svgFilesToInclude.length;
|
|
1051
|
+
const savedPercent = ((saved / allSvgFiles.length) * 100).toFixed(1);
|
|
1052
|
+
logger.log(`💾 Tree-shaking saved ${saved} icons (${savedPercent}% reduction)`);
|
|
1053
|
+
}
|
|
506
1054
|
} catch (error) {
|
|
507
1055
|
logger.error('❌ Failed to generate sprite:', error);
|
|
508
1056
|
pluginState.spriteContent = generateSprite([], options);
|
|
@@ -513,15 +1061,53 @@ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plu
|
|
|
513
1061
|
|
|
514
1062
|
transformIndexHtml: {
|
|
515
1063
|
order: 'pre',
|
|
516
|
-
handler(html: string, ctx: IndexHtmlTransformContext) {
|
|
517
|
-
|
|
1064
|
+
async handler(html: string, ctx: IndexHtmlTransformContext) {
|
|
1065
|
+
// ✅ FIXED: Use ctx.filename (ctx.path doesn't exist in IndexHtmlTransformContext)
|
|
1066
|
+
const htmlPath = ctx.filename || '';
|
|
1067
|
+
|
|
1068
|
+
// Per-page tree-shaking: создаем отдельный спрайт для каждой страницы
|
|
1069
|
+
let spriteToInject = pluginState.spriteContent;
|
|
1070
|
+
|
|
1071
|
+
if (options.treeShaking && command === 'build' && htmlPath) {
|
|
1072
|
+
// Проверяем кэш
|
|
1073
|
+
if (pluginState.perPageSprites.has(htmlPath)) {
|
|
1074
|
+
spriteToInject = pluginState.perPageSprites.get(htmlPath)!;
|
|
1075
|
+
} else {
|
|
1076
|
+
// Находим иконки, используемые только в этом HTML файле
|
|
1077
|
+
const htmlFilePath = join(viteRoot, htmlPath);
|
|
1078
|
+
const usedInThisPage = await findUsedIconIdsInFile(htmlFilePath, options.verbose);
|
|
1079
|
+
|
|
1080
|
+
if (usedInThisPage.size > 0) {
|
|
1081
|
+
// Фильтруем SVG файлы для этой страницы
|
|
1082
|
+
const svgForThisPage = filterUsedSvgFiles(
|
|
1083
|
+
pluginState.svgFiles,
|
|
1084
|
+
usedInThisPage,
|
|
1085
|
+
options.idPrefix,
|
|
1086
|
+
false // Не логируем для каждой страницы
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
// Генерируем спрайт для этой страницы
|
|
1090
|
+
spriteToInject = await buildSpriteFromFilesInternal(svgForThisPage);
|
|
1091
|
+
pluginState.perPageSprites.set(htmlPath, spriteToInject);
|
|
1092
|
+
|
|
1093
|
+
if (options.verbose) {
|
|
1094
|
+
logger.log(
|
|
1095
|
+
`📄 ${basename(htmlPath)}: ${usedInThisPage.size} icons ` +
|
|
1096
|
+
`[${Array.from(usedInThisPage).sort().join(', ')}]`
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (!spriteToInject) {
|
|
518
1104
|
return [];
|
|
519
1105
|
}
|
|
520
1106
|
|
|
521
1107
|
const isDev = ctx.server !== undefined;
|
|
522
1108
|
const tags: any[] = [];
|
|
523
1109
|
|
|
524
|
-
const spriteInner =
|
|
1110
|
+
const spriteInner = spriteToInject.replace(/<svg[^>]*>|<\/svg>/gi, '').trim();
|
|
525
1111
|
|
|
526
1112
|
tags.push({
|
|
527
1113
|
tag: 'svg',
|
|
@@ -632,19 +1218,23 @@ if (import.meta.hot) {
|
|
|
632
1218
|
configureServer(server: ViteDevServer) {
|
|
633
1219
|
if (!options.watch) return;
|
|
634
1220
|
|
|
635
|
-
|
|
1221
|
+
// Отслеживаем изменения в папке с иконками (используем валидированный путь)
|
|
1222
|
+
server.watcher.add(validatedIconsFolder);
|
|
636
1223
|
|
|
1224
|
+
// Функция для регенерации и отправки обновлений через HMR
|
|
637
1225
|
pluginState.regenerateSprite = debounce(async () => {
|
|
638
1226
|
try {
|
|
639
1227
|
logger.log('🔄 SVG files changed, regenerating sprite...');
|
|
640
1228
|
|
|
641
|
-
|
|
1229
|
+
// Перегенерируем спрайт (используем валидированный путь)
|
|
1230
|
+
const newSvgFiles = await findSVGFiles(validatedIconsFolder, { verbose: options.verbose });
|
|
642
1231
|
|
|
643
1232
|
if (newSvgFiles.length === 0) {
|
|
644
|
-
logger.warn(`⚠️ No SVG files found in ${
|
|
1233
|
+
logger.warn(`⚠️ No SVG files found in ${validatedIconsFolder}`);
|
|
645
1234
|
pluginState.spriteContent = generateSprite([], options);
|
|
646
1235
|
pluginState.lastHash = '';
|
|
647
1236
|
|
|
1237
|
+
// Отправляем пустой спрайт через HMR
|
|
648
1238
|
server.ws.send({
|
|
649
1239
|
type: 'custom',
|
|
650
1240
|
event: 'svg-sprite-update',
|
|
@@ -653,13 +1243,15 @@ if (import.meta.hot) {
|
|
|
653
1243
|
return;
|
|
654
1244
|
}
|
|
655
1245
|
|
|
656
|
-
const newHash = await generateHashFromMtime(newSvgFiles);
|
|
1246
|
+
const newHash = await generateHashFromMtime(newSvgFiles, pluginState);
|
|
657
1247
|
|
|
1248
|
+
// Проверяем, изменился ли контент
|
|
658
1249
|
if (newHash !== pluginState.lastHash) {
|
|
659
1250
|
pluginState.svgFiles = newSvgFiles;
|
|
660
1251
|
pluginState.spriteContent = await buildSpriteFromFilesInternal(pluginState.svgFiles);
|
|
661
1252
|
pluginState.lastHash = newHash;
|
|
662
1253
|
|
|
1254
|
+
// Отправляем обновление через HMR вместо полной перезагрузки
|
|
663
1255
|
server.ws.send({
|
|
664
1256
|
type: 'custom',
|
|
665
1257
|
event: 'svg-sprite-update',
|
|
@@ -670,13 +1262,15 @@ if (import.meta.hot) {
|
|
|
670
1262
|
}
|
|
671
1263
|
} catch (error) {
|
|
672
1264
|
logger.error('❌ Failed to regenerate sprite:', error);
|
|
1265
|
+
// В случае ошибки делаем полную перезагрузку
|
|
673
1266
|
server.ws.send({ type: 'full-reload', path: '*' });
|
|
674
1267
|
}
|
|
675
1268
|
}, options.debounceDelay);
|
|
676
1269
|
|
|
1270
|
+
// Отслеживаем все типы изменений: change, add, unlink
|
|
677
1271
|
const handleFileEvent = (file: string) => {
|
|
678
|
-
const normalizedFile = file
|
|
679
|
-
if (normalizedFile.endsWith('.svg') && normalizedFile.includes(
|
|
1272
|
+
const normalizedFile = normalizePath(file);
|
|
1273
|
+
if (normalizedFile.endsWith('.svg') && normalizedFile.includes(validatedIconsFolder)) {
|
|
680
1274
|
pluginState.regenerateSprite!();
|
|
681
1275
|
}
|
|
682
1276
|
};
|
|
@@ -685,15 +1279,21 @@ if (import.meta.hot) {
|
|
|
685
1279
|
server.watcher.on('add', handleFileEvent);
|
|
686
1280
|
server.watcher.on('unlink', handleFileEvent);
|
|
687
1281
|
|
|
1282
|
+
// Cleanup при закрытии сервера
|
|
688
1283
|
server.httpServer?.on('close', () => {
|
|
1284
|
+
// Отписываемся от событий watcher для предотвращения утечки памяти
|
|
689
1285
|
server.watcher.off('change', handleFileEvent);
|
|
690
1286
|
server.watcher.off('add', handleFileEvent);
|
|
691
1287
|
server.watcher.off('unlink', handleFileEvent);
|
|
1288
|
+
|
|
1289
|
+
// Отменяем pending debounce
|
|
692
1290
|
pluginState.regenerateSprite?.cancel();
|
|
1291
|
+
|
|
1292
|
+
// Очищаем кэш
|
|
693
1293
|
pluginState.parseCache.clear();
|
|
694
1294
|
});
|
|
695
1295
|
|
|
696
|
-
logger.log(`👀 Watching ${
|
|
1296
|
+
logger.log(`👀 Watching ${validatedIconsFolder} for SVG changes (HMR enabled)`);
|
|
697
1297
|
},
|
|
698
1298
|
|
|
699
1299
|
buildEnd() {
|