| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * Icons API: WP_Icons_Registry class 4 * 5 * @package WordPress 6 * @since 7.0.0 7 */ 8 9 /** 10 * Core class used for interacting with registered icons. 11 * 12 * @since 7.0.0 13 */ 14 class WP_Icons_Registry { 15 /** 16 * Registered icons array. 17 * 18 * @since 7.0.0 19 * @var array[] 20 */ 21 protected $registered_icons = array(); 22 23 /** 24 * Resolved path of the core icon library directory. 25 * 26 * @since 7.2.0 27 * @var string|false|null 28 */ 29 private $core_icon_library_directory = null; 30 31 /** 32 * Container for the main instance of the class. 33 * 34 * @since 7.0.0 35 * @var WP_Icons_Registry|null 36 */ 37 protected static $instance = null; 38 39 /** 40 * Names of the icons that replace Dashicons, keyed by Dashicon class name. 41 * 42 * Built from the icon library manifest on first use. 43 * 44 * @since 7.2.0 45 * @var array<string, string>|null 46 */ 47 protected $dashicon_replacements = null; 48 49 /** 50 * Constructor. 51 * 52 * WP_Icons_Registry is a singleton class, so keep this protected. 53 * 54 * Icons are populated via `_wp_register_default_icons()` during the 55 * `init` action. Third-party icons can be registered via 56 * {@see wp_register_icon()} once their collection is registered. 57 * 58 * @since 7.0.0 59 */ 60 protected function __construct() {} 61 62 /** 63 * Registers an icon. 64 * 65 * @since 7.0.0 66 * @since 7.1.0 The icon name must be namespaced in the form "collection/icon-name". 67 * @since 7.2.0 Added the `keywords` property. 68 * 69 * @param string $icon_name Namespaced icon name in the form "collection/icon-name" 70 * (e.g. "core/arrow-left"). 71 * @param array $icon_properties { 72 * List of properties for the icon. 73 * 74 * @type string $label Required. A human-readable label for the icon. 75 * @type string $content Optional. SVG markup for the icon. 76 * If not provided, the content will be retrieved from the `file_path` if set. 77 * If both `content` and `file_path` are not set, the icon will not be registered. 78 * @type string $file_path Optional. The full path to the file containing the icon content. 79 * @type string[] $keywords Optional. Additional search terms for the icon, matched by 80 * `get_registered_icons()` alongside the name and label. 81 * } 82 * @return bool True if the icon was registered with success and false otherwise. 83 * 84 * @phpstan-param lowercase-string&non-falsy-string $icon_name 85 */ 86 public function register( $icon_name, $icon_properties ) { 87 if ( ! isset( $icon_name ) || ! is_string( $icon_name ) ) { 88 _doing_it_wrong( 89 __METHOD__, 90 __( 'Icon name must be a string.' ), 91 '7.0.0' 92 ); 93 return false; 94 } 95 96 // Require a namespaced name in the form "collection/icon-name". 97 if ( ! str_contains( $icon_name, '/' ) ) { 98 _doing_it_wrong( 99 __METHOD__, 100 __( 'Icon name must be namespaced in the form "collection/icon-name".' ), 101 '7.1.0' 102 ); 103 return false; 104 } 105 106 // Split the namespaced name into a collection slug and an unqualified icon name. 107 list( $collection, $unqualified_name ) = explode( '/', $icon_name, 2 ); 108 109 if ( preg_match( '/[A-Z]/', $unqualified_name ) ) { 110 _doing_it_wrong( 111 __METHOD__, 112 __( 'Icon names must not contain uppercase characters.' ), 113 '7.1.0' 114 ); 115 return false; 116 } 117 118 if ( ! preg_match( '/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/', $unqualified_name ) ) { 119 _doing_it_wrong( 120 __METHOD__, 121 __( 'Icon names must start and end with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, and underscores.' ), 122 '7.1.0' 123 ); 124 return false; 125 } 126 127 $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'keywords' ), 1 ); 128 foreach ( array_keys( $icon_properties ) as $key ) { 129 if ( ! array_key_exists( $key, $allowed_keys ) ) { 130 _doing_it_wrong( 131 __METHOD__, 132 sprintf( 133 /* translators: %s: The name of a user-provided key. */ 134 __( 'Invalid icon property: "%s".' ), 135 $key 136 ), 137 '7.0.0' 138 ); 139 return false; 140 } 141 } 142 143 if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( $collection ) ) { 144 _doing_it_wrong( 145 __METHOD__, 146 sprintf( 147 /* translators: %s: Icon collection slug. */ 148 __( 'Icon collection "%s" is not registered.' ), 149 $collection 150 ), 151 '7.1.0' 152 ); 153 return false; 154 } 155 156 if ( ! isset( $icon_properties['label'] ) || ! is_string( $icon_properties['label'] ) ) { 157 _doing_it_wrong( 158 __METHOD__, 159 __( 'Icon label must be a string.' ), 160 '7.0.0' 161 ); 162 return false; 163 } 164 165 if ( array_key_exists( 'keywords', $icon_properties ) ) { 166 if ( ! is_array( $icon_properties['keywords'] ) ) { 167 _doing_it_wrong( 168 __METHOD__, 169 __( 'Icon keywords must be an array of strings.' ), 170 '7.2.0' 171 ); 172 return false; 173 } 174 175 foreach ( $icon_properties['keywords'] as $keyword ) { 176 if ( ! is_string( $keyword ) ) { 177 _doing_it_wrong( 178 __METHOD__, 179 __( 'Icon keywords must be an array of strings.' ), 180 '7.2.0' 181 ); 182 return false; 183 } 184 } 185 } 186 187 if ( 188 ( ! isset( $icon_properties['content'] ) && ! isset( $icon_properties['file_path'] ) ) || 189 ( isset( $icon_properties['content'] ) && isset( $icon_properties['file_path'] ) ) 190 ) { 191 _doing_it_wrong( 192 __METHOD__, 193 __( 'Icons must provide either `content` or `file_path`.' ), 194 '7.0.0' 195 ); 196 return false; 197 } 198 199 if ( isset( $icon_properties['content'] ) ) { 200 if ( ! is_string( $icon_properties['content'] ) ) { 201 _doing_it_wrong( 202 __METHOD__, 203 __( 'Icon content must be a string.' ), 204 '7.0.0' 205 ); 206 return false; 207 } 208 209 $sanitized_icon_content = $this->sanitize_icon_content( $icon_properties['content'] ); 210 if ( empty( $sanitized_icon_content ) ) { 211 _doing_it_wrong( 212 __METHOD__, 213 __( 'Icon content does not contain valid SVG markup.' ), 214 '7.0.0' 215 ); 216 return false; 217 } 218 219 $icon_properties['content'] = $sanitized_icon_content; 220 } 221 222 $qualified_name = $collection . '/' . $unqualified_name; 223 224 if ( $this->is_registered( $qualified_name ) ) { 225 _doing_it_wrong( 226 __METHOD__, 227 __( 'Icon is already registered.' ), 228 '7.1.0' 229 ); 230 return false; 231 } 232 233 $icon = array_merge( 234 $icon_properties, 235 array( 236 'name' => $qualified_name, 237 'collection' => $collection, 238 ) 239 ); 240 241 $this->registered_icons[ $qualified_name ] = $icon; 242 243 return true; 244 } 245 246 /** 247 * Unregisters an icon. 248 * 249 * @since 7.1.0 250 * 251 * @param string $icon_name Namespaced icon name in the form "collection/icon-name" 252 * (e.g. "core/arrow-left"). 253 * @return bool True if the icon was unregistered successfully, false otherwise. 254 */ 255 public function unregister( $icon_name ) { 256 if ( ! $this->is_registered( $icon_name ) ) { 257 _doing_it_wrong( 258 __METHOD__, 259 sprintf( 260 /* translators: %s: Icon name. */ 261 __( 'Icon "%s" is not registered.' ), 262 $icon_name 263 ), 264 '7.1.0' 265 ); 266 return false; 267 } 268 269 unset( $this->registered_icons[ $icon_name ] ); 270 return true; 271 } 272 273 /** 274 * Builds the allowed attribute list for wp_kses() from attribute names. 275 * 276 * @since 7.2.0 277 * 278 * @param non-falsy-string ...$attribute_names Attribute names to allow. 279 * @return array<non-falsy-string, true> Attribute names mapped to true. 280 */ 281 private function get_allowed_attribute_list( ...$attribute_names ): array { 282 return array_fill_keys( $attribute_names, true ); 283 } 284 285 /** 286 * Sanitizes the icon SVG content. 287 * 288 * @since 7.0.0 289 * 290 * @param string $icon_content The icon SVG content to sanitize. 291 * @return string The sanitized icon SVG content. 292 */ 293 protected function sanitize_icon_content( $icon_content ) { 294 $stroke_attributes = $this->get_allowed_attribute_list( 295 'style', 296 'stroke', 297 'stroke-width', 298 'stroke-linecap', 299 'stroke-linejoin', 300 'stroke-miterlimit', 301 'vector-effect', 302 ); 303 304 $allowed_tags = array( 305 'svg' => array_merge( 306 $this->get_allowed_attribute_list( 307 'class', 308 'xmlns', 309 'width', 310 'height', 311 'viewbox', 312 'aria-hidden', 313 'role', 314 'focusable', 315 'fill', 316 'fill-rule', 317 'clip-rule', 318 ), 319 $stroke_attributes 320 ), 321 'path' => array_merge( 322 $this->get_allowed_attribute_list( 323 'fill', 324 'fill-rule', 325 'clip-rule', 326 'd', 327 'opacity', 328 'transform', 329 ), 330 $stroke_attributes 331 ), 332 'polygon' => array_merge( 333 $this->get_allowed_attribute_list( 334 'fill', 335 'fill-rule', 336 'clip-rule', 337 'points', 338 'transform', 339 'focusable', 340 ), 341 $stroke_attributes 342 ), 343 'rect' => array_merge( 344 $this->get_allowed_attribute_list( 345 'fill', 346 'fill-rule', 347 'clip-rule', 348 'x', 349 'y', 350 'width', 351 'height', 352 'rx', 353 'ry', 354 'transform', 355 ), 356 $stroke_attributes 357 ), 358 'circle' => array_merge( 359 $this->get_allowed_attribute_list( 360 'fill', 361 'fill-rule', 362 'clip-rule', 363 'cx', 364 'cy', 365 'r', 366 'transform', 367 ), 368 $stroke_attributes 369 ), 370 ); 371 return wp_kses( $icon_content, $allowed_tags ); 372 } 373 374 /** 375 * Retrieves the content of a registered icon. 376 * 377 * @since 7.0.0 378 * @since 7.2.0 Core icons skip sanitization. 379 * 380 * @param string $icon_name Icon name including namespace. 381 * @return string|null The content of the icon, if found. 382 */ 383 protected function get_content( $icon_name ) { 384 if ( ! isset( $this->registered_icons[ $icon_name ]['content'] ) ) { 385 $file_path = $this->registered_icons[ $icon_name ]['file_path'] ?? ''; 386 $is_stringy = is_string( $file_path ) || ( is_object( $file_path ) && method_exists( $file_path, '__toString' ) ); 387 $icon_path = $is_stringy ? realpath( (string) $file_path ) : false; 388 389 if ( 390 ! is_string( $icon_path ) || 391 ! str_ends_with( $icon_path, '.svg' ) || 392 ! is_file( $icon_path ) || 393 ! is_readable( $icon_path ) 394 ) { 395 wp_trigger_error( 396 __METHOD__, 397 __( 'Icon file is missing or unreadable.' ) 398 ); 399 return null; 400 } 401 402 $content = file_get_contents( $icon_path ); 403 404 // Core icons are trusted, so skip the costly sanitization. 405 if ( ! $this->is_core_icon_file( $icon_path ) ) { 406 $content = $this->sanitize_icon_content( $content ); 407 } 408 409 if ( empty( $content ) ) { 410 wp_trigger_error( 411 __METHOD__, 412 __( 'Icon content does not contain valid SVG markup.' ) 413 ); 414 return null; 415 } 416 417 $this->registered_icons[ $icon_name ]['content'] = $content; 418 } 419 return $this->registered_icons[ $icon_name ]['content']; 420 } 421 422 /** 423 * Determines whether an icon file is a core icon. 424 * 425 * @since 7.2.0 426 * 427 * @param string $icon_path Icon file path, resolved with `realpath()`. 428 * @return bool Whether the file is in the core icon library. 429 */ 430 private function is_core_icon_file( $icon_path ) { 431 if ( null === $this->core_icon_library_directory ) { 432 $this->core_icon_library_directory = realpath( ABSPATH . WPINC . '/images/icon-library' ); 433 } 434 435 return is_string( $this->core_icon_library_directory ) && str_starts_with( $icon_path, $this->core_icon_library_directory . DIRECTORY_SEPARATOR ); 436 } 437 438 /** 439 * Retrieves an array containing the properties of a registered icon. 440 * 441 * @since 7.0.0 442 * 443 * @param string $icon_name Icon name including namespace. 444 * @return array|null Registered icon properties or `null` if the icon is not registered. 445 */ 446 public function get_registered_icon( $icon_name ) { 447 if ( ! $this->is_registered( $icon_name ) ) { 448 return null; 449 } 450 451 $icon = $this->registered_icons[ $icon_name ]; 452 $icon['content'] ??= $this->get_content( $icon_name ); 453 454 return $icon; 455 } 456 457 /** 458 * Determines whether an icon matches a search term. 459 * 460 * The term is matched case-insensitively against the icon's name, its label, 461 * and any of its keywords. 462 * 463 * @since 7.2.0 464 * 465 * @param array $icon Registered icon properties. 466 * @param string $search Search term. 467 * @return bool True if the icon matches the search term, false otherwise. 468 */ 469 protected function icon_matches_search( $icon, $search ) { 470 if ( false !== stripos( $icon['name'], $search ) ) { 471 return true; 472 } 473 474 if ( false !== stripos( $icon['label'], $search ) ) { 475 return true; 476 } 477 478 foreach ( $icon['keywords'] ?? array() as $keyword ) { 479 if ( false !== stripos( $keyword, $search ) ) { 480 return true; 481 } 482 } 483 484 return false; 485 } 486 487 /** 488 * Retrieves all registered icons. 489 * 490 * @since 7.0.0 491 * @since 7.1.0 Search also matches icon labels. 492 * @since 7.2.0 Search also matches icon keywords. 493 * 494 * @param string $search Optional. Search term matched against each icon's name, 495 * label, and keywords. Default empty string, which returns 496 * every registered icon. 497 * @return array[] Array of arrays containing the registered icon properties. 498 */ 499 public function get_registered_icons( $search = '' ) { 500 $icons = array(); 501 502 foreach ( $this->registered_icons as $icon ) { 503 if ( ! empty( $search ) && ! $this->icon_matches_search( $icon, $search ) ) { 504 continue; 505 } 506 507 $icon['content'] ??= $this->get_content( $icon['name'] ); 508 $icons[] = $icon; 509 } 510 511 return $icons; 512 } 513 514 /** 515 * Checks if an icon is registered. 516 * 517 * @since 7.0.0 518 * 519 * @param string $icon_name Icon name including namespace. 520 * @return bool True if the icon is registered, false otherwise. 521 */ 522 public function is_registered( $icon_name ) { 523 return isset( $this->registered_icons[ $icon_name ] ); 524 } 525 526 /** 527 * Returns the name of the icon that replaces a Dashicon in the admin interface. 528 * 529 * The replacements come from the `dashicons` property of the `core-admin` 530 * icons in the icon library manifest. 531 * 532 * @since 7.2.0 533 * 534 * @param string $dashicon Dashicon class name, such as `dashicons-admin-post`. 535 * @return string|null Namespaced name of the replacement icon, such as `core-admin/pin`, 536 * or null if the Dashicon has no replacement or the replacement 537 * is not registered. 538 */ 539 public function get_admin_icon_name_for_dashicon( $dashicon ) { 540 if ( ! is_string( $dashicon ) ) { 541 return null; 542 } 543 544 if ( null === $this->dashicon_replacements ) { 545 $this->dashicon_replacements = array(); 546 547 $manifest_path = ABSPATH . WPINC . '/assets/icon-library-manifest.php'; 548 $manifest = is_readable( $manifest_path ) ? include $manifest_path : array(); 549 550 foreach ( is_array( $manifest ) ? $manifest : array() as $icon_name => $icon_data ) { 551 if ( 552 empty( $icon_data['dashicons'] ) || 553 ! in_array( 'core-admin', $icon_data['collections'] ?? array(), true ) 554 ) { 555 continue; 556 } 557 558 foreach ( $icon_data['dashicons'] as $replaced_dashicon ) { 559 $this->dashicon_replacements[ $replaced_dashicon ] = 'core-admin/' . $icon_name; 560 } 561 } 562 } 563 564 $icon_name = $this->dashicon_replacements[ $dashicon ] ?? null; 565 566 return null !== $icon_name && $this->is_registered( $icon_name ) ? $icon_name : null; 567 } 568 569 /** 570 * Utility method to retrieve the main instance of the class. 571 * 572 * The instance will be created if it does not exist yet. 573 * 574 * @since 7.0.0 575 * 576 * @return WP_Icons_Registry The main instance. 577 */ 578 public static function get_instance() { 579 self::$instance ??= new self(); 580 581 return self::$instance; 582 } 583 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Tue Nov 3 08:20:36 2026 | Cross-referenced by PHPXref |