array( 'xmlns' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'class' => true, 'aria-hidden' => true, 'role' => true, 'focusable' => true, 'style' => true, ), 'path' => array( 'd' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'fill-rule' => true, 'clip-rule' => true, 'opacity' => true, 'fill-opacity' => true, 'stroke-opacity' => true, ), 'circle' => array( 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'opacity' => true, ), 'rect' => array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'opacity' => true, ), 'line' => array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'opacity' => true, ), 'polyline' => array( 'points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'opacity' => true, ), 'polygon' => array( 'points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'opacity' => true, ), 'ellipse' => array( 'cx' => true, 'cy' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'opacity' => true, ), 'g' => array( 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true, 'opacity' => true, ), 'defs' => array(), 'clippath' => array( 'id' => true, ), 'use' => array( 'href' => true, 'xlink:href' => true, ), ); /** * Download an entire icon set. * * @param string $slug Icon set slug. * @param callable|null $progress_callback Optional progress callback. * @return array|WP_Error Download results or error. */ public function download_set( $slug, $progress_callback = null ) { // Validate slug. if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) { return new WP_Error( 'invalid_set', __( 'Invalid icon set.', 'maple-icons' ) ); } $set_config = MI_Icon_Sets::get( $slug ); $manifest = MI_Icon_Sets::load_manifest( $slug ); if ( is_wp_error( $manifest ) ) { return $manifest; } // Create base directory. $base_dir = MI_ICONS_DIR . $slug; if ( ! $this->ensure_directory( $base_dir ) ) { return new WP_Error( 'directory_error', __( 'Could not create icon directory.', 'maple-icons' ) ); } // Create style directories. foreach ( $set_config['styles'] as $style_slug => $style_config ) { $style_dir = $base_dir . '/' . $style_slug; if ( ! $this->ensure_directory( $style_dir ) ) { return new WP_Error( 'directory_error', __( 'Could not create style directory.', 'maple-icons' ) ); } } $icons = isset( $manifest['icons'] ) ? $manifest['icons'] : array(); $total_icons = count( $icons ); $downloaded = 0; $failed = 0; $errors = array(); $total_to_download = 0; // Calculate total icons to download (icon × styles). foreach ( $icons as $icon ) { $icon_styles = isset( $icon['styles'] ) ? $icon['styles'] : array_keys( $set_config['styles'] ); $total_to_download += count( $icon_styles ); } // Initialize progress transient. set_transient( 'mi_download_progress_' . $slug, array( 'completed' => 0, 'total' => $total_to_download, 'status' => 'downloading', ), HOUR_IN_SECONDS ); $current = 0; // Download each icon. foreach ( $icons as $icon ) { $icon_name = $icon['name']; $icon_styles = isset( $icon['styles'] ) ? $icon['styles'] : array_keys( $set_config['styles'] ); foreach ( $icon_styles as $style ) { if ( ! isset( $set_config['styles'][ $style ] ) ) { continue; } $result = $this->download_icon( $slug, $style, $icon_name ); if ( is_wp_error( $result ) ) { $failed++; $errors[] = sprintf( '%s/%s: %s', $style, $icon_name, $result->get_error_message() ); } else { $downloaded++; } $current++; // Update progress. set_transient( 'mi_download_progress_' . $slug, array( 'completed' => $current, 'total' => $total_to_download, 'status' => 'downloading', ), HOUR_IN_SECONDS ); // Call progress callback if provided. if ( is_callable( $progress_callback ) ) { call_user_func( $progress_callback, $current, $total_to_download ); } // Allow some breathing room for the server. if ( 0 === $current % MI_DOWNLOAD_BATCH_SIZE ) { usleep( 100000 ); // 100ms pause every batch. } } } // Clear progress transient. delete_transient( 'mi_download_progress_' . $slug ); return array( 'success' => $failed === 0, 'downloaded' => $downloaded, 'failed' => $failed, 'total' => $total_to_download, 'errors' => $errors, ); } /** * Download a single icon from CDN. * * @param string $slug Icon set slug. * @param string $style Style slug. * @param string $name Icon name. * @return string|WP_Error Local file path or error. */ public function download_icon( $slug, $style, $name ) { // Validate inputs. if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) { return new WP_Error( 'invalid_set', __( 'Invalid icon set.', 'maple-icons' ) ); } if ( ! MI_Icon_Sets::is_valid_style( $slug, $style ) ) { return new WP_Error( 'invalid_style', __( 'Invalid icon style.', 'maple-icons' ) ); } // Validate icon name (only allow alphanumeric, hyphens, underscores). if ( ! preg_match( '/^[a-z0-9\-_]+$/i', $name ) ) { return new WP_Error( 'invalid_name', __( 'Invalid icon name.', 'maple-icons' ) ); } $cdn_url = MI_Icon_Sets::get_cdn_url( $slug, $style, $name ); $local_path = MI_Icon_Sets::get_local_path( $slug, $style, $name ); // Check if already downloaded. if ( file_exists( $local_path ) ) { return $local_path; } // Fetch from CDN. $response = wp_remote_get( $cdn_url, array( 'timeout' => MI_DOWNLOAD_TIMEOUT, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return $response; } $status_code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status_code ) { return new WP_Error( 'cdn_error', sprintf( /* translators: %d: HTTP status code */ __( 'CDN returned status %d.', 'maple-icons' ), $status_code ) ); } $svg_content = wp_remote_retrieve_body( $response ); if ( empty( $svg_content ) ) { return new WP_Error( 'empty_response', __( 'Empty response from CDN.', 'maple-icons' ) ); } // Get set config for normalization. $set_config = MI_Icon_Sets::get( $slug ); // Normalize and sanitize SVG. $svg_content = $this->normalize_svg( $svg_content, $set_config ); $svg_content = $this->sanitize_svg( $svg_content ); if ( empty( $svg_content ) ) { return new WP_Error( 'invalid_svg', __( 'Invalid or empty SVG content.', 'maple-icons' ) ); } // Ensure directory exists. $dir = dirname( $local_path ); if ( ! $this->ensure_directory( $dir ) ) { return new WP_Error( 'directory_error', __( 'Could not create directory.', 'maple-icons' ) ); } // Write file. global $wp_filesystem; if ( empty( $wp_filesystem ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if ( ! $wp_filesystem->put_contents( $local_path, $svg_content, FS_CHMOD_FILE ) ) { return new WP_Error( 'write_error', __( 'Could not write SVG file.', 'maple-icons' ) ); } return $local_path; } /** * Normalize SVG content based on set configuration. * * @param string $svg SVG content. * @param array $set_config Icon set configuration. * @return string Normalized SVG content. */ private function normalize_svg( $svg, $set_config ) { // Strip XML declaration. $svg = preg_replace( '/<\?xml[^>]*\?>/i', '', $svg ); // Strip DOCTYPE. $svg = preg_replace( '/]*>/i', '', $svg ); // Strip comments. $svg = preg_replace( '//s', '', $svg ); // Strip title and desc elements. $svg = preg_replace( '/