cache = CacheManager::for('icons', WEEK_IN_SECONDS); $this->style = (array_key_exists('icons', JVB_SITE) && in_array(JVB_SITE['icons'], $this->styles)) ? JVB_SITE['icons'] : 'regular'; $this->addMap(); // Allow custom icon registration $this->customIcons = apply_filters('jvbRegisterCustomIcons', [ 'syncing' => JVB_DIR .'/assets/icons/cloud-sync-thin.svg', 'alphabetical' => JVB_DIR.'/assets/icons/alphabetical.svg' ]); $this->usedIcons = get_option(BASE.'usedIcons', []); $this->includeIcons(); // Track custom icons for CSS generation $this->trackCustomIcons(); // Register hooks only once $this->registerHooks(); } /** * Ensure custom icons are tracked for CSS generation */ protected function trackCustomIcons(): void { if (empty($this->customIcons)) { return; } foreach ($this->customIcons as $name => $path) { $this->trackIconUsage($name, $this->style); } } /** * Include icons via filter (for JS usage, etc.) */ protected function includeIcons():void { $icons = get_option(BASE.'includeIcons'); if (!$icons) { $icons = [ 'check-circle', 'close-circle', 'cloud-slash', 'exclamation-mark', 'cloud-arrow-down', 'cloud-arrow-up', 'cloud-check', 'cloud-slash', 'cloud-warning', 'syncing', 'cloud-x', 'arrows-clockwise', 'share-fat', 'trash', 'star', ['name' => 'star-half', 'style' => 'fill'], ['name' => 'star', 'style' => 'fill'], //FORMATTING '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', ]; $check = [JVB_CONTENT, JVB_TAXONOMY, JVB_USER]; foreach ($check as $constant) { foreach ($constant as $key => $value) { if (array_key_exists('icon', $value) && !in_array($value['icon'], $icons)) { $icons[] = $value['icon']; } } } $icons = apply_filters('jvbIncludeIcons', $icons); $icons = $this->maybePrefixIcons($icons); update_option(BASE.'includeIcons', $icons); } // Ensure icons are in the correct format (handle legacy data) if (!$this->isIconsArrayPrefixed($icons)) { $icons = $this->maybePrefixIcons($icons); update_option(BASE.'includeIcons', $icons); } $additional = apply_filters('jvbIncludeIcons', []); if (!empty($additional)) { $additional = $this->maybePrefixIcons($additional); $merged = $this->mergeUsedIcons($icons, $additional); if ($icons != $merged) { update_option(BASE.'includeIcons', $merged); $icons = $merged; } } foreach ($icons as $style => $theIcons) { foreach($theIcons as $icon) { $this->trackIconUsage($icon, $style); } } } /** * Check if icons array is in the prefixed format [style => [icons]] */ protected function isIconsArrayPrefixed(array $icons): bool { if (empty($icons)) { return true; } // Check if first key is a valid style name $first_key = array_key_first($icons); if (!in_array($first_key, $this->styles)) { return false; } // Check if first value is an array return is_array($icons[$first_key]); } protected function maybePrefixIcons(array $icons):array { $out = []; foreach ($icons as $icon) { if (is_array($icon) && array_key_exists('style', $icon)) { if (!array_key_exists($icon['style'], $out)) { $out[$icon['style']] = []; } if (!in_array($icon['name'], $out[$icon['style']])) { $out[$icon['style']][] = $icon['name']; } } elseif(is_array($icon)) { $icon = $icon['name']; } if (!is_array($icon)) { if (!array_key_exists($this->style, $out)) { $out[$this->style] = []; } if (!in_array($icon, $out[$this->style])){ $out[$this->style][] = $icon; } } } return $out; } protected function addMap():void { $map = get_option(BASE.'iconMap'); if (!$map) { $map = [ 'seo' => 'robot' ]; if (Features::forSite()->has('referrals')){ $map['referrals'] = 'hand-heart'; } if (Features::forSite()->has('dashboard')){ $map['dash'] = 'door'; } if (Features::forSite()->has('magicLink')){ $map['magicLink'] = 'magic-wand'; } if (Features::hasAnyIntegration()) { $map['integrations'] = 'plugs-connected'; } update_option(BASE.'iconMap', $map); } $this->map = apply_filters('jvbMapIcons', $map); } /** * Register WordPress hooks */ protected function registerHooks(): void { add_action('init', [$this, 'includeIcons'], 1); add_action('init', [$this, 'checkCSS'], 10); add_action('wp_enqueue_scripts', [$this, 'enqueueIconStyles']); add_action('admin_enqueue_scripts', [$this, 'enqueueIconStyles']); } public function checkCSS():void { // update_option(BASE.'icons_needs_update', true); if (get_option(BASE.'icons_needs_update', false)) { error_log('Regenerating CSS'); delete_option(BASE.'icons_needs_update'); $this->regenerateCSS(); } } protected function regenerateCSS(): void { error_log('[IconsManager]:regenerateCSS'); $css_dir = JVB_CHILD_DIR.'/assets/css/'; if (!file_exists($css_dir)) { wp_mkdir_p($css_dir); } // Generate CSS for each source foreach ($this->usedIcons as $source => $styles) { $css = $this->generateIconCSS($source); $css_path = $css_dir . $source . '.css'; $this->archiveCurrentVersion($css, $source); if (file_put_contents($css_path, $css) !== false) { CacheManager::updateTimestamp('icons_' . $source); } 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 getIcon(string $name, array $options = []): string { $style = array_key_exists('style', $options) ? $options['style'] :$this->style; $source = $options['source'] ?? 'icons'; $name = (array_key_exists($name, $this->map)) ? $this->map[$name] : $name; // Validate icon exists if (!$this->iconExists($name, $style)) { error_log('[IconsManager] Icon not found: ' . $name); return ''; } // Track icon usage $this->trackIconUsage($name, $style, $source); $styleClass = ($style !== $this->style) ? '-'.substr($style, 0,2) : ''; // Build classes $classes = ['icon', 'icon-' . $name.$styleClass]; if (!empty($options['class'])) { $classes[] = $options['class']; } $attrs = ['class="' . esc_attr(implode(' ', $classes)) . '"']; $attrs[] = 'aria-hidden="true"'; return ''; } /** * Track icon usage for CSS generation */ protected function trackIconUsage(string $name, string $style, string $source = 'icons'): void { // Initialize source array if needed if (!isset($this->usedIcons[$source])) { $this->usedIcons[$source] = []; } // Initialize style array if needed if (!isset($this->usedIcons[$source][$style])) { $this->usedIcons[$source][$style] = []; } // Add icon if not already tracked if (!in_array($name, $this->usedIcons[$source][$style])) { $this->usedIcons[$source][$style][] = $name; $needsUpdate = true; } if ($needsUpdate) { $existing = get_option(BASE.'usedIcons', []); $merged = $this->mergeUsedIcons($existing, $this->usedIcons); update_option(BASE.'usedIcons', $merged); update_option(BASE.'icons_needs_update', true); $this->cache->delete('icon_styles_css'); } } /** * 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); $svg = trim($svg); return $svg; } /** * Enqueue icon styles via REST endpoint */ public function enqueueIconStyles(): void { $timestamp = CacheManager::getTimestamp('icons'); wp_enqueue_style( 'jvb-icons', JVB_CHILD_URL.'assets/css/icons.css', [], $timestamp ); } /** * Generate CSS from icon list */ protected function generateIconCSS(string $source = 'icons'): string { $css = ''; if (!isset($this->usedIcons[$source])) { return $css; } foreach ($this->usedIcons[$source] as $style => $icons) { $styleClass = ($style !== $this->style) ? '-'.substr($style, 0,2) : ''; foreach ($icons as $icon) { $svg = $this->getEncodedSVG($icon, $style); if ($svg !== '') { $css .= ".icon-{$icon}{$styleClass}{"; $css .= "--icon:url('data:image/svg+xml;base64,{$svg}');"; $css .= "}"; } } } return $this->minifyCss($css); } protected function mergeUsedIcons(array|bool $oldIcons = true, array|bool $newIcons = true):array { $set = false; if ($oldIcons === true) { $oldIcons = $this->usedIcons; $set = true; } if ($newIcons === true) { $history = $this->getVersionHistory(); $newIcons = (count($history) > 0) ? $history[0]['iconList'] : []; } foreach ($newIcons as $style => $icons) { if (!isset($oldIcons[$style])) { //Style doesn't exist in previous set, add the whole thing $oldIcons[$style] = $icons; } else { $oldIcons[$style] = array_unique( array_merge($oldIcons[$style], $icons) ); } } if ($set) { $this->usedIcons = $oldIcons; update_option(BASE.'usedIcons', $oldIcons); } return $oldIcons; } 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; } $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'); // Clear DB option delete_option(BASE.'usedIcons'); delete_option(BASE.'includeIcons'); delete_option(BASE.'iconMap'); $this->cache->delete('icon_styles_css'); CacheManager::updateTimestamp('icons'); } protected function archiveCurrentVersion(string $css, string $source = 'icons'): void { $history = $this->getVersionHistory($source); $icon_count = 0; if (isset($this->usedIcons[$source])) { foreach ($this->usedIcons[$source] as $style => $icons) { $icon_count += count($icons); } } $newEntry = [ 'css' => $css, 'iconList' => $this->usedIcons[$source] ?? [], '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_' . $source, $history); } public function getVersionHistory(string $source = 'icons'): array { return get_option(BASE.'icon_css_history_' . $source, []); } public function restoreVersion(int $timestamp): bool { $history = $this->getVersionHistory(); foreach ($history as $entry) { if ($entry['timestamp'] === $timestamp) { $css_path = JVB_DIR . '/assets/css/icons.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->usedIcons = $entry['iconList']; update_option(BASE.'usedIcons', $this->usedIcons); CacheManager::updateTimestamp('icons'); return true; } return false; } } error_log("[IconsManager] Version {$timestamp} not found in history"); return false; } public function forceRefresh(): void { $this->clearIconCache(); update_option(BASE.'icons_needs_update', true); CacheManager::updateTimestamp('icons'); } public function mergeVersions(array $timestamps): bool { if (empty($timestamps)) { return false; } $history = get_option(BASE.'icon_css_history', []); $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_DIR . '/assets/css/icons.css'); if ($current_css !== false) { $this->archiveCurrentVersion($current_css); } // Update used icons and regenerate $this->usedIcons = $merged_icons; update_option(BASE.'usedIcons', $this->usedIcons); // Force regeneration $this->regenerateCSS(); return true; } }