> */ function gutenberg_get_allowed_icon_svg_tags(): array { $allow_attributes = static function ( string ...$attribute_names ): array { return array_fill_keys( $attribute_names, true ); }; $stroke_attributes = $allow_attributes( 'style', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'vector-effect', ); return array( 'svg' => array_merge( $allow_attributes( 'class', 'xmlns', 'width', 'height', 'viewbox', 'aria-hidden', 'role', 'focusable', 'fill', 'fill-rule', 'clip-rule', ), $stroke_attributes ), 'path' => array_merge( $allow_attributes( 'fill', 'fill-rule', 'clip-rule', 'd', 'opacity', 'transform', ), $stroke_attributes ), 'polygon' => array_merge( $allow_attributes( 'fill', 'fill-rule', 'clip-rule', 'points', 'transform', 'focusable', ), $stroke_attributes ), 'rect' => array_merge( $allow_attributes( 'fill', 'fill-rule', 'clip-rule', 'x', 'y', 'width', 'height', 'rx', 'ry', 'transform', ), $stroke_attributes ), 'circle' => array_merge( $allow_attributes( 'fill', 'fill-rule', 'clip-rule', 'cx', 'cy', 'r', 'transform', ), $stroke_attributes ), ); } class WP_Icons_Registry_Gutenberg extends WP_Icons_Registry { /** * Overridden to skip the parent's core icon registration, which uses the * core manifest path. Core icons are registered via * `gutenberg_register_default_icons()` using the Gutenberg manifest instead. */ protected function __construct() {} /** * Registers an icon. * * @param string $icon_name Namespaced icon name in the form "collection/icon-name" * (e.g. "core/arrow-left"). * @param array $icon_properties { * List of properties for the icon. * * @type string $label Required. A human-readable label for the icon. * @type string $content Optional. SVG markup for the icon. * If not provided, the content will be retrieved from the `file_path` if set. * If both `content` and `file_path` are not set, the icon will not be registered. * @type string $file_path Optional. The full path to the file containing the icon content. * } * @return bool True if the icon was registered with success and false otherwise. */ public function register( $icon_name, $icon_properties ) { if ( ! isset( $icon_name ) || ! is_string( $icon_name ) ) { _doing_it_wrong( __METHOD__, __( 'Icon name must be a string.', 'gutenberg' ), '7.0.0' ); return false; } if ( ! str_contains( $icon_name, '/' ) ) { _doing_it_wrong( __METHOD__, __( 'Icon name must be namespaced in the form "collection/icon-name".', 'gutenberg' ), '7.1.0' ); return false; } list( $collection, $unqualified_name ) = explode( '/', $icon_name, 2 ); if ( preg_match( '/[A-Z]/', $unqualified_name ) ) { _doing_it_wrong( __METHOD__, __( 'Icon names must not contain uppercase characters.', 'gutenberg' ), '7.1.0' ); return false; } if ( ! preg_match( '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/', $unqualified_name ) ) { _doing_it_wrong( __METHOD__, __( 'Icon names must start and end with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, and underscores.', 'gutenberg' ), '7.1.0' ); return false; } $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path' ), 1 ); foreach ( array_keys( $icon_properties ) as $key ) { if ( ! array_key_exists( $key, $allowed_keys ) ) { _doing_it_wrong( __METHOD__, sprintf( // translators: %s is the name of any user-provided key __( 'Invalid icon property: "%s".', 'gutenberg' ), $key ), '7.0.0' ); return false; } } if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( $collection ) ) { _doing_it_wrong( __METHOD__, sprintf( /* translators: %s: Icon collection slug. */ __( 'Icon collection "%s" is not registered.', 'gutenberg' ), $collection ), '7.1.0' ); return false; } if ( ! isset( $icon_properties['label'] ) || ! is_string( $icon_properties['label'] ) ) { _doing_it_wrong( __METHOD__, __( 'Icon label must be a string.', 'gutenberg' ), '7.0.0' ); return false; } if ( ( ! isset( $icon_properties['content'] ) && ! isset( $icon_properties['file_path'] ) ) || ( isset( $icon_properties['content'] ) && isset( $icon_properties['file_path'] ) ) ) { _doing_it_wrong( __METHOD__, __( 'Icons must provide either `content` or `file_path`.', 'gutenberg' ), '7.0.0' ); return false; } if ( isset( $icon_properties['content'] ) ) { if ( ! is_string( $icon_properties['content'] ) ) { _doing_it_wrong( __METHOD__, __( 'Icon content must be a string.', 'gutenberg' ), '7.0.0' ); return false; } $sanitized_icon_content = $this->sanitize_icon_content( $icon_properties['content'] ); if ( empty( $sanitized_icon_content ) ) { _doing_it_wrong( __METHOD__, __( 'Icon content does not contain valid SVG markup.', 'gutenberg' ), '7.0.0' ); return false; } $icon_properties['content'] = $sanitized_icon_content; } $qualified_name = $collection . '/' . $unqualified_name; if ( $this->is_registered( $qualified_name ) ) { _doing_it_wrong( __METHOD__, __( 'Icon is already registered.', 'gutenberg' ), '7.1.0' ); return false; } $icon = array_merge( $icon_properties, array( 'name' => $qualified_name, 'collection' => $collection, ) ); $this->registered_icons[ $qualified_name ] = $icon; return true; } /** * Unregisters an icon. * * @param string $icon_name Namespaced icon name in the form "collection/icon-name" * (e.g. "core/arrow-left"). * @return bool True if the icon was unregistered successfully, else false. */ public function unregister( $icon_name ) { if ( ! $this->is_registered( $icon_name ) ) { _doing_it_wrong( __METHOD__, sprintf( /* translators: %s: Icon name. */ __( 'Icon "%s" is not registered.', 'gutenberg' ), $icon_name ), '7.1.0' ); return false; } unset( $this->registered_icons[ $icon_name ] ); return true; } /** * Sanitizes the icon SVG content. * * Overrides the base class to allow the `rect` and `circle` shapes, plus the * stroke-related attributes and inline styles required by stroke-based icons. * * The signature is intentionally left without type declarations to stay * compatible with the parent WP_Icons_Registry::sanitize_icon_content() * shipped in WordPress core, which declares none. * * @param string $icon_content The icon SVG content to sanitize. * @return string The sanitized icon SVG content. */ protected function sanitize_icon_content( $icon_content ) { return wp_kses( $icon_content, gutenberg_get_allowed_icon_svg_tags() ); } /** * Retrieves the content of a registered icon. * * Overridden so that the file validation is applied even when the base * `WP_Icons_Registry` is provided by WordPress core rather than the * Gutenberg compat shim. * * @param string $icon_name Icon name including namespace. * @return string|null The content of the icon, if found. */ protected function get_content( $icon_name ) { if ( ! isset( $this->registered_icons[ $icon_name ]['content'] ) ) { $file_path = $this->registered_icons[ $icon_name ]['file_path'] ?? ''; $is_stringy = is_string( $file_path ) || ( is_object( $file_path ) && method_exists( $file_path, '__toString' ) ); $icon_path = $is_stringy ? realpath( (string) $file_path ) : false; if ( ! is_string( $icon_path ) || ! str_ends_with( $icon_path, '.svg' ) || ! is_file( $icon_path ) || ! is_readable( $icon_path ) ) { wp_trigger_error( __METHOD__, __( 'Icon file is missing or unreadable.', 'gutenberg' ) ); return null; } $content = $this->sanitize_icon_content( file_get_contents( $icon_path ) ); if ( empty( $content ) ) { wp_trigger_error( __METHOD__, __( 'Icon content does not contain valid SVG markup.', 'gutenberg' ) ); return null; } $this->registered_icons[ $icon_name ]['content'] = $content; } return $this->registered_icons[ $icon_name ]['content']; } /** * Modified to also search in icon labels */ public function get_registered_icons( $search = '' ) { $icons = array(); foreach ( $this->registered_icons as $icon ) { if ( ! empty( $search ) && false === stripos( $icon['name'], $search ) && false === stripos( $icon['label'], $search ) ) { continue; } $icon['content'] = $icon['content'] ?? $this->get_content( $icon['name'] ); $icons[] = $icon; } return $icons; } /** * Returns the shared registry instance. * * The base `$instance` slot is intentionally not redefined, so both * `WP_Icons_Registry::get_instance()` (used by core) and this method share * one instance. An existing base registry is upgraded, replaying any * non-`core/` and non-`core-admin/` icons so they are not lost. */ public static function get_instance() { if ( ! self::$instance instanceof self ) { $original_registry = self::$instance; $gutenberg_registry = new self(); if ( null !== $original_registry ) { foreach ( $original_registry->get_registered_icons() as $icon ) { if ( str_starts_with( $icon['name'], 'core/' ) || str_starts_with( $icon['name'], 'core-admin/' ) ) { continue; } $icon_properties = array( 'label' => $icon['label'] ); if ( ! empty( $icon['content'] ) ) { $icon_properties['content'] = $icon['content']; } elseif ( ! empty( $icon['file_path'] ) ) { $icon_properties['file_path'] = $icon['file_path']; } else { continue; } $gutenberg_registry->register( $icon['name'], $icon_properties ); } } self::$instance = $gutenberg_registry; } return self::$instance; } } /** * Overrides the base `WP_Icons_Registry` singleton with the Gutenberg registry so * that all code using `WP_Icons_Registry::{method_name}()` receives it. */ function gutenberg_override_wp_icons_registry() { WP_Icons_Registry_Gutenberg::get_instance(); } add_action( 'init', 'gutenberg_override_wp_icons_registry', 1 );