vite-svg-sprite-generator-plugin 1.1.1

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.
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Vite SVG Sprite Generator Plugin
3
+ * Production-ready plugin for automatic SVG sprite generation
4
+ * with HMR support and SVGO optimization
5
+ *
6
+ * @version 1.0.0
7
+ * @package vite-svg-sprite-generator-plugin
8
+ */
9
+
10
+ import { existsSync, statSync } from 'fs';
11
+ import { readFile, readdir, stat } from 'fs/promises';
12
+ import { join, extname, basename } from 'path';
13
+ import { createHash } from 'crypto';
14
+ import type { Plugin, ViteDevServer, IndexHtmlTransformContext } from 'vite';
15
+
16
+ // Опциональный импорт SVGO
17
+ type SVGOConfig = any;
18
+ type OptimizeResult = { data: string };
19
+
20
+ /**
21
+ * Опции для SVG Sprite плагина
22
+ */
23
+ export interface SvgSpriteOptions {
24
+ /** Путь к папке с иконками (по умолчанию: 'src/icons') */
25
+ iconsFolder?: string;
26
+ /** ID для SVG спрайта (по умолчанию: 'icon-sprite') */
27
+ spriteId?: string;
28
+ /** CSS класс для SVG спрайта (по умолчанию: 'svg-sprite') */
29
+ spriteClass?: string;
30
+ /** Префикс для ID символов (по умолчанию: '' - только имя файла) */
31
+ idPrefix?: string;
32
+ /** Отслеживать изменения в dev режиме (по умолчанию: true) */
33
+ watch?: boolean;
34
+ /** Задержка debounce для HMR (по умолчанию: 100ms) */
35
+ debounceDelay?: number;
36
+ /** Подробное логирование (по умолчанию: только в dev) */
37
+ verbose?: boolean;
38
+ /** Оптимизация SVGO (по умолчанию: только в production, если svgo установлен) */
39
+ svgoOptimize?: boolean;
40
+ /** Настройки SVGO (опционально) */
41
+ svgoConfig?: SVGOConfig;
42
+ }
43
+
44
+ /**
45
+ * Результат парсинга SVG файла
46
+ */
47
+ interface ParsedSVG {
48
+ viewBox: string;
49
+ content: string;
50
+ }
51
+
52
+
53
+ // Дефолтные опции плагина
54
+ const defaultOptions: Required<SvgSpriteOptions> = {
55
+ iconsFolder: 'src/icons',
56
+ spriteId: 'icon-sprite',
57
+ spriteClass: 'svg-sprite',
58
+ idPrefix: '',
59
+ watch: true,
60
+ debounceDelay: 100,
61
+ verbose: process.env.NODE_ENV === 'development',
62
+ svgoOptimize: process.env.NODE_ENV === 'production',
63
+ svgoConfig: undefined
64
+ };
65
+
66
+ // Размеры кэша (теперь настраиваемые через опции)
67
+ const MAX_CACHE_SIZE = 1000;
68
+
69
+ /**
70
+ * Получить оптимальную конфигурацию SVGO для спрайтов
71
+ */
72
+ function getDefaultSVGOConfig(): SVGOConfig {
73
+ return {
74
+ multipass: true,
75
+ plugins: [
76
+ 'preset-default',
77
+ {
78
+ name: 'removeViewBox',
79
+ active: false,
80
+ },
81
+ {
82
+ name: 'cleanupNumericValues',
83
+ params: {
84
+ floatPrecision: 2,
85
+ },
86
+ },
87
+ 'sortAttrs',
88
+ ],
89
+ };
90
+ }
91
+
92
+
93
+ /**
94
+ * Санитизирует SVG контент, удаляя потенциально опасные элементы
95
+ */
96
+ function sanitizeSVGContent(content: string): string {
97
+ return content
98
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
99
+ .replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
100
+ .replace(/href\s*=\s*["']javascript:[^"']*["']/gi, '')
101
+ .replace(/xlink:href\s*=\s*["']javascript:[^"']*["']/gi, '')
102
+ .replace(/<foreignObject\b[^>]*>.*?<\/foreignObject>/gis, '')
103
+ .replace(/href\s*=\s*["']data:text\/html[^"']*["']/gi, '');
104
+ }
105
+
106
+
107
+
108
+ /**
109
+ * Генерирует тег <symbol> из SVG контента
110
+ */
111
+ function generateSymbol(id: string, content: string, viewBox: string): string {
112
+ const safeId = id.replace(/[<>"'&]/g, (char) => {
113
+ const entities: Record<string, string> = {
114
+ '<': '&lt;',
115
+ '>': '&gt;',
116
+ '"': '&quot;',
117
+ "'": '&#39;',
118
+ '&': '&amp;'
119
+ };
120
+ return entities[char] || char;
121
+ });
122
+
123
+ return `<symbol id="${safeId}" viewBox="${viewBox}">${content}</symbol>`;
124
+ }
125
+
126
+ /**
127
+ * Генерирует финальный SVG спрайт
128
+ */
129
+ function generateSprite(symbols: string[], options: Required<SvgSpriteOptions>): string {
130
+ const symbolsHtml = symbols.length > 0 ? `\n ${symbols.join('\n ')}\n` : '';
131
+ return `<svg id="${options.spriteId}" class="${options.spriteClass}" style="display: none;">${symbolsHtml}</svg>`;
132
+ }
133
+
134
+ /**
135
+ * Подсчитывает количество иконок в спрайте
136
+ */
137
+ function getIconCount(sprite: string): number {
138
+ return (sprite.match(/<symbol/g) || []).length;
139
+ }
140
+
141
+ /**
142
+ * Асинхронно рекурсивно сканирует папку и находит все SVG файлы
143
+ */
144
+ async function findSVGFiles(folderPath: string): Promise<string[]> {
145
+ const svgFiles: string[] = [];
146
+
147
+ if (!existsSync(folderPath)) {
148
+ console.warn(`Icons folder not found: ${folderPath}`);
149
+ return svgFiles;
150
+ }
151
+
152
+ async function scanDirectory(dir: string): Promise<void> {
153
+ try {
154
+ const items = await readdir(dir, { withFileTypes: true });
155
+
156
+ // Параллельная обработка всех элементов директории
157
+ await Promise.all(items.map(async (item) => {
158
+ // Пропускаем скрытые файлы и node_modules
159
+ if (item.name.startsWith('.') || item.name === 'node_modules') {
160
+ return;
161
+ }
162
+
163
+ const fullPath = join(dir, item.name);
164
+
165
+ if (item.isDirectory()) {
166
+ await scanDirectory(fullPath);
167
+ } else if (extname(item.name).toLowerCase() === '.svg') {
168
+ svgFiles.push(fullPath);
169
+ }
170
+ }));
171
+ } catch (error) {
172
+ console.error(`Failed to scan directory ${dir}:`, (error as Error).message);
173
+ }
174
+ }
175
+
176
+ await scanDirectory(folderPath);
177
+ return svgFiles;
178
+ }
179
+
180
+ /**
181
+ * Создает уникальный ID для символа
182
+ */
183
+ function generateSymbolId(filePath: string, prefix: string): string {
184
+ const fileName = basename(filePath, '.svg');
185
+ const cleanName = fileName
186
+ .replace(/[^a-zA-Z0-9-_]+/g, '-')
187
+ .replace(/^-+|-+$/g, '');
188
+
189
+ return prefix ? `${prefix}-${cleanName}` : cleanName;
190
+ }
191
+
192
+ /**
193
+ * Асинхронно генерирует быстрый хеш на основе mtime файлов
194
+ */
195
+ async function generateHashFromMtime(svgFiles: string[]): Promise<string> {
196
+ const hash = createHash('md5');
197
+
198
+ // Параллельно получаем stat для всех файлов
199
+ await Promise.all(svgFiles.map(async (file) => {
200
+ try {
201
+ const stats = await stat(file);
202
+ hash.update(`${file}:${stats.mtimeMs}`);
203
+ } catch (error) {
204
+ // Файл удален или недоступен - удаляем из кэша
205
+ for (const key of parseCache.keys()) {
206
+ if (key.startsWith(file + ':')) {
207
+ parseCache.delete(key);
208
+ }
209
+ }
210
+ }
211
+ }));
212
+
213
+ return hash.digest('hex').substring(0, 8);
214
+ }
215
+
216
+ /**
217
+ * Создает debounced функцию
218
+ */
219
+ function debounce<T extends (...args: any[]) => void>(
220
+ func: T,
221
+ delay: number
222
+ ): T & { cancel: () => void } {
223
+ let timeoutId: NodeJS.Timeout | undefined;
224
+
225
+ const debouncedFunc = function(this: any, ...args: Parameters<T>) {
226
+ clearTimeout(timeoutId);
227
+ timeoutId = setTimeout(() => {
228
+ func.apply(this, args);
229
+ }, delay);
230
+ } as T & { cancel: () => void };
231
+
232
+ debouncedFunc.cancel = () => {
233
+ clearTimeout(timeoutId);
234
+ };
235
+
236
+ return debouncedFunc;
237
+ }
238
+
239
+ /**
240
+ * Валидирует опции плагина
241
+ */
242
+ function validateOptions(userOptions: SvgSpriteOptions): void {
243
+ const errors: string[] = [];
244
+
245
+ if (userOptions.debounceDelay !== undefined) {
246
+ if (typeof userOptions.debounceDelay !== 'number' || userOptions.debounceDelay < 0) {
247
+ errors.push('debounceDelay must be a positive number');
248
+ }
249
+ }
250
+
251
+ if (userOptions.iconsFolder !== undefined) {
252
+ if (typeof userOptions.iconsFolder !== 'string' || !userOptions.iconsFolder.trim()) {
253
+ errors.push('iconsFolder must be a non-empty string');
254
+ }
255
+ }
256
+
257
+ if (userOptions.spriteId !== undefined) {
258
+ if (!/^[a-zA-Z][\w-]*$/.test(userOptions.spriteId)) {
259
+ errors.push('spriteId must be a valid HTML ID');
260
+ }
261
+ }
262
+
263
+ if (userOptions.idPrefix !== undefined) {
264
+ if (typeof userOptions.idPrefix !== 'string') {
265
+ errors.push('idPrefix must be a string');
266
+ }
267
+ }
268
+
269
+ if (errors.length > 0) {
270
+ throw new Error(`❌ Invalid SVG Sprite Plugin options:\n- ${errors.join('\n- ')}`);
271
+ }
272
+ }
273
+
274
+
275
+ /**
276
+ * Логгер с учетом verbose режима
277
+ */
278
+ function createLogger(options: Required<SvgSpriteOptions>) {
279
+ return {
280
+ log: (...args: any[]) => {
281
+ if (options.verbose) console.log(...args);
282
+ },
283
+ warn: (...args: any[]) => {
284
+ if (options.verbose) console.warn(...args);
285
+ },
286
+ error: (...args: any[]) => {
287
+ console.error(...args);
288
+ }
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Vite SVG Sprite Plugin с опциональной SVGO оптимизацией
294
+ * @version 1.0.0
295
+ * @param userOptions - пользовательские опции
296
+ */
297
+ export default function svgSpritePlugin(userOptions: SvgSpriteOptions = {}): Plugin {
298
+ validateOptions(userOptions);
299
+
300
+ const options: Required<SvgSpriteOptions> = { ...defaultOptions, ...userOptions };
301
+ const logger = createLogger(options);
302
+
303
+ // Нормализуем путь к папке один раз для кроссплатформенности
304
+ const normalizedIconsFolder = options.iconsFolder.replace(/\\/g, '/');
305
+
306
+ // ===== ИНКАПСУЛИРОВАННОЕ СОСТОЯНИЕ ПЛАГИНА =====
307
+ const pluginState = {
308
+ parseCache: new Map<string, ParsedSVG>(),
309
+ svgoModule: null as { optimize: (svg: string, config?: any) => { data: string } } | null,
310
+ svgoLoadAttempted: false,
311
+ svgFiles: [] as string[],
312
+ spriteContent: '',
313
+ lastHash: '',
314
+ regenerateSprite: undefined as ReturnType<typeof debounce> | undefined
315
+ };
316
+
317
+ // ===== ВНУТРЕННИЕ ФУНКЦИИ С ДОСТУПОМ К СОСТОЯНИЮ =====
318
+
319
+ async function loadSVGOInternal() {
320
+ if (pluginState.svgoLoadAttempted) {
321
+ return pluginState.svgoModule;
322
+ }
323
+
324
+ pluginState.svgoLoadAttempted = true;
325
+
326
+ try {
327
+ pluginState.svgoModule = await import('svgo');
328
+ return pluginState.svgoModule;
329
+ } catch (error) {
330
+ pluginState.svgoModule = null;
331
+ return null;
332
+ }
333
+ }
334
+
335
+ async function optimizeSVGInternal(content: string, config?: any, verbose = false): Promise<string> {
336
+ const svgo = await loadSVGOInternal();
337
+
338
+ if (!svgo) {
339
+ if (verbose) {
340
+ logger.warn('⚠️ SVGO not installed. Skipping optimization. Install with: npm install -D svgo');
341
+ }
342
+ return content;
343
+ }
344
+
345
+ try {
346
+ const originalSize = Buffer.byteLength(content);
347
+ const result = svgo.optimize(content, config || getDefaultSVGOConfig());
348
+ const optimizedSize = Buffer.byteLength(result.data);
349
+
350
+ if (verbose) {
351
+ const savedPercent = ((1 - optimizedSize / originalSize) * 100).toFixed(1);
352
+ logger.log(` SVGO: ${originalSize} → ${optimizedSize} bytes (-${savedPercent}%)`);
353
+ }
354
+
355
+ return result.data;
356
+ } catch (error) {
357
+ logger.warn('⚠️ SVGO optimization failed:', (error as Error).message);
358
+ return content;
359
+ }
360
+ }
361
+
362
+ async function parseSVGCachedInternal(filePath: string, retryCount = 0): Promise<ParsedSVG | null> {
363
+ try {
364
+ const stats = await stat(filePath);
365
+
366
+ const MAX_FILE_SIZE = 5 * 1024 * 1024;
367
+ if (stats.size > MAX_FILE_SIZE) {
368
+ throw new Error(`File too large: ${(stats.size / 1024 / 1024).toFixed(2)}MB (max 5MB)`);
369
+ }
370
+
371
+ const cacheKey = `${filePath}:${stats.mtimeMs}:${options.svgoOptimize ? '1' : '0'}`;
372
+
373
+ if (pluginState.parseCache.has(cacheKey)) {
374
+ return pluginState.parseCache.get(cacheKey)!;
375
+ }
376
+
377
+ const content = await readFile(filePath, 'utf-8');
378
+
379
+ if (!content.trim()) {
380
+ if (retryCount < 3) {
381
+ await new Promise(resolve => setTimeout(resolve, 50));
382
+ return parseSVGCachedInternal(filePath, retryCount + 1);
383
+ }
384
+ throw new Error('File is empty');
385
+ }
386
+
387
+ if (!content.includes('<svg')) {
388
+ throw new Error('File does not contain <svg> tag');
389
+ }
390
+
391
+ const viewBoxMatch = content.match(/viewBox\s*=\s*["']([^"']+)["']/i);
392
+ const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24';
393
+
394
+ if (!viewBoxMatch && options.verbose) {
395
+ logger.warn(`⚠️ ${basename(filePath)}: No viewBox found, using default "0 0 24 24"`);
396
+ }
397
+
398
+ const svgContentMatch = content.match(/<svg[^>]*>(.*?)<\/svg>/is);
399
+ if (!svgContentMatch) {
400
+ throw new Error('Could not extract content between <svg> tags');
401
+ }
402
+
403
+ let svgContent = svgContentMatch[1];
404
+ svgContent = sanitizeSVGContent(svgContent);
405
+
406
+ if (options.svgoOptimize) {
407
+ const wrappedSvg = `<svg viewBox="${viewBox}">${svgContent}</svg>`;
408
+ const optimized = await optimizeSVGInternal(wrappedSvg, options.svgoConfig, options.verbose);
409
+
410
+ const optimizedMatch = optimized.match(/<svg[^>]*>(.*?)<\/svg>/is);
411
+ if (optimizedMatch) {
412
+ svgContent = optimizedMatch[1];
413
+ }
414
+ }
415
+
416
+ const result: ParsedSVG = {
417
+ viewBox,
418
+ content: svgContent.trim()
419
+ };
420
+
421
+ pluginState.parseCache.set(cacheKey, result);
422
+
423
+ if (pluginState.parseCache.size > MAX_CACHE_SIZE) {
424
+ const firstKey = pluginState.parseCache.keys().next().value;
425
+ if (firstKey) {
426
+ pluginState.parseCache.delete(firstKey);
427
+ }
428
+ }
429
+
430
+ return result;
431
+ } catch (error) {
432
+ if (options.verbose) {
433
+ logger.error(
434
+ `\n❌ Failed to parse SVG: ${basename(filePath)}\n` +
435
+ ` Reason: ${(error as Error).message}\n`
436
+ );
437
+ }
438
+ return null;
439
+ }
440
+ }
441
+
442
+ async function buildSpriteFromFilesInternal(svgFiles: string[]): Promise<string> {
443
+ const symbols: string[] = [];
444
+ const symbolIds = new Set<string>();
445
+ const duplicates: Array<{ id: string; file: string }> = [];
446
+
447
+ for (const filePath of svgFiles) {
448
+ const parsed = await parseSVGCachedInternal(filePath);
449
+ if (parsed) {
450
+ const symbolId = generateSymbolId(filePath, options.idPrefix);
451
+
452
+ if (symbolIds.has(symbolId)) {
453
+ duplicates.push({ id: symbolId, file: filePath });
454
+ if (options.verbose) {
455
+ logger.warn(`⚠️ Duplicate symbol ID detected: ${symbolId} from ${basename(filePath)}`);
456
+ }
457
+ continue;
458
+ }
459
+
460
+ symbolIds.add(symbolId);
461
+ const symbol = generateSymbol(symbolId, parsed.content, parsed.viewBox);
462
+ symbols.push(symbol);
463
+ }
464
+ }
465
+
466
+ if (duplicates.length > 0 && options.verbose) {
467
+ logger.warn(
468
+ `\n⚠️ Found ${duplicates.length} duplicate symbol ID(s). ` +
469
+ `These icons were skipped to prevent conflicts.\n`
470
+ );
471
+ }
472
+
473
+ return generateSprite(symbols, options);
474
+ }
475
+
476
+ return {
477
+ name: 'svg-sprite',
478
+
479
+ async buildStart() {
480
+ try {
481
+ logger.log('🎨 SVG Sprite Plugin: Starting sprite generation...');
482
+
483
+ if (options.svgoOptimize) {
484
+ const svgo = await loadSVGOInternal();
485
+ if (svgo) {
486
+ logger.log('🔧 SVGO optimization enabled');
487
+ }
488
+ }
489
+
490
+ pluginState.svgFiles = await findSVGFiles(options.iconsFolder);
491
+
492
+ if (pluginState.svgFiles.length === 0) {
493
+ logger.warn(`⚠️ No SVG files found in ${options.iconsFolder}`);
494
+ pluginState.spriteContent = generateSprite([], options);
495
+ return;
496
+ }
497
+
498
+ logger.log(`📁 Found ${pluginState.svgFiles.length} SVG files`);
499
+
500
+ pluginState.spriteContent = await buildSpriteFromFilesInternal(pluginState.svgFiles);
501
+ pluginState.lastHash = await generateHashFromMtime(pluginState.svgFiles);
502
+
503
+ const iconCount = getIconCount(pluginState.spriteContent);
504
+ const spriteSize = (Buffer.byteLength(pluginState.spriteContent) / 1024).toFixed(2);
505
+ logger.log(`✅ Generated sprite with ${iconCount} icons (${spriteSize} KB)`);
506
+ } catch (error) {
507
+ logger.error('❌ Failed to generate sprite:', error);
508
+ pluginState.spriteContent = generateSprite([], options);
509
+ pluginState.svgFiles = [];
510
+ pluginState.lastHash = '';
511
+ }
512
+ },
513
+
514
+ transformIndexHtml: {
515
+ order: 'pre',
516
+ handler(html: string, ctx: IndexHtmlTransformContext) {
517
+ if (!pluginState.spriteContent) {
518
+ return [];
519
+ }
520
+
521
+ const isDev = ctx.server !== undefined;
522
+ const tags: any[] = [];
523
+
524
+ const spriteInner = pluginState.spriteContent.replace(/<svg[^>]*>|<\/svg>/gi, '').trim();
525
+
526
+ tags.push({
527
+ tag: 'svg',
528
+ attrs: {
529
+ id: options.spriteId,
530
+ class: options.spriteClass,
531
+ style: 'display: none;',
532
+ xmlns: 'http://www.w3.org/2000/svg'
533
+ },
534
+ children: spriteInner,
535
+ injectTo: 'body-prepend'
536
+ });
537
+
538
+ if (isDev && options.watch) {
539
+ tags.push({
540
+ tag: 'script',
541
+ attrs: { type: 'module' },
542
+ children: `
543
+ if (import.meta.hot) {
544
+ import.meta.hot.on('svg-sprite-update', (data) => {
545
+ console.log('🔄 HMR: Updating SVG sprite...', data);
546
+ const oldSprite = document.getElementById('${options.spriteId}');
547
+ if (!oldSprite) {
548
+ console.error('❌ SVG sprite not found in DOM. Expected id: ${options.spriteId}');
549
+ return;
550
+ }
551
+ try {
552
+ // ✅ БЕЗОПАСНО: Используем DOMParser вместо innerHTML для защиты от XSS
553
+ const parser = new DOMParser();
554
+ const doc = parser.parseFromString(data.spriteContent, 'image/svg+xml');
555
+
556
+ // Проверяем на ошибки парсинга XML
557
+ const parserError = doc.querySelector('parsererror');
558
+ if (parserError) {
559
+ console.error('❌ Invalid SVG XML received:', parserError.textContent);
560
+ return;
561
+ }
562
+
563
+ const newSprite = doc.documentElement;
564
+
565
+ // Дополнительная валидация: убеждаемся что это действительно SVG
566
+ if (!newSprite || newSprite.tagName.toLowerCase() !== 'svg') {
567
+ console.error('❌ Expected <svg> root element, got:', newSprite?.tagName);
568
+ return;
569
+ }
570
+
571
+ // Безопасное обновление: берем innerHTML из валидированного элемента
572
+ // Данные уже прошли валидацию через DOMParser, поэтому безопасно
573
+ oldSprite.innerHTML = newSprite.innerHTML;
574
+
575
+ // Принудительно обновляем все <use> элементы с более агрессивным подходом
576
+ const useElements = document.querySelectorAll('use[href^="#"]');
577
+
578
+ // Сохраняем все href
579
+ const hrefs = Array.from(useElements).map(use => ({
580
+ element: use,
581
+ href: use.getAttribute('href'),
582
+ parentSVG: use.closest('svg')
583
+ }));
584
+
585
+ // Сбрасываем все href
586
+ hrefs.forEach(({ element }) => {
587
+ element.removeAttribute('href');
588
+ });
589
+
590
+ // Принудительная перерисовка через тройной RAF + явный reflow
591
+ requestAnimationFrame(() => {
592
+ // Принудительный reflow
593
+ document.body.offsetHeight;
594
+
595
+ requestAnimationFrame(() => {
596
+ // Восстанавливаем href
597
+ hrefs.forEach(({ element, href, parentSVG }) => {
598
+ if (href) {
599
+ element.setAttribute('href', href);
600
+ // Принудительный reflow для каждого SVG родителя
601
+ if (parentSVG) {
602
+ parentSVG.style.display = 'none';
603
+ parentSVG.offsetHeight; // Trigger reflow
604
+ parentSVG.style.display = '';
605
+ }
606
+ }
607
+ });
608
+
609
+ requestAnimationFrame(() => {
610
+ // Финальная перерисовка
611
+ document.body.offsetHeight;
612
+ });
613
+ });
614
+ });
615
+
616
+ console.log(\`✅ HMR: Sprite updated with \${data.iconCount} icons\`);
617
+ } catch (error) {
618
+ console.error('HMR: Failed to update sprite:', error);
619
+ }
620
+ });
621
+ console.log('🎨 SVG Sprite HMR: Ready');
622
+ }
623
+ `.trim(),
624
+ injectTo: 'head'
625
+ });
626
+ }
627
+
628
+ return tags;
629
+ }
630
+ },
631
+
632
+ configureServer(server: ViteDevServer) {
633
+ if (!options.watch) return;
634
+
635
+ server.watcher.add(options.iconsFolder);
636
+
637
+ pluginState.regenerateSprite = debounce(async () => {
638
+ try {
639
+ logger.log('🔄 SVG files changed, regenerating sprite...');
640
+
641
+ const newSvgFiles = await findSVGFiles(options.iconsFolder);
642
+
643
+ if (newSvgFiles.length === 0) {
644
+ logger.warn(`⚠️ No SVG files found in ${options.iconsFolder}`);
645
+ pluginState.spriteContent = generateSprite([], options);
646
+ pluginState.lastHash = '';
647
+
648
+ server.ws.send({
649
+ type: 'custom',
650
+ event: 'svg-sprite-update',
651
+ data: { spriteContent: pluginState.spriteContent, iconCount: 0 }
652
+ });
653
+ return;
654
+ }
655
+
656
+ const newHash = await generateHashFromMtime(newSvgFiles);
657
+
658
+ if (newHash !== pluginState.lastHash) {
659
+ pluginState.svgFiles = newSvgFiles;
660
+ pluginState.spriteContent = await buildSpriteFromFilesInternal(pluginState.svgFiles);
661
+ pluginState.lastHash = newHash;
662
+
663
+ server.ws.send({
664
+ type: 'custom',
665
+ event: 'svg-sprite-update',
666
+ data: { spriteContent: pluginState.spriteContent, iconCount: getIconCount(pluginState.spriteContent) }
667
+ });
668
+
669
+ logger.log(`✅ HMR: Sprite updated with ${getIconCount(pluginState.spriteContent)} icons`);
670
+ }
671
+ } catch (error) {
672
+ logger.error('❌ Failed to regenerate sprite:', error);
673
+ server.ws.send({ type: 'full-reload', path: '*' });
674
+ }
675
+ }, options.debounceDelay);
676
+
677
+ const handleFileEvent = (file: string) => {
678
+ const normalizedFile = file.replace(/\\/g, '/');
679
+ if (normalizedFile.endsWith('.svg') && normalizedFile.includes(normalizedIconsFolder)) {
680
+ pluginState.regenerateSprite!();
681
+ }
682
+ };
683
+
684
+ server.watcher.on('change', handleFileEvent);
685
+ server.watcher.on('add', handleFileEvent);
686
+ server.watcher.on('unlink', handleFileEvent);
687
+
688
+ server.httpServer?.on('close', () => {
689
+ server.watcher.off('change', handleFileEvent);
690
+ server.watcher.off('add', handleFileEvent);
691
+ server.watcher.off('unlink', handleFileEvent);
692
+ pluginState.regenerateSprite?.cancel();
693
+ pluginState.parseCache.clear();
694
+ });
695
+
696
+ logger.log(`👀 Watching ${options.iconsFolder} for SVG changes (HMR enabled)`);
697
+ },
698
+
699
+ buildEnd() {
700
+ if (pluginState.spriteContent) {
701
+ const iconCount = getIconCount(pluginState.spriteContent);
702
+ logger.log(`🎨 SVG Sprite Plugin: Build completed successfully (${iconCount} icons)`);
703
+ }
704
+ pluginState.regenerateSprite?.cancel();
705
+ }
706
+ };
707
+ }
708
+