From e1309056681d2a23d24d7096b5fdd5e6021ef599 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 11 Jun 2026 15:42:48 +0900 Subject: [PATCH 01/17] Icons: Use snake_case file_path internally in the icon registry. Align the icon registry's internal property with WordPress core's snake_case array-key convention by renaming the registered icon property from filePath to file_path. The conversion now happens only at the registration/mapping boundary in the constructor: the generated manifest keeps the upstream camelCase `filePath` key (as produced by Gutenberg), which is read and mapped to the internal `file_path` property when each icon is registered. This updates the registry validation, allowed property keys, `get_content()` lookup, and the related docblocks and error messages, while leaving the manifest and the copy:icon-library-manifest Grunt task untouched. Co-Authored-By: Claude --- src/wp-includes/class-wp-icons-registry.php | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php index f82739fc5d91d..a442a2f99af94 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -81,8 +81,8 @@ protected function __construct() { $this->register( 'core/' . $icon_name, array( - 'label' => $icon_data['label'], - 'filePath' => $icons_directory . $icon_data['filePath'], + 'label' => $icon_data['label'], + 'file_path' => $icons_directory . $icon_data['filePath'], ) ); } @@ -97,11 +97,11 @@ protected function __construct() { * @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 `filePath` if set. - * If both `content` and `filePath` are not set, the icon will not be registered. - * @type string $filePath Optional. The full path to the file containing the icon content. + * @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. */ @@ -115,7 +115,7 @@ protected function register( $icon_name, $icon_properties ) { return false; } - $allowed_keys = array_fill_keys( array( 'label', 'content', 'filePath' ), 1 ); + $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( @@ -141,12 +141,12 @@ protected function register( $icon_name, $icon_properties ) { } if ( - ( ! isset( $icon_properties['content'] ) && ! isset( $icon_properties['filePath'] ) ) || - ( isset( $icon_properties['content'] ) && isset( $icon_properties['filePath'] ) ) + ( ! 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 `filePath`.' ), + __( 'Icons must provide either `content` or `file_path`.' ), '7.0.0' ); return false; @@ -234,7 +234,7 @@ protected function sanitize_icon_content( $icon_content ) { protected function get_content( $icon_name ) { if ( ! isset( $this->registered_icons[ $icon_name ]['content'] ) ) { $content = file_get_contents( - $this->registered_icons[ $icon_name ]['filePath'] + $this->registered_icons[ $icon_name ]['file_path'] ); $content = $this->sanitize_icon_content( $content ); From d08cbfa12bfba43b9d16b8cd53c32d29744a1a18 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 11 Jun 2026 17:56:58 +0900 Subject: [PATCH 02/17] Icons: Add APIs for collection and icon registration. Add the public icon collection and icon registration APIs on top of the snake_case icon registry. Introduces WP_Icon_Collections_Registry, icons.php (wp_register_icon/wp_unregister_icon/wp_register_icon_collection and the default registration callbacks), the collection-scoped REST routes, and the related tests. Icon properties use the internal snake_case `file_path` key established in the icon registry; the Gutenberg manifest's upstream camelCase `filePath` key is read and mapped to `file_path` at the registration boundary. Co-Authored-By: Claude --- .../class-wp-icon-collections-registry.php | 221 ++++++++++++++++ src/wp-includes/class-wp-icons-registry.php | 179 +++++++------ src/wp-includes/default-filters.php | 4 + src/wp-includes/icons.php | 142 ++++++++++ .../class-wp-rest-icons-controller.php | 84 ++++-- src/wp-settings.php | 2 + .../tests/icons/wpIconCollectionsRegistry.php | 178 +++++++++++++ tests/phpunit/tests/icons/wpIconsRegistry.php | 243 ++++++++++++++++++ .../tests/icons/wpRestIconsController.php | 79 ++++++ 9 files changed, 1038 insertions(+), 94 deletions(-) create mode 100644 src/wp-includes/class-wp-icon-collections-registry.php create mode 100644 src/wp-includes/icons.php create mode 100644 tests/phpunit/tests/icons/wpIconCollectionsRegistry.php create mode 100644 tests/phpunit/tests/icons/wpIconsRegistry.php diff --git a/src/wp-includes/class-wp-icon-collections-registry.php b/src/wp-includes/class-wp-icon-collections-registry.php new file mode 100644 index 0000000000000..4129bb0264e90 --- /dev/null +++ b/src/wp-includes/class-wp-icon-collections-registry.php @@ -0,0 +1,221 @@ +is_registered( $collection_slug ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon collection is already registered.' ), + '7.1.0' + ); + return false; + } + + if ( ! is_array( $collection_properties ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon collection properties must be an array.' ), + '7.1.0' + ); + return false; + } + + $allowed_keys = array_fill_keys( array( 'label', 'description' ), 1 ); + foreach ( array_keys( $collection_properties ) as $key ) { + if ( ! array_key_exists( $key, $allowed_keys ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The name of a user-provided key. */ + __( 'Invalid icon collection property: "%s".' ), + $key + ), + '7.1.0' + ); + return false; + } + } + + if ( ! isset( $collection_properties['label'] ) || ! is_string( $collection_properties['label'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon collection label must be a string.' ), + '7.1.0' + ); + return false; + } + + $defaults = array( + 'description' => '', + ); + + $collection = array_merge( + $defaults, + $collection_properties, + array( 'slug' => $collection_slug ) + ); + + $this->registered_collections[ $collection_slug ] = $collection; + + return true; + } + + /** + * Unregisters an icon collection. + * + * Any icons registered under the given collection are also unregistered. + * + * @since 7.1.0 + * + * @param string $collection_slug Icon collection slug. + * @return bool True if the collection was unregistered successfully, false otherwise. + */ + public function unregister( $collection_slug ) { + if ( ! $this->is_registered( $collection_slug ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Icon collection slug. */ + __( 'Icon collection "%s" not found.' ), + $collection_slug + ), + '7.1.0' + ); + return false; + } + + $icons_registry = WP_Icons_Registry::get_instance(); + $prefix = $collection_slug . '/'; + foreach ( $icons_registry->get_registered_icons() as $icon ) { + if ( isset( $icon['collection'] ) && $icon['collection'] === $collection_slug ) { + $unqualified_name = substr( $icon['name'], strlen( $prefix ) ); + $icons_registry->unregister( $unqualified_name, $collection_slug ); + } + } + + unset( $this->registered_collections[ $collection_slug ] ); + + return true; + } + + /** + * Retrieves an array containing the properties of a registered icon collection. + * + * @since 7.1.0 + * + * @param string $collection_slug Icon collection slug. + * @return array|null Registered collection properties, or `null` if the collection is not registered. + */ + public function get_registered( $collection_slug ) { + if ( ! $this->is_registered( $collection_slug ) ) { + return null; + } + + return $this->registered_collections[ $collection_slug ]; + } + + /** + * Retrieves all registered icon collections. + * + * @since 7.1.0 + * + * @return array[] Array of arrays containing the registered icon collections properties. + */ + public function get_all_registered() { + return array_values( $this->registered_collections ); + } + + /** + * Checks if an icon collection is registered. + * + * @since 7.1.0 + * + * @param string|null $collection_slug Icon collection slug. + * @return bool True if the icon collection is registered, false otherwise. + */ + public function is_registered( $collection_slug ) { + return isset( $collection_slug, $this->registered_collections[ $collection_slug ] ); + } + + /** + * Utility method to retrieve the main instance of the class. + * + * The instance will be created if it does not exist yet. + * + * @since 7.1.0 + * + * @return WP_Icon_Collections_Registry The main instance. + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } +} diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php index a442a2f99af94..d223eda879b1c 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -16,15 +16,14 @@ class WP_Icons_Registry { /** * Registered icons array. * - * @since 7.0.0 * @var array[] */ protected $registered_icons = array(); + /** * Container for the main instance of the class. * - * @since 7.0.0 * @var WP_Icons_Registry|null */ protected static $instance = null; @@ -34,78 +33,32 @@ class WP_Icons_Registry { * * WP_Icons_Registry is a singleton class, so keep this protected. * - * For 7.0, the Icons Registry is closed for third-party icon registry, - * serving only a subset of core icons. - * - * These icons are defined in @wordpress/packages (Gutenberg repository) as - * SVG files and as entries in a single manifest file. On init, the - * registry is loaded with those icons listed in the manifest. - * - * @since 7.0.0 + * Icons are populated via `_wp_register_default_icons()` during the + * `init` action. Third-party icons can be registered via + * {@see wp_register_icon()} once their collection is registered. */ - protected function __construct() { - $icons_directory = __DIR__ . '/images/icon-library/'; - $manifest_path = __DIR__ . '/assets/icon-library-manifest.php'; - - if ( ! is_readable( $manifest_path ) ) { - wp_trigger_error( - __METHOD__, - __( 'Core icon collection manifest is missing or unreadable.' ) - ); - return; - } - - $collection = include $manifest_path; - - if ( empty( $collection ) ) { - wp_trigger_error( - __METHOD__, - __( 'Core icon collection manifest is empty or invalid.' ) - ); - return; - } - - foreach ( $collection as $icon_name => $icon_data ) { - if ( - empty( $icon_data['filePath'] ) - || ! is_string( $icon_data['filePath'] ) - ) { - _doing_it_wrong( - __METHOD__, - __( 'Core icon collection manifest must provide valid a "filePath" for each icon.' ), - '7.0.0' - ); - return; - } - - $this->register( - 'core/' . $icon_name, - array( - 'label' => $icon_data['label'], - 'file_path' => $icons_directory . $icon_data['filePath'], - ) - ); - } - } + protected function __construct() {} /** * Registers an icon. * * @since 7.0.0 + * @since 7.1.0 `collection` is required. The icon name no longer includes a namespace. * - * @param string $icon_name Icon name including namespace. + * @param string $icon_name Icon name (e.g. "arrow-left"). Must not contain a namespace prefix. * @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. + * @type string $label Required. A human-readable label for the icon. + * @type string $collection Required. The slug of a registered icon collection that this icon belongs to. + * @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. */ - protected function register( $icon_name, $icon_properties ) { + public function register( $icon_name, $icon_properties ) { if ( ! isset( $icon_name ) || ! is_string( $icon_name ) ) { _doing_it_wrong( __METHOD__, @@ -115,13 +68,31 @@ protected function register( $icon_name, $icon_properties ) { return false; } - $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path' ), 1 ); + if ( ! is_array( $icon_properties ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon properties must be an array.' ), + '7.1.0' + ); + return false; + } + + if ( ! preg_match( '/^[a-z][a-z0-9-]*$/', $icon_name ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.' ), + '7.1.0' + ); + return false; + } + + $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'collection' ), 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 + /* translators: %s: The name of a user-provided key. */ __( 'Invalid icon property: "%s".' ), $key ), @@ -131,6 +102,28 @@ protected function register( $icon_name, $icon_properties ) { } } + if ( ! isset( $icon_properties['collection'] ) || ! is_string( $icon_properties['collection'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon collection is required and must be a string.' ), + '7.1.0' + ); + return false; + } + + if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( $icon_properties['collection'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Icon collection slug. */ + __( 'Icon collection "%s" is not registered.' ), + $icon_properties['collection'] + ), + '7.1.0' + ); + return false; + } + if ( ! isset( $icon_properties['label'] ) || ! is_string( $icon_properties['label'] ) ) { _doing_it_wrong( __METHOD__, @@ -173,13 +166,53 @@ protected function register( $icon_name, $icon_properties ) { } } + $qualified_name = $icon_properties['collection'] . '/' . $icon_name; + + if ( $this->is_registered( $qualified_name ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon is already registered.' ), + '7.1.0' + ); + return false; + } + $icon = array_merge( $icon_properties, - array( 'name' => $icon_name ) + array( 'name' => $qualified_name ) ); - $this->registered_icons[ $icon_name ] = $icon; + $this->registered_icons[ $qualified_name ] = $icon; + + return true; + } + + /** + * Unregisters an icon. + * + * @since 7.1.0 + * + * @param string $icon_name Icon name (e.g. "arrow-left"). Must not contain a namespace prefix. + * @param string $collection Slug of the collection the icon belongs to. + * @return bool True if the icon was unregistered successfully, false otherwise. + */ + public function unregister( $icon_name, $collection ) { + $qualified_name = $collection . '/' . $icon_name; + if ( ! $this->is_registered( $qualified_name ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Icon name. */ + __( 'Icon "%s" is not registered.' ), + $qualified_name + ), + '7.1.0' + ); + return false; + } + + unset( $this->registered_icons[ $qualified_name ] ); return true; } @@ -189,8 +222,6 @@ protected function register( $icon_name, $icon_properties ) { * Logic borrowed from twentytwenty. * @see twentytwenty_get_theme_svg * - * @since 7.0.0 - * * @param string $icon_content The icon SVG content to sanitize. * @return string The sanitized icon SVG content. */ @@ -226,8 +257,6 @@ protected function sanitize_icon_content( $icon_content ) { /** * Retrieves the content of a registered icon. * - * @since 7.0.0 - * * @param string $icon_name Icon name including namespace. * @return string|null The content of the icon, if found. */ @@ -254,7 +283,6 @@ protected function get_content( $icon_name ) { /** * Retrieves an array containing the properties of a registered icon. * - * @since 7.0.0 * * @param string $icon_name Icon name including namespace. * @return array|null Registered icon properties or `null` if the icon is not registered. @@ -274,6 +302,7 @@ public function get_registered_icon( $icon_name ) { * Retrieves all registered icons. * * @since 7.0.0 + * @since 7.1.0 Search also matches icon labels. * * @param string $search Optional. Search term by which to filter the icons. * @return array[] Array of arrays containing the registered icon properties. @@ -282,8 +311,12 @@ public function get_registered_icons( $search = '' ) { $icons = array(); foreach ( $this->registered_icons as $icon ) { - if ( ! empty( $search ) && false === stripos( $icon['name'], $search ) ) { - continue; + if ( ! empty( $search ) ) { + $matches_name = false !== stripos( $icon['name'], $search ); + $matches_label = isset( $icon['label'] ) && false !== stripos( $icon['label'], $search ); + if ( ! $matches_name && ! $matches_label ) { + continue; + } } $icon['content'] = $icon['content'] ?? $this->get_content( $icon['name'] ); @@ -296,7 +329,6 @@ public function get_registered_icons( $search = '' ) { /** * Checks if an icon is registered. * - * @since 7.0.0 * * @param string $icon_name Icon name including namespace. * @return bool True if the icon is registered, false otherwise. @@ -310,7 +342,6 @@ public function is_registered( $icon_name ) { * * The instance will be created if it does not exist yet. * - * @since 7.0.0 * * @return WP_Icons_Registry The main instance. */ diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 5581828a10b61..749535ba8fa66 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -813,6 +813,10 @@ add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 ); add_action( 'init', '_wp_register_default_font_collections' ); +// Icons. +add_action( 'init', '_wp_register_default_icon_collections', 0 ); +add_action( 'init', '_wp_register_default_icons' ); + // Add ignoredHookedBlocks metadata attribute to the template and template part post types. add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' ); add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' ); diff --git a/src/wp-includes/icons.php b/src/wp-includes/icons.php new file mode 100644 index 0000000000000..9c7835b5cc084 --- /dev/null +++ b/src/wp-includes/icons.php @@ -0,0 +1,142 @@ +register( $slug, $args ); +} + +/** + * Unregisters an icon collection. + * + * @since 7.1.0 + * + * @param string $slug Icon collection slug. + * @return bool True if the icon collection was unregistered successfully, else false. + */ +function wp_unregister_icon_collection( $slug ) { + return WP_Icon_Collections_Registry::get_instance()->unregister( $slug ); +} + +/** + * Registers a new icon. + * + * @since 7.1.0 + * + * @param string $icon_name Icon name (e.g. "arrow-left"). + * @param string $collection Slug of a registered icon collection that this icon belongs to. + * @param array $args { + * 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 successfully, else false. + */ +function wp_register_icon( $icon_name, $collection, $args ) { + $args['collection'] = $collection; + return WP_Icons_Registry::get_instance()->register( $icon_name, $args ); +} + +/** + * Unregisters an icon. + * + * @since 7.1.0 + * + * @param string $icon_name Icon name (e.g. "arrow-left"). + * @param string $collection Slug of the collection the icon belongs to. + * @return bool True if the icon was unregistered successfully, else false. + */ +function wp_unregister_icon( $icon_name, $collection ) { + return WP_Icons_Registry::get_instance()->unregister( $icon_name, $collection ); +} + +/** + * Registers the default icon collections. + * + * @since 7.1.0 + * @access private + */ +function _wp_register_default_icon_collections() { + wp_register_icon_collection( + 'core', + array( + 'label' => __( 'WordPress' ), + 'description' => __( 'Default icon collection.' ), + ) + ); +} + +/** + * Registers the default core icons from the manifest. + * + * @since 7.1.0 + * @access private + */ +function _wp_register_default_icons() { + $icons_directory = ABSPATH . WPINC . '/images/icon-library/'; + $manifest_path = ABSPATH . WPINC . '/assets/icon-library-manifest.php'; + + if ( ! is_readable( $manifest_path ) ) { + wp_trigger_error( + __FUNCTION__, + __( 'Core icon collection manifest is missing or unreadable.' ) + ); + return; + } + + $collection = include $manifest_path; + + if ( empty( $collection ) ) { + wp_trigger_error( + __FUNCTION__, + __( 'Core icon collection manifest is empty or invalid.' ) + ); + return; + } + + foreach ( $collection as $icon_name => $icon_data ) { + if ( + empty( $icon_data['filePath'] ) + || ! is_string( $icon_data['filePath'] ) + ) { + _doing_it_wrong( + __FUNCTION__, + __( 'Core icon collection manifest must provide a valid "filePath" for each icon.' ), + '7.0.0' + ); + return; + } + + wp_register_icon( + $icon_name, + 'core', + array( + 'label' => $icon_data['label'], + 'file_path' => $icons_directory . $icon_data['filePath'], + ) + ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php index 91126b498d338..08f4a2be37100 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php @@ -1,4 +1,5 @@ namespace = 'wp/v2'; @@ -33,6 +30,7 @@ public function __construct() { * Registers the routes for the objects of the controller. * * @since 7.0.0 + * @since 7.1.0 Added the `/icons/` collection-scoped route. */ public function register_routes() { register_rest_route( @@ -49,6 +47,26 @@ public function register_routes() { ) ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[a-z][a-z-]*)', + array( + 'args' => array( + 'namespace' => array( + 'description' => __( 'Icon collection slug.' ), + 'type' => 'string', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)', @@ -75,8 +93,6 @@ public function register_routes() { /** * Checks whether a given request has permission to read icons. * - * @since 7.0.0 - * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ @@ -104,8 +120,6 @@ public function get_items_permissions_check( /** * Checks if a given request has access to read a specific icon. * - * @since 7.0.0 - * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. */ @@ -119,18 +133,37 @@ public function get_item_permissions_check( $request ) { } /** - * Retrieves all icons. + * Retrieves all icons, optionally scoped to a collection. * * @since 7.0.0 + * @since 7.1.0 Supports filtering by collection via the `namespace` URL segment. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { + $collection = $request->get_param( 'namespace' ); + + if ( null !== $collection && ! WP_Icon_Collections_Registry::get_instance()->is_registered( $collection ) ) { + return new WP_Error( + 'rest_icon_collection_not_found', + sprintf( + /* translators: %s: Icon collection slug. */ + __( 'Icon collection not found: "%s".' ), + $collection + ), + array( 'status' => 404 ) + ); + } + $response = array(); $search = $request->get_param( 'search' ); $icons = WP_Icons_Registry::get_instance()->get_registered_icons( $search ); + foreach ( $icons as $icon ) { + if ( null !== $collection && ( ! isset( $icon['collection'] ) || $icon['collection'] !== $collection ) ) { + continue; + } $prepared_icon = $this->prepare_item_for_response( $icon, $request ); $response[] = $this->prepare_response_for_collection( $prepared_icon ); } @@ -140,8 +173,6 @@ public function get_items( $request ) { /** * Retrieves a specific icon. * - * @since 7.0.0 - * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ @@ -158,8 +189,6 @@ public function get_item( $request ) { /** * Retrieves a specific icon from the registry. * - * @since 7.0.0 - * * @param string $name Icon name. * @return array|WP_Error Icon data on success, or WP_Error object on failure. */ @@ -186,6 +215,7 @@ public function get_icon( $name ) { * Prepare a raw icon before it gets output in a REST API response. * * @since 7.0.0 + * @since 7.1.0 Added the `collection` field. * * @param array $item Raw icon as registered, before any changes. * @param WP_REST_Request $request Request object. @@ -194,9 +224,10 @@ public function get_icon( $name ) { public function prepare_item_for_response( $item, $request ) { $fields = $this->get_fields_for_response( $request ); $keys = array( - 'name' => 'name', - 'label' => 'label', - 'content' => 'content', + 'name' => 'name', + 'label' => 'label', + 'content' => 'content', + 'collection' => 'collection', ); $data = array(); foreach ( $keys as $item_key => $rest_key ) { @@ -215,6 +246,7 @@ public function prepare_item_for_response( $item, $request ) { * Retrieves the icon schema, conforming to JSON Schema. * * @since 7.0.0 + * @since 7.1.0 Added the `collection` property. * * @return array Item schema data. */ @@ -228,24 +260,30 @@ public function get_item_schema() { 'title' => 'icon', 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'description' => __( 'The icon name.' ), 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit', 'embed' ), ), - 'label' => array( + 'label' => array( 'description' => __( 'The icon label.' ), 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit', 'embed' ), ), - 'content' => array( + 'content' => array( 'description' => __( 'The icon content (SVG markup).' ), 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit', 'embed' ), ), + 'collection' => array( + 'description' => __( 'The slug of the collection this icon belongs to.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), ), ); @@ -258,12 +296,18 @@ public function get_item_schema() { * Retrieves the query params for the icons collection. * * @since 7.0.0 + * @since 7.1.0 Added the `namespace` parameter. * * @return array Collection parameters. */ public function get_collection_params() { $query_params = parent::get_collection_params(); $query_params['context']['default'] = 'view'; + $query_params['namespace'] = array( + 'description' => __( 'Limit results to icons belonging to the given collection slug.' ), + 'type' => 'string', + 'pattern' => '^[a-z][a-z-]*$', + ); return $query_params; } } diff --git a/src/wp-settings.php b/src/wp-settings.php index ef5c7784ee561..1fe638380e122 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -297,7 +297,9 @@ require ABSPATH . WPINC . '/ai-client.php'; require ABSPATH . WPINC . '/class-wp-connector-registry.php'; require ABSPATH . WPINC . '/connectors.php'; +require ABSPATH . WPINC . '/class-wp-icon-collections-registry.php'; require ABSPATH . WPINC . '/class-wp-icons-registry.php'; +require ABSPATH . WPINC . '/icons.php'; require ABSPATH . WPINC . '/widgets.php'; require ABSPATH . WPINC . '/class-wp-widget.php'; require ABSPATH . WPINC . '/class-wp-widget-factory.php'; diff --git a/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php new file mode 100644 index 0000000000000..4f1698e589062 --- /dev/null +++ b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php @@ -0,0 +1,178 @@ +collections = WP_Icon_Collections_Registry::get_instance(); + } + + public function tear_down() { + foreach ( array( 'plugin-a', 'plugin-b', 'my-collection' ) as $slug ) { + if ( $this->collections->is_registered( $slug ) ) { + $this->collections->unregister( $slug ); + } + } + parent::tear_down(); + } + + /** + * @ticket 64847 + * + * @covers ::register + */ + public function test_register_collection() { + $result = $this->collections->register( + 'my-collection', + array( + 'label' => 'My Collection', + 'description' => 'A collection.', + ) + ); + + $this->assertTrue( $result ); + $this->assertTrue( $this->collections->is_registered( 'my-collection' ) ); + + $registered = $this->collections->get_registered( 'my-collection' ); + $this->assertSame( 'my-collection', $registered['slug'] ); + $this->assertSame( 'My Collection', $registered['label'] ); + $this->assertSame( 'A collection.', $registered['description'] ); + } + + /** + * @ticket 64847 + * + * @dataProvider data_invalid_collection_slugs + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icon_Collections_Registry::register + * + * @param mixed $slug Invalid slug candidate. + */ + public function test_register_rejects_invalid_slug( $slug ) { + $result = $this->collections->register( $slug, array( 'label' => 'X' ) ); + $this->assertFalse( $result ); + } + + /** + * Data provider for invalid collection slug candidates. + * + * Collection slugs must be strings that start with a lowercase letter + * and contain only lowercase letters and hyphens (no digits, no slashes, + * no uppercase characters). + * + * @return array[] + */ + public function data_invalid_collection_slugs() { + return array( + 'non-string slug' => array( 1 ), + 'contains slash' => array( 'plugin/icons' ), + 'contains digits' => array( 'plugin1' ), + 'uppercase characters' => array( 'Plugin' ), + 'leading hyphen' => array( '-plugin' ), + ); + } + + /** + * @ticket 64847 + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icon_Collections_Registry::register + */ + public function test_register_twice_fails() { + $this->assertTrue( $this->collections->register( 'my-collection', array( 'label' => 'A' ) ) ); + $this->assertFalse( $this->collections->register( 'my-collection', array( 'label' => 'A' ) ) ); + } + + /** + * @ticket 64847 + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icon_Collections_Registry::register + */ + public function test_register_rejects_unknown_property() { + $result = $this->collections->register( + 'my-collection', + array( + 'label' => 'A', + 'bogus' => 'nope', + ) + ); + $this->assertFalse( $result ); + } + + /** + * @ticket 64847 + * + * @covers ::unregister + */ + public function test_unregister_collection_cascades_to_icons() { + $this->collections->register( 'plugin-a', array( 'label' => 'A' ) ); + $this->collections->register( 'plugin-b', array( 'label' => 'B' ) ); + + $icons = WP_Icons_Registry::get_instance(); + $icons->register( + 'alpha', + array( + 'label' => 'Alpha', + 'content' => '', + 'collection' => 'plugin-a', + ) + ); + $icons->register( + 'beta', + array( + 'label' => 'Beta', + 'content' => '', + 'collection' => 'plugin-a', + ) + ); + $icons->register( + 'gamma', + array( + 'label' => 'Gamma', + 'content' => '', + 'collection' => 'plugin-b', + ) + ); + + $this->assertTrue( $icons->is_registered( 'plugin-a/alpha' ) ); + $this->assertTrue( $icons->is_registered( 'plugin-a/beta' ) ); + + $this->assertTrue( $this->collections->unregister( 'plugin-a' ) ); + + $this->assertFalse( $icons->is_registered( 'plugin-a/alpha' ) ); + $this->assertFalse( $icons->is_registered( 'plugin-a/beta' ) ); + $this->assertTrue( $icons->is_registered( 'plugin-b/gamma' ) ); + + $icons->unregister( 'gamma', 'plugin-b' ); + } + + /** + * @ticket 64847 + * + * @covers ::unregister + * + * @expectedIncorrectUsage WP_Icon_Collections_Registry::unregister + */ + public function test_unregister_unknown_collection() { + $this->assertFalse( $this->collections->unregister( 'ghost' ) ); + } +} diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php new file mode 100644 index 0000000000000..d8affd26690b0 --- /dev/null +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -0,0 +1,243 @@ +registry = WP_Icons_Registry::get_instance(); + + $collections = WP_Icon_Collections_Registry::get_instance(); + if ( ! $collections->is_registered( 'test-collection' ) ) { + $collections->register( 'test-collection', array( 'label' => 'Test Plugin' ) ); + } + } + + public function tear_down() { + $reflection = new ReflectionClass( WP_Icons_Registry::class ); + $instance_property = $reflection->getProperty( 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_property->setAccessible( true ); + } + $instance_property->setValue( null, null ); + + $collections = WP_Icon_Collections_Registry::get_instance(); + if ( $collections->is_registered( 'test-collection' ) ) { + $collections->unregister( 'test-collection' ); + } + if ( $collections->is_registered( 'other-collection' ) ) { + $collections->unregister( 'other-collection' ); + } + + $this->registry = null; + parent::tear_down(); + } + + /** + * @ticket 64651 + * + * @covers ::register + */ + public function test_register_icon() { + $result = $this->registry->register( + 'my-icon', + array( + 'label' => 'My Icon', + 'content' => '', + 'collection' => 'test-collection', + ) + ); + + $this->assertTrue( $result ); + $this->assertTrue( $this->registry->is_registered( 'test-collection/my-icon' ) ); + } + + public function data_invalid_icon_names() { + return array( + 'non-string name' => array( 1 ), + 'contains slash' => array( 'test-collection/plus' ), + 'uppercase characters' => array( 'Plus' ), + 'invalid characters' => array( '_doing_it_wrong' ), + ); + } + + /** + * @ticket 64651 + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icons_Registry::register + */ + public function test_register_icon_twice() { + $settings = array( + 'label' => 'Icon', + 'content' => '', + 'collection' => 'test-collection', + ); + + $this->assertTrue( $this->registry->register( 'duplicate', $settings ) ); + $this->assertFalse( $this->registry->register( 'duplicate', $settings ) ); + } + + /** + * @ticket 64651 + * + * @dataProvider data_invalid_icon_names + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icons_Registry::register + * + * @param mixed $name Invalid icon name candidate. + */ + public function test_register_invalid_name( $name ) { + $result = $this->registry->register( + $name, + array( + 'label' => 'Icon', + 'content' => '', + 'collection' => 'test-collection', + ) + ); + $this->assertFalse( $result ); + } + + /** + * @ticket 64651 + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icons_Registry::register + */ + public function test_register_requires_collection() { + $result = $this->registry->register( + 'my-icon', + array( + 'label' => 'Icon', + 'content' => '', + ) + ); + $this->assertFalse( $result ); + } + + /** + * @ticket 64651 + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icons_Registry::register + */ + public function test_register_rejects_non_string_collection() { + $result = $this->registry->register( + 'my-icon', + array( + 'label' => 'Icon', + 'content' => '', + 'collection' => 123, + ) + ); + $this->assertFalse( $result ); + } + + /** + * @ticket 64651 + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icons_Registry::register + */ + public function test_register_rejects_unregistered_collection() { + $result = $this->registry->register( + 'my-icon', + array( + 'label' => 'Icon', + 'content' => '', + 'collection' => 'unregistered-collection', + ) + ); + $this->assertFalse( $result ); + } + + /** + * @ticket 64651 + * + * @covers ::register + */ + public function test_same_name_across_collections_does_not_collide() { + $collections = WP_Icon_Collections_Registry::get_instance(); + $collections->register( 'other-collection', array( 'label' => 'Other' ) ); + + $this->assertTrue( + $this->registry->register( + 'shared', + array( + 'label' => 'Shared A', + 'content' => '', + 'collection' => 'test-collection', + ) + ) + ); + $this->assertTrue( + $this->registry->register( + 'shared', + array( + 'label' => 'Shared B', + 'content' => '', + 'collection' => 'other-collection', + ) + ) + ); + + $this->assertTrue( $this->registry->is_registered( 'test-collection/shared' ) ); + $this->assertTrue( $this->registry->is_registered( 'other-collection/shared' ) ); + + $icon_a = $this->registry->get_registered_icon( 'test-collection/shared' ); + $icon_b = $this->registry->get_registered_icon( 'other-collection/shared' ); + $this->assertSame( 'Shared A', $icon_a['label'] ); + $this->assertSame( 'Shared B', $icon_b['label'] ); + } + + /** + * @ticket 64651 + * + * @covers ::unregister + */ + public function test_unregister_icon() { + $this->registry->register( + 'my-icon', + array( + 'label' => 'Icon', + 'content' => '', + 'collection' => 'test-collection', + ) + ); + + $this->assertTrue( $this->registry->is_registered( 'test-collection/my-icon' ) ); + $this->assertTrue( $this->registry->unregister( 'my-icon', 'test-collection' ) ); + $this->assertFalse( $this->registry->is_registered( 'test-collection/my-icon' ) ); + } + + /** + * @ticket 64651 + * + * @covers ::unregister + * + * @expectedIncorrectUsage WP_Icons_Registry::unregister + */ + public function test_unregister_unknown_icon() { + $this->assertFalse( $this->registry->unregister( 'ghost', 'test-collection' ) ); + } +} diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index f6fd935061f0e..10d45ceb21cd0 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -39,9 +39,88 @@ public static function wpTearDownAfterClass() { public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/icons', $routes ); + $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z][a-z-]*)', $routes ); $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)', $routes ); } + /** + * @ticket 64651 + * + * @covers WP_REST_Icons_Controller::get_items + */ + public function test_get_items_collection_scope() { + wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) ); + wp_register_icon( + 'bell', + 'rest-test-collection', + array( + 'label' => 'Bell', + 'content' => '', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons/rest-test-collection' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertIsArray( $data ); + + $names = array_column( $data, 'name' ); + $this->assertContains( 'rest-test-collection/bell', $names ); + foreach ( $data as $icon ) { + $this->assertSame( 'rest-test-collection', $icon['collection'] ); + } + + wp_unregister_icon_collection( 'rest-test-collection' ); + } + + /** + * @ticket 64651 + * + * @covers WP_REST_Icons_Controller::get_items + */ + public function test_get_items_unknown_collection_returns_404() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons/unknown-collection' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_icon_collection_not_found', $response, 404 ); + } + + /** + * @ticket 64651 + * + * @covers WP_REST_Icons_Controller::prepare_item_for_response + */ + public function test_response_includes_collection_field() { + wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) ); + wp_register_icon( + 'bell', + 'rest-test-collection', + array( + 'label' => 'Bell', + 'content' => '', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons/rest-test-collection/bell' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'collection', $data ); + $this->assertSame( 'rest-test-collection', $data['collection'] ); + $this->assertSame( 'rest-test-collection/bell', $data['name'] ); + + wp_unregister_icon_collection( 'rest-test-collection' ); + } + /** * @doesNotPerformAssertions */ From 4391d929748a64fe1c5a111d9ac00ac6031600a7 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 11 Jun 2026 18:35:21 +0900 Subject: [PATCH 03/17] Icons: Unhook default icon registration after init in tests. The icon registries warn via _doing_it_wrong() when a collection or icon is already registered. Because tests such as the Customize widgets suite fire `do_action( 'init' )` again after bootstrap, the default icon registration runs twice and triggers those notices, failing unrelated tests. Match the existing pattern used for font and connector registration by unhooking `_wp_register_default_icon_collections` and `_wp_register_default_icons` from `init` after the first run. Co-Authored-By: Claude --- tests/phpunit/includes/functions.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/phpunit/includes/functions.php b/tests/phpunit/includes/functions.php index d6b6218278ae3..d27af5c172a7a 100644 --- a/tests/phpunit/includes/functions.php +++ b/tests/phpunit/includes/functions.php @@ -375,6 +375,18 @@ function _unhook_font_registration() { } tests_add_filter( 'init', '_unhook_font_registration', 1000 ); +/** + * After the init action has been run once, trying to re-register icon collections and icons + * can cause errors. To avoid this, unhook the icon registration functions. + * + * @since 7.1.0 + */ +function _unhook_icon_registration() { + remove_action( 'init', '_wp_register_default_icon_collections', 0 ); + remove_action( 'init', '_wp_register_default_icons' ); +} +tests_add_filter( 'init', '_unhook_icon_registration', 1000 ); + /** * After the init action has been run once, trying to re-register connector settings can cause * duplicate registrations. To avoid this, unhook the connector registration functions. From 49c55c716b56f351407bfcbf9e1ca98d51541cb3 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 11 Jun 2026 18:52:51 +0900 Subject: [PATCH 04/17] Icons: Add new icons collection route to schema test. The REST icons controller now registers a per-collection route, `/wp/v2/icons/(?P[a-z][a-z-]*)`. Add it to the expected routes list so test_expected_routes_in_schema reflects the registered routes. Co-Authored-By: Claude --- tests/phpunit/tests/rest-api/rest-schema-setup.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 89bf2c481c567..f7483a6e61361 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -201,6 +201,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', '/wp/v2/font-families/(?P[\d]+)', '/wp/v2/icons', + '/wp/v2/icons/(?P[a-z][a-z-]*)', '/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)', '/wp-abilities/v1', '/wp-abilities/v1/categories', From 095efe2e859f08986b760ccc3053a6020bd3e9a5 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 11 Jun 2026 19:09:16 +0900 Subject: [PATCH 05/17] Icons: Regenerate REST API client fixtures. The icons controller now exposes a `namespace` argument on the collection endpoint and a new per-collection route. Regenerate the QUnit fixtures so `git diff --exit-code` passes in CI. Co-Authored-By: Claude --- tests/qunit/fixtures/wp-api-generated.js | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..561c2ecb414f4 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12729,6 +12729,12 @@ mockedApiResponse.Schema = { "description": "Limit results to those matching a string.", "type": "string", "required": false + }, + "namespace": { + "description": "Limit results to icons belonging to the given collection slug.", + "type": "string", + "pattern": "^[a-z][a-z-]*$", + "required": false } } } @@ -12741,6 +12747,58 @@ mockedApiResponse.Schema = { ] } }, + "/wp/v2/icons/(?P[a-z][a-z-]*)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "description": "Limit results to icons belonging to the given collection slug.", + "type": "string", + "pattern": "^[a-z][a-z-]*$", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + } + } + } + ] + }, "/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)": { "namespace": "wp/v2", "methods": [ From e1e9b3baf4d6440c2cfc1c40ac027a38fde8d75d Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 13 Jun 2026 23:15:02 +0900 Subject: [PATCH 06/17] Icons: Validate icon file path before reading content. Custom icons can be registered with a `file_path`, but the path was never checked before `file_get_contents()`, so a missing, unreadable, non-SVG, or non-string path emitted a raw PHP warning and a misleading "invalid SVG markup" error. Guard `get_content()` by resolving the path via `realpath()`, then requiring an `.svg` extension, a regular file, and readability, returning null with a clear message otherwise. Add tests covering valid and invalid file paths. Co-Authored-By: Claude --- src/wp-includes/class-wp-icons-registry.php | 22 ++++- tests/phpunit/tests/icons/wpIconsRegistry.php | 91 +++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php index d223eda879b1c..eb6db5d4c5e32 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -262,10 +262,24 @@ protected function sanitize_icon_content( $icon_content ) { */ protected function get_content( $icon_name ) { if ( ! isset( $this->registered_icons[ $icon_name ]['content'] ) ) { - $content = file_get_contents( - $this->registered_icons[ $icon_name ]['file_path'] - ); - $content = $this->sanitize_icon_content( $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.' ) + ); + return null; + } + + $content = $this->sanitize_icon_content( file_get_contents( $icon_path ) ); if ( empty( $content ) ) { wp_trigger_error( diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php index d8affd26690b0..914144b5d2f4e 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -16,6 +16,13 @@ class Tests_Icons_WpIconsRegistry extends WP_UnitTestCase { */ protected $registry; + /** + * Path to a temporary icon file created during a test, removed in tear_down. + * + * @var string|null + */ + private $temp_file = null; + public function set_up() { parent::set_up(); $this->registry = WP_Icons_Registry::get_instance(); @@ -42,10 +49,31 @@ public function tear_down() { $collections->unregister( 'other-collection' ); } + if ( $this->temp_file && file_exists( $this->temp_file ) ) { + unlink( $this->temp_file ); + } + $this->temp_file = null; + $this->registry = null; parent::tear_down(); } + /** + * Builds a unique temporary icon file path with the given extension. + * + * @param string|null $contents File contents, or null to leave the file uncreated. + * @param string $extension File extension, without the leading dot. + * @return string Absolute path to the temporary file. + */ + private function create_temp_icon_file( $contents, $extension = 'svg' ) { + $dir = get_temp_dir(); + $this->temp_file = trailingslashit( $dir ) . wp_unique_filename( $dir, uniqid() . '.' . $extension ); + if ( null !== $contents ) { + file_put_contents( $this->temp_file, $contents ); + } + return $this->temp_file; + } + /** * @ticket 64651 * @@ -240,4 +268,67 @@ public function test_unregister_icon() { public function test_unregister_unknown_icon() { $this->assertFalse( $this->registry->unregister( 'ghost', 'test-collection' ) ); } + + /** + * @ticket 64651 + * + * @covers ::get_content + */ + public function test_get_content_reads_from_valid_file_path() { + $path = $this->create_temp_icon_file( '' ); + + $this->registry->register( + 'from-file', + array( + 'label' => 'From File', + 'file_path' => $path, + 'collection' => 'test-collection', + ) + ); + + $icon = $this->registry->get_registered_icon( 'test-collection/from-file' ); + $this->assertStringContainsString( ' Data sets of [ $contents, $extension ]. + */ + public function data_invalid_icon_files() { + return array( + 'missing file' => array( null, 'svg' ), + 'non-svg extension' => array( '', 'txt' ), + 'invalid svg content' => array( '', 'svg' ), + ); + } + + /** + * @ticket 64651 + * + * @dataProvider data_invalid_icon_files + * + * @covers ::get_content + * + * @param string|null $contents File contents, or null to leave the file uncreated. + * @param string $extension File extension, without the leading dot. + */ + public function test_get_content_returns_null_for_invalid_file( $contents, $extension ) { + $path = $this->create_temp_icon_file( $contents, $extension ); + + $this->registry->register( + 'invalid-file', + array( + 'label' => 'Invalid File', + 'file_path' => $path, + 'collection' => 'test-collection', + ) + ); + + add_filter( 'wp_trigger_error_trigger_error', '__return_false' ); + $icon = $this->registry->get_registered_icon( 'test-collection/invalid-file' ); + remove_filter( 'wp_trigger_error_trigger_error', '__return_false' ); + + $this->assertNull( $icon['content'] ); + } } From 0f18d8ffd9ef1968b67277b6712ac47d120ee990 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 13 Jun 2026 23:43:16 +0900 Subject: [PATCH 07/17] Icons: Allow digits and leading hyphens in collection slugs. The collection slug pattern required a leading lowercase letter and rejected digits, which excluded legitimate plugin slugs such as `a8c` or vendor names containing numbers. Relax the validation to accept any combination of lowercase alphanumeric characters and hyphens, and update the error message and tests to match. Co-Authored-By: Claude --- src/wp-includes/class-wp-icon-collections-registry.php | 4 ++-- tests/phpunit/tests/icons/wpIconCollectionsRegistry.php | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-icon-collections-registry.php b/src/wp-includes/class-wp-icon-collections-registry.php index 4129bb0264e90..138ce1d66f431 100644 --- a/src/wp-includes/class-wp-icon-collections-registry.php +++ b/src/wp-includes/class-wp-icon-collections-registry.php @@ -58,10 +58,10 @@ public function register( $collection_slug, $collection_properties ) { return false; } - if ( ! preg_match( '/^[a-z][a-z-]*$/', $collection_slug ) ) { + if ( ! preg_match( '/^[a-z0-9-]+$/', $collection_slug ) ) { _doing_it_wrong( __METHOD__, - __( 'Icon collection slug must start with a lowercase letter and may only contain lowercase letters and hyphens.' ), + __( 'Icon collection slug must only contain lowercase alphanumeric characters and hyphens.' ), '7.1.0' ); return false; diff --git a/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php index 4f1698e589062..be6815f4d10d1 100644 --- a/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php @@ -82,9 +82,8 @@ public function data_invalid_collection_slugs() { return array( 'non-string slug' => array( 1 ), 'contains slash' => array( 'plugin/icons' ), - 'contains digits' => array( 'plugin1' ), 'uppercase characters' => array( 'Plugin' ), - 'leading hyphen' => array( '-plugin' ), + 'underscore' => array( 'my_plugin' ), ); } From 2c5cda5633a51d0c21f9c79910a594622654d268 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 13 Jun 2026 23:52:33 +0900 Subject: [PATCH 08/17] Icons: Restore @since tags and drop blank line after the opening PHP tag. Several @since 7.0.0 tags were accidentally dropped from properties and internal methods while reworking the Icons API, leaving docblocks without a version per the inline documentation standards. Restore them. Also remove the stray blank line after the opening PHP tag to match the rest of the core class files. Co-Authored-By: Claude --- .../class-wp-icon-collections-registry.php | 1 - src/wp-includes/class-wp-icons-registry.php | 13 +++++++++++-- .../endpoints/class-wp-rest-icons-controller.php | 10 ++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-icon-collections-registry.php b/src/wp-includes/class-wp-icon-collections-registry.php index 138ce1d66f431..5209da8835f27 100644 --- a/src/wp-includes/class-wp-icon-collections-registry.php +++ b/src/wp-includes/class-wp-icon-collections-registry.php @@ -1,5 +1,4 @@ namespace = 'wp/v2'; @@ -93,6 +95,8 @@ public function register_routes() { /** * Checks whether a given request has permission to read icons. * + * @since 7.0.0 + * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ @@ -120,6 +124,8 @@ public function get_items_permissions_check( /** * Checks if a given request has access to read a specific icon. * + * @since 7.0.0 + * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. */ @@ -173,6 +179,8 @@ public function get_items( $request ) { /** * Retrieves a specific icon. * + * @since 7.0.0 + * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ @@ -189,6 +197,8 @@ public function get_item( $request ) { /** * Retrieves a specific icon from the registry. * + * @since 7.0.0 + * * @param string $name Icon name. * @return array|WP_Error Icon data on success, or WP_Error object on failure. */ From 5eb200a67867c12e38294b7d231c508d378f3128 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Mon, 15 Jun 2026 19:05:28 +0900 Subject: [PATCH 09/17] Icons: Require namespaced icon names in the form "collection/icon-name". Derive the collection from the icon name instead of passing it separately, so an icon is always identified by a single namespaced name. The collection prefix is now required; a non-namespaced name is rejected via _doing_it_wrong(). This drops the $collection parameter from wp_register_icon()/wp_unregister_icon() and the "collection" icon property, and simplifies the cascade unregister when a collection is removed. The "core" collection is reserved for WordPress core icons. Co-Authored-By: Claude --- .../class-wp-icon-collections-registry.php | 4 +- src/wp-includes/class-wp-icons-registry.php | 67 +++++++------- src/wp-includes/icons.php | 21 +++-- .../tests/icons/wpIconCollectionsRegistry.php | 23 ++--- tests/phpunit/tests/icons/wpIconsRegistry.php | 92 +++++++++---------- .../tests/icons/wpRestIconsController.php | 6 +- 6 files changed, 106 insertions(+), 107 deletions(-) diff --git a/src/wp-includes/class-wp-icon-collections-registry.php b/src/wp-includes/class-wp-icon-collections-registry.php index 5209da8835f27..b396b50856bd4 100644 --- a/src/wp-includes/class-wp-icon-collections-registry.php +++ b/src/wp-includes/class-wp-icon-collections-registry.php @@ -149,11 +149,9 @@ public function unregister( $collection_slug ) { } $icons_registry = WP_Icons_Registry::get_instance(); - $prefix = $collection_slug . '/'; foreach ( $icons_registry->get_registered_icons() as $icon ) { if ( isset( $icon['collection'] ) && $icon['collection'] === $collection_slug ) { - $unqualified_name = substr( $icon['name'], strlen( $prefix ) ); - $icons_registry->unregister( $unqualified_name, $collection_slug ); + $icons_registry->unregister( $icon['name'] ); } } diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php index e801e4a6a73a9..a4ea13da37496 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -45,18 +45,18 @@ protected function __construct() {} * Registers an icon. * * @since 7.0.0 - * @since 7.1.0 `collection` is required. The icon name no longer includes a namespace. + * @since 7.1.0 The icon name must be namespaced in the form "collection/icon-name". * - * @param string $icon_name Icon name (e.g. "arrow-left"). Must not contain a namespace prefix. + * @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 $collection Required. The slug of a registered icon collection that this icon belongs to. - * @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. + * @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. */ @@ -79,7 +79,20 @@ public function register( $icon_name, $icon_properties ) { return false; } - if ( ! preg_match( '/^[a-z][a-z0-9-]*$/', $icon_name ) ) { + // Require a namespaced name in the form "collection/icon-name". + if ( false === strpos( $icon_name, '/' ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon name must be namespaced in the form "collection/icon-name".' ), + '7.1.0' + ); + return false; + } + + // Split the namespaced name into a collection slug and an unqualified icon name. + list( $collection, $unqualified_name ) = explode( '/', $icon_name, 2 ); + + if ( ! preg_match( '/^[a-z][a-z0-9-]*$/', $unqualified_name ) ) { _doing_it_wrong( __METHOD__, __( 'Icon names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.' ), @@ -88,7 +101,7 @@ public function register( $icon_name, $icon_properties ) { return false; } - $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'collection' ), 1 ); + $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( @@ -104,22 +117,13 @@ public function register( $icon_name, $icon_properties ) { } } - if ( ! isset( $icon_properties['collection'] ) || ! is_string( $icon_properties['collection'] ) ) { - _doing_it_wrong( - __METHOD__, - __( 'Icon collection is required and must be a string.' ), - '7.1.0' - ); - return false; - } - - if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( $icon_properties['collection'] ) ) { + 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.' ), - $icon_properties['collection'] + $collection ), '7.1.0' ); @@ -168,7 +172,7 @@ public function register( $icon_name, $icon_properties ) { } } - $qualified_name = $icon_properties['collection'] . '/' . $icon_name; + $qualified_name = $collection . '/' . $unqualified_name; if ( $this->is_registered( $qualified_name ) ) { _doing_it_wrong( @@ -181,7 +185,10 @@ public function register( $icon_name, $icon_properties ) { $icon = array_merge( $icon_properties, - array( 'name' => $qualified_name ) + array( + 'name' => $qualified_name, + 'collection' => $collection, + ) ); $this->registered_icons[ $qualified_name ] = $icon; @@ -194,27 +201,25 @@ public function register( $icon_name, $icon_properties ) { * * @since 7.1.0 * - * @param string $icon_name Icon name (e.g. "arrow-left"). Must not contain a namespace prefix. - * @param string $collection Slug of the collection the icon belongs to. + * @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, false otherwise. */ - public function unregister( $icon_name, $collection ) { - $qualified_name = $collection . '/' . $icon_name; - - if ( ! $this->is_registered( $qualified_name ) ) { + 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.' ), - $qualified_name + $icon_name ), '7.1.0' ); return false; } - unset( $this->registered_icons[ $qualified_name ] ); + unset( $this->registered_icons[ $icon_name ] ); return true; } diff --git a/src/wp-includes/icons.php b/src/wp-includes/icons.php index 9c7835b5cc084..bb8e5da3ef08e 100644 --- a/src/wp-includes/icons.php +++ b/src/wp-includes/icons.php @@ -42,8 +42,11 @@ function wp_unregister_icon_collection( $slug ) { * * @since 7.1.0 * - * @param string $icon_name Icon name (e.g. "arrow-left"). - * @param string $collection Slug of a registered icon collection that this icon belongs to. + * @param string $icon_name Namespaced icon name in the form "collection/icon-name" + * (e.g. "my-plugin/arrow-left"). The "core" collection is + * reserved for WordPress core icons; third-party code should + * register icons under its own collection rather than the + * "core" collection. * @param array $args { * List of properties for the icon. * @@ -55,8 +58,7 @@ function wp_unregister_icon_collection( $slug ) { * } * @return bool True if the icon was registered successfully, else false. */ -function wp_register_icon( $icon_name, $collection, $args ) { - $args['collection'] = $collection; +function wp_register_icon( $icon_name, $args ) { return WP_Icons_Registry::get_instance()->register( $icon_name, $args ); } @@ -65,12 +67,12 @@ function wp_register_icon( $icon_name, $collection, $args ) { * * @since 7.1.0 * - * @param string $icon_name Icon name (e.g. "arrow-left"). - * @param string $collection Slug of the collection the icon belongs to. + * @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. */ -function wp_unregister_icon( $icon_name, $collection ) { - return WP_Icons_Registry::get_instance()->unregister( $icon_name, $collection ); +function wp_unregister_icon( $icon_name ) { + return WP_Icons_Registry::get_instance()->unregister( $icon_name ); } /** @@ -131,8 +133,7 @@ function _wp_register_default_icons() { } wp_register_icon( - $icon_name, - 'core', + 'core/' . $icon_name, array( 'label' => $icon_data['label'], 'file_path' => $icons_directory . $icon_data['filePath'], diff --git a/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php index be6815f4d10d1..a2b5a43dd489d 100644 --- a/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php @@ -128,27 +128,24 @@ public function test_unregister_collection_cascades_to_icons() { $icons = WP_Icons_Registry::get_instance(); $icons->register( - 'alpha', + 'plugin-a/alpha', array( - 'label' => 'Alpha', - 'content' => '', - 'collection' => 'plugin-a', + 'label' => 'Alpha', + 'content' => '', ) ); $icons->register( - 'beta', + 'plugin-a/beta', array( - 'label' => 'Beta', - 'content' => '', - 'collection' => 'plugin-a', + 'label' => 'Beta', + 'content' => '', ) ); $icons->register( - 'gamma', + 'plugin-b/gamma', array( - 'label' => 'Gamma', - 'content' => '', - 'collection' => 'plugin-b', + 'label' => 'Gamma', + 'content' => '', ) ); @@ -161,7 +158,7 @@ public function test_unregister_collection_cascades_to_icons() { $this->assertFalse( $icons->is_registered( 'plugin-a/beta' ) ); $this->assertTrue( $icons->is_registered( 'plugin-b/gamma' ) ); - $icons->unregister( 'gamma', 'plugin-b' ); + $icons->unregister( 'plugin-b/gamma' ); } /** diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php index 914144b5d2f4e..e9fef3978b1c8 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -81,11 +81,10 @@ private function create_temp_icon_file( $contents, $extension = 'svg' ) { */ public function test_register_icon() { $result = $this->registry->register( - 'my-icon', + 'test-collection/my-icon', array( - 'label' => 'My Icon', - 'content' => '', - 'collection' => 'test-collection', + 'label' => 'My Icon', + 'content' => '', ) ); @@ -95,10 +94,11 @@ public function test_register_icon() { public function data_invalid_icon_names() { return array( - 'non-string name' => array( 1 ), - 'contains slash' => array( 'test-collection/plus' ), - 'uppercase characters' => array( 'Plus' ), - 'invalid characters' => array( '_doing_it_wrong' ), + 'non-string name' => array( 1 ), + 'non-namespaced name' => array( 'plus' ), + 'empty unqualified name' => array( 'test-collection/' ), + 'uppercase characters' => array( 'test-collection/Plus' ), + 'invalid characters' => array( 'test-collection/_doing_it_wrong' ), ); } @@ -111,13 +111,12 @@ public function data_invalid_icon_names() { */ public function test_register_icon_twice() { $settings = array( - 'label' => 'Icon', - 'content' => '', - 'collection' => 'test-collection', + 'label' => 'Icon', + 'content' => '', ); - $this->assertTrue( $this->registry->register( 'duplicate', $settings ) ); - $this->assertFalse( $this->registry->register( 'duplicate', $settings ) ); + $this->assertTrue( $this->registry->register( 'test-collection/duplicate', $settings ) ); + $this->assertFalse( $this->registry->register( 'test-collection/duplicate', $settings ) ); } /** @@ -135,22 +134,24 @@ public function test_register_invalid_name( $name ) { $result = $this->registry->register( $name, array( - 'label' => 'Icon', - 'content' => '', - 'collection' => 'test-collection', + 'label' => 'Icon', + 'content' => '', ) ); $this->assertFalse( $result ); } /** + * Should reject a non-namespaced name, since the collection is derived from + * the namespaced icon name in the form "collection/icon-name". + * * @ticket 64651 * * @covers ::register * * @expectedIncorrectUsage WP_Icons_Registry::register */ - public function test_register_requires_collection() { + public function test_register_rejects_non_namespaced_name() { $result = $this->registry->register( 'my-icon', array( @@ -162,25 +163,30 @@ public function test_register_requires_collection() { } /** + * Should reject `collection` passed as an icon property, since the collection + * is derived from the namespaced icon name instead. + * * @ticket 64651 * * @covers ::register * * @expectedIncorrectUsage WP_Icons_Registry::register */ - public function test_register_rejects_non_string_collection() { + public function test_register_rejects_collection_property() { $result = $this->registry->register( - 'my-icon', + 'test-collection/my-icon', array( 'label' => 'Icon', 'content' => '', - 'collection' => 123, + 'collection' => 'test-collection', ) ); $this->assertFalse( $result ); } /** + * Should fail when the name references a collection that is not registered. + * * @ticket 64651 * * @covers ::register @@ -189,11 +195,10 @@ public function test_register_rejects_non_string_collection() { */ public function test_register_rejects_unregistered_collection() { $result = $this->registry->register( - 'my-icon', + 'unregistered-collection/my-icon', array( - 'label' => 'Icon', - 'content' => '', - 'collection' => 'unregistered-collection', + 'label' => 'Icon', + 'content' => '', ) ); $this->assertFalse( $result ); @@ -210,21 +215,19 @@ public function test_same_name_across_collections_does_not_collide() { $this->assertTrue( $this->registry->register( - 'shared', + 'test-collection/shared', array( - 'label' => 'Shared A', - 'content' => '', - 'collection' => 'test-collection', + 'label' => 'Shared A', + 'content' => '', ) ) ); $this->assertTrue( $this->registry->register( - 'shared', + 'other-collection/shared', array( - 'label' => 'Shared B', - 'content' => '', - 'collection' => 'other-collection', + 'label' => 'Shared B', + 'content' => '', ) ) ); @@ -245,16 +248,15 @@ public function test_same_name_across_collections_does_not_collide() { */ public function test_unregister_icon() { $this->registry->register( - 'my-icon', + 'test-collection/my-icon', array( - 'label' => 'Icon', - 'content' => '', - 'collection' => 'test-collection', + 'label' => 'Icon', + 'content' => '', ) ); $this->assertTrue( $this->registry->is_registered( 'test-collection/my-icon' ) ); - $this->assertTrue( $this->registry->unregister( 'my-icon', 'test-collection' ) ); + $this->assertTrue( $this->registry->unregister( 'test-collection/my-icon' ) ); $this->assertFalse( $this->registry->is_registered( 'test-collection/my-icon' ) ); } @@ -266,7 +268,7 @@ public function test_unregister_icon() { * @expectedIncorrectUsage WP_Icons_Registry::unregister */ public function test_unregister_unknown_icon() { - $this->assertFalse( $this->registry->unregister( 'ghost', 'test-collection' ) ); + $this->assertFalse( $this->registry->unregister( 'test-collection/ghost' ) ); } /** @@ -278,11 +280,10 @@ public function test_get_content_reads_from_valid_file_path() { $path = $this->create_temp_icon_file( '' ); $this->registry->register( - 'from-file', + 'test-collection/from-file', array( - 'label' => 'From File', - 'file_path' => $path, - 'collection' => 'test-collection', + 'label' => 'From File', + 'file_path' => $path, ) ); @@ -317,11 +318,10 @@ public function test_get_content_returns_null_for_invalid_file( $contents, $exte $path = $this->create_temp_icon_file( $contents, $extension ); $this->registry->register( - 'invalid-file', + 'test-collection/invalid-file', array( - 'label' => 'Invalid File', - 'file_path' => $path, - 'collection' => 'test-collection', + 'label' => 'Invalid File', + 'file_path' => $path, ) ); diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index 10d45ceb21cd0..ce490d48d06f8 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -51,8 +51,7 @@ public function test_register_routes() { public function test_get_items_collection_scope() { wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) ); wp_register_icon( - 'bell', - 'rest-test-collection', + 'rest-test-collection/bell', array( 'label' => 'Bell', 'content' => '', @@ -99,8 +98,7 @@ public function test_get_items_unknown_collection_returns_404() { public function test_response_includes_collection_field() { wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) ); wp_register_icon( - 'bell', - 'rest-test-collection', + 'rest-test-collection/bell', array( 'label' => 'Bell', 'content' => '', From 02352b04752b828d94d913aae69e65f5dcf5d700 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 27 Jun 2026 12:05:18 +0900 Subject: [PATCH 10/17] Icons: Restore core icons in REST controller tests and unskip them. The REST controller tests depend on the default core icons, but those are registered only once on `init` and the registration is then unhooked, so once another suite resets the `WP_Icons_Registry` singleton in teardown the core icons are gone. The affected tests were therefore skipped. Re-register the default icons in `set_up()` when the registry is empty so the tests run deterministically regardless of execution order, and drop the `markTestSkipped()` guards. Co-Authored-By: Claude --- .../tests/icons/wpRestIconsController.php | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index afcbd900e5291..bdfeeff64e269 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -31,6 +31,21 @@ public static function wpTearDownAfterClass() { self::delete_user( self::$subscriber_id ); } + public function set_up() { + parent::set_up(); + + /* + * Other suites reset the `WP_Icons_Registry` singleton, wiping the core icons that + * `init` only registers once. Re-register them when empty so order-dependent tests pass. + */ + if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( 'core' ) ) { + _wp_register_default_icon_collections(); + } + if ( empty( WP_Icons_Registry::get_instance()->get_registered_icons() ) ) { + _wp_register_default_icons(); + } + } + /** * @ticket 64651 * @@ -199,7 +214,6 @@ public function test_get_items() { * @covers WP_REST_Icons_Controller::prepare_item_for_response */ public function test_prepare_item() { - $this->markTestSkipped( 'No public icons are available in manifest.php yet' ); wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); @@ -232,7 +246,6 @@ public function test_get_item_schema() { * @covers ::get_items */ public function test_get_items_returns_icons_list() { - $this->markTestSkipped( 'No public icons are available in manifest.php yet' ); wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); @@ -300,7 +313,6 @@ public function test_get_items_contributor_has_access() { * @covers ::get_item */ public function test_get_item_returns_specific_icon() { - $this->markTestSkipped( 'No public icons are available in manifest.php yet' ); wp_set_current_user( self::$editor_id ); /* @@ -352,7 +364,6 @@ public function test_get_item_returns_404_for_invalid_icon() { * @covers ::get_items */ public function test_get_items_search_filters_results() { - $this->markTestSkipped( 'No public icons are available in manifest.php yet' ); wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); @@ -470,7 +481,6 @@ public function test_get_items_fields_parameter() { * @covers ::get_item_permissions_check */ public function test_get_item_requires_permissions() { - $this->markTestSkipped( 'No public icons are available in manifest.php yet' ); // Get a valid icon name first with proper permissions wp_set_current_user( self::$editor_id ); $list_request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); From 73a1267a37b488778eccc64c7f1205dd4565d0d0 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Mon, 29 Jun 2026 17:57:41 +0900 Subject: [PATCH 11/17] Icons: Allow digits and underscores in icon slugs. Relax the icon slug validation so names can start with a lowercase letter or digit and contain lowercase letters, digits, hyphens, and underscores. Many real-world icon sets use digits (e.g. html5, 500px, w3c) and some consumers want underscores, which the previous regex rejected. Co-Authored-By: Claude --- src/wp-includes/class-wp-icons-registry.php | 4 +- tests/phpunit/tests/icons/wpIconsRegistry.php | 42 +++++++++++++++---- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php index 0d6804dbf9edd..36fc4c42e484c 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -92,10 +92,10 @@ public function register( $icon_name, $icon_properties ) { return false; } - if ( ! preg_match( '/^[a-z][a-z0-9-]*$/', $unqualified_name ) ) { + if ( ! preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $unqualified_name ) ) { _doing_it_wrong( __METHOD__, - __( 'Icon names must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.' ), + __( 'Icon names must start with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, and underscores.' ), '7.1.0' ); return false; diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php index f6571d00de299..f30d15dab2def 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -74,14 +74,36 @@ private function create_temp_icon_file( $contents, $extension = 'svg' ) { return $this->temp_file; } + /** + * Provides valid namespaced icon names, including names that contain or + * start with digits, as well as underscores. + * + * @return array + */ + public function data_valid_icon_names() { + return array( + 'simple name' => array( 'test-collection/my-icon' ), + 'digit at the start' => array( 'test-collection/1-icon' ), + 'digit in the name' => array( 'test-collection/my-1-icon' ), + 'digit at the end' => array( 'test-collection/icon1' ), + 'underscore in the name' => array( 'test-collection/my_icon' ), + 'underscore at the end' => array( 'test-collection/my-icon_' ), + 'hyphen at the end' => array( 'test-collection/my-icon-' ), + ); + } + /** * @ticket 64651 * + * @dataProvider data_valid_icon_names + * * @covers ::register + * + * @param string $name Valid icon name candidate. */ - public function test_register_icon() { + public function test_register_icon( $name ) { $result = $this->registry->register( - 'test-collection/my-icon', + $name, array( 'label' => 'My Icon', 'content' => '', @@ -89,15 +111,21 @@ public function test_register_icon() { ); $this->assertTrue( $result ); - $this->assertTrue( $this->registry->is_registered( 'test-collection/my-icon' ) ); + $this->assertTrue( $this->registry->is_registered( $name ) ); } public function data_invalid_icon_names() { return array( - 'non-string name' => array( 1 ), - 'empty name' => array( 'test-collection/' ), - 'uppercase characters' => array( 'test-collection/Plus' ), - 'invalid characters' => array( 'test-collection/_doing_it_wrong' ), + 'integer name' => array( 1 ), + 'null name' => array( null ), + 'boolean name' => array( true ), + 'array name' => array( array() ), + 'empty name' => array( 'test-collection/' ), + 'uppercase at the start' => array( 'test-collection/Icon' ), + 'uppercase in the name' => array( 'test-collection/my-Icon' ), + 'uppercase at the end' => array( 'test-collection/my-iconX' ), + 'underscore at the start' => array( 'test-collection/_my-icon' ), + 'hyphen at the start' => array( 'test-collection/-my-icon' ), ); } From 3bf04292321e11fb2dac73797a36551765dd1b70 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 1 Jul 2026 01:30:27 +0900 Subject: [PATCH 12/17] Icons: Add an icon collections REST endpoint and tighten name rules Port the equivalent of WordPress/gutenberg#79686 to core: - Add WP_REST_Icon_Collections_Controller exposing /wp/v2/icon-collections and /wp/v2/icon-collections/ so the editor can group icons by collection in the icon picker. - Unify the naming rules for icon controllers and the collection registry on /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/, allowing leading digits and underscores while still requiring alphanumeric boundaries. This fixes requests such as /wp/v2/icons/bootstrap-icons/0-circle-fill that previously 404'd. - Add unit tests for the new collection controller and extend the collection registry tests to cover the updated slug rules. Co-Authored-By: Claude --- .../class-wp-icon-collections-registry.php | 4 +- src/wp-includes/rest-api.php | 4 + ...ss-wp-rest-icon-collections-controller.php | 266 ++++++++++++++++ .../class-wp-rest-icons-controller.php | 6 +- src/wp-settings.php | 1 + .../tests/icons/wpIconCollectionsRegistry.php | 96 ++++-- .../icons/wpRestIconCollectionsController.php | 301 ++++++++++++++++++ .../tests/icons/wpRestIconsController.php | 4 +- 8 files changed, 642 insertions(+), 40 deletions(-) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-icon-collections-controller.php create mode 100644 tests/phpunit/tests/icons/wpRestIconCollectionsController.php diff --git a/src/wp-includes/class-wp-icon-collections-registry.php b/src/wp-includes/class-wp-icon-collections-registry.php index b396b50856bd4..6bb9c8ef5f23b 100644 --- a/src/wp-includes/class-wp-icon-collections-registry.php +++ b/src/wp-includes/class-wp-icon-collections-registry.php @@ -57,10 +57,10 @@ public function register( $collection_slug, $collection_properties ) { return false; } - if ( ! preg_match( '/^[a-z0-9-]+$/', $collection_slug ) ) { + if ( ! preg_match( '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/', $collection_slug ) ) { _doing_it_wrong( __METHOD__, - __( 'Icon collection slug must only contain lowercase alphanumeric characters and hyphens.' ), + __( 'Icon collection slug must start and end with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, and underscores.' ), '7.1.0' ); return false; diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 9710ffaf6a21b..4475aa143d2a4 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -429,6 +429,10 @@ function create_initial_rest_routes() { $icons_controller = new WP_REST_Icons_Controller(); $icons_controller->register_routes(); + // Icon Collections. + $icon_collections_controller = new WP_REST_Icon_Collections_Controller(); + $icon_collections_controller->register_routes(); + // View Config. $view_config_controller = new WP_REST_View_Config_Controller(); $view_config_controller->register_routes(); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-icon-collections-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-icon-collections-controller.php new file mode 100644 index 0000000000000..9e8a935b9440d --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-icon-collections-controller.php @@ -0,0 +1,266 @@ +namespace = 'wp/v2'; + $this->rest_base = 'icon-collections'; + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 7.1.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', + array( + 'args' => array( + 'slug' => array( + 'description' => __( 'Icon collection slug.' ), + 'type' => 'string', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks whether a given request has permission to read icon collections. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( + // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $request + ) { + if ( current_user_can( 'edit_posts' ) ) { + return true; + } + + foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) { + if ( current_user_can( $post_type->cap->edit_posts ) ) { + return true; + } + } + + return new WP_Error( + 'rest_cannot_view', + __( 'Sorry, you are not allowed to view the registered icon collections.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Checks if a given request has access to read a specific icon collection. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $check = $this->get_items_permissions_check( $request ); + if ( is_wp_error( $check ) ) { + return $check; + } + + return true; + } + + /** + * Retrieves all icon collections. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $response = array(); + $collections = WP_Icon_Collections_Registry::get_instance()->get_all_registered(); + foreach ( $collections as $collection ) { + $prepared_collection = $this->prepare_item_for_response( $collection, $request ); + $response[] = $this->prepare_response_for_collection( $prepared_collection ); + } + return rest_ensure_response( $response ); + } + + /** + * Retrieves a specific icon collection. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $collection = $this->get_icon_collection( $request['slug'] ); + if ( is_wp_error( $collection ) ) { + return $collection; + } + + $data = $this->prepare_item_for_response( $collection, $request ); + return rest_ensure_response( $data ); + } + + /** + * Retrieves a specific icon collection from the registry. + * + * @since 7.1.0 + * + * @param string $slug Icon collection slug. + * @return array|WP_Error Icon collection data on success, or WP_Error object on failure. + */ + public function get_icon_collection( $slug ) { + $registry = WP_Icon_Collections_Registry::get_instance(); + $collection = $registry->get_registered( $slug ); + + if ( null === $collection ) { + return new WP_Error( + 'rest_icon_collection_not_found', + sprintf( + /* translators: %s: Icon collection slug. */ + __( 'Icon collection not found: "%s".' ), + $slug + ), + array( 'status' => 404 ) + ); + } + + return $collection; + } + + /** + * Prepares a raw icon collection before it gets output in a REST API response. + * + * @since 7.1.0 + * + * @param array $item Raw icon collection as registered, before any changes. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $keys = array( + 'slug' => 'slug', + 'label' => 'label', + 'description' => 'description', + ); + $data = array(); + foreach ( $keys as $item_key => $rest_key ) { + if ( isset( $item[ $item_key ] ) && rest_is_field_included( $rest_key, $fields ) ) { + $data[ $rest_key ] = $item[ $item_key ]; + } + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + return rest_ensure_response( $data ); + } + + /** + * Retrieves the icon collection schema, conforming to JSON Schema. + * + * @since 7.1.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'icon-collection', + 'type' => 'object', + 'properties' => array( + 'slug' => array( + 'description' => __( 'The icon collection slug.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'label' => array( + 'description' => __( 'The icon collection label.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'description' => array( + 'description' => __( 'The icon collection description.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the query params for the icon collections collection. + * + * @since 7.1.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + $query_params['context']['default'] = 'view'; + return $query_params; + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php index 69efbad4adfad..300bbe5fba853 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php @@ -51,7 +51,7 @@ public function register_routes() { register_rest_route( $this->namespace, - '/' . $this->rest_base . '/(?P[a-z][a-z-]*)', + '/' . $this->rest_base . '/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', array( 'args' => array( 'namespace' => array( @@ -71,7 +71,7 @@ public function register_routes() { register_rest_route( $this->namespace, - '/' . $this->rest_base . '/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)', + '/' . $this->rest_base . '/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', array( 'args' => array( 'name' => array( @@ -316,7 +316,7 @@ public function get_collection_params() { $query_params['namespace'] = array( 'description' => __( 'Limit results to icons belonging to the given collection slug.' ), 'type' => 'string', - 'pattern' => '^[a-z][a-z-]*$', + 'pattern' => '^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$', ); return $query_params; } diff --git a/src/wp-settings.php b/src/wp-settings.php index 8586b1faf348e..1c279fd382ee8 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -361,6 +361,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icons-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icon-collections-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-view-config-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; diff --git a/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php index a2b5a43dd489d..a0155c4ed434c 100644 --- a/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php @@ -31,29 +31,68 @@ public function tear_down() { } /** + * Registers an icon in the icons registry. + * + * @param string $icon_name Namespaced icon name (e.g. "plugin-a/alpha"). + * @param array $icon_properties Icon properties (label, content, file_path). + * @return bool True if the icon was registered successfully. + */ + private function register_icon( $icon_name, $icon_properties ) { + return WP_Icons_Registry::get_instance()->register( $icon_name, $icon_properties ); + } + + /** + * Data provider for valid collection slug candidates. + * + * @return array[] + */ + public function data_valid_collection_slugs() { + return array( + 'simple slug' => array( 'mycollection' ), + 'digit at the start' => array( '1-collection' ), + 'digit in the slug' => array( 'my-1-collection' ), + 'digit at the end' => array( 'collection1' ), + 'underscore in the slug' => array( 'my_collection' ), + 'hyphen in the slug' => array( 'my-collection' ), + ); + } + + /** + * Should register a collection with a valid slug. + * * @ticket 64847 * + * @dataProvider data_valid_collection_slugs + * * @covers ::register */ - public function test_register_collection() { - $result = $this->collections->register( - 'my-collection', - array( - 'label' => 'My Collection', - 'description' => 'A collection.', - ) - ); + public function test_register_collection( $slug ) { + $result = $this->collections->register( $slug, array( 'label' => 'My Collection' ) ); $this->assertTrue( $result ); - $this->assertTrue( $this->collections->is_registered( 'my-collection' ) ); + $this->assertTrue( $this->collections->is_registered( $slug ) ); + } - $registered = $this->collections->get_registered( 'my-collection' ); - $this->assertSame( 'my-collection', $registered['slug'] ); - $this->assertSame( 'My Collection', $registered['label'] ); - $this->assertSame( 'A collection.', $registered['description'] ); + /** + * Data provider for invalid collection slug candidates. + * + * @return array[] + */ + public function data_invalid_collection_slugs() { + return array( + 'non-string slug' => array( 1 ), + 'contains slash' => array( 'plugin/icons' ), + 'uppercase characters' => array( 'Plugin' ), + 'underscore at the start' => array( '_my-plugin' ), + 'underscore at the end' => array( 'my-plugin_' ), + 'hyphen at the start' => array( '-my-plugin' ), + 'hyphen at the end' => array( 'my-plugin-' ), + ); } /** + * Should fail to register a collection with an invalid slug. + * * @ticket 64847 * * @dataProvider data_invalid_collection_slugs @@ -70,24 +109,8 @@ public function test_register_rejects_invalid_slug( $slug ) { } /** - * Data provider for invalid collection slug candidates. + * Should fail to register the same collection twice. * - * Collection slugs must be strings that start with a lowercase letter - * and contain only lowercase letters and hyphens (no digits, no slashes, - * no uppercase characters). - * - * @return array[] - */ - public function data_invalid_collection_slugs() { - return array( - 'non-string slug' => array( 1 ), - 'contains slash' => array( 'plugin/icons' ), - 'uppercase characters' => array( 'Plugin' ), - 'underscore' => array( 'my_plugin' ), - ); - } - - /** * @ticket 64847 * * @covers ::register @@ -100,6 +123,8 @@ public function test_register_twice_fails() { } /** + * Should fail to register a collection with an unknown property. + * * @ticket 64847 * * @covers ::register @@ -118,6 +143,9 @@ public function test_register_rejects_unknown_property() { } /** + * Unregistering a collection should cascade and remove all icons + * belonging to it, while leaving icons from other collections intact. + * * @ticket 64847 * * @covers ::unregister @@ -127,21 +155,21 @@ public function test_unregister_collection_cascades_to_icons() { $this->collections->register( 'plugin-b', array( 'label' => 'B' ) ); $icons = WP_Icons_Registry::get_instance(); - $icons->register( + $this->register_icon( 'plugin-a/alpha', array( 'label' => 'Alpha', 'content' => '', ) ); - $icons->register( + $this->register_icon( 'plugin-a/beta', array( 'label' => 'Beta', 'content' => '', ) ); - $icons->register( + $this->register_icon( 'plugin-b/gamma', array( 'label' => 'Gamma', @@ -162,6 +190,8 @@ public function test_unregister_collection_cascades_to_icons() { } /** + * Should fail to unregister a collection that was never registered. + * * @ticket 64847 * * @covers ::unregister diff --git a/tests/phpunit/tests/icons/wpRestIconCollectionsController.php b/tests/phpunit/tests/icons/wpRestIconCollectionsController.php new file mode 100644 index 0000000000000..3a1bf87ad1295 --- /dev/null +++ b/tests/phpunit/tests/icons/wpRestIconCollectionsController.php @@ -0,0 +1,301 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); + self::$contributor_id = $factory->user->create( array( 'role' => 'contributor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + self::delete_user( self::$contributor_id ); + self::delete_user( self::$subscriber_id ); + } + + public function set_up() { + parent::set_up(); + + /* + * Other suites reset the `WP_Icon_Collections_Registry` singleton, wiping the + * core collection that `init` only registers once. Re-register it when empty + * so order-dependent tests pass. + */ + if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( 'core' ) ) { + _wp_register_default_icon_collections(); + } + } + + /** + * @ticket 64847 + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/icon-collections', $routes ); + $this->assertArrayHasKey( '/wp/v2/icon-collections/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', $routes ); + } + + /** + * @ticket 64847 + * + * @covers ::get_items + */ + public function test_get_items() { + wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertIsArray( $data ); + $this->assertNotEmpty( $data ); + + $slugs = array_column( $data, 'slug' ); + $this->assertContains( 'core', $slugs ); + $this->assertContains( 'rest-test-collection', $slugs ); + + wp_unregister_icon_collection( 'rest-test-collection' ); + } + + /** + * @ticket 64847 + * + * @covers ::get_item + */ + public function test_get_item() { + wp_register_icon_collection( + 'rest-test-collection', + array( + 'label' => 'REST Test', + 'description' => 'A REST test collection.', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections/rest-test-collection' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'rest-test-collection', $data['slug'] ); + $this->assertSame( 'REST Test', $data['label'] ); + $this->assertSame( 'A REST test collection.', $data['description'] ); + + wp_unregister_icon_collection( 'rest-test-collection' ); + } + + /** + * @ticket 64847 + * + * @covers ::get_item + */ + public function test_get_item_returns_404_for_unknown_collection() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections/unknown-collection' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_icon_collection_not_found', $response, 404 ); + } + + /** + * @ticket 64847 + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_requires_edit_posts_capability() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 403 ); + } + + /** + * @ticket 64847 + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_requires_authentication() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 401 ); + } + + /** + * @ticket 64847 + * + * @covers ::get_items + */ + public function test_get_items_admin_has_access() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64847 + * + * @covers ::get_items + */ + public function test_get_items_contributor_has_access() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64847 + * + * @covers ::get_item_permissions_check + */ + public function test_get_item_requires_authentication() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections/core' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 401 ); + } + + /** + * @ticket 64847 + * + * @covers ::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections/core' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'slug', $data ); + $this->assertArrayHasKey( 'label', $data ); + $this->assertArrayHasKey( 'description', $data ); + $this->assertSame( 'core', $data['slug'] ); + } + + /** + * @ticket 64847 + * + * @covers ::prepare_item_for_response + */ + public function test_get_items_fields_parameter() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icon-collections' ); + $request->set_param( '_fields', 'slug' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + + foreach ( $data as $collection ) { + $this->assertArrayHasKey( 'slug', $collection ); + $this->assertArrayNotHasKey( 'label', $collection ); + } + } + + /** + * @ticket 64847 + * + * @covers ::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/icon-collections' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $properties = $data['schema']['properties']; + $this->assertCount( 3, $properties ); + $this->assertArrayHasKey( 'slug', $properties ); + $this->assertArrayHasKey( 'label', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + } + + /** + * Asserts that no icon collections can be created. + * No controller method is executed; 404 is returned by route matching. + * + * @ticket 64847 + */ + public function test_create_item() { + $request = new WP_REST_Request( 'POST', '/wp/v2/icon-collections' ); + $request->set_param( 'slug', 'foo' ); + $request->set_param( 'label', 'Foo' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Asserts that no icon collections can be updated. + * No controller method is executed; 404 is returned by route matching. + * + * @ticket 64847 + */ + public function test_update_item() { + $request = new WP_REST_Request( 'POST', '/wp/v2/icon-collections/core' ); + $request->set_param( 'label', 'Foo' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Asserts that no icon collections can be deleted. + * No controller method is executed; 404 is returned by route matching. + * + * @ticket 64847 + */ + public function test_delete_item() { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/icon-collections/core' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + } +} diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index bdfeeff64e269..f1ad30efdd5e8 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -54,8 +54,8 @@ public function set_up() { public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/icons', $routes ); - $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z][a-z-]*)', $routes ); - $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)', $routes ); + $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', $routes ); + $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', $routes ); } /** From 88f1834c2f9f5d9507b312146fb74aa5f5abaa73 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Tue, 7 Jul 2026 20:27:37 +0900 Subject: [PATCH 13/17] Icons: Use str_contains() for the namespaced icon name check. Replace the legacy false === strpos() idiom with str_contains(), which expresses the intent more clearly and avoids the loose/strict comparison pitfalls of the strpos()-against-false pattern. WordPress polyfills str_contains() in wp-includes/compat.php, so it is safe on PHP 7.4. Co-Authored-By: Claude --- src/wp-includes/class-wp-icons-registry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php index 36fc4c42e484c..8f54b798055b4 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -71,7 +71,7 @@ public function register( $icon_name, $icon_properties ) { } // Require a namespaced name in the form "collection/icon-name". - if ( false === strpos( $icon_name, '/' ) ) { + if ( ! str_contains( $icon_name, '/' ) ) { _doing_it_wrong( __METHOD__, __( 'Icon name must be namespaced in the form "collection/icon-name".' ), From 8667d7248459e95ec4d9ff3e02385cdb7af557e6 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 9 Jul 2026 15:55:40 +0900 Subject: [PATCH 14/17] Icons: Validate that the collection description is a string. The collection label was already type-checked, but the optional description was merged into the collection unvalidated, allowing non-string values to violate the documented @type string contract. Reject a non-string description with _doing_it_wrong() for consistency. Co-Authored-By: Claude --- src/wp-includes/class-wp-icon-collections-registry.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/wp-includes/class-wp-icon-collections-registry.php b/src/wp-includes/class-wp-icon-collections-registry.php index 6bb9c8ef5f23b..822c9c165764a 100644 --- a/src/wp-includes/class-wp-icon-collections-registry.php +++ b/src/wp-includes/class-wp-icon-collections-registry.php @@ -109,6 +109,15 @@ public function register( $collection_slug, $collection_properties ) { return false; } + if ( isset( $collection_properties['description'] ) && ! is_string( $collection_properties['description'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon collection description must be a string.' ), + '7.1.0' + ); + return false; + } + $defaults = array( 'description' => '', ); From 60bb86ac3fb0067458251d5326617a01cd55b19b Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 9 Jul 2026 16:02:51 +0900 Subject: [PATCH 15/17] Icons: Update expected REST routes in the schema setup test. The icon name/namespace regexes were tightened and an icon-collections controller was added, but the expected route list in the schema test was not updated to match, causing the full-set comparison to fail. Co-Authored-By: Claude --- tests/phpunit/tests/rest-api/rest-schema-setup.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 38b18234a32fa..2e2ace3fd5059 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -200,9 +200,11 @@ public function test_expected_routes_in_schema() { '/wp/v2/font-families/(?P[\d]+)/font-faces', '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', '/wp/v2/font-families/(?P[\d]+)', + '/wp/v2/icon-collections', + '/wp/v2/icon-collections/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', '/wp/v2/icons', - '/wp/v2/icons/(?P[a-z][a-z-]*)', - '/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)', + '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', + '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', '/wp/v2/view-config', '/wp-abilities/v1', '/wp-abilities/v1/categories', From 8034efacbf6ca2e4e5e0ae73f2bbe4fa06d15fab Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 9 Jul 2026 16:12:29 +0900 Subject: [PATCH 16/17] Icons: Rename the collection-scoping route param to `collection`. The collection-scoped icons route used a `namespace` URL param whose value is actually a collection slug, which both collided with the REST API namespace concept and diverged from the icon `collection` field that names the same relationship. Rename it to `collection` so the same concept reads consistently across the icons endpoints. Co-Authored-By: Claude --- .../endpoints/class-wp-rest-icons-controller.php | 14 +++++++------- .../phpunit/tests/icons/wpRestIconsController.php | 2 +- tests/phpunit/tests/rest-api/rest-schema-setup.php | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php index 300bbe5fba853..b896cd758e28f 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php @@ -32,7 +32,7 @@ public function __construct() { * Registers the routes for the objects of the controller. * * @since 7.0.0 - * @since 7.1.0 Added the `/icons/` collection-scoped route. + * @since 7.1.0 Added the `/icons/` collection-scoped route. */ public function register_routes() { register_rest_route( @@ -51,10 +51,10 @@ public function register_routes() { register_rest_route( $this->namespace, - '/' . $this->rest_base . '/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', + '/' . $this->rest_base . '/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', array( 'args' => array( - 'namespace' => array( + 'collection' => array( 'description' => __( 'Icon collection slug.' ), 'type' => 'string', ), @@ -142,13 +142,13 @@ public function get_item_permissions_check( $request ) { * Retrieves all icons, optionally scoped to a collection. * * @since 7.0.0 - * @since 7.1.0 Supports filtering by collection via the `namespace` URL segment. + * @since 7.1.0 Supports filtering by collection. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { - $collection = $request->get_param( 'namespace' ); + $collection = $request->get_param( 'collection' ); if ( null !== $collection && ! WP_Icon_Collections_Registry::get_instance()->is_registered( $collection ) ) { return new WP_Error( @@ -306,14 +306,14 @@ public function get_item_schema() { * Retrieves the query params for the icons collection. * * @since 7.0.0 - * @since 7.1.0 Added the `namespace` parameter. + * @since 7.1.0 Added the `collection` parameter. * * @return array Collection parameters. */ public function get_collection_params() { $query_params = parent::get_collection_params(); $query_params['context']['default'] = 'view'; - $query_params['namespace'] = array( + $query_params['collection'] = array( 'description' => __( 'Limit results to icons belonging to the given collection slug.' ), 'type' => 'string', 'pattern' => '^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$', diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index f1ad30efdd5e8..dc899ce2bd7be 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -54,7 +54,7 @@ public function set_up() { public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/icons', $routes ); - $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', $routes ); + $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', $routes ); $this->assertArrayHasKey( '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', $routes ); } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 2e2ace3fd5059..9ee6cc4cbed16 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -203,7 +203,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/icon-collections', '/wp/v2/icon-collections/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', '/wp/v2/icons', - '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', + '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', '/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', '/wp/v2/view-config', '/wp-abilities/v1', From 02d7ff42103a36129345654a9b93b6cc8669ec3d Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 11 Jul 2026 22:58:50 +0900 Subject: [PATCH 17/17] Icons: Regenerate the REST API client fixtures. The icon REST routes changed on this branch: the collection-scoping route param was renamed from `namespace` to `collection`, the slug pattern was relaxed, and the `/wp/v2/icon-collections` route was added. The generated fixture was left stale, so the PHPUnit job regenerated it and failed the "version-controlled files are not modified" check. Co-Authored-By: Claude --- tests/qunit/fixtures/wp-api-generated.js | 96 ++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 52de0471d52e6..1be56017826ff 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12811,10 +12811,10 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, - "namespace": { + "collection": { "description": "Limit results to icons belonging to the given collection slug.", "type": "string", - "pattern": "^[a-z][a-z-]*$", + "pattern": "^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$", "required": false } } @@ -12828,7 +12828,7 @@ mockedApiResponse.Schema = { ] } }, - "/wp/v2/icons/(?P[a-z][a-z-]*)": { + "/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)": { "namespace": "wp/v2", "methods": [ "GET" @@ -12839,10 +12839,10 @@ mockedApiResponse.Schema = { "GET" ], "args": { - "namespace": { + "collection": { "description": "Limit results to icons belonging to the given collection slug.", "type": "string", - "pattern": "^[a-z][a-z-]*$", + "pattern": "^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$", "required": false }, "context": { @@ -12880,7 +12880,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)": { + "/wp/v2/icons/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)": { "namespace": "wp/v2", "methods": [ "GET" @@ -12911,6 +12911,90 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/icon-collections": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/icon-collections" + } + ] + } + }, + "/wp/v2/icon-collections/(?P[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "slug": { + "description": "Icon collection slug.", + "type": "string", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ] + }, "/wp/v2/view-config": { "namespace": "wp/v2", "methods": [