viconic-react-icons 1.0.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.
@@ -0,0 +1,2132 @@
1
+ /**
2
+ * CopyIcons Smart Loader v8.3 (CDN Smart Fallback + Early Abort)
3
+ *
4
+ * SMART CDN STRATEGY: Uses the fastest path based on how many icons are needed.
5
+ * - Normal browsing (≤200 per collection): Individual CDN SVGs (parallel, stream-inject)
6
+ * - Bulk embedding (>200 per collection): Full bundle JSON from CDN (one request)
7
+ * - Search & Collection pages: Individual CDN (stream-inject, icons appear instantly)
8
+ * - Kit embedding with many icons: Bundle mode (efficient for 200+ icons)
9
+ * Django is ONLY used as last-resort fallback.
10
+ *
11
+ * Architecture:
12
+ * 1. Inject cached icons immediately (sync, zero network)
13
+ * 2. Load prefix-map from CDN/localStorage (maps prefix → CDN base URL)
14
+ * 3. Smart path selection based on icon count per prefix:
15
+ * a) Individual CDN: fetch parallel SVGs from CDN (~50ms each, 15 concurrent)
16
+ * b) Bundle CDN: fetch entire collection JSON (~200-500ms, thousands of icons)
17
+ * 4. API batch fallback for any icons neither CDN source has
18
+ *
19
+ * Simple Usage (zero config):
20
+ * <script src="https://api.copyicons.click/static/js/copyicons-smart-loader.js"></script>
21
+ *
22
+ * @author CopyIcons Team
23
+ * @license MIT
24
+ * @version 8.2
25
+ */
26
+ (function () {
27
+ 'use strict';
28
+
29
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
30
+ return;
31
+ }
32
+
33
+ // Inject base styles for viconic-icon custom element so it behaves correctly
34
+ if (typeof document !== 'undefined') {
35
+ const style = document.createElement('style');
36
+ // Use :where() to set specificity to 0, so user styles can easily override without !important
37
+ style.textContent = ':where(viconic-icon) { display: inline-flex; align-items: center; justify-content: center; width: 1em; height: 1em; font-size: inherit; line-height: 1; } :where(viconic-icon):not(.ci-multicolor) svg { width: 100%; height: 100%; display: block; fill: currentColor; } :where(viconic-icon).ci-multicolor svg { width: 100%; height: 100%; display: block; }';
38
+ if (document.head.firstChild) {
39
+ document.head.insertBefore(style, document.head.firstChild);
40
+ } else {
41
+ document.head.appendChild(style);
42
+ }
43
+ }
44
+
45
+ // === PERFORMANCE CONSTANTS ===
46
+ const CACHE_TTL = 3 * 24 * 60 * 60 * 1000; // 3 days cache
47
+ const CACHE_PREFIX = 'copyicons:svg:';
48
+ const ICON_CACHE_PREFIX = 'ci:'; // Shorter prefix to save localStorage space
49
+ const PREFIXMAP_CACHE_KEY = 'ci:pfx-map';
50
+ const PREFIXMAP_TTL = 5 * 60 * 1000; // 5 min (matches CDN Cache-Control)
51
+ const MAX_MEMORY_CACHE_SIZE = 500;
52
+ const CDN_TIMEOUT = 3000; // 3s timeout per individual CDN fetch
53
+ const PREFIXMAP_TIMEOUT = 2000; // 2s timeout for prefix-map.json
54
+ const API_TIMEOUT = 8000; // 8s timeout for multi-prefix batch API
55
+ const BUNDLE_TIMEOUT = 5000; // 5s timeout for CDN bundle fetch
56
+ const CDN_CONCURRENCY = 15; // Concurrency limit for CDN fallback pool
57
+ const BUNDLE_THRESHOLD = 80; // Use CDN bundle when ≥80 icons needed (collection pages only)
58
+ const MAX_BUNDLE_CACHE = 2; // Max bundles kept in memory (LRU eviction)
59
+ const MEMORY_BUDGET = 290 * 1024 * 1024; // 290MB memory budget
60
+ const MEMORY_CHECK_INTERVAL = 30000; // Check memory every 30s
61
+
62
+ // === CDN BUNDLE CACHE: Stores entire collection SVG bundles in memory ===
63
+ // Key: collectionId → { data, accessTime, size }
64
+ const bundleCache = new Map();
65
+
66
+ // === IN-MEMORY CACHE with LRU eviction ===
67
+ const memoryCache = new Map();
68
+
69
+ // Prefix detection
70
+ const PREFIX_TOKEN_RE = /^[a-z0-9_]{1,32}$/i;
71
+ const DENY_PREFIXES = new Set([
72
+ 'icon', 'icons', 'active', 'disabled', 'hidden',
73
+ 'editor', 'preview', 'modal', 'btn', 'js', 'svg',
74
+ 'text', 'bg', 'flex', 'inline', 'block', 'grid', 'gap',
75
+ 'px', 'py', 'pt', 'pb', 'pl', 'pr', 'm', 'mx', 'my', 'mt', 'mb', 'ml', 'mr',
76
+ 'w', 'h', 'min', 'max', 'border', 'rounded', 'shadow', 'opacity',
77
+ 'hover', 'focus', 'group', 'transition', 'duration', 'ease',
78
+ 'absolute', 'relative', 'fixed', 'sticky', 'top', 'bottom', 'left', 'right',
79
+ 'z', 'overflow', 'truncate', 'translate', 'scale', 'rotate', 'skew',
80
+ 'zinc', 'gray', 'slate', 'neutral', 'stone', 'red', 'orange', 'amber',
81
+ 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue',
82
+ 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'white', 'black'
83
+ ]);
84
+
85
+ const ICON_CLASS_PREFIXES = ['cid-', 'cis-', 'cim-', 'cir-'];
86
+
87
+ // === VICONIC NAME CONVERSION ===
88
+ function camelToKebab(str) {
89
+ if (!str) return '';
90
+ // Three-step conversion to match backend's _kebab_to_camel round-trip:
91
+ // Step 1: handle consecutive uppercase before uppercase+lowercase (XMLParser → XML-Parser)
92
+ // Step 2: handle normal camelCase boundaries (arrowDown → arrow-Down)
93
+ let result = str
94
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
95
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2');
96
+ // Step 3: split trailing consecutive single-uppercase segments
97
+ // Backend _kebab_to_camel converts -a-z → AZ, so we need AZ$ → A-Z
98
+ // Apply repeatedly for 3+ chars (e.g. AZX$ → A-Z-X)
99
+ while (/[A-Z]{2,}$/.test(result)) {
100
+ result = result.replace(/([A-Z])([A-Z])$/, '$1-$2');
101
+ }
102
+ return result.toLowerCase();
103
+ }
104
+
105
+ function kebabToCamel(str) {
106
+ if (!str) return '';
107
+ // Only uppercase LETTERS after dashes. Dashes before digits stay.
108
+ return str.replace(/-([a-zA-Z])/g, (_, c) => c.toUpperCase());
109
+ }
110
+
111
+ function parseViconicIcon(attrValue) {
112
+ if (!attrValue) return null;
113
+ const colonIdx = attrValue.indexOf(':');
114
+ if (colonIdx < 1) return null;
115
+ const prefix = attrValue.slice(0, colonIdx).trim();
116
+ const camelName = attrValue.slice(colonIdx + 1).trim();
117
+ if (!prefix || !camelName) return null;
118
+ // icon attr uses camelCase, convert to kebab-case for CDN filenames
119
+ return { prefix, iconName: camelToKebab(camelName) };
120
+ }
121
+
122
+ function iconClassToViconic(iconClass) {
123
+ if (!iconClass) return null;
124
+ const tokens = iconClass.trim().split(/\s+/);
125
+ let prefix = '', rawName = '', animate = '';
126
+ for (const token of tokens) {
127
+ if (token === 'svg-loaded' || token === 'js-processed' || token === 'card-icon') continue;
128
+ if (!token.includes('-')) {
129
+ if (PREFIX_TOKEN_RE.test(token)) prefix = token;
130
+ } else {
131
+ let name = '';
132
+ let isIconPfx = false;
133
+ for (const iconPfx of ICON_CLASS_PREFIXES) {
134
+ if (token.startsWith(iconPfx)) {
135
+ name = token.slice(iconPfx.length);
136
+ isIconPfx = true;
137
+ break;
138
+ }
139
+ }
140
+ if (isIconPfx && name) {
141
+ rawName = name;
142
+ } else if (token.startsWith('ci-')) {
143
+ animate = token.slice(3);
144
+ } else if (!isCSSUtilityClass(token)) {
145
+ const dashIdx = token.indexOf('-');
146
+ if (dashIdx > 0) rawName = token.slice(dashIdx + 1);
147
+ }
148
+ }
149
+ }
150
+ if (!rawName) return null;
151
+ // Convert to camelCase for the icon attribute
152
+ return { prefix, name: kebabToCamel(rawName), animate: animate || null };
153
+ }
154
+
155
+ function viconicTagFromClass(iconClass, animation) {
156
+ const parsed = iconClassToViconic(iconClass);
157
+ if (!parsed) return `<viconic-icon icon="${iconClass}"></viconic-icon>`;
158
+ const anim = animation || parsed.animate;
159
+ let tag = `<viconic-icon icon="${parsed.prefix}:${parsed.name}"`;
160
+ if (anim && anim !== 'none') tag += ` animate="${anim}"`;
161
+ tag += '></viconic-icon>';
162
+ return tag;
163
+ }
164
+
165
+ // Configuration
166
+ const currentScript = document.currentScript;
167
+ const config = {
168
+ apiBase: (window.CONFIG?.API_BASE || currentScript?.dataset?.apiBase || 'https://api.viconic.io.vn').replace(/\/$/, ''),
169
+ // Batch API for Iconify-style loading (by-prefix endpoint)
170
+ batchApiBase: (window.CONFIG?.BATCH_API_BASE || window.CONFIG?.API_BASE || currentScript?.dataset?.batchApiBase || 'https://api.viconic.io.vn').replace(/\/$/, ''),
171
+ cdnDomain: currentScript?.dataset?.cdnDomain || window.CONFIG?.CDN_DOMAIN || 'pub-1f6e5603f8674e37b76ceac17e995ec3.r2.dev',
172
+ // Custom domain for R2 CDN via Cloudflare edge (HTTP/2, edge cache)
173
+ cdnCustomDomain: window.CONFIG?.CDN_CUSTOM_DOMAIN || currentScript?.dataset?.cdnCustomDomain || '',
174
+ observe: currentScript?.hasAttribute('data-observe') ?? false,
175
+ lazy: currentScript?.hasAttribute('data-lazy') ?? false,
176
+ };
177
+
178
+ // === PRECONNECT TO CDN + API (saves DNS+TLS ~100-300ms on first visit) ===
179
+ try {
180
+ // Preconnect to CDN (custom domain takes priority)
181
+ const cdnHost = config.cdnCustomDomain || config.cdnDomain;
182
+ const cdnLink = document.createElement('link');
183
+ cdnLink.rel = 'preconnect';
184
+ cdnLink.href = `https://${cdnHost}`;
185
+ cdnLink.crossOrigin = '';
186
+ document.head.appendChild(cdnLink);
187
+
188
+ // Preconnect to batch API for Iconify-style loading
189
+ const batchApiLink = document.createElement('link');
190
+ batchApiLink.rel = 'preconnect';
191
+ batchApiLink.href = config.batchApiBase;
192
+ batchApiLink.crossOrigin = '';
193
+ document.head.appendChild(batchApiLink);
194
+
195
+ // Preconnect to main API if different
196
+ if (config.apiBase !== config.batchApiBase) {
197
+ const apiLink = document.createElement('link');
198
+ apiLink.rel = 'preconnect';
199
+ apiLink.href = config.apiBase;
200
+ apiLink.crossOrigin = '';
201
+ document.head.appendChild(apiLink);
202
+ }
203
+ } catch (e) { }
204
+
205
+ // State
206
+ let processedElements = new WeakSet();
207
+ const pendingIcons = new Map(); // prefix -> [{element, iconName}]
208
+ let batchTimer = null;
209
+ let mutationObserver = null;
210
+ let intersectionObserver = null;
211
+ let currentLoadId = 0;
212
+
213
+ // In-flight fetch deduplication: prefix -> Promise
214
+ const inflightFetches = new Map();
215
+
216
+ // Track prefixes that returned 404 (collection deleted/missing)
217
+ const failedPrefixes = new Set();
218
+
219
+ // === CDN PREFIX MAP with localStorage caching ===
220
+ let prefixMap = null;
221
+ let prefixMapPromise = null;
222
+
223
+ // Try to load prefix-map from localStorage SYNCHRONOUSLY (instant on revisit!)
224
+ try {
225
+ const cached = localStorage.getItem(PREFIXMAP_CACHE_KEY);
226
+ if (cached) {
227
+ const parsed = JSON.parse(cached);
228
+ if (Date.now() - parsed.t < PREFIXMAP_TTL) {
229
+ prefixMap = parsed.d;
230
+ }
231
+ }
232
+ } catch (e) { }
233
+
234
+ // Rewrite R2 CDN URLs to use custom domain (Cloudflare edge)
235
+ // e.g. pub-xxx.r2.dev/icons/solar → cdn.viconic.io.vn/icons/solar
236
+ function rewriteCdnUrl(url) {
237
+ if (!config.cdnCustomDomain || !url) return url;
238
+ return url.replace(
239
+ /https:\/\/pub-[a-z0-9]+\.r2\.dev/,
240
+ `https://${config.cdnCustomDomain}`
241
+ );
242
+ }
243
+
244
+ function rewritePrefixMap(map) {
245
+ if (!config.cdnCustomDomain || !map) return map;
246
+ const rewritten = {};
247
+ for (const [key, val] of Object.entries(map)) {
248
+ rewritten[key] = rewriteCdnUrl(val);
249
+ }
250
+ return rewritten;
251
+ }
252
+
253
+ async function loadPrefixMap() {
254
+ if (prefixMap) return prefixMap;
255
+ if (prefixMapPromise) return prefixMapPromise;
256
+
257
+ prefixMapPromise = (async () => {
258
+ try {
259
+ // Fetch from custom domain if available, fallback to r2.dev
260
+ const cdnHost = config.cdnCustomDomain || config.cdnDomain;
261
+ const resp = await Promise.race([
262
+ fetch(`https://${cdnHost}/prefix-map.json`).catch(() => null),
263
+ new Promise(resolve => setTimeout(() => resolve(null), PREFIXMAP_TIMEOUT))
264
+ ]);
265
+
266
+ if (resp && resp.ok) {
267
+ const raw = await resp.json();
268
+ prefixMap = rewritePrefixMap(raw);
269
+ // Persist to localStorage for instant access on next page load
270
+ try {
271
+ localStorage.setItem(PREFIXMAP_CACHE_KEY, JSON.stringify({
272
+ d: prefixMap, t: Date.now()
273
+ }));
274
+ } catch (e) { }
275
+ return prefixMap;
276
+ }
277
+ } catch (e) { }
278
+ prefixMap = {};
279
+ return prefixMap;
280
+ })();
281
+ return prefixMapPromise;
282
+ }
283
+
284
+ // If we loaded from localStorage, start background refresh silently
285
+ // If not cached, start fetching now (non-blocking)
286
+ if (prefixMap) {
287
+ // Have cached data — also rewrite URLs if custom domain is set
288
+ prefixMap = rewritePrefixMap(prefixMap);
289
+ // Refresh in background without blocking anything
290
+ const bgHost = config.cdnCustomDomain || config.cdnDomain;
291
+ fetch(`https://${bgHost}/prefix-map.json`)
292
+ .then(r => r.ok ? r.json() : null)
293
+ .then(data => {
294
+ if (data) {
295
+ prefixMap = rewritePrefixMap(data);
296
+ try {
297
+ localStorage.setItem(PREFIXMAP_CACHE_KEY, JSON.stringify({
298
+ d: prefixMap, t: Date.now()
299
+ }));
300
+ } catch (e) { }
301
+ }
302
+ })
303
+ .catch(() => { });
304
+ } else {
305
+ loadPrefixMap();
306
+ }
307
+
308
+ // === CACHE FUNCTIONS ===
309
+
310
+ function getMemKey(prefix, iconName) {
311
+ return `${prefix}:${iconName}`;
312
+ }
313
+
314
+ function getFromMemory(prefix, iconName) {
315
+ const key = getMemKey(prefix, iconName);
316
+ const svg = memoryCache.get(key);
317
+ if (svg) {
318
+ // LRU: move to end
319
+ memoryCache.delete(key);
320
+ memoryCache.set(key, svg);
321
+ }
322
+ return svg;
323
+ }
324
+
325
+ function setToMemory(prefix, iconName, svg) {
326
+ const key = getMemKey(prefix, iconName);
327
+ if (memoryCache.has(key)) {
328
+ memoryCache.delete(key);
329
+ } else if (memoryCache.size >= MAX_MEMORY_CACHE_SIZE) {
330
+ // Evict oldest 50%
331
+ const deleteCount = Math.floor(MAX_MEMORY_CACHE_SIZE * 0.5);
332
+ const keysToDelete = [...memoryCache.keys()].slice(0, deleteCount);
333
+ for (const k of keysToDelete) memoryCache.delete(k);
334
+ }
335
+ memoryCache.set(key, svg);
336
+ }
337
+
338
+ // Get from localStorage (populates memory cache)
339
+ function getPerIconCache(prefix, iconName) {
340
+ const memCached = getFromMemory(prefix, iconName);
341
+ if (memCached && typeof memCached === 'string') return memCached;
342
+
343
+ try {
344
+ const key = `${ICON_CACHE_PREFIX}${prefix}:${iconName}`;
345
+ const cached = localStorage.getItem(key);
346
+ if (cached) {
347
+ const { s: svg, t: timestamp } = JSON.parse(cached);
348
+ if (typeof svg === 'string' && svg.trim() && Date.now() - timestamp < CACHE_TTL) {
349
+ setToMemory(prefix, iconName, svg);
350
+ return svg;
351
+ }
352
+ localStorage.removeItem(key);
353
+ }
354
+ } catch (e) { }
355
+ return null;
356
+ }
357
+
358
+ // === DEFERRED LOCALSTORAGE WRITES (don't block rendering!) ===
359
+ const _deferredWrites = [];
360
+ let _deferTimer = null;
361
+
362
+ function deferCacheWrite(prefix, iconName, svg) {
363
+ if (typeof svg !== 'string' || !svg.trim()) return;
364
+ _deferredWrites.push({ prefix, iconName, svg });
365
+ if (!_deferTimer) {
366
+ if ('requestIdleCallback' in window) {
367
+ _deferTimer = requestIdleCallback(_flushCacheWrites, { timeout: 2000 });
368
+ } else {
369
+ _deferTimer = setTimeout(_flushCacheWrites, 100);
370
+ }
371
+ }
372
+ }
373
+
374
+ function _flushCacheWrites() {
375
+ _deferTimer = null;
376
+ const writes = _deferredWrites.splice(0);
377
+ const now = Date.now();
378
+ for (const { prefix, iconName, svg } of writes) {
379
+ try {
380
+ const key = `${ICON_CACHE_PREFIX}${prefix}:${iconName}`;
381
+ localStorage.setItem(key, JSON.stringify({ s: svg, t: now }));
382
+ } catch (e) {
383
+ cleanupOldCache();
384
+ break; // Storage full, stop
385
+ }
386
+ }
387
+ }
388
+
389
+ // Set to cache (memory instantly, localStorage deferred)
390
+ function setPerIconCache(prefix, iconName, svg) {
391
+ if (typeof svg !== 'string' || !svg.trim()) return;
392
+ setToMemory(prefix, iconName, svg);
393
+ deferCacheWrite(prefix, iconName, svg);
394
+ }
395
+
396
+ // Batch cache (legacy, rarely used)
397
+ function getCachedData(key) {
398
+ try {
399
+ const cached = localStorage.getItem(CACHE_PREFIX + key);
400
+ if (cached) {
401
+ const { data, timestamp } = JSON.parse(cached);
402
+ if (Date.now() - timestamp < CACHE_TTL) return data;
403
+ localStorage.removeItem(CACHE_PREFIX + key);
404
+ }
405
+ } catch (e) { }
406
+ return null;
407
+ }
408
+
409
+ function setCachedData(key, data) {
410
+ try {
411
+ localStorage.setItem(CACHE_PREFIX + key, JSON.stringify({
412
+ data, timestamp: Date.now()
413
+ }));
414
+ } catch (e) { }
415
+ }
416
+
417
+ function cleanupOldCache() {
418
+ try {
419
+ const keysToRemove = [];
420
+ const now = Date.now();
421
+ for (let i = 0; i < localStorage.length; i++) {
422
+ const key = localStorage.key(i);
423
+ if (!key) continue;
424
+ if (key.startsWith(ICON_CACHE_PREFIX) || key.startsWith(CACHE_PREFIX)) {
425
+ try {
426
+ const cached = localStorage.getItem(key);
427
+ if (cached) {
428
+ const parsed = JSON.parse(cached);
429
+ const timestamp = parsed.t || parsed.timestamp;
430
+ if (now - timestamp > CACHE_TTL) keysToRemove.push(key);
431
+ }
432
+ } catch (e) {
433
+ keysToRemove.push(key);
434
+ }
435
+ }
436
+ }
437
+ keysToRemove.forEach(k => localStorage.removeItem(k));
438
+ } catch (e) { }
439
+ }
440
+
441
+ // One-time purge of corrupted cache entries
442
+ function purgeCorruptedCache() {
443
+ const PURGE_KEY = 'ci:purged_v2';
444
+ if (localStorage.getItem(PURGE_KEY)) return;
445
+ try {
446
+ const keysToRemove = [];
447
+ for (let i = 0; i < localStorage.length; i++) {
448
+ const key = localStorage.key(i);
449
+ if (!key || !key.startsWith(ICON_CACHE_PREFIX)) continue;
450
+ try {
451
+ const cached = localStorage.getItem(key);
452
+ if (!cached) continue;
453
+ const parsed = JSON.parse(cached);
454
+ if (typeof parsed.s !== 'string' || parsed.s.includes('[object') || !parsed.s.trim()) {
455
+ keysToRemove.push(key);
456
+ }
457
+ } catch (e) {
458
+ keysToRemove.push(key);
459
+ }
460
+ }
461
+ keysToRemove.forEach(k => localStorage.removeItem(k));
462
+ localStorage.setItem(PURGE_KEY, '1');
463
+ } catch (e) { }
464
+ }
465
+
466
+ purgeCorruptedCache();
467
+
468
+ // === ICON DETECTION ===
469
+
470
+ function detectPrefix(iconEl) {
471
+ const dataCollection = iconEl.getAttribute('data-collection');
472
+ if (dataCollection) return dataCollection;
473
+
474
+ const cls = iconEl.classList;
475
+ if (!cls || cls.length === 0) return null;
476
+
477
+ let hasIconClass = false;
478
+ let prefix = null;
479
+
480
+ for (let i = 0; i < cls.length; i++) {
481
+ const token = cls[i];
482
+ if (!token) continue;
483
+
484
+ if (token.includes('-')) {
485
+ hasIconClass = true;
486
+ continue;
487
+ }
488
+
489
+ if (!PREFIX_TOKEN_RE.test(token)) continue;
490
+ if (DENY_PREFIXES.has(token.toLowerCase())) continue;
491
+
492
+ prefix = token;
493
+ }
494
+
495
+ return hasIconClass ? prefix : null;
496
+ }
497
+
498
+ function extractIconName(className, dataCollection) {
499
+ for (const iconPrefix of ICON_CLASS_PREFIXES) {
500
+ if (className.startsWith(iconPrefix)) {
501
+ return className.slice(iconPrefix.length) || null;
502
+ }
503
+ }
504
+ if (dataCollection) {
505
+ const colPrefix = dataCollection.endsWith('-') ? dataCollection : dataCollection + '-';
506
+ if (className.startsWith(colPrefix)) {
507
+ return className.slice(colPrefix.length) || null;
508
+ }
509
+ }
510
+ const dashIndex = className.indexOf('-');
511
+ if (dashIndex > 0) {
512
+ return className.slice(dashIndex + 1);
513
+ }
514
+ return null;
515
+ }
516
+
517
+ const STYLE_SUFFIXES = new Set([
518
+ 'solid', 'regular', 'brands', 'light', 'thin', 'duotone', 'sharp',
519
+ 'outlined', 'round', 'two-tone', 'filled', 'line', 'bulk',
520
+ 'broken', 'linear', 'twotone', 'outline', 'fill'
521
+ ]);
522
+
523
+ const CSS_UTILITY_PREFIXES = [
524
+ 'text-', 'bg-', 'w-', 'h-', 'p-', 'm-', 'px-', 'py-', 'pt-', 'pb-', 'pl-', 'pr-',
525
+ 'mx-', 'my-', 'mt-', 'mb-', 'ml-', 'mr-', 'gap-', 'space-', 'flex-', 'grid-',
526
+ 'col-', 'row-', 'border-', 'rounded-', 'shadow-', 'opacity-', 'z-', 'top-', 'bottom-',
527
+ 'left-', 'right-', 'inset-', 'min-', 'max-', 'overflow-', 'translate-', 'scale-',
528
+ 'rotate-', 'skew-', 'transition-', 'duration-', 'ease-', 'delay-', 'animate-',
529
+ 'font-', 'leading-', 'tracking-', 'decoration-', 'underline-', 'line-',
530
+ 'hover:', 'focus:', 'active:', 'group-', 'dark:', 'sm:', 'md:', 'lg:', 'xl:',
531
+ 'from-', 'to-', 'via-', 'ring-', 'divide-', 'place-', 'items-', 'justify-',
532
+ 'self-', 'order-', 'cursor-', 'select-', 'resize-', 'fill-', 'stroke-',
533
+ 'object-', 'aspect-', 'columns-', 'break-', 'box-', 'float-', 'clear-',
534
+ 'isolation-', 'overscroll-', 'scroll-', 'snap-', 'touch-', 'appearance-',
535
+ 'outline-', 'accent-', 'caret-', 'will-', 'contain-', 'content-',
536
+ 'data-', 'nav-', 'card-', 'btn-', 'modal-', 'sidebar-', 'editor-',
537
+ 'hero-', 'mobile-', 'search-', 'pagination-', 'collection-', 'explore-',
538
+ 'svg-', 'trending-', 'png-', 'kit-', 'color-', 'section-', 'toolbar-',
539
+ '-translate-', '-mt-', '-mb-', '-ml-', '-mr-'
540
+ ];
541
+
542
+ function isCSSUtilityClass(token) {
543
+ const lower = token.toLowerCase();
544
+ for (const prefix of CSS_UTILITY_PREFIXES) {
545
+ if (lower.startsWith(prefix)) return true;
546
+ }
547
+ if (lower.includes(':')) return true;
548
+ return false;
549
+ }
550
+
551
+ function getIconNames(iconEl) {
552
+ const knownPrefixNames = [];
553
+ const otherNames = [];
554
+ const cls = iconEl.classList;
555
+ const dataCollection = iconEl.getAttribute('data-collection') || '';
556
+
557
+ for (let i = 0; i < cls.length; i++) {
558
+ const token = cls[i];
559
+ if (!token || !token.includes('-')) continue;
560
+
561
+ if (isCSSUtilityClass(token)) continue;
562
+
563
+ let isKnownPrefix = false;
564
+ for (const iconPrefix of ICON_CLASS_PREFIXES) {
565
+ if (token.startsWith(iconPrefix)) {
566
+ isKnownPrefix = true;
567
+ break;
568
+ }
569
+ }
570
+
571
+ const name = extractIconName(token, dataCollection);
572
+ if (name) {
573
+ if (STYLE_SUFFIXES.has(name.toLowerCase())) continue;
574
+ if (isKnownPrefix) {
575
+ knownPrefixNames.push(name);
576
+ } else {
577
+ otherNames.push(name);
578
+ }
579
+ }
580
+ }
581
+
582
+ return knownPrefixNames.length > 0 ? knownPrefixNames : otherNames;
583
+ }
584
+
585
+ // === SVG INJECTION ===
586
+
587
+ function isInDOM(element) {
588
+ return element && element.isConnected !== false && document.body.contains(element);
589
+ }
590
+
591
+ // === MULTI-COLOR DETECTION ===
592
+ // Detects if an SVG has multiple distinct colors (fill/stroke) that should be preserved.
593
+ // Returns true if the icon is multi-color and colors should be locked.
594
+ function isMultiColorSvg(svgString) {
595
+ // Normalize color values for comparison
596
+ function normalizeColor(c) {
597
+ if (!c) return null;
598
+ c = c.trim().toLowerCase();
599
+ // Skip non-color values
600
+ if (c === 'none' || c === 'inherit' || c === 'transparent' || c === 'currentcolor') return null;
601
+ // Normalize common black representations
602
+ if (c === '#000' || c === '#000000' || c === 'black' || c === 'rgb(0,0,0)' || c === 'rgb(0, 0, 0)') return '#000000';
603
+ // Normalize common white representations
604
+ if (c === '#fff' || c === '#ffffff' || c === 'white' || c === 'rgb(255,255,255)' || c === 'rgb(255, 255, 255)') return '#ffffff';
605
+ // Expand 3-char hex to 6-char
606
+ const hex3 = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
607
+ if (hex3) return '#' + hex3[1] + hex3[1] + hex3[2] + hex3[2] + hex3[3] + hex3[3];
608
+ return c;
609
+ }
610
+
611
+ const colors = new Set();
612
+
613
+ // 1. Extract colors from fill="..." and stroke="..." attributes
614
+ const attrRe = /(?:fill|stroke)\s*=\s*"([^"]+)"/gi;
615
+ let m;
616
+ while ((m = attrRe.exec(svgString)) !== null) {
617
+ const nc = normalizeColor(m[1]);
618
+ if (nc) colors.add(nc);
619
+ }
620
+
621
+ // 2. Extract colors from inline style="..." (fill:...; stroke:...;)
622
+ const styleAttrRe = /style\s*=\s*"([^"]+)"/gi;
623
+ while ((m = styleAttrRe.exec(svgString)) !== null) {
624
+ const styleStr = m[1];
625
+ const propRe = /(?:fill|stroke)\s*:\s*([^;"\s]+)/gi;
626
+ let pm;
627
+ while ((pm = propRe.exec(styleStr)) !== null) {
628
+ const nc = normalizeColor(pm[1]);
629
+ if (nc) colors.add(nc);
630
+ }
631
+ }
632
+
633
+ // 3. Extract colors from <style> blocks (CSS rules)
634
+ const styleBlockRe = /<style[^>]*>([\s\S]*?)<\/style>/gi;
635
+ while ((m = styleBlockRe.exec(svgString)) !== null) {
636
+ const css = m[1];
637
+ const cssPropRe = /(?:fill|stroke)\s*:\s*([^;}\s]+)/gi;
638
+ let cm;
639
+ while ((cm = cssPropRe.exec(css)) !== null) {
640
+ const nc = normalizeColor(cm[1]);
641
+ if (nc) colors.add(nc);
642
+ }
643
+ }
644
+
645
+ // Multi-color = has 2+ distinct non-black colors, OR has black + another color
646
+ // Single non-black color = also lock (e.g. icon purely in blue should keep blue)
647
+ if (colors.size >= 2) return true;
648
+ if (colors.size === 1 && !colors.has('#000000')) return true;
649
+ return false;
650
+ }
651
+
652
+ function injectSVG(element, svgContent) {
653
+ if (!isInDOM(element)) return false;
654
+ if (element.classList.contains('svg-loaded')) return true; // Already injected (racing dedup)
655
+ if (typeof svgContent !== 'string' || !svgContent.trim()) return false;
656
+
657
+ // Detect multi-color before scoping (scoping doesn't change colors)
658
+ const multiColor = isMultiColorSvg(svgContent);
659
+
660
+ // Apply fast SVG scoping to prevent CSS/ID collisions between icons
661
+ let scopedContent = svgContent;
662
+
663
+ // Lock colors for multi-color icons: replace currentColor with #000 so
664
+ // the icon is fully immune to CSS color inheritance
665
+ if (multiColor) {
666
+ // Replace currentColor in attributes: fill="currentColor", stroke="currentColor"
667
+ scopedContent = scopedContent.replace(/((?:fill|stroke)\s*=\s*")currentColor(")/gi, '$1#000$2');
668
+ // Replace currentColor in inline styles: style="fill:currentColor" or "stroke: currentColor"
669
+ scopedContent = scopedContent.replace(/((?:fill|stroke)\s*:\s*)currentColor/gi, '$1#000');
670
+ }
671
+ if (scopedContent.includes('<style') || scopedContent.includes('id=')) {
672
+ const uid = 'ci' + Math.random().toString(36).slice(2, 7);
673
+
674
+ // 1. Scope inline <style> CSS classes
675
+ if (scopedContent.includes('<style')) {
676
+ scopedContent = scopedContent.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (match, css) => {
677
+ return match.replace(css, css.replace(/\.([a-zA-Z0-9_\-]+)/g, '.' + uid + '-$1'));
678
+ });
679
+ // Scope the matching class="..." attributes
680
+ scopedContent = scopedContent.replace(/class="([^"]+)"/gi, (match, classes) => {
681
+ if (classes.includes('svg-loaded')) return match; // skip our own wrapper
682
+ return 'class="' + classes.split(/\s+/).map(c => c ? uid + '-' + c : '').join(' ') + '"';
683
+ });
684
+ }
685
+
686
+ // 2. Scope generic IDs (clip-paths, gradients, masks)
687
+ if (scopedContent.includes('id=')) {
688
+ scopedContent = scopedContent.replace(/id="([^"]+)"/gi, `id="${uid}-$1"`);
689
+ scopedContent = scopedContent.replace(/url\(['"]?#([^'"\)]+)['"]?\)/gi, `url(#${uid}-$1)`);
690
+ scopedContent = scopedContent.replace(/href="#([^"]+)"/gi, `href="#${uid}-$1"`);
691
+ }
692
+ }
693
+
694
+ element.innerHTML = scopedContent;
695
+ element.classList.add('svg-loaded');
696
+
697
+ // Mark multi-color icons so CSS doesn't override their colors
698
+ if (multiColor) {
699
+ element.classList.add('ci-multicolor');
700
+ }
701
+
702
+ const svg = element.querySelector('svg');
703
+ if (svg) {
704
+ const originalFill = svg.getAttribute('fill');
705
+ if (multiColor) {
706
+ // Multi-color: preserve original colors, only set sizing
707
+ svg.style.cssText = 'width:1em;height:1em;display:inline-block;vertical-align:middle;';
708
+ } else {
709
+ const fillStyle = (originalFill === 'none') ? 'none' : 'currentColor';
710
+ svg.style.cssText = `width:1em;height:1em;fill:${fillStyle};display:inline-block;vertical-align:middle;`;
711
+ }
712
+
713
+ if (element.getAttribute('animate') === 'trace') {
714
+ const isStrokeBased = (originalFill === 'none');
715
+ svg.style.setProperty('--trace-end-stroke', isStrokeBased ? 'currentColor' : 'transparent');
716
+ svg.style.setProperty('--trace-end-stroke-width', isStrokeBased ? (svg.getAttribute('stroke-width') || '2px') : '0px');
717
+ svg.style.setProperty('--trace-stroke-width', isStrokeBased ? (svg.getAttribute('stroke-width') || '2px') : '1px');
718
+
719
+ const paths = svg.querySelectorAll('path, ellipse, circle, rect, polygon, polyline');
720
+ for (let i = 0; i < paths.length; i++) {
721
+ const p = paths[i];
722
+ const len = (typeof p.getTotalLength === 'function') ? Math.ceil(p.getTotalLength()) : 400;
723
+ p.style.setProperty('--path-len', len);
724
+ }
725
+ }
726
+ }
727
+
728
+ return true;
729
+ }
730
+
731
+ function tryInjectFromCache(iconEl, prefix, iconName) {
732
+ const svg = getFromMemory(prefix, iconName) || getPerIconCache(prefix, iconName);
733
+ if (svg) {
734
+ iconEl.classList.add('cache-hit');
735
+ injectSVG(iconEl, svg);
736
+ return true;
737
+ }
738
+ return false;
739
+ }
740
+
741
+ // === ICON REGISTRATION ===
742
+
743
+ // === VICONIC ELEMENT REGISTRATION ===
744
+
745
+ function registerViconicIcon(el) {
746
+ if (!el || el.nodeType !== 1) return;
747
+ if (processedElements.has(el)) return;
748
+ processedElements.add(el);
749
+
750
+ const iconAttr = el.getAttribute('icon');
751
+ const parsed = parseViconicIcon(iconAttr);
752
+ if (!parsed) {
753
+ if (iconAttr) console.warn(`[CopyIcons] ⚠️ Cannot parse viconic-icon icon="${iconAttr}"`);
754
+ return;
755
+ }
756
+
757
+ const { prefix, iconName } = parsed;
758
+
759
+ if (tryInjectFromCache(el, prefix, iconName)) {
760
+ return;
761
+ }
762
+
763
+ if (!pendingIcons.has(prefix)) {
764
+ pendingIcons.set(prefix, []);
765
+ }
766
+ pendingIcons.get(prefix).push({ element: el, iconName });
767
+ scheduleBatchLoad();
768
+ }
769
+
770
+ function registerIcon(iconEl) {
771
+ if (!iconEl || iconEl.nodeType !== 1) return;
772
+ if (processedElements.has(iconEl)) return;
773
+ processedElements.add(iconEl);
774
+
775
+ const prefix = detectPrefix(iconEl);
776
+ if (!prefix) return;
777
+
778
+ const iconNames = getIconNames(iconEl);
779
+ if (iconNames.length === 0) return;
780
+
781
+ const iconName = iconNames[0];
782
+ if (tryInjectFromCache(iconEl, prefix, iconName)) {
783
+ return; // Cache hit - done synchronously!
784
+ }
785
+
786
+ // Cache miss - queue for network fetch
787
+ if (!pendingIcons.has(prefix)) {
788
+ pendingIcons.set(prefix, []);
789
+ }
790
+ pendingIcons.get(prefix).push({ element: iconEl, iconName: iconName });
791
+
792
+ scheduleBatchLoad();
793
+ }
794
+
795
+ function scheduleBatchLoad() {
796
+ if (batchTimer) return;
797
+ batchTimer = true;
798
+ // Use setTimeout(0) instead of queueMicrotask so ALL icons from
799
+ // processMutationBatch() get registered first, THEN loadPendingSVGs
800
+ // fires once with the full batch. queueMicrotask fires too early
801
+ // (after first registerIcon, before loop finishes).
802
+ setTimeout(() => {
803
+ batchTimer = null;
804
+ loadPendingSVGs();
805
+ }, 0);
806
+ }
807
+
808
+ function clearPending() {
809
+ if (batchTimer) {
810
+ clearTimeout(batchTimer);
811
+ batchTimer = null;
812
+ }
813
+ pendingIcons.clear();
814
+ processedElements = new WeakSet();
815
+ currentLoadId++;
816
+ inflightFetches.clear();
817
+
818
+ mutationBatch.length = 0;
819
+ if (mutationBatchTimer) {
820
+ clearTimeout(mutationBatchTimer);
821
+ mutationBatchTimer = null;
822
+ }
823
+ }
824
+
825
+ // === SVG LOADING (CDN Bundle Direct v8.0) ===
826
+
827
+ async function loadPendingSVGs() {
828
+ if (pendingIcons.size === 0) return;
829
+ const loadId = ++currentLoadId;
830
+
831
+ // Collect all prefix groups
832
+ const groups = new Map();
833
+ for (const [prefix, iconList] of pendingIcons) {
834
+ const iconNames = [...new Set(iconList.map(i => i.iconName))];
835
+ groups.set(prefix, { iconNames, iconList });
836
+ }
837
+ pendingIcons.clear();
838
+
839
+ // Phase 1: Inject cached + collect uncached (sync, zero network)
840
+ const uncachedGroups = new Map();
841
+ let totalUncached = 0;
842
+
843
+ for (const [prefix, { iconNames, iconList }] of groups) {
844
+ if (failedPrefixes.has(prefix)) continue;
845
+
846
+ const elementMap = new Map();
847
+ for (const { element, iconName } of iconList) {
848
+ if (!elementMap.has(iconName)) elementMap.set(iconName, []);
849
+ elementMap.get(iconName).push(element);
850
+ }
851
+
852
+ // Inject cached icons immediately (sync)
853
+ const uncachedNames = [];
854
+ for (const iconName of iconNames) {
855
+ const svg = getFromMemory(prefix, iconName) || getPerIconCache(prefix, iconName);
856
+ if (svg) {
857
+ const elements = elementMap.get(iconName);
858
+ if (elements) for (const el of elements) { el.classList.add('cache-hit'); injectSVG(el, svg); }
859
+ } else {
860
+ uncachedNames.push(iconName);
861
+ }
862
+ }
863
+ if (uncachedNames.length === 0) continue;
864
+
865
+ uncachedGroups.set(prefix, { uncachedNames, elementMap });
866
+ totalUncached += uncachedNames.length;
867
+ }
868
+
869
+ if (totalUncached === 0 || loadId !== currentLoadId) return;
870
+
871
+ // ================================================================
872
+ // Phase 2: CDN-FIRST — fetch SVGs directly from R2 CDN
873
+ //
874
+ // NO server API calls. Fetch individual SVGs from R2 CDN.
875
+ // Browser HTTP/2 multiplexes ALL requests on ONE connection.
876
+ // 100 SVGs × ~500B on 1 HTTP/2 conn ≈ 300-500ms total.
877
+ //
878
+ // Why this is fast:
879
+ // - R2 CDN is behind Cloudflare → HTTP/2 + edge cache
880
+ // - Browser sends all requests simultaneously on 1 connection
881
+ // - Each SVG is tiny (~500B) → minimal transfer time
882
+ // - No server processing, no R2 bundle downloads, no DB queries
883
+ //
884
+ // For collection pages (≥80 icons same prefix) → CDN bundle.
885
+ // ================================================================
886
+
887
+ const t0 = performance.now();
888
+ const map = await loadPrefixMap();
889
+ if (loadId !== currentLoadId) return;
890
+
891
+ const bundlePromises = [];
892
+ const cdnPromises = [];
893
+ const unmappedIcons = []; // Icons without CDN mapping
894
+ let cdnCount = 0;
895
+
896
+ for (const [prefix, { uncachedNames, elementMap }] of uncachedGroups) {
897
+ const cdnBase = map?.[prefix];
898
+
899
+ // No CDN mapping → skip (will be unresolved)
900
+ if (!cdnBase) {
901
+ unmappedIcons.push({ prefix, uncachedNames, elementMap });
902
+ continue;
903
+ }
904
+
905
+ // Collection pages: use bundle for large same-prefix groups
906
+ if (uncachedNames.length >= BUNDLE_THRESHOLD) {
907
+ bundlePromises.push(
908
+ loadIconsFromBundle(prefix, cdnBase, uncachedNames, elementMap).catch(() => {
909
+ // Bundle failed → fall back to individual CDN fetches
910
+ for (const name of uncachedNames) {
911
+ cdnPromises.push(fetchAndInjectCDN(prefix, name, cdnBase, elementMap));
912
+ }
913
+ })
914
+ );
915
+ continue;
916
+ }
917
+
918
+ // CDN-FIRST: Fire individual SVG fetches — NO concurrency limit
919
+ // Browser HTTP/2 handles multiplexing on one connection
920
+ for (const name of uncachedNames) {
921
+ cdnCount++;
922
+ cdnPromises.push(fetchAndInjectCDN(prefix, name, cdnBase, elementMap));
923
+ }
924
+ }
925
+
926
+ // Single reusable fetch+inject function
927
+ async function fetchAndInjectCDN(prefix, name, cdnBase, elementMap) {
928
+ if (getFromMemory(prefix, name)) {
929
+ const els = elementMap.get(name);
930
+ if (els) for (const el of els) injectSVG(el, getFromMemory(prefix, name));
931
+ return { ok: true, prefix, name };
932
+ }
933
+ try {
934
+ const resp = await Promise.race([
935
+ fetch(`${cdnBase}/${name}.svg`).catch(() => null),
936
+ new Promise(r => setTimeout(() => r(null), CDN_TIMEOUT))
937
+ ]);
938
+ if (!resp || !resp.ok) return { ok: false, prefix, name };
939
+ const svg = await resp.text();
940
+ if (!svg || !svg.includes('<')) return { ok: false, prefix, name };
941
+ setToMemory(prefix, name, svg);
942
+ deferCacheWrite(prefix, name, svg);
943
+ const els = elementMap.get(name);
944
+ if (els) for (const el of els) injectSVG(el, svg);
945
+ return { ok: true, prefix, name };
946
+ } catch (e) {
947
+ return { ok: false, prefix, name };
948
+ }
949
+ }
950
+
951
+ // Fire ALL CDN fetches + bundles simultaneously
952
+ const cdnResults = await Promise.allSettled([...bundlePromises, ...cdnPromises]);
953
+
954
+ // Phase 3: Collect ALL failed icons (CDN failures + unmapped) → API fallback
955
+ // Group CDN failures by prefix for batch API call
956
+ const failedByPrefix = new Map();
957
+ for (const result of cdnResults) {
958
+ const val = result.status === 'fulfilled' ? result.value : null;
959
+ if (val && !val.ok && val.prefix && val.name) {
960
+ if (!failedByPrefix.has(val.prefix)) failedByPrefix.set(val.prefix, []);
961
+ failedByPrefix.get(val.prefix).push(val.name);
962
+ }
963
+ }
964
+
965
+ // Merge unmapped icons into the same fallback
966
+ for (const task of unmappedIcons) {
967
+ if (!failedByPrefix.has(task.prefix)) failedByPrefix.set(task.prefix, []);
968
+ failedByPrefix.get(task.prefix).push(...task.uncachedNames);
969
+ }
970
+
971
+ // Build combined element map for all groups
972
+ const allElementMaps = new Map();
973
+ for (const [prefix, { elementMap }] of uncachedGroups) {
974
+ allElementMaps.set(prefix, elementMap);
975
+ }
976
+ for (const task of unmappedIcons) {
977
+ allElementMaps.set(task.prefix, task.elementMap);
978
+ }
979
+
980
+ // API fallback for all failed icons
981
+ if (failedByPrefix.size > 0 && loadId === currentLoadId) {
982
+ const q = [...failedByPrefix.entries()]
983
+ .map(([prefix, names]) => `${prefix}:${names.join(',')}`)
984
+ .join('|');
985
+ try {
986
+ const resp = await Promise.race([
987
+ fetch(`${config.batchApiBase}/api/v1/subset/multi-prefix/?q=${encodeURIComponent(q)}`).catch(() => null),
988
+ new Promise(r => setTimeout(() => r(null), API_TIMEOUT))
989
+ ]);
990
+ if (resp?.ok) {
991
+ const data = await resp.json().catch(() => null);
992
+ if (data?.results) {
993
+ for (const [prefix, names] of failedByPrefix) {
994
+ const prefixResult = data.results[prefix];
995
+ if (!prefixResult) continue;
996
+ const elementMap = allElementMaps.get(prefix);
997
+ for (const name of names) {
998
+ const iconData = prefixResult[name];
999
+ const svg = typeof iconData === 'string' ? iconData : iconData?.svg;
1000
+ if (!svg || typeof svg !== 'string' || !svg.includes('<')) continue;
1001
+ setToMemory(prefix, name, svg);
1002
+ deferCacheWrite(prefix, name, svg);
1003
+ if (elementMap) {
1004
+ const els = elementMap.get(name);
1005
+ if (els) for (const el of els) injectSVG(el, svg);
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ } catch (e) { }
1012
+ }
1013
+
1014
+ const elapsed = Math.round(performance.now() - t0);
1015
+ const unmappedCount = unmappedIcons.reduce((sum, g) => sum + g.uncachedNames.length, 0);
1016
+ const info = unmappedCount > 0 ? ` (${cdnCount} CDN + ${unmappedCount} API)` : '';
1017
+ console.log(`[CopyIcons] ⚡ ${totalUncached} icons in ${elapsed}ms${info}`);
1018
+ }
1019
+
1020
+ // === CDN BUNDLE DIRECT: Fetch entire collection bundle from CDN, parse client-side ===
1021
+ // This BYPASSES Django entirely — 5-10x faster than API path
1022
+ // prefix-map: { "sol": "https://cdn/icons/solar-icons-1.0" }
1023
+ // → collectionId = "solar-icons-1.0"
1024
+ // → bundle URL = "https://cdn/collections/solar-icons-1.0/icons_svg.json"
1025
+ const bundleInflight = new Map(); // collectionId → Promise
1026
+
1027
+ function getCollectionId(cdnBase) {
1028
+ // cdnBase = "https://cdn.domain/icons/flowbite-icons" → "flowbite-icons"
1029
+ if (!cdnBase) return null;
1030
+ const parts = cdnBase.split('/icons/');
1031
+ return parts.length > 1 ? parts[parts.length - 1] : null;
1032
+ }
1033
+
1034
+ async function fetchBundleFromCDN(collectionId) {
1035
+ const cached = bundleCache.get(collectionId);
1036
+ if (cached) {
1037
+ cached.accessTime = Date.now();
1038
+ return cached.data;
1039
+ }
1040
+ if (bundleInflight.has(collectionId)) return bundleInflight.get(collectionId);
1041
+
1042
+ const promise = (async () => {
1043
+ // Try gzip first (always available), then uncompressed (small collections only)
1044
+ const urls = [
1045
+ `https://${config.cdnDomain}/collections/${collectionId}/icons_svg.json.gz`,
1046
+ `https://${config.cdnDomain}/collections/${collectionId}/icons_svg.json`,
1047
+ ];
1048
+
1049
+ for (const url of urls) {
1050
+ try {
1051
+ const resp = await Promise.race([
1052
+ fetch(url).catch(() => null),
1053
+ new Promise(resolve => setTimeout(() => resolve(null), BUNDLE_TIMEOUT))
1054
+ ]);
1055
+
1056
+ if (!resp || !resp.ok) continue;
1057
+
1058
+ const data = await resp.json().catch(() => null);
1059
+ if (data && typeof data === 'object' && Object.keys(data).length > 0) {
1060
+ const iconCount = Object.keys(data).length;
1061
+ // Estimate memory: ~500 bytes per SVG on average
1062
+ const estimatedSize = iconCount * 500;
1063
+
1064
+ // Evict oldest bundles if over limit
1065
+ enforceBundleCacheLimit();
1066
+
1067
+ bundleCache.set(collectionId, {
1068
+ data,
1069
+ accessTime: Date.now(),
1070
+ size: estimatedSize,
1071
+ iconCount
1072
+ });
1073
+ console.log(`[CopyIcons] 📦 Bundle loaded: ${collectionId} (${iconCount} icons from CDN, ~${Math.round(estimatedSize / 1024)}KB)`);
1074
+ return data;
1075
+ }
1076
+ } catch (e) { continue; }
1077
+ }
1078
+ return null;
1079
+ })();
1080
+
1081
+ bundleInflight.set(collectionId, promise);
1082
+ promise.finally(() => bundleInflight.delete(collectionId));
1083
+ return promise;
1084
+ }
1085
+
1086
+ function enforceBundleCacheLimit() {
1087
+ while (bundleCache.size >= MAX_BUNDLE_CACHE) {
1088
+ // Evict least recently accessed
1089
+ let oldestKey = null;
1090
+ let oldestTime = Infinity;
1091
+ for (const [key, entry] of bundleCache) {
1092
+ if (entry.accessTime < oldestTime) {
1093
+ oldestTime = entry.accessTime;
1094
+ oldestKey = key;
1095
+ }
1096
+ }
1097
+ if (oldestKey) {
1098
+ const evicted = bundleCache.get(oldestKey);
1099
+ bundleCache.delete(oldestKey);
1100
+ console.log(`[CopyIcons] 🗑️ Bundle evicted: ${oldestKey} (${evicted.iconCount} icons, ~${Math.round(evicted.size / 1024)}KB)`);
1101
+ } else {
1102
+ break;
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ // === CDN BUNDLE: Extract icons from bundle + inject into elements ===
1108
+ async function loadIconsFromBundle(prefix, cdnBase, iconNames, elementMap) {
1109
+ const collectionId = getCollectionId(cdnBase);
1110
+ if (!collectionId) return 0;
1111
+
1112
+ const bundle = await fetchBundleFromCDN(collectionId);
1113
+ if (!bundle) return 0;
1114
+
1115
+ let loadedCount = 0;
1116
+ for (const name of iconNames) {
1117
+ // Skip already loaded (from parallel CDN individual or cache)
1118
+ if (getFromMemory(prefix, name)) continue;
1119
+
1120
+ const svg = bundle[name];
1121
+ if (!svg || typeof svg !== 'string' || !svg.includes('<')) continue;
1122
+
1123
+ setToMemory(prefix, name, svg);
1124
+ deferCacheWrite(prefix, name, svg);
1125
+
1126
+ if (elementMap) {
1127
+ const elements = elementMap.get(name);
1128
+ if (elements) {
1129
+ for (const el of elements) {
1130
+ if (isInDOM(el)) injectSVG(el, svg);
1131
+ }
1132
+ loadedCount += elements.length;
1133
+ }
1134
+ } else {
1135
+ // Auto stream-inject: find matching DOM elements (both <i> and <viconic-icon>)
1136
+ try {
1137
+ const iCandidates = document.querySelectorAll(`i.${CSS.escape(prefix)}:not(.svg-loaded)`);
1138
+ for (const el of iCandidates) {
1139
+ const names = getIconNames(el);
1140
+ if (names.includes(name)) {
1141
+ injectSVG(el, svg);
1142
+ loadedCount++;
1143
+ }
1144
+ }
1145
+ const vCandidates = document.querySelectorAll(`viconic-icon[icon]:not(.svg-loaded)`);
1146
+ for (const el of vCandidates) {
1147
+ const parsed = parseViconicIcon(el.getAttribute('icon'));
1148
+ if (parsed && parsed.prefix === prefix && parsed.iconName === name) {
1149
+ injectSVG(el, svg);
1150
+ loadedCount++;
1151
+ }
1152
+ }
1153
+ } catch (e) { }
1154
+ }
1155
+ }
1156
+
1157
+ // After extracting needed icons, save ALL remaining bundle icons
1158
+ // to localStorage (deferred), then evict the bundle from memory.
1159
+ // This keeps memory low while ensuring future pages hit localStorage cache.
1160
+ scheduleBundleFlush(collectionId, prefix);
1161
+
1162
+ return loadedCount;
1163
+ }
1164
+
1165
+ function scheduleBundleFlush(collectionId, prefix) {
1166
+ // VICONIC OPTIMIZATION:
1167
+ // Do NOT flush 14,000 icons into localStorage (causes QuotaExceededError and lag).
1168
+ // Instead, we just keep the bundle in bundleCache (RAM).
1169
+ // MAX_BUNDLE_CACHE (2) ensures we only keep ~14MB of heap max.
1170
+ // The first load takes 1s, but subsequent paginations within the same
1171
+ // collection will be 0ms instant from memory!
1172
+ console.log(`[CopyIcons] ✨ Bundle preserved in memory: ${collectionId} (Fast Pagination Enabled)`);
1173
+ }
1174
+
1175
+ // === MULTI-PREFIX BATCH: One HTTP request for icons from ALL collections ===
1176
+ async function fetchMultiPrefix(bulkPrefixes, map) {
1177
+ const loadId = currentLoadId;
1178
+
1179
+ // Build query: sol:user,home|flo:arrow-left
1180
+ const queryParts = [];
1181
+ for (const [prefix, { uncachedNames }] of bulkPrefixes) {
1182
+ queryParts.push(`${prefix}:${uncachedNames.join(',')}`);
1183
+ }
1184
+ const q = queryParts.join('|');
1185
+
1186
+ try {
1187
+ const url = `${config.batchApiBase}/api/v1/subset/multi-prefix/?q=${encodeURIComponent(q)}`;
1188
+ const response = await Promise.race([
1189
+ fetch(url).catch(() => null),
1190
+ new Promise(resolve => setTimeout(() => resolve(null), API_TIMEOUT))
1191
+ ]);
1192
+
1193
+ if (response && response.ok) {
1194
+ const data = await response.json();
1195
+ if (loadId !== currentLoadId) return;
1196
+
1197
+ let totalLoaded = 0;
1198
+ const results = data.results || {};
1199
+
1200
+ for (const [prefix, icons] of Object.entries(results)) {
1201
+ const group = bulkPrefixes.get(prefix);
1202
+ if (!group) continue;
1203
+
1204
+ for (const [name, svgData] of Object.entries(icons)) {
1205
+ // Skip icons CDN already loaded (dual-path dedup)
1206
+ if (getFromMemory(prefix, name)) continue;
1207
+
1208
+ const svg = extractSvg(svgData);
1209
+ if (!svg || typeof svg !== 'string' || !svg.trim()) continue;
1210
+
1211
+ setToMemory(prefix, name, svg);
1212
+ deferCacheWrite(prefix, name, svg);
1213
+
1214
+ const elements = group.elementMap.get(name);
1215
+ if (elements) {
1216
+ for (const el of elements) {
1217
+ if (isInDOM(el)) injectSVG(el, svg);
1218
+ }
1219
+ totalLoaded += elements.length;
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ if (totalLoaded > 0) {
1225
+ console.log(`[CopyIcons] ✅ ${totalLoaded} icons loaded via multi-prefix batch (${bulkPrefixes.size} collections)`);
1226
+ }
1227
+ return; // Success - done
1228
+ }
1229
+ } catch (e) { /* multi-prefix failed, fallback below */ }
1230
+
1231
+ // === FALLBACK: per-prefix API batch (if multi-prefix endpoint not available) ===
1232
+ const fallbackPromises = [];
1233
+ for (const [prefix, { uncachedNames, elementMap }] of bulkPrefixes) {
1234
+ fallbackPromises.push(
1235
+ fetchIconsViaAPI(prefix, uncachedNames, elementMap)
1236
+ .then(count => {
1237
+ if (count > 0) console.log(`[CopyIcons] ✅ ${count} icons loaded for "${prefix}" (API batch)`);
1238
+ })
1239
+ );
1240
+ }
1241
+ await Promise.allSettled(fallbackPromises);
1242
+ }
1243
+
1244
+ // === EXTRACT SVG from API response (handles both object and string formats) ===
1245
+ function extractSvg(iconData) {
1246
+ if (typeof iconData === 'string') return iconData;
1247
+ if (typeof iconData === 'object' && iconData !== null) return iconData.svg || '';
1248
+ return '';
1249
+ }
1250
+
1251
+ // === API BATCH FETCH: ONE request returns all icons for a prefix ===
1252
+ async function fetchIconsViaAPI(prefix, iconNames, elementMap) {
1253
+ if (failedPrefixes.has(prefix)) return 0;
1254
+
1255
+ const MAX_BATCH = 100;
1256
+ let totalLoaded = 0;
1257
+
1258
+ // Split into batches of 100 (API limit)
1259
+ const batches = [];
1260
+ for (let i = 0; i < iconNames.length; i += MAX_BATCH) {
1261
+ batches.push(iconNames.slice(i, i + MAX_BATCH));
1262
+ }
1263
+
1264
+ // Fire all batches in parallel (usually just 1 batch)
1265
+ const loadId = currentLoadId;
1266
+ const batchPromises = batches.map(async (batch) => {
1267
+ if (loadId !== currentLoadId) return;
1268
+ try {
1269
+ const url = `${config.batchApiBase}/api/v1/subset/by-prefix/?prefix=${encodeURIComponent(prefix)}&icons=${encodeURIComponent(batch.join(','))}`;
1270
+ const response = await Promise.race([
1271
+ fetch(url).catch(() => null),
1272
+ new Promise(resolve => setTimeout(() => resolve(null), API_TIMEOUT))
1273
+ ]);
1274
+
1275
+ if (!response) return; // AbortError or network error
1276
+ if (!response.ok) {
1277
+ if (response.status === 404) failedPrefixes.add(prefix);
1278
+ return;
1279
+ }
1280
+
1281
+ const data = await response.json();
1282
+ if (loadId !== currentLoadId || data.error) return;
1283
+
1284
+ for (const [name, iconData] of Object.entries(data.icons || {})) {
1285
+ const svg = extractSvg(iconData);
1286
+ if (!svg || typeof svg !== 'string' || !svg.trim()) continue;
1287
+
1288
+ // Memory cache + deferred localStorage write
1289
+ setToMemory(prefix, name, svg);
1290
+ deferCacheWrite(prefix, name, svg);
1291
+
1292
+ // Inject immediately into DOM
1293
+ if (elementMap) {
1294
+ const elements = elementMap.get(name);
1295
+ if (elements) {
1296
+ for (const el of elements) {
1297
+ if (isInDOM(el)) injectSVG(el, svg);
1298
+ }
1299
+ totalLoaded += elements.length;
1300
+ }
1301
+ } else {
1302
+ // Auto stream-inject: find matching DOM elements (both <i> and <viconic-icon>)
1303
+ try {
1304
+ const iCandidates = document.querySelectorAll(`i.${CSS.escape(prefix)}:not(.svg-loaded)`);
1305
+ for (const el of iCandidates) {
1306
+ const names = getIconNames(el);
1307
+ if (names.includes(name)) {
1308
+ injectSVG(el, svg);
1309
+ totalLoaded++;
1310
+ }
1311
+ }
1312
+ const vCandidates = document.querySelectorAll(`viconic-icon[icon]:not(.svg-loaded)`);
1313
+ for (const el of vCandidates) {
1314
+ const parsed = parseViconicIcon(el.getAttribute('icon'));
1315
+ if (parsed && parsed.prefix === prefix && parsed.iconName === name) {
1316
+ injectSVG(el, svg);
1317
+ totalLoaded++;
1318
+ }
1319
+ }
1320
+ } catch (e) { }
1321
+ }
1322
+ }
1323
+ } catch (e) { /* timeout or network error */ }
1324
+ });
1325
+
1326
+ await Promise.allSettled(batchPromises);
1327
+ return totalLoaded;
1328
+ }
1329
+
1330
+ // === CDN STREAM-INJECT: Each icon appears as its fetch resolves ===
1331
+ // Uses concurrency pool to avoid overwhelming the connection
1332
+ async function fetchIconsViaCDN(prefix, cdnBase, iconNames, elementMap) {
1333
+ const loadId = currentLoadId;
1334
+ let loadedCount = 0;
1335
+ let failCount = 0;
1336
+ let notFoundCount = 0; // 404s = file doesn't exist on R2
1337
+ const t0 = performance.now();
1338
+
1339
+ // Concurrency pool: process CDN_CONCURRENCY fetches at a time
1340
+ // HTTP/2 multiplexing makes 30+ parallel requests on 1 connection very fast
1341
+ let index = 0;
1342
+
1343
+ function nextFetch() {
1344
+ if (index >= iconNames.length || loadId !== currentLoadId) return null;
1345
+ const name = iconNames[index++];
1346
+
1347
+ // Skip if already loaded (by parallel preloadMulti or earlier fetch)
1348
+ const cached = getFromMemory(prefix, name);
1349
+ if (cached) {
1350
+ loadedCount++;
1351
+ if (elementMap) {
1352
+ const elements = elementMap.get(name);
1353
+ if (elements) {
1354
+ for (const el of elements) {
1355
+ if (isInDOM(el)) injectSVG(el, cached);
1356
+ }
1357
+ }
1358
+ }
1359
+ return Promise.resolve();
1360
+ }
1361
+
1362
+ const url = `${cdnBase}/${name}.svg`;
1363
+
1364
+ return (async () => {
1365
+ const t1 = performance.now();
1366
+ const resp = await Promise.race([
1367
+ fetch(url).catch(() => null),
1368
+ new Promise(resolve => setTimeout(() => resolve(null), CDN_TIMEOUT))
1369
+ ]);
1370
+
1371
+ if (!resp) {
1372
+ failCount++; // timeout or network error
1373
+ return;
1374
+ }
1375
+ if (!resp.ok) {
1376
+ if (resp.status === 404) notFoundCount++; // file doesn't exist on R2
1377
+ failCount++;
1378
+ return;
1379
+ }
1380
+
1381
+ const svg = await resp.text().catch(() => '');
1382
+ if (!svg || !svg.includes('<')) return;
1383
+
1384
+ setToMemory(prefix, name, svg);
1385
+ deferCacheWrite(prefix, name, svg);
1386
+ loadedCount++;
1387
+
1388
+ // Stream inject: show this icon NOW (tải đến đâu hiện đến đó)
1389
+ if (elementMap) {
1390
+ const elements = elementMap.get(name);
1391
+ if (elements) {
1392
+ for (const el of elements) {
1393
+ if (isInDOM(el)) injectSVG(el, svg);
1394
+ }
1395
+ }
1396
+ } else {
1397
+ // Auto stream-inject: preloadMulti has no elementMap,
1398
+ // so scan DOM for matching elements and inject immediately
1399
+ try {
1400
+ const iCandidates = document.querySelectorAll(`i.${CSS.escape(prefix)}:not(.svg-loaded)`);
1401
+ for (const el of iCandidates) {
1402
+ const names = getIconNames(el);
1403
+ if (names.includes(name)) {
1404
+ injectSVG(el, svg);
1405
+ }
1406
+ }
1407
+ const vCandidates = document.querySelectorAll(`viconic-icon[icon]:not(.svg-loaded)`);
1408
+ for (const el of vCandidates) {
1409
+ const parsed = parseViconicIcon(el.getAttribute('icon'));
1410
+ if (parsed && parsed.prefix === prefix && parsed.iconName === name) {
1411
+ injectSVG(el, svg);
1412
+ }
1413
+ }
1414
+ } catch (e) { }
1415
+ }
1416
+ })();
1417
+ }
1418
+
1419
+ // Run all fetches with concurrency pool
1420
+ async function runPool() {
1421
+ const running = new Set();
1422
+ for (let i = 0; i < Math.min(CDN_CONCURRENCY, iconNames.length); i++) {
1423
+ const p = nextFetch();
1424
+ if (p) { running.add(p); p.finally(() => running.delete(p)); }
1425
+ }
1426
+
1427
+ while (running.size > 0) {
1428
+ await Promise.race(running).catch(() => { });
1429
+ const p = nextFetch();
1430
+ if (p) { running.add(p); p.finally(() => running.delete(p)); }
1431
+ }
1432
+ }
1433
+
1434
+ await runPool();
1435
+ const elapsed = Math.round(performance.now() - t0);
1436
+ if (loadedCount > 0 || failCount > 0) {
1437
+ console.log(`[CopyIcons] 📊 CDN "${prefix}": ${loadedCount}/${iconNames.length} loaded, ${failCount} failed (${notFoundCount} not-found), ${elapsed}ms`);
1438
+ }
1439
+ return { loadedCount, failCount, notFoundCount };
1440
+ }
1441
+
1442
+ // === MAIN LOAD: CDN-first with API fallback ===
1443
+
1444
+ async function loadSVGsForPrefix(prefix, iconNames, iconList) {
1445
+ if (failedPrefixes.has(prefix)) return;
1446
+
1447
+ // Build element map: iconName -> [elements]
1448
+ const elementMap = new Map();
1449
+ for (const { element, iconName } of iconList) {
1450
+ if (!elementMap.has(iconName)) elementMap.set(iconName, []);
1451
+ elementMap.get(iconName).push(element);
1452
+ }
1453
+
1454
+ // Step 1: Inject cached icons immediately (sync)
1455
+ const uncachedNames = [];
1456
+ for (const iconName of iconNames) {
1457
+ const svg = getFromMemory(prefix, iconName) || getPerIconCache(prefix, iconName);
1458
+ if (svg) {
1459
+ const elements = elementMap.get(iconName);
1460
+ if (elements) {
1461
+ for (const el of elements) injectSVG(el, svg);
1462
+ }
1463
+ } else {
1464
+ uncachedNames.push(iconName);
1465
+ }
1466
+ }
1467
+
1468
+ if (uncachedNames.length === 0) return;
1469
+
1470
+ // Step 2: If there's an in-flight preload, wait and re-check cache
1471
+ if (inflightFetches.has(prefix)) {
1472
+ try { await inflightFetches.get(prefix); } catch (e) { }
1473
+
1474
+ const stillUncached = [];
1475
+ for (const name of uncachedNames) {
1476
+ const svg = getFromMemory(prefix, name);
1477
+ if (svg) {
1478
+ const elements = elementMap.get(name);
1479
+ if (elements) {
1480
+ for (const el of elements) injectSVG(el, svg);
1481
+ }
1482
+ } else {
1483
+ stillUncached.push(name);
1484
+ }
1485
+ }
1486
+ if (stillUncached.length === 0) return;
1487
+ uncachedNames.length = 0;
1488
+ uncachedNames.push(...stillUncached);
1489
+ }
1490
+
1491
+ // Step 3: CDN-first strategy
1492
+ const map = await loadPrefixMap();
1493
+ const cdnBase = map[prefix];
1494
+ let loadedCount = 0;
1495
+
1496
+ if (cdnBase) {
1497
+ const colId = getCollectionId(cdnBase);
1498
+ const bundleEntry = bundleCache.get(colId);
1499
+ if (bundleEntry) bundleEntry.accessTime = Date.now();
1500
+ const useBundle = uncachedNames.length >= BUNDLE_THRESHOLD || !!bundleEntry;
1501
+
1502
+ if (useBundle) {
1503
+ // BUNDLE path: many icons or bundle already cached
1504
+ loadedCount = await loadIconsFromBundle(prefix, cdnBase, uncachedNames, elementMap);
1505
+ } else {
1506
+ // INDIVIDUAL CDN path
1507
+ loadedCount = await fetchIconsViaCDN(prefix, cdnBase, uncachedNames, elementMap);
1508
+
1509
+ // Smart fallback: bundle only when enough icons failed (>= 10)
1510
+ // For 1-9 icons, API is faster than downloading 300KB+ bundle
1511
+ const afterCDN = uncachedNames.filter(n => !getFromMemory(prefix, n));
1512
+ const BUNDLE_FALLBACK_MIN = 10;
1513
+ if (afterCDN.length >= BUNDLE_FALLBACK_MIN) {
1514
+ console.log(`[CopyIcons] 🔄 "${prefix}": ${afterCDN.length}/${uncachedNames.length} CDN failures → bundle fallback`);
1515
+ try {
1516
+ const bundleLoaded = await loadIconsFromBundle(prefix, cdnBase, afterCDN, elementMap);
1517
+ loadedCount += bundleLoaded;
1518
+ } catch (e) { }
1519
+ }
1520
+ }
1521
+
1522
+ // Final API fallback for any remaining
1523
+ const remaining = uncachedNames.filter(n => !getFromMemory(prefix, n));
1524
+ if (remaining.length > 0) {
1525
+ const apiLoaded = await fetchIconsViaAPI(prefix, remaining, elementMap);
1526
+ loadedCount += apiLoaded;
1527
+ }
1528
+ } else {
1529
+ // === NO CDN ENTRY: API batch only ===
1530
+ loadedCount = await fetchIconsViaAPI(prefix, uncachedNames, elementMap);
1531
+ }
1532
+
1533
+ if (loadedCount > 0) {
1534
+ console.log(`[CopyIcons] ✅ ${loadedCount} icons for "${prefix}" (${cdnBase ? 'CDN+fallback' : 'API'})`);
1535
+ }
1536
+ }
1537
+
1538
+ // === BATCH SVG INJECTION (legacy, used by injectSVGs) ===
1539
+
1540
+ function injectSVGs(iconList, svgData) {
1541
+ const BATCH_SIZE = 100;
1542
+ let index = 0;
1543
+
1544
+ function processBatch() {
1545
+ const end = Math.min(index + BATCH_SIZE, iconList.length);
1546
+ for (let i = index; i < end; i++) {
1547
+ const { element, iconName } = iconList[i];
1548
+ if (!svgData[iconName]) continue;
1549
+
1550
+ const iconInfo = svgData[iconName];
1551
+ let svgContent = (typeof iconInfo === 'object' && iconInfo !== null) ? (iconInfo.svg || '') : (typeof iconInfo === 'string' ? iconInfo : '');
1552
+ if (!svgContent || typeof svgContent !== 'string') continue;
1553
+
1554
+ const bgType = element.dataset.bgType;
1555
+ if (bgType && bgType !== 'none') {
1556
+ svgContent = addBackground(svgContent, element.dataset);
1557
+ }
1558
+ injectSVG(element, svgContent);
1559
+ }
1560
+ index = end;
1561
+ if (index < iconList.length) requestAnimationFrame(processBatch);
1562
+ }
1563
+
1564
+ if (iconList.length <= BATCH_SIZE) {
1565
+ processBatch();
1566
+ } else {
1567
+ requestAnimationFrame(processBatch);
1568
+ }
1569
+ }
1570
+
1571
+ function addBackground(svgContent, dataset) {
1572
+ const bgColor = dataset.bgColor || '#6366f1';
1573
+ const bgScale = parseFloat(dataset.bgScale || '1');
1574
+ const bgType = dataset.bgType;
1575
+
1576
+ const vbMatch = svgContent.match(/viewBox=["']([^"']+)["']/);
1577
+ if (!vbMatch) return svgContent;
1578
+
1579
+ const [minX, minY, width, height] = vbMatch[1].split(/[\s,]+/).map(parseFloat);
1580
+ const baseSize = Math.min(width, height);
1581
+ const centerX = minX + width / 2;
1582
+ const centerY = minY + height / 2;
1583
+ const bgSize = Math.min(baseSize * 0.7 * bgScale, baseSize);
1584
+
1585
+ let bgShape = '';
1586
+ if (bgType === 'circle') {
1587
+ bgShape = `<circle cx="${centerX}" cy="${centerY}" r="${bgSize / 2}" fill="${bgColor}" />`;
1588
+ } else if (bgType === 'square') {
1589
+ bgShape = `<rect x="${centerX - bgSize / 2}" y="${centerY - bgSize / 2}" width="${bgSize}" height="${bgSize}" fill="${bgColor}" />`;
1590
+ } else if (bgType === 'rounded') {
1591
+ const radius = bgSize * 0.15;
1592
+ bgShape = `<rect x="${centerX - bgSize / 2}" y="${centerY - bgSize / 2}" width="${bgSize}" height="${bgSize}" rx="${radius}" ry="${radius}" fill="${bgColor}" />`;
1593
+ }
1594
+
1595
+ return svgContent.replace(/(<svg[^>]*>)/, `$1${bgShape}`);
1596
+ }
1597
+
1598
+ // === DOM SCANNING ===
1599
+
1600
+ function scanDOM(root = document) {
1601
+ const icons = root.querySelectorAll('i[class], i[data-collection]');
1602
+ for (let i = 0; i < icons.length; i++) {
1603
+ registerIcon(icons[i]);
1604
+ }
1605
+ const viconics = root.querySelectorAll('viconic-icon[icon]');
1606
+ for (let i = 0; i < viconics.length; i++) {
1607
+ registerViconicIcon(viconics[i]);
1608
+ }
1609
+ }
1610
+
1611
+ // === LAZY LOADING ===
1612
+
1613
+ function setupLazyLoading() {
1614
+ if (!config.lazy || !('IntersectionObserver' in window)) return;
1615
+
1616
+ intersectionObserver = new IntersectionObserver((entries) => {
1617
+ entries.forEach(entry => {
1618
+ if (entry.isIntersecting) {
1619
+ registerIcon(entry.target);
1620
+ intersectionObserver.unobserve(entry.target);
1621
+ }
1622
+ });
1623
+ }, {
1624
+ rootMargin: '100px'
1625
+ });
1626
+ }
1627
+
1628
+ function registerIconLazy(iconEl) {
1629
+ if (config.lazy && intersectionObserver) {
1630
+ intersectionObserver.observe(iconEl);
1631
+ } else {
1632
+ registerIcon(iconEl);
1633
+ }
1634
+ }
1635
+
1636
+ // === MUTATION OBSERVER ===
1637
+
1638
+ let mutationBatchTimer = null;
1639
+ let mutationBatch = [];
1640
+
1641
+ function setupMutationObserver() {
1642
+ if (mutationObserver) return;
1643
+
1644
+ mutationObserver = new MutationObserver((mutations) => {
1645
+ for (const m of mutations) {
1646
+ if (!m.addedNodes || m.addedNodes.length === 0) continue;
1647
+ for (const node of m.addedNodes) {
1648
+ if (node.nodeType !== 1) continue;
1649
+ mutationBatch.push(node);
1650
+ }
1651
+ }
1652
+
1653
+ if (mutationBatch.length > 500) {
1654
+ mutationBatch.splice(0, mutationBatch.length - 200);
1655
+ }
1656
+
1657
+ if (mutationBatchTimer) clearTimeout(mutationBatchTimer);
1658
+ if (mutationBatch.length > 20) {
1659
+ mutationBatchTimer = setTimeout(processMutationBatch, 16);
1660
+ } else {
1661
+ mutationBatchTimer = setTimeout(processMutationBatch, 5);
1662
+ }
1663
+ });
1664
+
1665
+ if (document.body) {
1666
+ mutationObserver.observe(document.body, { childList: true, subtree: true });
1667
+ }
1668
+ }
1669
+
1670
+ function processMutationBatch() {
1671
+ if (mutationBatch.length === 0) return;
1672
+
1673
+ const nodes = mutationBatch.splice(0);
1674
+
1675
+ for (const node of nodes) {
1676
+ if (!isInDOM(node)) continue;
1677
+
1678
+ if (node.tagName === 'I') {
1679
+ registerIcon(node);
1680
+ } else if (node.tagName === 'VICONIC-ICON') {
1681
+ registerViconicIcon(node);
1682
+ } else if (node.querySelectorAll) {
1683
+ const icons = node.querySelectorAll('i[class], i[data-collection]');
1684
+ icons.forEach(registerIcon);
1685
+ const viconics = node.querySelectorAll('viconic-icon[icon]');
1686
+ viconics.forEach(registerViconicIcon);
1687
+ }
1688
+ }
1689
+ }
1690
+
1691
+ // === MEMORY MONITORING & COMPACTION ===
1692
+
1693
+ let memoryCheckTimer = null;
1694
+
1695
+ function getMemoryUsageMB() {
1696
+ // Chrome/Edge: performance.memory (non-standard but widely available)
1697
+ if (performance && performance.memory) {
1698
+ return Math.round(performance.memory.usedJSHeapSize / (1024 * 1024));
1699
+ }
1700
+ return null;
1701
+ }
1702
+
1703
+ function compactMemory(aggressive = false) {
1704
+ // 1. Compact memoryCache: keep only most recent entries
1705
+ const keepCount = aggressive ? 50 : 100;
1706
+ if (memoryCache.size > keepCount) {
1707
+ const keysToDelete = [...memoryCache.keys()].slice(0, memoryCache.size - keepCount);
1708
+ for (const k of keysToDelete) memoryCache.delete(k);
1709
+ }
1710
+
1711
+ // 2. Clear ALL bundles from bundleCache (biggest memory hog)
1712
+ if (bundleCache.size > 0) {
1713
+ const totalIcons = [...bundleCache.values()].reduce((sum, e) => sum + (e.iconCount || 0), 0);
1714
+ bundleCache.clear();
1715
+ console.log(`[CopyIcons] 🗜️ Bundles cleared (${totalIcons} icons freed from memory)`);
1716
+ }
1717
+
1718
+ // 3. Clear deferred writes queue
1719
+ if (_deferredWrites.length > 100) {
1720
+ _flushCacheWrites();
1721
+ }
1722
+
1723
+ const memMB = getMemoryUsageMB();
1724
+ console.log(`[CopyIcons] 🗜️ Memory compacted: cache=${memoryCache.size}, bundles=${bundleCache.size}${memMB ? `, heap=${memMB}MB` : ''}`);
1725
+ }
1726
+
1727
+ function checkMemoryBudget() {
1728
+ const memMB = getMemoryUsageMB();
1729
+ if (memMB === null) return; // Can't measure, skip
1730
+
1731
+ if (memMB > MEMORY_BUDGET / (1024 * 1024)) {
1732
+ console.warn(`[CopyIcons] ⚠️ Memory ${memMB}MB exceeds budget ${Math.round(MEMORY_BUDGET / (1024 * 1024))}MB — compacting`);
1733
+ compactMemory(true);
1734
+ } else if (memMB > MEMORY_BUDGET / (1024 * 1024) * 0.85) {
1735
+ // Approaching limit (85%) — gentle compact
1736
+ compactMemory(false);
1737
+ }
1738
+ }
1739
+
1740
+ function setupMemoryMonitor() {
1741
+ if (!performance || !performance.memory) return;
1742
+ memoryCheckTimer = setInterval(checkMemoryBudget, MEMORY_CHECK_INTERVAL);
1743
+ }
1744
+
1745
+ // === INITIALIZATION ===
1746
+
1747
+ function boot() {
1748
+ console.log('[CopyIcons] 🚀 Smart Loader v8.3 (CDN Smart Fallback + Early Abort)');
1749
+
1750
+ const style = document.createElement('style');
1751
+ style.textContent = `
1752
+ i.svg-loaded {
1753
+ display: inline-flex !important;
1754
+ align-items: center;
1755
+ justify-content: center;
1756
+ background: none !important;
1757
+ background-color: transparent !important;
1758
+ -webkit-mask-image: none !important;
1759
+ mask-image: none !important;
1760
+ }
1761
+ i.svg-loaded::before {
1762
+ content: none !important;
1763
+ display: none !important;
1764
+ }
1765
+ i.svg-loaded:not(.ci-multicolor) svg {
1766
+ width: 1em;
1767
+ height: 1em;
1768
+ fill: currentColor;
1769
+ }
1770
+ i.svg-loaded.ci-multicolor svg {
1771
+ width: 1em;
1772
+ height: 1em;
1773
+ }
1774
+ .modal-icon-display:not(.svg-loaded) {
1775
+ visibility: hidden;
1776
+ }
1777
+ :where(viconic-icon) {
1778
+ display: inline-flex;
1779
+ align-items: center;
1780
+ justify-content: center;
1781
+ vertical-align: middle;
1782
+ font-size: inherit;
1783
+ line-height: 1;
1784
+ width: 1em;
1785
+ height: 1em;
1786
+ opacity: 0;
1787
+ }
1788
+ :where(viconic-icon).svg-loaded {
1789
+ display: inline-flex !important;
1790
+ align-items: center;
1791
+ justify-content: center;
1792
+ background: none !important;
1793
+ background-color: transparent !important;
1794
+ opacity: 1;
1795
+ }
1796
+ :where(viconic-icon).svg-loaded:not(.ci-multicolor) svg {
1797
+ width: 1em;
1798
+ height: 1em;
1799
+ fill: currentColor;
1800
+ }
1801
+ :where(viconic-icon).svg-loaded.ci-multicolor svg {
1802
+ width: 1em;
1803
+ height: 1em;
1804
+ }
1805
+ @keyframes ci-appear { from { opacity: 0; } to { opacity: 1; } }
1806
+ :where(viconic-icon).svg-loaded:not(.cache-hit) { animation: ci-appear 0.12s ease-out; }
1807
+ :where(viconic-icon).cache-hit { opacity: 1 !important; animation: none !important; }
1808
+ i.cache-hit { animation: none !important; }
1809
+ /* --- ANIMATION COMMENTED OUT START ---
1810
+ @keyframes ci-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
1811
+ @keyframes ci-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
1812
+ @keyframes ci-bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-0.4em); } 60% { transform: translateY(-0.2em); } }
1813
+ @keyframes ci-shake { 0%, 100% { transform: translateX(0); } 10%, 30%, 50%, 70%, 90% { transform: translateX(-0.1em); } 20%, 40%, 60%, 80% { transform: translateX(0.1em); } }
1814
+ @keyframes ci-flip { 0%, 100% { transform: perspective(400px) rotateY(0deg); } 50% { transform: perspective(400px) rotateY(180deg); } }
1815
+ @keyframes ci-heartbeat { 0%, 100% { transform: scale(1); } 14% { transform: scale(1.25); } 28% { transform: scale(1); } 42% { transform: scale(1.25); } 70% { transform: scale(1); } }
1816
+ @keyframes ci-fade { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
1817
+ @keyframes ci-wobble { 0%, 100% { transform: rotate(0deg); } 15% { transform: rotate(-12deg); } 30% { transform: rotate(8deg); } 45% { transform: rotate(-6deg); } 60% { transform: rotate(4deg); } 75% { transform: rotate(-2deg); } }
1818
+ @keyframes ci-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-0.3em); } }
1819
+ @keyframes ci-trace { 0% { fill-opacity: 0; stroke: currentColor; stroke-width: var(--trace-stroke-width, 1px); stroke-dasharray: var(--path-len, 400); stroke-dashoffset: var(--path-len, 400); } 50%, 60% { fill-opacity: 0; stroke: currentColor; stroke-width: var(--trace-stroke-width, 1px); stroke-dasharray: var(--path-len, 400); stroke-dashoffset: 0; } 100% { fill-opacity: 1; stroke: var(--trace-end-stroke, transparent); stroke-width: var(--trace-end-stroke-width, 0); stroke-dasharray: var(--path-len, 400); stroke-dashoffset: 0; } }
1820
+ :where(viconic-icon)[animate="spin"] { animation: ci-spin 1.5s linear infinite !important; }
1821
+ :where(viconic-icon)[animate="pulse"] { animation: ci-pulse 2s ease-in-out infinite !important; }
1822
+ :where(viconic-icon)[animate="bounce"] { animation: ci-bounce 1s ease infinite !important; }
1823
+ :where(viconic-icon)[animate="shake"] { animation: ci-shake 0.8s ease-in-out infinite !important; }
1824
+ :where(viconic-icon)[animate="flip"] { animation: ci-flip 2s ease-in-out infinite !important; }
1825
+ :where(viconic-icon)[animate="heartbeat"] { animation: ci-heartbeat 1.2s ease-in-out infinite !important; }
1826
+ :where(viconic-icon)[animate="fade"] { animation: ci-fade 1.5s ease-in-out infinite !important; }
1827
+ :where(viconic-icon)[animate="wobble"] { animation: ci-wobble 1s ease-in-out infinite !important; }
1828
+ :where(viconic-icon)[animate="float"] { animation: ci-float 2.5s ease-in-out infinite !important; }
1829
+ :where(viconic-icon)[animate="trace"] svg path, :where(viconic-icon)[animate="trace"] svg circle, :where(viconic-icon)[animate="trace"] svg rect, :where(viconic-icon)[animate="trace"] svg polygon, :where(viconic-icon)[animate="trace"] svg polyline { animation: ci-trace 3s ease-in-out infinite !important; }
1830
+ --- ANIMATION COMMENTED OUT END --- */
1831
+ `;
1832
+ document.head.appendChild(style);
1833
+
1834
+ setupLazyLoading();
1835
+ setupMutationObserver();
1836
+ setupMemoryMonitor();
1837
+ scanDOM(document);
1838
+ }
1839
+
1840
+ if (document.readyState === 'loading') {
1841
+ document.addEventListener('DOMContentLoaded', boot);
1842
+ } else {
1843
+ boot();
1844
+ }
1845
+
1846
+ // === PUBLIC API ===
1847
+
1848
+ window.CopyIcons = {
1849
+ refresh() {
1850
+ scanDOM(document);
1851
+ this.expandAll();
1852
+ },
1853
+ expand() {
1854
+ scanDOM(document);
1855
+ this.expandAll();
1856
+ },
1857
+ expandAll() { /* No-op for SVG mode */ },
1858
+
1859
+ expandElement(element) {
1860
+ if (!element || element.classList.contains('svg-loaded')) return true;
1861
+ processedElements.delete(element);
1862
+ if (element.tagName === 'VICONIC-ICON') {
1863
+ registerViconicIcon(element);
1864
+ } else {
1865
+ registerIcon(element);
1866
+ }
1867
+ return true;
1868
+ },
1869
+
1870
+ reset() { clearPending(); },
1871
+ clearPending() { clearPending(); },
1872
+
1873
+ forceProcess(element) {
1874
+ if (element) {
1875
+ element.classList.remove('svg-loaded', 'js-processed');
1876
+ element.innerHTML = '';
1877
+ processedElements.delete(element);
1878
+ if (element.tagName === 'VICONIC-ICON') {
1879
+ registerViconicIcon(element);
1880
+ } else {
1881
+ registerIcon(element);
1882
+ }
1883
+ } else {
1884
+ document.querySelectorAll('i:not(.svg-loaded)').forEach(icon => {
1885
+ processedElements.delete(icon);
1886
+ registerIcon(icon);
1887
+ });
1888
+ document.querySelectorAll('viconic-icon[icon]:not(.svg-loaded)').forEach(el => {
1889
+ processedElements.delete(el);
1890
+ registerViconicIcon(el);
1891
+ });
1892
+ }
1893
+ },
1894
+
1895
+ // Viconic utilities
1896
+ camelToKebab,
1897
+ kebabToCamel,
1898
+ classToViconicTag: viconicTagFromClass,
1899
+ parseViconicIcon,
1900
+ iconClassToViconic,
1901
+
1902
+ injectInstant(element) {
1903
+ if (!element || element.classList.contains('svg-loaded')) return true;
1904
+
1905
+ let prefix, iconName;
1906
+ if (element.tagName === 'VICONIC-ICON') {
1907
+ const parsed = parseViconicIcon(element.getAttribute('icon'));
1908
+ if (!parsed) return false;
1909
+ prefix = parsed.prefix;
1910
+ iconName = parsed.iconName;
1911
+ } else {
1912
+ prefix = detectPrefix(element);
1913
+ if (!prefix) return false;
1914
+ const iconNames = getIconNames(element);
1915
+ if (iconNames.length === 0) return false;
1916
+ iconName = iconNames[0];
1917
+ }
1918
+
1919
+ const svg = getFromMemory(prefix, iconName) || getPerIconCache(prefix, iconName);
1920
+ if (svg) {
1921
+ injectSVG(element, svg);
1922
+ return true;
1923
+ }
1924
+
1925
+ processedElements.delete(element);
1926
+ if (element.tagName === 'VICONIC-ICON') {
1927
+ registerViconicIcon(element);
1928
+ } else {
1929
+ registerIcon(element);
1930
+ }
1931
+ return false;
1932
+ },
1933
+
1934
+ preload(prefix, iconNames) {
1935
+ if (!prefix || !iconNames || iconNames.length === 0) return Promise.resolve();
1936
+
1937
+ const uncached = iconNames.filter(name =>
1938
+ !getFromMemory(prefix, name) && !getPerIconCache(prefix, name)
1939
+ );
1940
+ if (uncached.length === 0) return Promise.resolve();
1941
+
1942
+ if (inflightFetches.has(prefix)) {
1943
+ return inflightFetches.get(prefix);
1944
+ }
1945
+
1946
+ const promise = (async () => {
1947
+ // CDN Smart: choose individual SVGs vs bundle based on count
1948
+ const map = await loadPrefixMap();
1949
+ const cdnBase = map[prefix];
1950
+ if (cdnBase) {
1951
+ const colId = getCollectionId(cdnBase);
1952
+ const useBundle = uncached.length >= BUNDLE_THRESHOLD || (colId && bundleCache.has(colId));
1953
+ if (useBundle) {
1954
+ await loadIconsFromBundle(prefix, cdnBase, uncached, null);
1955
+ } else {
1956
+ await fetchIconsViaCDN(prefix, cdnBase, uncached, null).catch(() => { });
1957
+ }
1958
+ const remaining = uncached.filter(n => !getFromMemory(prefix, n));
1959
+ if (remaining.length === 0) return;
1960
+ await fetchIconsViaAPI(prefix, remaining, null).catch(() => { });
1961
+ } else {
1962
+ await fetchIconsViaAPI(prefix, uncached, null).catch(() => { });
1963
+ }
1964
+ })()
1965
+ .catch(() => { })
1966
+ .finally(() => { inflightFetches.delete(prefix); });
1967
+
1968
+ inflightFetches.set(prefix, promise);
1969
+ return promise;
1970
+ },
1971
+
1972
+ // Preload icons from MULTIPLE prefixes in ONE request (for search pages)
1973
+ // Usage: CopyIcons.preloadMulti({ "sol": ["user","home"], "flo": ["arrow"] })
1974
+ preloadMulti(prefixMap) {
1975
+ if (!prefixMap || typeof prefixMap !== 'object') return Promise.resolve();
1976
+
1977
+ // Filter to only uncached icons
1978
+ const toFetch = {};
1979
+ let totalUncached = 0;
1980
+ for (const [prefix, names] of Object.entries(prefixMap)) {
1981
+ if (!names || !names.length) continue;
1982
+ const uncached = names.filter(n => !getFromMemory(prefix, n) && !getPerIconCache(prefix, n));
1983
+ if (uncached.length > 0) {
1984
+ toFetch[prefix] = uncached;
1985
+ totalUncached += uncached.length;
1986
+ }
1987
+ }
1988
+ if (totalUncached === 0) return Promise.resolve();
1989
+
1990
+ const promise = (async () => {
1991
+ // === CDN INDIVIDUAL SVGs: fetch each icon directly from CDN (fast for search) ===
1992
+ // Search pages typically need 1-10 icons per collection — downloading
1993
+ // entire bundles (500KB-13MB) for a few icons is extremely wasteful.
1994
+ // Individual SVGs are ~1-5KB each and fetched in parallel.
1995
+ const map = await loadPrefixMap().catch(() => ({}));
1996
+
1997
+ // Fetch individual SVGs from CDN in parallel for each prefix
1998
+ await Promise.allSettled(
1999
+ Object.entries(toFetch).map(async ([prefix, names]) => {
2000
+ const cdnBase = map[prefix];
2001
+ if (!cdnBase) return;
2002
+ try {
2003
+ await fetchIconsViaCDN(prefix, cdnBase, names, null);
2004
+ const loaded = names.filter(n => getFromMemory(prefix, n)).length;
2005
+ if (loaded > 0) console.log(`[CopyIcons] ⚡ CDN preloaded ${loaded} "${prefix}" icons`);
2006
+ } catch (e) { }
2007
+ })
2008
+ );
2009
+
2010
+ // === FALLBACK: API batch for any icons CDN couldn't serve ===
2011
+ const stillMissing = {};
2012
+ let missingCount = 0;
2013
+ for (const [prefix, names] of Object.entries(toFetch)) {
2014
+ const missed = names.filter(n => !getFromMemory(prefix, n));
2015
+ if (missed.length > 0) {
2016
+ stillMissing[prefix] = missed;
2017
+ missingCount += missed.length;
2018
+ }
2019
+ }
2020
+
2021
+ if (missingCount > 0) {
2022
+ const queryParts = Object.entries(stillMissing).map(([p, ns]) => `${p}:${ns.join(',')}`);
2023
+ const q = queryParts.join('|');
2024
+ try {
2025
+ const url = `${config.batchApiBase}/api/v1/subset/multi-prefix/?q=${encodeURIComponent(q)}`;
2026
+ const resp = await Promise.race([
2027
+ fetch(url).catch(() => null),
2028
+ new Promise(resolve => setTimeout(() => resolve(null), API_TIMEOUT))
2029
+ ]);
2030
+ if (resp && resp.ok) {
2031
+ const data = await resp.json();
2032
+ let count = 0;
2033
+ for (const [prefix, icons] of Object.entries(data.results || {})) {
2034
+ for (const [name, svgData] of Object.entries(icons)) {
2035
+ if (getFromMemory(prefix, name)) continue;
2036
+ const svg = extractSvg(svgData);
2037
+ if (svg && typeof svg === 'string' && svg.trim()) {
2038
+ setToMemory(prefix, name, svg);
2039
+ deferCacheWrite(prefix, name, svg);
2040
+ count++;
2041
+ }
2042
+ }
2043
+ }
2044
+ if (count > 0) console.log(`[CopyIcons] 📥 API fallback preloaded ${count} icons`);
2045
+ }
2046
+ } catch (e) { }
2047
+ }
2048
+ })().catch(() => { });
2049
+
2050
+ // Track all prefixes as in-flight
2051
+ for (const prefix of Object.keys(toFetch)) {
2052
+ inflightFetches.set(prefix, promise);
2053
+ }
2054
+ promise.finally(() => {
2055
+ for (const prefix of Object.keys(toFetch)) {
2056
+ inflightFetches.delete(prefix);
2057
+ }
2058
+ });
2059
+
2060
+ return promise;
2061
+ },
2062
+
2063
+ getStats() {
2064
+ const memMB = getMemoryUsageMB();
2065
+ const bundleStats = [];
2066
+ for (const [id, entry] of bundleCache) {
2067
+ bundleStats.push({ id, icons: entry.iconCount, sizeKB: Math.round(entry.size / 1024) });
2068
+ }
2069
+ return {
2070
+ memoryCache: memoryCache.size,
2071
+ pendingPrefixes: pendingIcons.size,
2072
+ bundleCache: bundleCache.size,
2073
+ bundles: bundleStats,
2074
+ heapMB: memMB,
2075
+ budgetMB: Math.round(MEMORY_BUDGET / (1024 * 1024)),
2076
+ };
2077
+ },
2078
+
2079
+ getConfig() { return { ...config }; },
2080
+
2081
+ clearCache() {
2082
+ memoryCache.clear();
2083
+ const keysToRemove = [];
2084
+ for (let i = 0; i < localStorage.length; i++) {
2085
+ const key = localStorage.key(i);
2086
+ if (key && (key.startsWith(ICON_CACHE_PREFIX) || key.startsWith(CACHE_PREFIX))) {
2087
+ keysToRemove.push(key);
2088
+ }
2089
+ }
2090
+ // Also clear prefix-map cache
2091
+ keysToRemove.push(PREFIXMAP_CACHE_KEY);
2092
+ keysToRemove.forEach(k => localStorage.removeItem(k));
2093
+ prefixMap = null;
2094
+ prefixMapPromise = null;
2095
+ console.log(`[CopyIcons] 🗑️ Cleared ${keysToRemove.length} cache entries`);
2096
+ },
2097
+
2098
+ compactMemory() {
2099
+ compactMemory(false);
2100
+ }
2101
+ };
2102
+
2103
+ window.addEventListener('beforeunload', () => {
2104
+ // Flush any pending cache writes before leaving
2105
+ if (_deferredWrites.length > 0) _flushCacheWrites();
2106
+
2107
+ memoryCache.clear();
2108
+ bundleCache.clear();
2109
+ pendingIcons.clear();
2110
+ if (memoryCheckTimer) {
2111
+ clearInterval(memoryCheckTimer);
2112
+ memoryCheckTimer = null;
2113
+ }
2114
+ if (mutationObserver) {
2115
+ mutationObserver.disconnect();
2116
+ mutationObserver = null;
2117
+ }
2118
+ if (intersectionObserver) {
2119
+ intersectionObserver.disconnect();
2120
+ intersectionObserver = null;
2121
+ }
2122
+ });
2123
+
2124
+ // Backward Compatibility
2125
+ if (!window.IconLoader) {
2126
+ window.IconLoader = {
2127
+ expand: () => window.CopyIcons.refresh(),
2128
+ expandElement: (el) => window.CopyIcons.expandElement(el),
2129
+ preloadCollections: () => { }
2130
+ };
2131
+ }
2132
+ })();