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.
- package/CHANGELOG.md +210 -0
- package/LICENSE +22 -0
- package/README.md +573 -0
- package/package.json +69 -0
- package/vite-svg-sprite-generator-plugin.d.ts +171 -0
- package/vite-svg-sprite-generator-plugin.js +908 -0
- package/vite-svg-sprite-generator-plugin.ts +708 -0
|
@@ -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
|
+
'<': '<',
|
|
115
|
+
'>': '>',
|
|
116
|
+
'"': '"',
|
|
117
|
+
"'": ''',
|
|
118
|
+
'&': '&'
|
|
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
|
+
|