Skip to content
86 changes: 70 additions & 16 deletions src/wp-includes/class-wp-icons-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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'] ) )
Expand Down Expand Up @@ -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;
}

Expand Down
26 changes: 16 additions & 10 deletions src/wp-includes/icons.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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 );
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 );
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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' ),
),
),
);

Expand Down
152 changes: 152 additions & 0 deletions tests/phpunit/tests/icons/wpIconsRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<svg></svg>',
'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' => '<svg></svg>',
)
);

$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<string, array{0: mixed}>
*/
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' => '<svg></svg>',
'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' => '<svg></svg>',
'keywords' => array( 'peace' ),
)
);
$this->registry->register(
'test-collection/anvil',
array(
'label' => 'Anvil',
'content' => '<svg></svg>',
)
);

/*
* 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' => '<svg></svg>',
'keywords' => array( 'peace' ),
)
);

$names = array_column( $this->registry->get_registered_icons( 'PEACE' ), 'name' );

$this->assertContains( 'test-collection/dove', $names );
}
}
Loading
Loading