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..822c9c165764a --- /dev/null +++ b/src/wp-includes/class-wp-icon-collections-registry.php @@ -0,0 +1,227 @@ +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; + } + + 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' => '', + ); + + $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(); + foreach ( $icons_registry->get_registered_icons() as $icon ) { + if ( isset( $icon['collection'] ) && $icon['collection'] === $collection_slug ) { + $icons_registry->unregister( $icon['name'] ); + } + } + + 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 6753231345f31..8f54b798055b4 100644 --- a/src/wp-includes/class-wp-icons-registry.php +++ b/src/wp-includes/class-wp-icons-registry.php @@ -1,5 +1,4 @@ $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 The icon name must be namespaced in the form "collection/icon-name". * - * @param string $icon_name Icon name including namespace. + * @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. * @@ -105,7 +60,7 @@ protected function __construct() { * } * @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,29 +70,32 @@ protected function register( $icon_name, $icon_properties ) { return false; } - if ( preg_match( '/[A-Z]/', $icon_name ) ) { + // Require a namespaced name in the form "collection/icon-name". + if ( ! str_contains( $icon_name, '/' ) ) { _doing_it_wrong( __METHOD__, - __( 'Icon names must not contain uppercase characters.' ), + __( 'Icon name must be namespaced in the form "collection/icon-name".' ), '7.1.0' ); return false; } - $name_matcher = '/^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/'; - if ( ! preg_match( $name_matcher, $icon_name ) ) { + // 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]/', $unqualified_name ) ) { _doing_it_wrong( __METHOD__, - __( 'Icon names must contain a namespace prefix. Example: my-plugin/my-custom-icon' ), + __( 'Icon names must not contain uppercase characters.' ), '7.1.0' ); return false; } - if ( $this->is_registered( $icon_name ) ) { + if ( ! preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $unqualified_name ) ) { _doing_it_wrong( __METHOD__, - __( 'Icon is already registered.' ), + __( 'Icon names must start with a lowercase letter or digit and contain only lowercase letters, digits, hyphens, and underscores.' ), '7.1.0' ); return false; @@ -149,7 +107,7 @@ protected function register( $icon_name, $icon_properties ) { _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 ), @@ -159,6 +117,19 @@ protected function register( $icon_name, $icon_properties ) { } } + 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.' ), + $collection + ), + '7.1.0' + ); + return false; + } + if ( ! isset( $icon_properties['label'] ) || ! is_string( $icon_properties['label'] ) ) { _doing_it_wrong( __METHOD__, @@ -201,13 +172,54 @@ protected function register( $icon_name, $icon_properties ) { } } + $qualified_name = $collection . '/' . $unqualified_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, + 'collection' => $collection, + ) ); - $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 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 ) { + if ( ! $this->is_registered( $icon_name ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Icon name. */ + __( 'Icon "%s" is not registered.' ), + $icon_name + ), + '7.1.0' + ); + return false; + } + unset( $this->registered_icons[ $icon_name ] ); return true; } @@ -261,10 +273,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( @@ -302,6 +328,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. diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 783dd6fe42fc5..3702c17b418e8 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -818,6 +818,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 index 2c2d1dd6fc6b5..0af4b6d61b873 100644 --- a/src/wp-includes/icons.php +++ b/src/wp-includes/icons.php @@ -1,12 +1,147 @@ 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 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. + * + * @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, $args ) { + return WP_Icons_Registry::get_instance()->register( $icon_name, $args ); +} + +/** + * Unregisters an icon. + * + * @since 7.1.0 + * + * @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 ) { + return WP_Icons_Registry::get_instance()->unregister( $icon_name ); +} + +/** + * 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( + 'core/' . $icon_name, + array( + 'label' => $icon_data['label'], + 'file_path' => $icons_directory . $icon_data['filePath'], + ) + ); + } +} + /** * Returns the SVG markup for a registered icon. * 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 91126b498d338..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 @@ -1,4 +1,5 @@ ` collection-scoped route. */ public function register_routes() { register_rest_route( @@ -51,7 +51,27 @@ 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])?)', + array( + 'args' => array( + 'collection' => 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-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)', array( 'args' => array( 'name' => array( @@ -119,18 +139,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. * * @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( 'collection' ); + + 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 ); } @@ -186,6 +225,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 +234,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 +256,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 +270,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 +306,18 @@ public function get_item_schema() { * Retrieves the query params for the icons collection. * * @since 7.0.0 + * @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['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])?$', + ); return $query_params; } } diff --git a/src/wp-settings.php b/src/wp-settings.php index f633058c10222..dc01c516ca810 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -299,6 +299,7 @@ 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'; @@ -361,6 +362,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/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. diff --git a/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php new file mode 100644 index 0000000000000..a0155c4ed434c --- /dev/null +++ b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php @@ -0,0 +1,204 @@ +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(); + } + + /** + * 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( $slug ) { + $result = $this->collections->register( $slug, array( 'label' => 'My Collection' ) ); + + $this->assertTrue( $result ); + $this->assertTrue( $this->collections->is_registered( $slug ) ); + } + + /** + * 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 + * + * @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 ); + } + + /** + * Should fail to register the same collection twice. + * + * @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' ) ) ); + } + + /** + * Should fail to register a collection with an unknown property. + * + * @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 ); + } + + /** + * Unregistering a collection should cascade and remove all icons + * belonging to it, while leaving icons from other collections intact. + * + * @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(); + $this->register_icon( + 'plugin-a/alpha', + array( + 'label' => 'Alpha', + 'content' => '', + ) + ); + $this->register_icon( + 'plugin-a/beta', + array( + 'label' => 'Beta', + 'content' => '', + ) + ); + $this->register_icon( + 'plugin-b/gamma', + array( + 'label' => 'Gamma', + 'content' => '', + ) + ); + + $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( 'plugin-b/gamma' ); + } + + /** + * Should fail to unregister a collection that was never registered. + * + * @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 index 23352964db0f5..f30d15dab2def 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -1,110 +1,235 @@ 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() { - $instance_property = new ReflectionProperty( WP_Icons_Registry::class, 'instance' ); - + $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' ); + } + + if ( $this->temp_file && file_exists( $this->temp_file ) ) { + unlink( $this->temp_file ); + } + $this->temp_file = null; + $this->registry = null; parent::tear_down(); } /** - * Invokes WP_Icons_Registry::register despite it being private + * Builds a unique temporary icon file path with the given extension. * - * @param string $icon_name Icon name including namespace. - * @param array $icon_properties Icon properties (label, content, file_path). - * @return bool True if the icon was registered successfully. + * @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 register( $icon_name, $icon_properties ) { - $method = new ReflectionMethod( $this->registry, 'register' ); - - if ( PHP_VERSION_ID < 80100 ) { - $method->setAccessible( true ); + 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; + } - return $method->invoke( $this->registry, $icon_name, $icon_properties ); + /** + * 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-' ), + ); } /** - * Provides invalid icon names. + * @ticket 64651 + * + * @dataProvider data_valid_icon_names + * + * @covers ::register * - * @return array[] + * @param string $name Valid icon name candidate. */ + public function test_register_icon( $name ) { + $result = $this->registry->register( + $name, + array( + 'label' => 'My Icon', + 'content' => '', + ) + ); + + $this->assertTrue( $result ); + $this->assertTrue( $this->registry->is_registered( $name ) ); + } + public function data_invalid_icon_names() { return array( - 'non-string name' => array( 1 ), - 'no namespace' => array( 'plus' ), - 'uppercase characters' => array( 'Test/Plus' ), - 'invalid characters' => array( 'test/_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' ), ); } /** - * Should fail to re-register the same icon. + * @ticket 64651 * - * @ticket 64847 + * @covers ::register * * @expectedIncorrectUsage WP_Icons_Registry::register */ public function test_register_icon_twice() { - $name = 'test-plugin/duplicate'; $settings = array( 'label' => 'Icon', 'content' => '', ); - $result = $this->register( $name, $settings ); - $this->assertTrue( $result ); - $result2 = $this->register( $name, $settings ); - $this->assertFalse( $result2 ); + $this->assertTrue( $this->registry->register( 'test-collection/duplicate', $settings ) ); + $this->assertFalse( $this->registry->register( 'test-collection/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' => '', + ) + ); + $this->assertFalse( $result ); + } /** - * Should fail to register icon with invalid names. + * Should reject a non-namespaced name, since the collection is derived from + * the namespaced icon name in the form "collection/icon-name". * - * @ticket 64847 + * @ticket 64651 + * + * @covers ::register * - * @dataProvider data_invalid_icon_names * @expectedIncorrectUsage WP_Icons_Registry::register + */ + public function test_register_rejects_non_namespaced_name() { + $result = $this->registry->register( + 'non-namespaced-icon', + array( + 'label' => 'Icon', + 'content' => '', + ) + ); + $this->assertFalse( $result ); + $this->assertFalse( $this->registry->is_registered( 'core/non-namespaced-icon' ) ); + $this->assertFalse( $this->registry->is_registered( 'non-namespaced-icon' ) ); + } + + /** + * Should reject `collection` passed as an icon property, since the collection + * is derived from the namespaced icon name instead. + * + * @ticket 64651 * - * @param mixed $icon_name Icon name to register. + * @covers ::register + * + * @expectedIncorrectUsage WP_Icons_Registry::register */ - public function test_register_invalid_name( $icon_name ) { - $settings = array( - 'label' => 'Icon', - 'content' => '', + public function test_register_rejects_collection_property() { + $result = $this->registry->register( + 'test-collection/my-icon', + array( + 'label' => 'Icon', + 'content' => '', + 'collection' => 'test-collection', + ) ); + $this->assertFalse( $result ); + } - $result = $this->register( $icon_name, $settings ); + /** + * Should fail when the name references a collection that is not registered. + * + * @ticket 64651 + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icons_Registry::register + */ + public function test_register_rejects_unregistered_collection() { + $result = $this->registry->register( + 'unregistered-collection/my-icon', + array( + 'label' => 'Icon', + 'content' => '', + ) + ); $this->assertFalse( $result ); } @@ -116,24 +241,21 @@ public function test_register_invalid_name( $icon_name ) { * @covers ::register */ public function test_register_icon_with_file_path() { - $file_path = tempnam( get_temp_dir(), 'wp-icon-' ); - file_put_contents( $file_path, '' ); + $path = $this->create_temp_icon_file( '' ); - $name = 'test-plugin/file-path-icon'; - $settings = array( - 'label' => 'Icon', - 'file_path' => $file_path, + $result = $this->registry->register( + 'test-collection/file-path-icon', + array( + 'label' => 'Icon', + 'file_path' => $path, + ) ); - $result = $this->register( $name, $settings ); $this->assertTrue( $result ); - $this->assertTrue( $this->registry->is_registered( $name ) ); - - $registered_icons = $this->registry->get_registered_icons( $name ); - $this->assertCount( 1, $registered_icons ); - $this->assertStringContainsString( 'assertTrue( $this->registry->is_registered( 'test-collection/file-path-icon' ) ); - unlink( $file_path ); + $icon = $this->registry->get_registered_icon( 'test-collection/file-path-icon' ); + $this->assertStringContainsString( ' 'Icon', - 'content' => '', - 'file_path' => '/path/to/icon.svg', + $result = $this->registry->register( + 'test-collection/content-and-file-path', + array( + 'label' => 'Icon', + 'content' => '', + 'file_path' => '/path/to/icon.svg', + ) ); - - $result = $this->register( $name, $settings ); $this->assertFalse( $result ); - $this->assertFalse( $this->registry->is_registered( $name ) ); + $this->assertFalse( $this->registry->is_registered( 'test-collection/content-and-file-path' ) ); } /** @@ -168,13 +290,141 @@ public function test_register_icon_with_content_and_file_path() { * @expectedIncorrectUsage WP_Icons_Registry::register */ public function test_register_icon_without_content_or_file_path() { - $name = 'test-plugin/no-content'; - $settings = array( - 'label' => 'Icon', + $result = $this->registry->register( + 'test-collection/no-content', + array( + 'label' => 'Icon', + ) ); - - $result = $this->register( $name, $settings ); $this->assertFalse( $result ); - $this->assertFalse( $this->registry->is_registered( $name ) ); + $this->assertFalse( $this->registry->is_registered( 'test-collection/no-content' ) ); + } + + /** + * @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( + 'test-collection/shared', + array( + 'label' => 'Shared A', + 'content' => '', + ) + ) + ); + $this->assertTrue( + $this->registry->register( + 'other-collection/shared', + array( + 'label' => 'Shared B', + 'content' => '', + ) + ) + ); + + $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( + 'test-collection/my-icon', + array( + 'label' => 'Icon', + 'content' => '', + ) + ); + + $this->assertTrue( $this->registry->is_registered( 'test-collection/my-icon' ) ); + $this->assertTrue( $this->registry->unregister( 'test-collection/my-icon' ) ); + $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( 'test-collection/ghost' ) ); + } + + /** + * @ticket 64651 + * + * @covers ::get_content + */ + public function test_get_content_reads_from_valid_file_path() { + $path = $this->create_temp_icon_file( '' ); + + $this->registry->register( + 'test-collection/from-file', + array( + 'label' => 'From File', + 'file_path' => $path, + ) + ); + + $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( + 'test-collection/invalid-file', + array( + 'label' => 'Invalid File', + 'file_path' => $path, + ) + ); + + 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'] ); } } 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 a3e0c1d96a063..dc899ce2bd7be 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 * @@ -39,7 +54,84 @@ 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-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 ); + } + + /** + * @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( + 'rest-test-collection/bell', + 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( + 'rest-test-collection/bell', + 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' ); } /** @@ -122,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' ); @@ -155,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' ); @@ -223,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 ); /* @@ -275,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' ); @@ -393,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' ); diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 2b99a10fecca2..9ee6cc4cbed16 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -200,8 +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-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', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 14a26f8301e3b..1be56017826ff 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12810,6 +12810,12 @@ mockedApiResponse.Schema = { "description": "Limit results to those matching a string.", "type": "string", "required": false + }, + "collection": { + "description": "Limit results to icons belonging to the given collection slug.", + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$", + "required": false } } } @@ -12822,7 +12828,59 @@ 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])?)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "collection": { + "description": "Limit results to icons belonging to the given collection slug.", + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$", + "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-z0-9](?:[a-z0-9_-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?)": { "namespace": "wp/v2", "methods": [ "GET" @@ -12853,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": [