| @@ -1,67 +1,117 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | -class WP_Icons_Registry_Gutenberg extends WP_Icons_Registry { | |
| 4 | - /** | |
| 5 | - * Modified to point $manifest_path to Gutenberg packages | |
| 6 | - */ | |
| 7 | - protected function __construct() { | |
| 8 | - $icons_directory = gutenberg_dir_path() . 'packages/icons/src'; | |
| 9 | - $icons_directory = trailingslashit( $icons_directory ); | |
| 10 | - $manifest_path = $icons_directory . 'manifest.php'; | |
| 3 | +/** | |
| 4 | + * Returns the SVG elements and attributes allowed for registered icons. | |
| 5 | + * | |
| 6 | + * @return array[] Allowed SVG elements and attributes. | |
| 7 | + * @phpstan-return array<non-falsy-string, array<non-falsy-string, true>> | |
| 8 | + */ | |
| 9 | +function gutenberg_get_allowed_icon_svg_tags(): array { | |
| 10 | + $allow_attributes = static function ( string ...$attribute_names ): array { | |
| 11 | + return array_fill_keys( $attribute_names, true ); | |
| 12 | + }; | |
| 11 | 13 | |
| 12 | - if ( ! is_readable( $manifest_path ) ) { | |
| 13 | - wp_trigger_error( | |
| 14 | - __METHOD__, | |
| 15 | - __( 'Core icon collection manifest is missing or unreadable.', 'gutenberg' ) | |
| 16 | - ); | |
| 17 | - return; | |
| 18 | - } | |
| 14 | + $stroke_attributes = $allow_attributes( | |
| 15 | + 'style', | |
| 16 | + 'stroke', | |
| 17 | + 'stroke-width', | |
| 18 | + 'stroke-linecap', | |
| 19 | + 'stroke-linejoin', | |
| 20 | + 'stroke-miterlimit', | |
| 21 | + 'vector-effect', | |
| 22 | + ); | |
| 19 | 23 | |
| 20 | - $collection = include $manifest_path; | |
| 24 | + return array( | |
| 25 | + 'svg' => array_merge( | |
| 26 | + $allow_attributes( | |
| 27 | + 'class', | |
| 28 | + 'xmlns', | |
| 29 | + 'width', | |
| 30 | + 'height', | |
| 31 | + 'viewbox', | |
| 32 | + 'aria-hidden', | |
| 33 | + 'role', | |
| 34 | + 'focusable', | |
| 35 | + 'fill', | |
| 36 | + 'fill-rule', | |
| 37 | + 'clip-rule', | |
| 38 | + ), | |
| 39 | + $stroke_attributes | |
| 40 | + ), | |
| 41 | + 'path' => array_merge( | |
| 42 | + $allow_attributes( | |
| 43 | + 'fill', | |
| 44 | + 'fill-rule', | |
| 45 | + 'clip-rule', | |
| 46 | + 'd', | |
| 47 | + 'opacity', | |
| 48 | + 'transform', | |
| 49 | + ), | |
| 50 | + $stroke_attributes | |
| 51 | + ), | |
| 52 | + 'polygon' => array_merge( | |
| 53 | + $allow_attributes( | |
| 54 | + 'fill', | |
| 55 | + 'fill-rule', | |
| 56 | + 'clip-rule', | |
| 57 | + 'points', | |
| 58 | + 'transform', | |
| 59 | + 'focusable', | |
| 60 | + ), | |
| 61 | + $stroke_attributes | |
| 62 | + ), | |
| 63 | + 'rect' => array_merge( | |
| 64 | + $allow_attributes( | |
| 65 | + 'fill', | |
| 66 | + 'fill-rule', | |
| 67 | + 'clip-rule', | |
| 68 | + 'x', | |
| 69 | + 'y', | |
| 70 | + 'width', | |
| 71 | + 'height', | |
| 72 | + 'rx', | |
| 73 | + 'ry', | |
| 74 | + 'transform', | |
| 75 | + ), | |
| 76 | + $stroke_attributes | |
| 77 | + ), | |
| 78 | + 'circle' => array_merge( | |
| 79 | + $allow_attributes( | |
| 80 | + 'fill', | |
| 81 | + 'fill-rule', | |
| 82 | + 'clip-rule', | |
| 83 | + 'cx', | |
| 84 | + 'cy', | |
| 85 | + 'r', | |
| 86 | + 'transform', | |
| 87 | + ), | |
| 88 | + $stroke_attributes | |
| 89 | + ), | |
| 90 | + ); | |
| 91 | +} | |
| 21 | 92 | |
| 22 | - if ( empty( $collection ) ) { | |
| 23 | - wp_trigger_error( | |
| 24 | - __METHOD__, | |
| 25 | - __( 'Core icon collection manifest is empty or invalid.', 'gutenberg' ) | |
| 26 | - ); | |
| 27 | - return; | |
| 28 | - } | |
| 29 | 93 | |
| 30 | - foreach ( $collection as $icon_name => $icon_data ) { | |
| 31 | - if ( | |
| 32 | - empty( $icon_data['filePath'] ) | |
| 33 | - || ! is_string( $icon_data['filePath'] ) | |
| 34 | - ) { | |
| 35 | - _doing_it_wrong( | |
| 36 | - __METHOD__, | |
| 37 | - __( 'Core icon collection manifest must provide valid a "filePath" for each icon.', 'gutenberg' ), | |
| 38 | - '7.0.0' | |
| 39 | - ); | |
| 40 | - return; | |
| 41 | - } | |
| 94 | +class WP_Icons_Registry_Gutenberg extends WP_Icons_Registry { | |
| 95 | + /** | |
| 96 | + * Overridden to skip the parent's core icon registration, which uses the | |
| 97 | + * core manifest path. Core icons are registered via | |
| 98 | + * `gutenberg_register_default_icons()` using the Gutenberg manifest instead. | |
| 99 | + */ | |
| 100 | + protected function __construct() {} | |
| 42 | 101 | |
| 43 | - $this->register( | |
| 44 | - 'core/' . $icon_name, | |
| 45 | - array( | |
| 46 | - 'label' => $icon_data['label'], | |
| 47 | - 'file_path' => $icons_directory . $icon_data['filePath'], | |
| 48 | - ) | |
| 49 | - ); | |
| 50 | - } | |
| 51 | - } | |
| 52 | - | |
| 53 | 102 | /** |
| 54 | 103 | * Registers an icon. |
| 55 | 104 | * |
| 56 | - * @param string $icon_name Icon name including namespace. | |
| 105 | + * @param string $icon_name Namespaced icon name in the form "collection/icon-name" | |
| 106 | + * (e.g. "core/arrow-left"). | |
| 57 | 107 | * @param array $icon_properties { |
| 58 | 108 | * List of properties for the icon. |
| 59 | 109 | * |
| 60 | - * @type string $label Required. A human-readable label for the icon. | |
| 61 | - * @type string $content Optional. SVG markup for the icon. | |
| 62 | - * If not provided, the content will be retrieved from the `file_path` if set. | |
| 63 | - * If both `content` and `file_path` are not set, the icon will not be registered. | |
| 110 | + * @type string $label Required. A human-readable label for the icon. | |
| 111 | + * @type string $content Optional. SVG markup for the icon. | |
| 112 | + * If not provided, the content will be retrieved from the `file_path` if set. | |
| 113 | + * If both `content` and `file_path` are not set, the icon will not be registered. | |
| 64 | 114 | * @type string $file_path Optional. The full path to the file containing the icon content. |
| 65 | 115 | * } |
| 66 | 116 | * @return bool True if the icon was registered with success and false otherwise. |
| 67 | 117 | */ |
| @@ -74,31 +124,32 @@ | ||
| 74 | 124 | ); |
| 75 | 125 | return false; |
| 76 | 126 | } |
| 77 | 127 | |
| 78 | - if ( preg_match( '/[A-Z]/', $icon_name ) ) { | |
| 128 | + if ( ! str_contains( $icon_name, '/' ) ) { | |
| 79 | 129 | _doing_it_wrong( |
| 80 | 130 | __METHOD__, |
| 81 | - __( 'Icon names must not contain uppercase characters.', 'gutenberg' ), | |
| 131 | + __( 'Icon name must be namespaced in the form "collection/icon-name".', 'gutenberg' ), | |
| 82 | 132 | '7.1.0' |
| 83 | 133 | ); |
| 84 | 134 | return false; |
| 85 | 135 | } |
| 86 | 136 | |
| 87 | - $name_matcher = '/^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/'; | |
| 88 | - if ( ! preg_match( $name_matcher, $icon_name ) ) { | |
| 137 | + list( $collection, $unqualified_name ) = explode( '/', $icon_name, 2 ); | |
| 138 | + | |
| 139 | + if ( preg_match( '/[A-Z]/', $unqualified_name ) ) { | |
| 89 | 140 | _doing_it_wrong( |
| 90 | 141 | __METHOD__, |
| 91 | - __( 'Icon names must contain a namespace prefix. Example: my-plugin/my-custom-icon', 'gutenberg' ), | |
| 142 | + __( 'Icon names must not contain uppercase characters.', 'gutenberg' ), | |
| 92 | 143 | '7.1.0' |
| 93 | 144 | ); |
| 94 | 145 | return false; |
| 95 | 146 | } |
| 96 | 147 | |
| 97 | - if ( $this->is_registered( $icon_name ) ) { | |
| 148 | + if ( ! preg_match( '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/', $unqualified_name ) ) { | |
| 98 | 149 | _doing_it_wrong( |
| 99 | 150 | __METHOD__, |
| 100 | - __( 'Icon is already registered.', 'gutenberg' ), | |
| 151 | + __( 'Icon names must start and end with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, and underscores.', 'gutenberg' ), | |
| 101 | 152 | '7.1.0' |
| 102 | 153 | ); |
| 103 | 154 | return false; |
| 104 | 155 | } |
| @@ -118,8 +169,21 @@ | ||
| 118 | 169 | return false; |
| 119 | 170 | } |
| 120 | 171 | } |
| 121 | 172 | |
| 173 | + if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( $collection ) ) { | |
| 174 | + _doing_it_wrong( | |
| 175 | + __METHOD__, | |
| 176 | + sprintf( | |
| 177 | + /* translators: %s: Icon collection slug. */ | |
| 178 | + __( 'Icon collection "%s" is not registered.', 'gutenberg' ), | |
| 179 | + $collection | |
| 180 | + ), | |
| 181 | + '7.1.0' | |
| 182 | + ); | |
| 183 | + return false; | |
| 184 | + } | |
| 185 | + | |
| 122 | 186 | if ( ! isset( $icon_properties['label'] ) || ! is_string( $icon_properties['label'] ) ) { |
| 123 | 187 | _doing_it_wrong( |
| 124 | 188 | __METHOD__, |
| 125 | 189 | __( 'Icon label must be a string.', 'gutenberg' ), |
| @@ -158,33 +222,109 @@ | ||
| 158 | 222 | '7.0.0' |
| 159 | 223 | ); |
| 160 | 224 | return false; |
| 161 | 225 | } |
| 226 | + | |
| 227 | + $icon_properties['content'] = $sanitized_icon_content; | |
| 162 | 228 | } |
| 163 | 229 | |
| 230 | + $qualified_name = $collection . '/' . $unqualified_name; | |
| 231 | + | |
| 232 | + if ( $this->is_registered( $qualified_name ) ) { | |
| 233 | + _doing_it_wrong( | |
| 234 | + __METHOD__, | |
| 235 | + __( 'Icon is already registered.', 'gutenberg' ), | |
| 236 | + '7.1.0' | |
| 237 | + ); | |
| 238 | + return false; | |
| 239 | + } | |
| 240 | + | |
| 164 | 241 | $icon = array_merge( |
| 165 | 242 | $icon_properties, |
| 166 | - array( 'name' => $icon_name ) | |
| 243 | + array( | |
| 244 | + 'name' => $qualified_name, | |
| 245 | + 'collection' => $collection, | |
| 246 | + ) | |
| 167 | 247 | ); |
| 168 | 248 | |
| 169 | - $this->registered_icons[ $icon_name ] = $icon; | |
| 249 | + $this->registered_icons[ $qualified_name ] = $icon; | |
| 170 | 250 | |
| 171 | 251 | return true; |
| 172 | 252 | } |
| 173 | 253 | |
| 174 | 254 | /** |
| 175 | - * Redefined to read the icon content from the `file_path` property. | |
| 255 | + * Unregisters an icon. | |
| 176 | 256 | * |
| 257 | + * @param string $icon_name Namespaced icon name in the form "collection/icon-name" | |
| 258 | + * (e.g. "core/arrow-left"). | |
| 259 | + * @return bool True if the icon was unregistered successfully, else false. | |
| 260 | + */ | |
| 261 | + public function unregister( $icon_name ) { | |
| 262 | + if ( ! $this->is_registered( $icon_name ) ) { | |
| 263 | + _doing_it_wrong( | |
| 264 | + __METHOD__, | |
| 265 | + sprintf( | |
| 266 | + /* translators: %s: Icon name. */ | |
| 267 | + __( 'Icon "%s" is not registered.', 'gutenberg' ), | |
| 268 | + $icon_name | |
| 269 | + ), | |
| 270 | + '7.1.0' | |
| 271 | + ); | |
| 272 | + return false; | |
| 273 | + } | |
| 274 | + | |
| 275 | + unset( $this->registered_icons[ $icon_name ] ); | |
| 276 | + return true; | |
| 277 | + } | |
| 278 | + | |
| 279 | + /** | |
| 280 | + * Sanitizes the icon SVG content. | |
| 281 | + * | |
| 282 | + * Overrides the base class to allow the `rect` and `circle` shapes, plus the | |
| 283 | + * stroke-related attributes and inline styles required by stroke-based icons. | |
| 284 | + * | |
| 285 | + * The signature is intentionally left without type declarations to stay | |
| 286 | + * compatible with the parent WP_Icons_Registry::sanitize_icon_content() | |
| 287 | + * shipped in WordPress core, which declares none. | |
| 288 | + * | |
| 289 | + * @param string $icon_content The icon SVG content to sanitize. | |
| 290 | + * @return string The sanitized icon SVG content. | |
| 291 | + */ | |
| 292 | + protected function sanitize_icon_content( $icon_content ) { | |
| 293 | + return wp_kses( $icon_content, gutenberg_get_allowed_icon_svg_tags() ); | |
| 294 | + } | |
| 295 | + | |
| 296 | + /** | |
| 297 | + * Retrieves the content of a registered icon. | |
| 298 | + * | |
| 299 | + * Overridden so that the file validation is applied even when the base | |
| 300 | + * `WP_Icons_Registry` is provided by WordPress core rather than the | |
| 301 | + * Gutenberg compat shim. | |
| 302 | + * | |
| 177 | 303 | * @param string $icon_name Icon name including namespace. |
| 178 | 304 | * @return string|null The content of the icon, if found. |
| 179 | 305 | */ |
| 180 | 306 | protected function get_content( $icon_name ) { |
| 181 | 307 | if ( ! isset( $this->registered_icons[ $icon_name ]['content'] ) ) { |
| 182 | - $content = file_get_contents( | |
| 183 | - $this->registered_icons[ $icon_name ]['file_path'] | |
| 184 | - ); | |
| 185 | - $content = $this->sanitize_icon_content( $content ); | |
| 308 | + $file_path = $this->registered_icons[ $icon_name ]['file_path'] ?? ''; | |
| 309 | + $is_stringy = is_string( $file_path ) || ( is_object( $file_path ) && method_exists( $file_path, '__toString' ) ); | |
| 310 | + $icon_path = $is_stringy ? realpath( (string) $file_path ) : false; | |
| 186 | 311 | |
| 312 | + if ( | |
| 313 | + ! is_string( $icon_path ) || | |
| 314 | + ! str_ends_with( $icon_path, '.svg' ) || | |
| 315 | + ! is_file( $icon_path ) || | |
| 316 | + ! is_readable( $icon_path ) | |
| 317 | + ) { | |
| 318 | + wp_trigger_error( | |
| 319 | + __METHOD__, | |
| 320 | + __( 'Icon file is missing or unreadable.', 'gutenberg' ) | |
| 321 | + ); | |
| 322 | + return null; | |
| 323 | + } | |
| 324 | + | |
| 325 | + $content = $this->sanitize_icon_content( file_get_contents( $icon_path ) ); | |
| 326 | + | |
| 187 | 327 | if ( empty( $content ) ) { |
| 188 | 328 | wp_trigger_error( |
| 189 | 329 | __METHOD__, |
| 190 | 330 | __( 'Icon content does not contain valid SVG markup.', 'gutenberg' ) |
| @@ -218,18 +358,38 @@ | ||
| 218 | 358 | return $icons; |
| 219 | 359 | } |
| 220 | 360 | |
| 221 | 361 | /** |
| 222 | - * Redefined to break away from base class. | |
| 362 | + * Returns the shared registry instance. | |
| 363 | + * | |
| 364 | + * The base `$instance` slot is intentionally not redefined, so both | |
| 365 | + * `WP_Icons_Registry::get_instance()` (used by core) and this method share | |
| 366 | + * one instance. An existing base registry is upgraded, replaying any | |
| 367 | + * non-`core/` and non-`core-admin/` icons so they are not lost. | |
| 223 | 368 | */ |
| 224 | - protected static $instance = null; | |
| 369 | + public static function get_instance() { | |
| 370 | + if ( ! self::$instance instanceof self ) { | |
| 371 | + $original_registry = self::$instance; | |
| 372 | + $gutenberg_registry = new self(); | |
| 225 | 373 | |
| 226 | - /** | |
| 227 | - * Redefined to access new `$instance` | |
| 228 | - */ | |
| 229 | - public static function get_instance() { | |
| 230 | - if ( null === self::$instance ) { | |
| 231 | - self::$instance = new self(); | |
| 374 | + if ( null !== $original_registry ) { | |
| 375 | + foreach ( $original_registry->get_registered_icons() as $icon ) { | |
| 376 | + if ( str_starts_with( $icon['name'], 'core/' ) || str_starts_with( $icon['name'], 'core-admin/' ) ) { | |
| 377 | + continue; | |
| 378 | + } | |
| 379 | + $icon_properties = array( 'label' => $icon['label'] ); | |
| 380 | + if ( ! empty( $icon['content'] ) ) { | |
| 381 | + $icon_properties['content'] = $icon['content']; | |
| 382 | + } elseif ( ! empty( $icon['file_path'] ) ) { | |
| 383 | + $icon_properties['file_path'] = $icon['file_path']; | |
| 384 | + } else { | |
| 385 | + continue; | |
| 386 | + } | |
| 387 | + $gutenberg_registry->register( $icon['name'], $icon_properties ); | |
| 388 | + } | |
| 389 | + } | |
| 390 | + | |
| 391 | + self::$instance = $gutenberg_registry; | |
| 232 | 392 | } |
| 233 | 393 | |
| 234 | 394 | return self::$instance; |
| 235 | 395 | } |
| @@ -235,63 +395,11 @@ | ||
| 235 | 395 | } |
| 236 | 396 | } |
| 237 | 397 | |
| 238 | 398 | /** |
| 239 | - * Forces WP_Icons_Registry_Gutenberg instantiation and overrides WP_Icons_Registry | |
| 240 | - * so that all code using WP_Icons_Registry::{method_name}() receives the Gutenberg | |
| 241 | - * registry. | |
| 399 | + * Overrides the base `WP_Icons_Registry` singleton with the Gutenberg registry so | |
| 400 | + * that all code using `WP_Icons_Registry::{method_name}()` receives it. | |
| 242 | 401 | */ |
| 243 | 402 | function gutenberg_override_wp_icons_registry() { |
| 244 | - /* | |
| 245 | - * The plugin registers the `core/` icons from its own manifest, so core's | |
| 246 | - * registration would only re-register the same names on this registry. | |
| 247 | - */ | |
| 248 | - $wp_priority = has_action( 'init', '_wp_register_default_icons' ); | |
| 249 | - if ( false !== $wp_priority ) { | |
| 250 | - remove_action( 'init', '_wp_register_default_icons', $wp_priority ); | |
| 251 | - } | |
| 252 | - | |
| 253 | - $reflection = new ReflectionClass( WP_Icons_Registry::class ); | |
| 254 | - $property = $reflection->getProperty( 'instance' ); | |
| 255 | - /* | |
| 256 | - * ReflectionProperty::setAccessible is: | |
| 257 | - * - redundant as of 8.1.0, which made all properties accessible | |
| 258 | - * - deprecated as of 8.5.0 | |
| 259 | - * - needed until 8.1.0, as property `instance` is private | |
| 260 | - */ | |
| 261 | - if ( PHP_VERSION_ID < 80100 ) { | |
| 262 | - $property->setAccessible( true ); | |
| 263 | - } | |
| 264 | - $original_registry = $property->getValue( null ); | |
| 265 | - $gutenberg_registry = WP_Icons_Registry_Gutenberg::get_instance(); | |
| 266 | - | |
| 267 | - // If the original registry was already instantiated, replay any icons outside | |
| 268 | - // the `core/` namespace onto the Gutenberg registry so they are not lost. | |
| 269 | - if ( null !== $original_registry ) { | |
| 270 | - $register_method = new ReflectionMethod( WP_Icons_Registry_Gutenberg::class, 'register' ); | |
| 271 | - /* | |
| 272 | - * ReflectionMethod::setAccessible is: | |
| 273 | - * - redundant as of 8.1.0, which made all properties accessible | |
| 274 | - * - deprecated as of 8.5.0 | |
| 275 | - * - needed until 8.1.0, as property `instance` is private | |
| 276 | - */ | |
| 277 | - if ( PHP_VERSION_ID < 80100 ) { | |
| 278 | - $register_method->setAccessible( true ); | |
| 279 | - } | |
| 280 | - foreach ( $original_registry->get_registered_icons() as $icon ) { | |
| 281 | - if ( strpos( $icon['name'], 'core/' ) === 0 ) { | |
| 282 | - continue; | |
| 283 | - } | |
| 284 | - $icon_properties = array( 'label' => $icon['label'] ); | |
| 285 | - if ( ! empty( $icon['content'] ) ) { | |
| 286 | - $icon_properties['content'] = $icon['content']; | |
| 287 | - } elseif ( ! empty( $icon['file_path'] ) ) { | |
| 288 | - $icon_properties['file_path'] = $icon['file_path']; | |
| 289 | - } else { | |
| 290 | - continue; | |
| 291 | - } | |
| 292 | - $register_method->invoke( $gutenberg_registry, $icon['name'], $icon_properties ); | |
| 293 | - } | |
| 294 | - } | |
| 295 | - $property->setValue( null, $gutenberg_registry ); | |
| 403 | + WP_Icons_Registry_Gutenberg::get_instance(); | |
| 296 | 404 | } |
| 297 | 405 | add_action( 'init', 'gutenberg_override_wp_icons_registry', 1 ); |