diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php
index 5a0296a9209cd..745e369e7c872 100644
--- a/src/wp-includes/class-wp-icons-registry.php
+++ b/src/wp-includes/class-wp-icons-registry.php
@@ -46,22 +46,24 @@ protected function __construct() {}
*
* @since 7.0.0
* @since 7.1.0 The icon name must be namespaced in the form "collection/icon-name".
- * @since 7.2.0 Added the `public` property.
+ * @since 7.2.0 Added the `public` and `keywords` properties.
*
* @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 `file_path` if set.
- * If both `content` and `file_path` are not set, the icon will not be registered.
- * @type string $file_path Optional. The full path to the file containing the icon content.
- * @type bool $public Optional. Whether the icon is exposed through the REST API, and
- * therefore selectable in the editor's icon picker. Non-public icons
- * stay available to server-side code via {@see wp_get_icon()}.
- * Default true.
+ * @type string $label Required. A human-readable label for the icon.
+ * @type string $content Optional. SVG markup for the icon.
+ * If not provided, the content will be retrieved from the `file_path` if set.
+ * If both `content` and `file_path` are not set, the icon will not be registered.
+ * @type string $file_path Optional. The full path to the file containing the icon content.
+ * @type bool $public Optional. Whether the icon is exposed through the REST API, and
+ * therefore selectable in the editor's icon picker. Non-public icons
+ * stay available to server-side code via {@see wp_get_icon()}.
+ * Default true.
+ * @type string[] $keywords Optional. Additional search terms for the icon, matched by
+ * `get_registered_icons()` alongside the name and label.
* }
* @return bool True if the icon was registered with success and false otherwise.
*/
@@ -106,7 +108,7 @@ public function register( $icon_name, $icon_properties ) {
return false;
}
- $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'public' ), 1 );
+ $allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'public', 'keywords' ), 1 );
foreach ( array_keys( $icon_properties ) as $key ) {
if ( ! array_key_exists( $key, $allowed_keys ) ) {
_doing_it_wrong(
@@ -153,6 +155,28 @@ public function register( $icon_name, $icon_properties ) {
return false;
}
+ if ( array_key_exists( 'keywords', $icon_properties ) ) {
+ if ( ! is_array( $icon_properties['keywords'] ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Icon keywords must be an array of strings.' ),
+ '7.2.0'
+ );
+ return false;
+ }
+
+ foreach ( $icon_properties['keywords'] as $keyword ) {
+ if ( ! is_string( $keyword ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Icon keywords must be an array of strings.' ),
+ '7.2.0'
+ );
+ return false;
+ }
+ }
+ }
+
if (
( ! isset( $icon_properties['content'] ) && ! isset( $icon_properties['file_path'] ) ) ||
( isset( $icon_properties['content'] ) && isset( $icon_properties['file_path'] ) )
@@ -401,23 +425,53 @@ public function get_registered_icon( $icon_name ) {
return $icon;
}
+ /**
+ * Determines whether an icon matches a search term.
+ *
+ * The term is matched case-insensitively against the icon's name, its label,
+ * and any of its keywords.
+ *
+ * @since 7.2.0
+ *
+ * @param array $icon Registered icon properties.
+ * @param string $search Search term.
+ * @return bool True if the icon matches the search term, false otherwise.
+ */
+ protected function icon_matches_search( $icon, $search ) {
+ if ( false !== stripos( $icon['name'], $search ) ) {
+ return true;
+ }
+
+ if ( false !== stripos( $icon['label'], $search ) ) {
+ return true;
+ }
+
+ foreach ( $icon['keywords'] ?? array() as $keyword ) {
+ if ( false !== stripos( $keyword, $search ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Retrieves all registered icons.
*
* @since 7.0.0
* @since 7.1.0 Search also matches icon labels.
+ * @since 7.2.0 Search also matches icon keywords.
*
- * @param string $search Optional. Search term by which to filter the icons.
+ * @param string $search Optional. Search term matched against each icon's name,
+ * label, and keywords. Default empty string, which returns
+ * every registered icon.
* @return array[] Array of arrays containing the registered icon properties.
*/
public function get_registered_icons( $search = '' ) {
$icons = array();
foreach ( $this->registered_icons as $icon ) {
- if ( ! empty( $search )
- && false === stripos( $icon['name'], $search )
- && false === stripos( $icon['label'] ?? '', $search )
- ) {
+ if ( ! empty( $search ) && ! $this->icon_matches_search( $icon, $search ) ) {
continue;
}
diff --git a/src/wp-includes/icons.php b/src/wp-includes/icons.php
index 4c906ad939bc7..e6916aaed1ed0 100644
--- a/src/wp-includes/icons.php
+++ b/src/wp-includes/icons.php
@@ -41,7 +41,7 @@ function wp_unregister_icon_collection( $slug ) {
* Registers a new icon.
*
* @since 7.1.0
- * @since 7.2.0 Added the `public` property.
+ * @since 7.2.0 Added the `public` and `keywords` properties.
*
* @param string $icon_name Namespaced icon name in the form "collection/icon-name"
* (e.g. "my-plugin/arrow-left"). The "core" collection is
@@ -51,15 +51,17 @@ function wp_unregister_icon_collection( $slug ) {
* @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.
- * @type bool $public Optional. Whether the icon is exposed through the REST API, and
- * therefore selectable in the editor's icon picker. Non-public icons
- * stay available to server-side code via {@see wp_get_icon()}.
- * Default true.
+ * @type string $label Required. A human-readable label for the icon.
+ * @type string $content Optional. SVG markup for the icon.
+ * If not provided, the content will be retrieved from the `file_path` if set.
+ * If both `content` and `file_path` are not set, the icon will not be registered.
+ * @type string $file_path Optional. The full path to the file containing the icon content.
+ * @type bool $public Optional. Whether the icon is exposed through the REST API, and
+ * therefore selectable in the editor's icon picker. Non-public icons
+ * stay available to server-side code via {@see wp_get_icon()}.
+ * Default true.
+ * @type string[] $keywords Optional. Additional search terms for the icon, matched by
+ * `get_registered_icons()` alongside the name and label.
* }
* @return bool True if the icon was registered successfully, else false.
*/
@@ -146,6 +148,10 @@ function _wp_register_default_icons() {
$icon_args['public'] = $icon_data['public'];
}
+ if ( isset( $icon_data['keywords'] ) ) {
+ $icon_args['keywords'] = $icon_data['keywords'];
+ }
+
wp_register_icon( 'core/' . $icon_name, $icon_args );
}
}
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 4578df9d28c1e..610cc582304dd 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
@@ -229,8 +229,12 @@ public function get_icon( $name ) {
/**
* Prepare a raw icon before it gets output in a REST API response.
*
+ * Adds `collection` and `keywords` fields to the base response while keeping
+ * the namespaced icon name (e.g. `core/arrow-left`) as the `name` field.
+ *
* @since 7.0.0
* @since 7.1.0 Added the `collection` field.
+ * @since 7.2.0 Added the `keywords` field.
*
* @param array $item Raw icon as registered, before any changes.
* @param WP_REST_Request $request Request object.
@@ -251,6 +255,14 @@ public function prepare_item_for_response( $item, $request ) {
}
}
+ /*
+ * Keywords are optional at registration time, but the field is always
+ * present in the response so consumers do not have to handle its absence.
+ */
+ if ( rest_is_field_included( 'keywords', $fields ) ) {
+ $data['keywords'] = isset( $item['keywords'] ) ? array_values( $item['keywords'] ) : array();
+ }
+
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
@@ -262,6 +274,7 @@ public function prepare_item_for_response( $item, $request ) {
*
* @since 7.0.0
* @since 7.1.0 Added the `collection` property.
+ * @since 7.2.0 Added the `keywords` property.
*
* @return array Item schema data.
*/
@@ -299,6 +312,15 @@ public function get_item_schema() {
'readonly' => true,
'context' => array( 'view', 'edit', 'embed' ),
),
+ 'keywords' => array(
+ 'description' => __( 'Additional search terms for the icon.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ 'readonly' => true,
+ 'context' => array( 'view', 'edit', 'embed' ),
+ ),
),
);
diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php
index 4f87bb98ede8b..621ff00b0df20 100644
--- a/tests/phpunit/tests/icons/wpIconsRegistry.php
+++ b/tests/phpunit/tests/icons/wpIconsRegistry.php
@@ -542,4 +542,156 @@ public function test_register_rejects_non_boolean_public_property() {
$this->assertFalse( $result );
$this->assertFalse( $this->registry->is_registered( 'test-collection/invalid-visibility' ) );
}
+
+ /**
+ * Should register an icon that provides a valid `keywords` array.
+ *
+ * @ticket 66158
+ */
+ public function test_register_icon_with_keywords() {
+ $name = 'test-collection/with-keywords';
+
+ $result = $this->registry->register(
+ $name,
+ array(
+ 'label' => 'Icon',
+ 'content' => '',
+ 'keywords' => array( 'alpha', 'beta' ),
+ )
+ );
+
+ $this->assertTrue( $result );
+
+ $icon = $this->registry->get_registered_icon( $name );
+ $this->assertSame( array( 'alpha', 'beta' ), $icon['keywords'] );
+ }
+
+ /**
+ * Should register an icon that omits `keywords`, since the property is optional.
+ *
+ * @ticket 66158
+ */
+ public function test_register_icon_without_keywords() {
+ $name = 'test-collection/without-keywords';
+
+ $result = $this->registry->register(
+ $name,
+ array(
+ 'label' => 'Icon',
+ 'content' => '',
+ )
+ );
+
+ $this->assertTrue( $result );
+
+ $icon = $this->registry->get_registered_icon( $name );
+ $this->assertArrayNotHasKey( 'keywords', $icon );
+ }
+
+ /**
+ * Provides values that are not an array of strings.
+ *
+ * @return array
+ */
+ public function data_invalid_keywords() {
+ return array(
+ 'null' => array( null ),
+ 'a string' => array( 'alpha' ),
+ 'an integer' => array( 5 ),
+ 'an array of integers' => array( array( 1, 2 ) ),
+ 'a mixed array' => array( array( 'alpha', 5 ) ),
+ 'a nested array' => array( array( array( 'alpha' ) ) ),
+ 'an array of null' => array( array( null ) ),
+ );
+ }
+
+ /**
+ * Should fail to register an icon whose `keywords` is not an array of strings.
+ *
+ * @ticket 66158
+ *
+ * @dataProvider data_invalid_keywords
+ * @expectedIncorrectUsage WP_Icons_Registry::register
+ *
+ * @param mixed $keywords Invalid keywords candidate.
+ */
+ public function test_register_icon_with_invalid_keywords( $keywords ) {
+ $name = 'test-collection/invalid-keywords';
+
+ $result = $this->registry->register(
+ $name,
+ array(
+ 'label' => 'Icon',
+ 'content' => '',
+ 'keywords' => $keywords,
+ )
+ );
+
+ $this->assertFalse( $result );
+ $this->assertFalse( $this->registry->is_registered( $name ) );
+ }
+
+ /**
+ * Should match an icon by keyword when neither its name nor its label match.
+ *
+ * @ticket 66158
+ */
+ public function test_get_registered_icons_matches_keywords() {
+ $this->registry->register(
+ 'test-collection/dove',
+ array(
+ 'label' => 'Dove',
+ 'content' => '',
+ 'keywords' => array( 'peace' ),
+ )
+ );
+ $this->registry->register(
+ 'test-collection/anvil',
+ array(
+ 'label' => 'Anvil',
+ 'content' => '',
+ )
+ );
+
+ /*
+ * The search term is deliberately absent from both the name and the label,
+ * so a match can only come from the keywords.
+ */
+ $icon = $this->registry->get_registered_icon( 'test-collection/dove' );
+ $this->assertStringNotContainsStringIgnoringCase( 'peace', $icon['name'] );
+ $this->assertStringNotContainsStringIgnoringCase( 'peace', $icon['label'] );
+
+ $names = array_column( $this->registry->get_registered_icons( 'peace' ), 'name' );
+
+ $this->assertContains(
+ 'test-collection/dove',
+ $names,
+ 'Search results should include an icon matched only by its keyword'
+ );
+ $this->assertNotContains(
+ 'test-collection/anvil',
+ $names,
+ 'Search results should exclude an icon that matches on no property'
+ );
+ }
+
+ /**
+ * Should match keywords case-insensitively, as names and labels are.
+ *
+ * @ticket 66158
+ */
+ public function test_get_registered_icons_matches_keywords_case_insensitively() {
+ $this->registry->register(
+ 'test-collection/dove',
+ array(
+ 'label' => 'Dove',
+ 'content' => '',
+ 'keywords' => array( 'peace' ),
+ )
+ );
+
+ $names = array_column( $this->registry->get_registered_icons( 'PEACE' ), 'name' );
+
+ $this->assertContains( 'test-collection/dove', $names );
+ }
}
diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php
index 612c2f4ab846d..cdc9793242461 100644
--- a/tests/phpunit/tests/icons/wpRestIconsController.php
+++ b/tests/phpunit/tests/icons/wpRestIconsController.php
@@ -249,6 +249,7 @@ public function test_prepare_item() {
/**
* @ticket 40538
* @ticket 64651
+ * @ticket 66158
*
* @covers ::get_item_schema
*/
@@ -258,11 +259,12 @@ public function test_get_item_schema() {
$data = $response->get_data();
$properties = $data['schema']['properties'];
- $this->assertCount( 4, $properties );
+ $this->assertCount( 5, $properties );
$this->assertArrayHasKey( 'name', $properties );
$this->assertArrayHasKey( 'label', $properties );
$this->assertArrayHasKey( 'content', $properties );
$this->assertArrayHasKey( 'collection', $properties );
+ $this->assertArrayHasKey( 'keywords', $properties );
}
/**
@@ -432,6 +434,118 @@ public function test_get_items_search_includes_label() {
$this->assertSame( array( 'core/at-symbol' ), array_column( $data, 'name' ) );
}
+ /**
+ * Test that GET /wp/v2/icons/?search=%s searches icon keywords too.
+ *
+ * @ticket 66158
+ */
+ public function test_get_items_search_includes_keywords() {
+ wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) );
+ wp_register_icon(
+ 'rest-test-collection/dove',
+ array(
+ 'label' => 'Dove',
+ 'content' => '',
+ 'keywords' => array( 'peace' ),
+ )
+ );
+ wp_register_icon(
+ 'rest-test-collection/anvil',
+ array(
+ 'label' => 'Anvil',
+ 'content' => '',
+ )
+ );
+
+ wp_set_current_user( self::$editor_id );
+
+ try {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/icons' );
+
+ /*
+ * The search term appears in no icon's name or label, so a match can
+ * only come from the keywords.
+ */
+ $request->set_param( 'search', 'peace' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame(
+ array( 'rest-test-collection/dove' ),
+ array_column( $data, 'name' ),
+ 'Search results should contain only the icon matched by its keyword'
+ );
+ } finally {
+ wp_unregister_icon_collection( 'rest-test-collection' );
+ }
+ }
+
+ /**
+ * Test that the response exposes an icon's keywords, so that clients which
+ * filter icons locally can match against them.
+ *
+ * @ticket 66158
+ */
+ public function test_get_items_response_includes_keywords() {
+ wp_register_icon_collection( 'rest-test-collection', array( 'label' => 'REST Test' ) );
+ wp_register_icon(
+ 'rest-test-collection/dove',
+ array(
+ 'label' => 'Dove',
+ 'content' => '',
+ 'keywords' => array( 'peace', 'bird' ),
+ )
+ );
+
+ wp_set_current_user( self::$editor_id );
+
+ try {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/icons' );
+ $request->set_param( 'search', 'rest-test-collection/dove' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( 1, $data );
+ $this->assertArrayHasKey( 'keywords', $data[0] );
+ $this->assertSame( array( 'peace', 'bird' ), $data[0]['keywords'] );
+ } finally {
+ wp_unregister_icon_collection( 'rest-test-collection' );
+ }
+ }
+
+ /**
+ * Test that icons registered without keywords still expose an empty array,
+ * so consumers do not have to handle a missing field.
+ *
+ * @ticket 66158
+ */
+ public function test_get_items_response_keywords_defaults_to_empty_array() {
+ wp_set_current_user( self::$editor_id );
+
+ wp_register_icon(
+ 'core/no-keywords',
+ array(
+ 'label' => 'No Keywords',
+ 'content' => '',
+ )
+ );
+
+ try {
+ $request = new WP_REST_Request( 'GET', '/wp/v2/icons' );
+ $request->set_param( 'search', 'core/no-keywords' );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( 1, $data );
+ $this->assertSame( array(), $data[0]['keywords'] );
+ } finally {
+ wp_unregister_icon( 'core/no-keywords' );
+ }
+ }
+
/**
* Test that search is case-insensitive.
*