Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backport-changelog/7.2/13453.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/13453

* https://github.com/WordPress/gutenberg/pull/82634
5 changes: 3 additions & 2 deletions bin/build-plugin-zip.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ npm ci
status "Generating build... 👷‍♀️"
npm run build

# Only including public icons when building for WordPress Core.
# Only including icons shipped to WordPress Core when building for it. An icon is shipped when its
# manifest entry carries a `public` property, whether that property is `true` or `false`.
#
# This runs before creating the archive but after the build so icon collection validation passes as expected. Plugin
# builds keep the full library.
Expand All @@ -89,7 +90,7 @@ if [ "$IS_WORDPRESS_CORE" = "true" ]; then
(
cd packages/icons/src
non_public_icons=$(comm -13 \
<(jq -r "map(select(.public) | .filePath)[]" manifest.json | sort) \
<(jq -r "map(select(.public != null) | .filePath)[]" manifest.json | sort) \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need better validation here? A "public": "false" typo would keep the SVG in the core zip .

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Validating here instead: 1fa4107

<(ls library/*.svg))
echo "$non_public_icons" | sed 's|^| Deleting packages/icons/src/|'
echo "$non_public_icons" | xargs rm
Expand Down
18 changes: 17 additions & 1 deletion lib/class-wp-icons-registry-gutenberg.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ protected function __construct() {}
* 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 block. Non-public icons
* stay available to server-side code via {@see wp_get_icon()}.
* Default true.
* }
* @return bool True if the icon was registered with success and false otherwise.
*/
Expand Down Expand Up @@ -122,7 +126,7 @@ public function register( $icon_name, $icon_properties ) {
return false;
}

$allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path' ), 1 );
$allowed_keys = array_fill_keys( array( 'label', 'content', 'file_path', 'public' ), 1 );
foreach ( array_keys( $icon_properties ) as $key ) {
if ( ! array_key_exists( $key, $allowed_keys ) ) {
_doing_it_wrong(
Expand Down Expand Up @@ -160,6 +164,15 @@ public function register( $icon_name, $icon_properties ) {
return false;
}

if ( isset( $icon_properties['public'] ) && ! is_bool( $icon_properties['public'] ) ) {
_doing_it_wrong(
__METHOD__,
__( 'Icon public property must be a boolean.', 'gutenberg' ),
'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 @@ -352,6 +365,9 @@ public static function get_instance() {
} else {
continue;
}
if ( isset( $icon['public'] ) ) {
$icon_properties['public'] = $icon['public'];
}
$gutenberg_registry->register( $icon['name'], $icon_properties );
}
}
Expand Down
30 changes: 30 additions & 0 deletions lib/class-wp-rest-icons-controller-gutenberg.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ public function get_items( $request ) {
$icons = WP_Icons_Registry::get_instance()->get_registered_icons( $search );

foreach ( $icons as $icon ) {
if ( false === ( $icon['public'] ?? true ) ) {
continue;
}
if ( null !== $collection && ( ! isset( $icon['collection'] ) || $icon['collection'] !== $collection ) ) {
continue;
}
Expand All @@ -126,6 +129,33 @@ public function get_items( $request ) {
return rest_ensure_response( $response );
}

/**
* Retrieves a specific icon from the registry.
*
* Icons registered as non-public are reported as not found. Icons registered
* without an explicit `public` property are public.
*
* @param string $name Icon name.
* @return array|WP_Error Icon data on success, or WP_Error object on failure.
*/
public function get_icon( $name ) {
$icon = parent::get_icon( $name );

if ( ! is_wp_error( $icon ) && false === ( $icon['public'] ?? true ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Icon block loads the icons through this endpoint in the editor, but uses wp_get_icon() on the front end. So if someone writes <!-- wp:icon {"icon":"core/wordpress"} /--> by hand, the icon shows on the site, but the block is empty in the editor. Is this intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh.... this is a good point. Hmm. I'd vote to also skip rendering the block on frontend: 1aab75b. What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to be consistent, so not rendering the block might be best. I think that's a fine decision to make after landing, in another PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. OK, I removed it from this PR from now in the latest commit, will raise another later!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted here: #82774

return new WP_Error(
'rest_icon_not_found',
sprintf(
// translators: %s is the name of any user-provided name
__( 'Icon not found: "%s".', 'gutenberg' ),
$name
),
array( 'status' => 404 )
);
}

return $icon;
}

/**
* Prepare a raw icon before it gets output in a REST API response.
*
Expand Down
19 changes: 13 additions & 6 deletions lib/compat/wordpress-7.1/icons.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ function wp_unregister_icon_collection( $slug ) {
* 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 block. Non-public icons
* stay available to server-side code via {@see wp_get_icon()}.
* Default true.
* }
* @return bool True if the icon was registered successfully, else false.
*/
Expand Down Expand Up @@ -131,13 +135,16 @@ function gutenberg_register_default_icons() {
return;
}

wp_register_icon(
'core/' . $icon_name,
array(
'label' => $icon_data['label'],
'file_path' => $icons_directory . $icon_data['filePath'],
)
$icon_args = array(
'label' => $icon_data['label'],
'file_path' => $icons_directory . $icon_data['filePath'],
);

if ( isset( $icon_data['public'] ) ) {
$icon_args['public'] = $icon_data['public'];
}

wp_register_icon( 'core/' . $icon_name, $icon_args );
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/icons/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
### Enhancements

- Redraw a further 141 icons as stroke-based for variable stroke-width support, following the convention introduced in [#78808](https://github.com/WordPress/gutenberg/pull/78808), and refine the already stroke-based `image` drawing. Most retain their original footprint; the table icons, `capturePhoto` and `image` are slightly smaller by design. ([#82540](https://github.com/WordPress/gutenberg/pull/82540))
- `manifest.json`: The `public` property is now a tri-state. Omitting it keeps an icon in the JS library only; `true` ships it to WordPress Core and exposes it through the icons REST API, making it selectable in the Icon block; `false` ships it and registers it in the `core` collection for server-side use via `wp_get_icon()`, while hiding it from the REST API and the Icon block. ([#82634](https://github.com/WordPress/gutenberg/pull/82634))
- The `wordpress` icon now ships to WordPress Core as a non-public icon: it is registered in the `core` collection and stays available to server-side code via `wp_get_icon( 'core/wordpress' )`, but is not exposed through the icons REST API and is not selectable in the Icon block. ([#82634](https://github.com/WordPress/gutenberg/pull/82634))

### Bug Fixes

Expand Down
5 changes: 4 additions & 1 deletion packages/icons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ To add a new icon to the library, follow these steps:
- `slug`: The icon identifier (should match the SVG filename without the `.svg` extension)
- `label`: The human-readable label for the icon. Use Title Case (for example, `My New Icon`).
- `filePath`: The relative path to the SVG file (e.g., `library/my-new-icon.svg`)
- `public` (optional): Set to `true` if you want to expose this icon as a core icon through the SVG Icons API. **Important**: Once an icon is made public, removing it is difficult, so carefully consider whether to make it public before setting this field to `true`.
- `public` (optional): Controls whether the icon is shipped to WordPress core, and if so how far it is exposed. **Important**: Once an icon ships, removing it is difficult, so carefully consider this field before setting it.
- Omit the field to keep the icon in the JS library only. It is not shipped to core.
- `true` ships the icon to core and exposes it through the SVG Icons API, which makes it selectable in the Icon block.
- `false` ships the icon to core and registers it for server-side use through `wp_get_icon()`, but hides it from the REST API and the Icon block. Use this for icons that belong to the admin UI rather than to post content, such as admin menu icons.
4. **Do not edit `manifest.php`**: The `manifest.php` file is automatically generated from `manifest.json` by the build script. Do not edit it manually, as your changes will be overwritten when the build runs.

After adding your icon, run `npm run build` to generate the TypeScript files and update `manifest.php`.
Expand Down
13 changes: 9 additions & 4 deletions packages/icons/lib/generate-manifest-php.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ function generatePHPArray( manifest ) {
const label = escapePHPString( item.label );
const filePath = escapePHPString( item.filePath );

const publicEntry =
item.public === false ? "\n\t\t'public' => false," : '';

return `${ key } => array(
'label' => _x( '${ label }', 'icon label', 'gutenberg' ),
'filePath' => '${ filePath }',
'filePath' => '${ filePath }',${ publicEntry }
),`;
} );

Expand All @@ -56,20 +59,22 @@ ${ phpEntries.join( '\n' ) }

/**
* Generates manifest.php from manifest.json.
* Only includes icons with public: true.
* Only includes icons which have the `public` key set.
*/
async function generateManifestPHP() {
const manifestJson = await readFile( MANIFEST_JSON_PATH, 'utf8' );
const manifest = JSON.parse( manifestJson );
const publicIcons = manifest.filter( ( item ) => item.public === true );
const includedIcons = manifest.filter(
( item ) => typeof item.public === 'boolean'
);
const phpHeader = `<?php
// This file is automatically generated. Do not edit directly.
if ( ! defined( 'ABSPATH' ) ) {
die( 'Silence is golden.' );
}

`;
const phpArray = generatePHPArray( publicIcons );
const phpArray = generatePHPArray( includedIcons );
const phpContent = phpHeader + phpArray + '\n';
await writeFile( MANIFEST_PHP_PATH, phpContent, 'utf8' );
}
Expand Down
14 changes: 14 additions & 0 deletions packages/icons/lib/validate-collection.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function isStrokeBasedSvg( svgContent ) {
* Validating the icons collection checks that:
*
* - Each manifest entry has a matching SVG in library/, and vice versa.
* - Each manifest entry's `public` property, if present, is a boolean.
* - Each SVG uses currentColor so icons inherit text color.
* - Each SVG uses viewBox="0 0 24 24".
* - Each stroke-based SVG contains at least one stroked graphical element.
Expand Down Expand Up @@ -60,6 +61,19 @@ async function validateCollection() {

manifestPaths.push( icon.filePath );

/*
* Verify that `public`, if present, is a boolean.
*/
if ( 'public' in icon && typeof icon.public !== 'boolean' ) {
problems.push(
`- Invalid icon definition for icon '${
icon.slug
}': expected 'public' to be true or false, saw ${ JSON.stringify(
icon.public
) }`
);
}

/*
* Verify that the corresponding SVG file is found.
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/icons/src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1790,6 +1790,7 @@
{
"slug": "wordpress",
"label": "WordPress",
"filePath": "library/wordpress.svg"
"filePath": "library/wordpress.svg",
"public": false
}
]
5 changes: 5 additions & 0 deletions packages/icons/src/manifest.php
Original file line number Diff line number Diff line change
Expand Up @@ -357,4 +357,9 @@
'label' => _x( 'Verse', 'icon label', 'gutenberg' ),
'filePath' => 'library/verse.svg',
),
'wordpress' => array(
'label' => _x( 'WordPress', 'icon label', 'gutenberg' ),
'filePath' => 'library/wordpress.svg',
'public' => false,
),
);
2 changes: 1 addition & 1 deletion phpunit/blocks/render-block-icon-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function set_up() {
if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( 'core' ) ) {
gutenberg_register_default_icon_collections();
}
if ( empty( WP_Icons_Registry::get_instance()->get_registered_icons() ) ) {
if ( empty( WP_Icons_Registry_Gutenberg::get_instance()->get_registered_icons() ) ) {
gutenberg_register_default_icons();
}
}
Expand Down
5 changes: 3 additions & 2 deletions phpunit/class-wp-icon-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public function set_up() {
if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( 'core' ) ) {
gutenberg_register_default_icon_collections();
}
if ( empty( WP_Icons_Registry::get_instance()->get_registered_icons() ) ) {
if ( empty( WP_Icons_Registry_Gutenberg::get_instance()->get_registered_icons() ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make the same change in render-block-icon-test.php too? It uses WP_Icons_Registry::get_instance() right now.

@fushar fushar Sep 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, this is also done via latest commit: 1aab75b

gutenberg_register_default_icons();
}
}
Expand Down Expand Up @@ -52,7 +52,8 @@ public function test_wp_get_icon_custom_size() {

public function test_wp_get_icon_size_null_leaves_dimensions_untouched() {
$output = wp_get_icon( 'core/plus', array( 'size' => null ) );
$this->assertStringNotContainsString( 'width=', $output );
// Leading space to avoid matching `stroke-width`.
$this->assertStringNotContainsString( ' width=', $output );
$this->assertStringNotContainsString( 'height=', $output );
}

Expand Down
19 changes: 19 additions & 0 deletions phpunit/class-wp-icons-registry-gutenberg-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -501,4 +501,23 @@ public function test_register_icon_without_content_or_file_path() {
$this->assertFalse( $result );
$this->assertFalse( $this->registry->is_registered( $name ) );
}

/**
* Should fail to register an icon whose `public` property is not a boolean.
*
* @expectedIncorrectUsage WP_Icons_Registry_Gutenberg::register
*/
public function test_register_icon_rejects_non_boolean_public_property() {
$result = $this->registry->register(
'test-collection/invalid-visibility',
array(
'label' => 'Icon',
'content' => '<svg></svg>',
'public' => 'yes',
)
);

$this->assertFalse( $result );
$this->assertFalse( $this->registry->is_registered( 'test-collection/invalid-visibility' ) );
}
}
30 changes: 29 additions & 1 deletion phpunit/class-wp-rest-icon-controller-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public function set_up() {
if ( ! WP_Icon_Collections_Registry::get_instance()->is_registered( 'core' ) ) {
gutenberg_register_default_icon_collections();
}
if ( empty( WP_Icons_Registry::get_instance()->get_registered_icons() ) ) {
if ( empty( WP_Icons_Registry_Gutenberg::get_instance()->get_registered_icons() ) ) {
gutenberg_register_default_icons();
}
}
Expand Down Expand Up @@ -290,4 +290,32 @@ public function test_get_item_requires_authentication() {

$this->assertErrorResponse( 'rest_cannot_view', $response, 401 );
}

/**
* Test that icons registered as non-public are omitted from the collection.
*/
public function test_get_items_omits_non_public_icons() {
wp_set_current_user( self::$editor_id );

$request = new WP_REST_Request( 'GET', '/wp/v2/icons' );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 200, $response->get_status() );

$names = wp_list_pluck( $response->get_data(), 'name' );
$this->assertContains( 'core/plus', $names );
$this->assertNotContains( 'core/wordpress', $names );
}

/**
* Test that an icon registered as non-public is not readable by name.
*/
public function test_get_item_returns_404_for_non_public_icon() {
wp_set_current_user( self::$editor_id );

$request = new WP_REST_Request( 'GET', '/wp/v2/icons/core/wordpress' );
$response = rest_get_server()->dispatch( $request );

$this->assertErrorResponse( 'rest_icon_not_found', $response, 404 );
}
}
Loading