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..b396b50856bd4 --- /dev/null +++ b/src/wp-includes/class-wp-icon-collections-registry.php @@ -0,0 +1,218 @@ +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(); + 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 f82739fc5d91d..a4ea13da37496 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'], - 'filePath' => $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. * - * @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. */ - 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 +70,44 @@ protected function register( $icon_name, $icon_properties ) { return false; } - $allowed_keys = array_fill_keys( array( 'label', 'content', 'filePath' ), 1 ); + if ( ! is_array( $icon_properties ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon properties must be an array.' ), + '7.1.0' + ); + return false; + } + + // 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.' ), + '7.1.0' + ); + return false; + } + + $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path' ), 1 ); foreach ( array_keys( $icon_properties ) as $key ) { if ( ! array_key_exists( $key, $allowed_keys ) ) { _doing_it_wrong( __METHOD__, sprintf( - // translators: %s is the name of any user-provided key + /* translators: %s: The name of a user-provided key. */ __( 'Invalid icon property: "%s".' ), $key ), @@ -131,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__, @@ -141,12 +140,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; @@ -173,16 +172,57 @@ 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; + } + /** * Sanitizes the icon SVG content. * @@ -233,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 ]['filePath'] - ); - $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( @@ -274,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. @@ -282,8 +337,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'] ); 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..bb8e5da3ef08e --- /dev/null +++ b/src/wp-includes/icons.php @@ -0,0 +1,143 @@ +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'], + ) + ); + } +} 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..69efbad4adfad 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( @@ -49,6 +49,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-]*)', @@ -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 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 ); } @@ -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 `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/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..a2b5a43dd489d --- /dev/null +++ b/tests/phpunit/tests/icons/wpIconCollectionsRegistry.php @@ -0,0 +1,174 @@ +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' ), + 'uppercase characters' => array( 'Plugin' ), + 'underscore' => array( 'my_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( + 'plugin-a/alpha', + array( + 'label' => 'Alpha', + 'content' => '', + ) + ); + $icons->register( + 'plugin-a/beta', + array( + 'label' => 'Beta', + 'content' => '', + ) + ); + $icons->register( + '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' ); + } + + /** + * @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..e9fef3978b1c8 --- /dev/null +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -0,0 +1,334 @@ +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' ); + } + + 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 + * + * @covers ::register + */ + public function test_register_icon() { + $result = $this->registry->register( + 'test-collection/my-icon', + array( + 'label' => 'My Icon', + 'content' => '', + ) + ); + + $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 ), + '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' ), + ); + } + + /** + * @ticket 64651 + * + * @covers ::register + * + * @expectedIncorrectUsage WP_Icons_Registry::register + */ + public function test_register_icon_twice() { + $settings = array( + 'label' => 'Icon', + 'content' => '', + ); + + $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 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_rejects_non_namespaced_name() { + $result = $this->registry->register( + 'my-icon', + array( + 'label' => 'Icon', + 'content' => '', + ) + ); + $this->assertFalse( $result ); + } + + /** + * 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_collection_property() { + $result = $this->registry->register( + 'test-collection/my-icon', + array( + 'label' => 'Icon', + 'content' => '', + 'collection' => 'test-collection', + ) + ); + $this->assertFalse( $result ); + } + + /** + * 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 ); + } + + /** + * @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/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index f6fd935061f0e..ce490d48d06f8 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -39,9 +39,86 @@ 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( + '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' ); + } + /** * @doesNotPerformAssertions */ 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', 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": [