[names]] protected Cache $cache; protected string $style = 'regular'; protected array $styles = ['regular', 'bold', 'duotone', 'fill', 'light', 'thin']; protected array $customIcons = []; // Custom icons for THIS source protected array $map = []; protected const MAX_VERSIONS = 5; /** * Factory method - get or create instance for a source */ public static function for(string $source = 'icons'): IconsManager { if (!isset(self::$instances[$source])) { self::$instances[$source] = new self($source); } return self::$instances[$source]; } /** * Constructor now takes source parameter */ private function __construct(string $source) { $this->source = $source; $this->cache = Cache::for('icons_' . $source, WEEK_IN_SECONDS); $this->style = Site::icon(); $this->addMap(); // Register custom icons only once for all sources if ($source === 'icons') { $this->registerCustomIcons(); } // Load custom icons for THIS source $this->loadCustomIconsForSource(); // Load stored icons for this source $this->loadStoredIcons(); if (empty($this->icons)) { $this->includeIcons(); } // Register global hooks only once (first instance) if (count(self::$instances) === 1) { $this->registerGlobalHooks(); } // Register instance's hooks (every instance) $this->registerInstanceHooks(); } /** * Register all custom icons (runs once) */ protected function registerCustomIcons(): void { $icons = array_merge(apply_filters('jvbRegisterCustomIcons', []), ['syncing' => JVB_DIR . '/assets/icons/cloud-sync-thin.svg', 'alphabetical' => JVB_DIR . '/assets/icons/alphabetical.svg']); // Process and store in static property so all instances can access self::$customIconsRegistry = $this->processCustomIconsArray($icons); } /** * Process custom icons array into source-grouped format */ protected function processCustomIconsArray(array $icons): array { $out = []; foreach ($icons as $name => $source) { if (!file_exists($source)) { error_log('[IconsManager] No file exists for custom Icon: '.$name); continue; } $out[$name] = $source; } return $out; } /** * Load custom icons for this instance's source */ protected function loadCustomIconsForSource(): void { $this->customIcons = self::$customIconsRegistry; // foreach ($this->customIcons as $name => $path) { // if (!isset($this->icons[$this->style])) { // $this->icons[$this->style] = []; // } // if (!in_array($name, $this->icons[$this->style])) { // $this->icons[$this->style][] = $name; // } // } } /** * Load previously stored icons for this source */ protected function loadStoredIcons(): void { $allIcons = get_option(BASE.'usedIcons', []); $storedIcons = $allIcons[$this->source] ?? []; // Merge stored icons with any existing icons (like custom icons) foreach ($storedIcons as $style => $names) { if (!isset($this->icons[$style])) { $this->icons[$style] = []; } $this->icons[$style] = array_unique(array_merge($this->icons[$style], $names)); } } protected function includeIcons():void { $defaults = [ 'icons' => [ 'google-logo', 'apple-logo', 'check-circle', 'close-circle', 'faders-horizontal', 'cloud-slash', 'exclamation-mark', 'cloud-arrow-down', 'caret-down', 'cloud-arrow-up', 'cloud-check', 'cloud-slash', 'cloud-warning', 'syncing', 'cloud-x', 'arrows-clockwise', 'share-fat', 'trash', 'star', 'alphabetical', ['name' => 'star-half', 'style' => 'fill'], ['name' => 'star', 'style' => 'fill'], ], 'forms' => [ 'copy', 'paragraph', 'text-h-one', 'text-h-two', 'text-h-three', 'text-h-four', 'text-h-five', 'text-h-six', ['name' =>'text-b', 'style' => 'fill'], 'text-italic', 'text-underline', 'text-strikethrough', 'list-dashes', 'list-numbers', 'text-align-left', 'text-align-center', 'text-align-right', // 'text-align-justify', 'link', //FILE ICONS 'file-pdf', 'file-csv', 'file-doc', 'file-txt', 'file-xls', ], // 'dash' => [ // // ] ]; // Add icons from content/taxonomy/user configs (like old behavior) $configIcons = $this->getIconsFromConfigs(); if (!empty($configIcons)) { $defaults['icons'] = array_merge($defaults['icons'], $configIcons); } // Allow filtering per source (extensibility) $icons = apply_filters("jvbIncludeIcons_{$this->source}", $defaults[$this->source] ?? []); // Also allow filtering all sources at once $allIcons = apply_filters('jvbIncludeIcons', $defaults); if (isset($allIcons[$this->source])) { $icons = array_merge($icons, $allIcons[$this->source]); } if (!empty($icons)) { $this->include($icons); } } /** * Get icons from Registrar instances * */ protected function getIconsFromConfigs(): array { $icons = []; $registered = Registrar::getRegistered(); foreach ($registered as $type) { $registrar = Registrar::getInstance($type); $icons[] = $registrar->getIcon(); } return array_unique(array_filter($icons)); } /** * Public method to include icons in this source */ public function include(array $icons): self { $processed = $this->processIconArray($icons); $changed = false; foreach ($processed as $style => $names) { if (!isset($this->icons[$style])) { $this->icons[$style] = []; } foreach ($names as $name) { // Skip if already in this source if (in_array($name, $this->icons[$style])) { continue; } // Skip if already in main 'icons' source if ($this->iconExistsInMainSource($name, $style)) { error_log("[IconsManager] Skipping '{$name}' in '{$this->source}' - already in 'icons' source"); continue; } $this->icons[$style][] = $name; $changed = true; } } // Only save if something actually changed if ($changed) { $this->saveIcons(); } return $this; } /** * Process icon array into [style => [names]] format */ protected function processIconArray(array $icons): array { $out = []; foreach ($icons as $icon) { if (is_array($icon) && isset($icon['style'])) { $style = $icon['style']; $name = $icon['name']; } else { $style = $this->style; $name = is_array($icon) ? $icon['name'] : $icon; } if (!isset($out[$style])) { $out[$style] = []; } if (!in_array($name, $out[$style])) { $out[$style][] = $name; } } return $out; } /** * Save all icons across all instances */ protected function saveIcons(): void { $allIcons = []; foreach (self::$instances as $source => $instance) { $allIcons[$source] = $instance->icons; } update_option(BASE.'usedIcons', $allIcons); // Track WHICH source needs updating $needsUpdate = get_option(BASE.'icons_needs_update', []); if (!is_array($needsUpdate)) { $needsUpdate = []; } $needsUpdate[$this->source] = true; update_option(BASE.'icons_needs_update', $needsUpdate); } /** * Check if icon exists in other sources */ protected function checkDuplicateAcrossInstances(string $name, string $style): void { $foundIn = []; foreach (self::$instances as $source => $instance) { if (isset($instance->icons[$style]) && in_array($name, $instance->icons[$style])) { $foundIn[] = $source; } } if (count($foundIn) > 1) { error_log(sprintf( '[IconsManager] Warning: Icon "%s" (%s) is registered in multiple sources: %s. Consider consolidating to avoid duplicate CSS output.', $name, $style, implode(', ', $foundIn) )); } } protected function addMap(): void { $map = get_option(BASE.'iconMap'); if (!$map) { $map = [ 'seo' => 'robot' ]; if (Site::has('referrals')) { $map['referrals'] = 'hand-heart'; } if (Site::has('dashboard')) { $map['dash'] = 'door'; } if (Site::has('magicLink')) { $map['magicLink'] = 'magic-wand'; } if (Site::hasAnyIntegration()) { $map['integrations'] = 'plugs-connected'; } update_option(BASE.'iconMap', $map); } $this->map = apply_filters('jvbMapIcons', $map); } /** * Register global hooks (only once) */ protected function registerGlobalHooks(): void { add_action('wp_loaded', [self::class, 'checkCSS']); add_action('shutdown', [self::class, 'checkCSS']); } /** * Register instance-specific hooks (every instance) */ protected function registerInstanceHooks(): void { // Register this source's stylesheet add_action('init', [$this, 'registerStyle'], 11); // Auto-enqueue base icons on front-end if ($this->source === 'icons') { add_action('wp_enqueue_scripts', [$this, 'enqueueIconStyles']); } // Auto-enqueue all in admin add_action('admin_enqueue_scripts', [$this, 'enqueueIconStyles']); } public function enqueueIconStyles():void { // if (file_exists(JVB_CHILD_URL . "assets/css/{$this->source}.css")){ wp_enqueue_style('jvb-icons-'.$this->source); // } } public static function checkCSS(): void { $needsUpdate = get_option(BASE.'icons_needs_update', []); if (!empty($needsUpdate)) { error_log('Regenerating CSS for sources: ' . implode(', ', array_keys($needsUpdate))); delete_option(BASE.'icons_needs_update'); self::regenerateAllCSS($needsUpdate); } } public static function regenerateAllCSS(array $sourcesToUpdate = []): void { error_log('[IconsManager]:regenerateCSS'); $css_dir = JVB_CHILD_DIR.'/assets/css/'; if (!file_exists($css_dir)) { wp_mkdir_p($css_dir); } // Load all icons from database option $allIcons = get_option(BASE.'usedIcons', []); // If no specific sources provided, regenerate all if (empty($sourcesToUpdate)) { $sourcesToUpdate = array_fill_keys(array_keys($allIcons), true); } // Generate CSS for each source that needs it foreach ($sourcesToUpdate as $source => $needsUpdate) { if (!$needsUpdate || !isset($allIcons[$source])) { continue; } // Get or create instance for this source $instance = self::for($source); // Temporarily set icons from database $originalIcons = $instance->icons; $instance->icons = $allIcons[$source]; $css = $instance->generateIconCSS(); $css_path = $css_dir . $source . '.css'; $instance->archiveCurrentVersion($css); if (file_put_contents($css_path, $css) !== false) { Cache::touch('icons_' . $source); error_log("[IconsManager] Updated {$source}.css"); } else { error_log("[IconsManager] Could not write {$source}.css"); } // Restore original icons $instance->icons = $originalIcons; } } protected function regenerateCSS(array $sourcesToUpdate = []): void { error_log('[IconsManager]:regenerateCSS'); $css_dir = JVB_CHILD_DIR.'/assets/css/'; if (!file_exists($css_dir)) { wp_mkdir_p($css_dir); } // If no specific sources provided, regenerate all if (empty($sourcesToUpdate)) { $sourcesToUpdate = array_fill_keys(array_keys(self::$instances), true); } // Generate CSS only for sources that need it foreach (self::$instances as $source => $instance) { if (!isset($sourcesToUpdate[$source])) { continue; // Skip this source } $css = $instance->generateIconCSS(); $css_path = $css_dir . $source . '.css'; // Archive current version before overwriting $instance->archiveCurrentVersion($css); if (file_put_contents($css_path, $css) !== false) { Cache::touch('icons_' . $source); error_log("[IconsManager] Updated {$source}.css"); } else { error_log("[IconsManager] Could not write {$source}.css"); } } } /** * Prevent cloning */ private function __clone() {} /** * Prevent unserialization */ public function __wakeup() { throw new \Exception("Cannot unserialize singleton"); } /** * Get an icon element * * @param string $name Icon name (e.g., 'heart', 'calendar') * @param array $options Options array: * - 'style' => 'regular'|'bold'|'fill'|etc. * - 'label' => 'Accessible label' (for standalone icons) * - 'decorative' => true (for icons next to text) * - 'class' => 'additional classes' * - 'size' => 24 (for custom sizing via inline style) * @return string HTML icon element */ public function get(string $name, array $options = []): string { if (empty($name)) { //No icon requested return ''; } $style = $options['style'] ?? $this->style; $name = $this->map[$name] ?? $name; // Validate icon exists if (!$this->iconExists($name, $style)) { error_log('[IconsManager] Icon not found: ' . $name); return ''; } // Track usage - only if not already tracked if (!isset($this->icons[$style])) { $this->icons[$style] = []; } if (!in_array($name, $this->icons[$style])) { // Check if it's already in main source (for non-main sources) if ($this->iconExistsInMainSource($name, $style)) { // Don't add to this source, but still render the icon // The CSS from icons.css will handle it } else { // Add to this source $this->icons[$style][] = $name; $this->checkDuplicateAcrossInstances($name, $style); $this->saveIcons(); } } // Build icon HTML (same as before) $styleClass = ($style !== $this->style) ? '-'.substr($style, 0, 2) : ''; $classes = ['icon', 'icon-' . $name.$styleClass]; if (isset($options['class'])) { $classes[] = $options['class']; } $attrs = ['class' => implode(' ', $classes)]; if (isset($options['label'])) { $attrs['aria-label'] = esc_attr($options['label']); $attrs['role'] = 'img'; } elseif (isset($options['decorative']) && $options['decorative']) { $attrs['aria-hidden'] = 'true'; } if (isset($options['size'])) { $attrs['style'] = sprintf('--icon-size: %dpx;', absint($options['size'])); } $attr_string = ''; foreach ($attrs as $key => $value) { $attr_string .= sprintf(' %s="%s"', $key, $value); } return sprintf('', $attr_string); } /** * Check if icon file exists */ protected function iconExists(string $name, ?string $style = null): bool { if (!$style) { $style = $this->style; } // Check custom icons first if (array_key_exists($name, $this->customIcons)) { return file_exists($this->customIcons[$name]); } // Check standard icons $filepath = $this->buildFilePath($name, $style); return file_exists($filepath); } /** * Build file path for icon */ protected function buildFilePath(string $name, ?string $style = null): string { if (!$style) { $style = $this->style; } // Custom icons (absolute path provided) if (array_key_exists($name, $this->customIcons)) { return $this->customIcons[$name]; } // Standard SVG icons in /assets/icons/ if (str_ends_with($name, '.svg')) { return JVB_DIR . '/assets/icons/' . $name; } $name = ($style === 'regular') ? $name : $name . '-' . $style; // Phosphor icons with style variants return JVB_DIR . '/assets/phosphor-icons/' . $style . '/' . $name . '.svg'; } /** * Get raw SVG content for CSS mask-image */ protected function getRawSvg(string $name, ?string $style = null): ?string { if (!$style) { $style = $this->style; } $filepath = $this->buildFilePath($name, $style); if (!file_exists($filepath)) { return null; } $svg = file_get_contents($filepath); if ($svg === false) { return null; } // Clean up SVG for CSS usage $svg = preg_replace("/([\n\t]+)/", ' ', $svg); $svg = preg_replace('/>\s*<', $svg); return trim($svg); } public function registerStyle(): void { $timestamp = Cache::lastModified('icons_' . $this->source); $handle = 'jvb-icons-' . $this->source; wp_register_style( $handle, JVB_CHILD_URL . "assets/css/{$this->source}.css", [], $timestamp ); } /** * Generate CSS from icon list */ protected function generateIconCSS(): string { $css = ''; foreach ($this->icons as $style => $names) { $styleClass = ($style !== $this->style) ? '-'.substr($style, 0, 2) : ''; foreach ($names as $icon) { $svg = $this->getEncodedSVG($icon, $style); if ($svg !== '') { if ($icon === 'caret-down') { $css .= 'details summary::after,'; } elseif ($icon === 'faders-horizontal') { $css .= 'details.all-filters summary::after,'; } elseif ($icon === 'link') { $css .= 'input[type=url],'; } $css .= ".icon-{$icon}{$styleClass}{"; $css .= "--icon:url('data:image/svg+xml;base64,{$svg}');"; $css .= "}"; } } } return $this->minifyCss($css); } protected function minifyCSS(string $css): string { // Remove comments $css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css); // Remove whitespace $css = preg_replace('/\s+/', ' ', $css); // Remove spaces around specific characters $css = preg_replace('/\s*([:;{}])\s*/', '$1', $css); return trim($css); } public function getCSSIcon(string $icon, ?string $style = null): string { if (!$style) { $style = $this->style; } $icon = $this->map[$icon] ?? $icon; // Validate icon exists if (!$this->iconExists($icon, $style)) { error_log('[IconsManager] Icon not found: ' . $icon); return ''; } $svg = $this->getEncodedSVG($icon, $style); if ($svg !== '') { return "data:image/svg+xml;base64,{$svg}"; } return ''; } public function getEncodedSVG(string $icon, ?string $style = null): string { if (!$style) { $style = $this->style; } return $this->cache->remember($style.$icon, function () use ($icon, $style) { $svg = $this->getRawSvg($icon, $style); if ($svg) { return base64_encode($svg); } return ''; }); } /** * Clear icon cache (useful for development/debugging) */ public function clearIconCache(): void { delete_option(BASE . 'icon_usage_list'); // Legacy delete_option(BASE.'usedIcons'); delete_option(BASE.'iconMap'); // Clear cache for all sources foreach (self::$instances as $source => $instance) { $instance->cache->forget('icon_styles_css'); } } protected function archiveCurrentVersion(string $css): void { $history = $this->getVersionHistory(); $icon_count = 0; foreach ($this->icons as $style => $names) { $icon_count += count($names); } $newEntry = [ 'css' => $css, 'iconList' => $this->icons, 'timestamp' => time(), 'icon_count' => $icon_count, 'size' => strlen($css), 'size_formatted' => size_format(strlen($css), 2) ]; array_unshift($history, $newEntry); if (count($history) > self::MAX_VERSIONS) { $history = array_slice($history, 0, self::MAX_VERSIONS); } update_option(BASE.'icon_css_history_' . $this->source, $history); } public function getVersionHistory(): array { return get_option(BASE.'icon_css_history_' . $this->source, []); } public function restoreVersion(int $timestamp): bool { $history = $this->getVersionHistory(); foreach ($history as $entry) { if ($entry['timestamp'] === $timestamp) { $css_path = JVB_CHILD_DIR . '/assets/css/' . $this->source . '.css'; // Archive current before restoring $current_css = file_get_contents($css_path); if ($current_css !== false) { $this->archiveCurrentVersion($current_css); } // Restore the version if (file_put_contents($css_path, $entry['css']) !== false) { $this->icons = $entry['iconList']; $this->saveIcons(); Cache::touch('icons_' . $this->source); return true; } return false; } } error_log("[IconsManager] Version {$timestamp} not found in history for source {$this->source}"); return false; } public function forceRefresh(): void { $this->clearIconCache(); $needsUpdate = get_option(BASE.'icons_needs_update', []); if (!is_array($needsUpdate)) { $needsUpdate = []; } $needsUpdate[$this->source] = true; update_option(BASE.'icons_needs_update', $needsUpdate); Cache::touch('icons_' . $this->source); } public function mergeVersions(array $timestamps): bool { if (empty($timestamps)) { return false; } $history = get_option(BASE.'icon_css_history_' . $this->source, []); $merged_icons = []; // Collect icons from selected versions foreach ($history as $entry) { if (in_array($entry['timestamp'], $timestamps)) { foreach ($entry['iconList'] as $style => $icons) { if (!isset($merged_icons[$style])) { $merged_icons[$style] = []; } // Merge and keep unique $merged_icons[$style] = array_unique( array_merge($merged_icons[$style], $icons) ); } } } if (empty($merged_icons)) { error_log('[IconsManager] No icons found in selected versions'); return false; } // Archive current version $current_css = file_get_contents(JVB_CHILD_DIR . '/assets/css/' . $this->source . '.css'); if ($current_css !== false) { $this->archiveCurrentVersion($current_css); } // Update used icons and regenerate $this->icons = $merged_icons; $this->saveIcons(); return true; } /** * Check if icon already exists in the main 'icons' source */ protected function iconExistsInMainSource(string $name, string $style): bool { // If this IS the main source, no need to check if ($this->source === 'icons') { return false; } // Check if main icons source exists if (!isset(self::$instances['icons'])) { return false; } $mainIcons = self::$instances['icons']->icons; return isset($mainIcons[$style]) && in_array($name, $mainIcons[$style]); } }