diff --git a/.gitignore b/.gitignore index 3997df4c9d603..591a8bb299f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ wp-tests-config.php /src/wp-includes/css/*-rtl.css /src/wp-includes/blocks/* !/src/wp-includes/blocks/index.php +/src/wp-includes/icons /src/wp-includes/build /src/wp-includes/theme.json /packagehash.txt diff --git a/src/wp-includes/class-wp-icons-registry.php b/src/wp-includes/class-wp-icons-registry.php new file mode 100644 index 0000000000000..570d068c2ed09 --- /dev/null +++ b/src/wp-includes/class-wp-icons-registry.php @@ -0,0 +1,308 @@ + $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.', 'gutenberg' ), + '7.0.0' + ); + return; + } + + if ( ! ( $icon_data['public'] ?? false ) ) { + continue; + } + + $this->register( + 'core/' . $icon_name, + array( + 'label' => $icon_data['label'], + 'filePath' => $icons_directory . $icon_data['filePath'], + ) + ); + } + } + + /** + * Registers an icon. + * + * @param string $icon_name Icon name including namespace. + * @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. + * } + * @return bool True if the icon was registered with success and false otherwise. + */ + private function register( $icon_name, $icon_properties ) { + if ( ! isset( $icon_name ) || ! is_string( $icon_name ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon name must be a string.', 'gutenberg' ), + '7.0.0' + ); + return false; + } + + $allowed_keys = array_fill_keys( array( 'label', 'content', 'filePath' ), 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 + __( 'Invalid icon property: "%s".', 'gutenberg' ), + $key + ), + '7.0.0' + ); + return false; + } + } + + if ( ! isset( $icon_properties['label'] ) || ! is_string( $icon_properties['label'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon label must be a string.', 'gutenberg' ), + '7.0.0' + ); + return false; + } + + if ( + ( ! isset( $icon_properties['content'] ) && ! isset( $icon_properties['filePath'] ) ) || + ( isset( $icon_properties['content'] ) && isset( $icon_properties['filePath'] ) ) + ) { + _doing_it_wrong( + __METHOD__, + __( 'Icons must provide either `content` or `filePath`.', 'gutenberg' ), + '7.0.0' + ); + return false; + } + + if ( isset( $icon_properties['content'] ) ) { + if ( ! is_string( $icon_properties['content'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon content must be a string.', 'gutenberg' ), + '7.0.0' + ); + return false; + } + + $sanitized_icon_content = $this->sanitize_icon_content( $icon_properties['content'] ); + if ( empty( $sanitized_icon_content ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon content does not contain valid SVG markup.', 'gutenberg' ), + '7.0.0' + ); + return false; + } + } + + $icon = array_merge( + $icon_properties, + array( 'name' => $icon_name ) + ); + + $this->registered_icons[ $icon_name ] = $icon; + + return true; + } + + /** + * Sanitizes the icon SVG content. + * + * Logic borrowed from twentytwenty. + * @see twentytwenty_get_theme_svg + * + * @param string $icon_content The icon SVG content to sanitize. + * @return string The sanitized icon SVG content. + */ + private function sanitize_icon_content( $icon_content ) { + $allowed_tags = array( + 'svg' => array( + 'class' => true, + 'xmlns' => true, + 'width' => true, + 'height' => true, + 'viewbox' => true, + 'aria-hidden' => true, + 'role' => true, + 'focusable' => true, + ), + 'path' => array( + 'fill' => true, + 'fill-rule' => true, + 'd' => true, + 'transform' => true, + ), + 'polygon' => array( + 'fill' => true, + 'fill-rule' => true, + 'points' => true, + 'transform' => true, + 'focusable' => true, + ), + ); + return wp_kses( $icon_content, $allowed_tags ); + } + + /** + * Retrieves the content of a registered icon. + * + * @param string $icon_name Icon name including namespace. + * @return string|null The content of the icon, if found. + */ + private 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 ); + + if ( empty( $content ) ) { + wp_trigger_error( + __METHOD__, + __( 'Icon content does not contain valid SVG markup.', 'gutenberg' ) + ); + return null; + } + + $this->registered_icons[ $icon_name ]['content'] = $content; + } + return $this->registered_icons[ $icon_name ]['content']; + } + + /** + * Retrieves an array containing the properties of a registered icon. + * + * + * @param string $icon_name Icon name including namespace. + * @return array|null Registered icon properties or `null` if the icon is not registered. + */ + public function get_registered_icon( $icon_name ) { + if ( ! $this->is_registered( $icon_name ) ) { + return null; + } + + $icon = $this->registered_icons[ $icon_name ]; + $icon['content'] = $icon['content'] ?? $this->get_content( $icon_name ); + + return $icon; + } + + /** + * Retrieves all registered icons. + * + * @param string $search Optional. Search term by which to filter the icons. + * @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 ) ) { + continue; + } + + $icon['content'] = $icon['content'] ?? $this->get_content( $icon['name'] ); + $icons[] = $icon; + } + + return $icons; + } + + /** + * Checks if an icon is registered. + * + * + * @param string $icon_name Icon name including namespace. + * @return bool True if the icon is registered, false otherwise. + */ + public function is_registered( $icon_name ) { + return isset( $this->registered_icons[ $icon_name ] ); + } + + /** + * Utility method to retrieve the main instance of the class. + * + * The instance will be created if it does not exist yet. + * + * + * @return WP_Icons_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/rest-api.php b/src/wp-includes/rest-api.php index c4fce5a43e7d8..981892025c2a3 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -424,6 +424,10 @@ function create_initial_rest_routes() { $abilities_run_controller->register_routes(); $abilities_list_controller = new WP_REST_Abilities_V1_List_Controller(); $abilities_list_controller->register_routes(); + + // Icons. + $icons_controller = new WP_REST_Icons_Controller(); + $icons_controller->register_routes(); } /** 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 new file mode 100644 index 0000000000000..84c233c120df6 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-icons-controller.php @@ -0,0 +1,250 @@ +namespace = 'wp/v2'; + $this->rest_base = 'icons'; + } + + /** + * Registers the routes for the objects of the controller. + */ + 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-z][a-z0-9-]*/[a-z][a-z0-9-]*)', + array( + 'args' => array( + 'name' => array( + 'description' => __( 'Icon name.', 'gutenberg' ), + '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 icons. + * + * @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 icons.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Checks if a given request has access to read a specific icon. + * + * @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 icons. + * + * @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(); + $search = $request->get_param( 'search' ); + $icons = WP_Icons_Registry::get_instance()->get_registered_icons( $search ); + foreach ( $icons as $icon ) { + $prepared_icon = $this->prepare_item_for_response( $icon, $request ); + $response[] = $this->prepare_response_for_collection( $prepared_icon ); + } + return rest_ensure_response( $response ); + } + + /** + * Retrieves a specific icon. + * + * @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 ) { + $icon = $this->get_icon( $request['name'] ); + if ( is_wp_error( $icon ) ) { + return $icon; + } + + $data = $this->prepare_item_for_response( $icon, $request ); + return rest_ensure_response( $data ); + } + + /** + * Retrieves a specific icon from the registry. + * + * @param string $name Icon name. + * @return array|WP_Error Icon data on success, or WP_Error object on failure. + */ + public function get_icon( $name ) { + $registry = WP_Icons_Registry::get_instance(); + $icon = $registry->get_registered_icon( $name ); + + if ( null === $icon ) { + 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. + * + * @param array $item Raw icon 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( + 'name' => 'name', + 'label' => 'label', + 'content' => 'content', + ); + $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 schema, conforming to JSON Schema. + * + * @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', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'The icon name.', 'gutenberg' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'label' => array( + 'description' => __( 'The icon label.', 'gutenberg' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'content' => array( + 'description' => __( 'The icon content (SVG markup).', 'gutenberg' ), + '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 icons collection. + * + * @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-settings.php b/src/wp-settings.php index 60c220100f539..1efb13b465216 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -286,6 +286,7 @@ require ABSPATH . WPINC . '/class-wp-http-response.php'; require ABSPATH . WPINC . '/class-wp-http-requests-response.php'; require ABSPATH . WPINC . '/class-wp-http-requests-hooks.php'; +require ABSPATH . WPINC . '/class-wp-icons-registry.php'; require ABSPATH . WPINC . '/widgets.php'; require ABSPATH . WPINC . '/class-wp-widget.php'; require ABSPATH . WPINC . '/class-wp-widget-factory.php'; @@ -344,6 +345,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; 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-abilities-v1-categories-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php'; diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php new file mode 100644 index 0000000000000..c303083a36c76 --- /dev/null +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -0,0 +1,354 @@ +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 ); + } + + /** + * @covers WP_REST_Icons_Controller::register_routes + */ + 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 ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + } + + /** + * Asserts that no icons can be created. + */ + public function test_create_item() { + $request = new WP_REST_Request( 'POST', '/wp/v2/icons' ); + $request->set_param( 'name', 'foo' ); + $request->set_param( 'label', 'Foo' ); + $request->set_param( 'content', '' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Asserts that no icons can be updated. + */ + public function test_update_item() { + $request = new WP_REST_Request( 'POST', '/wp/v2/icons/core/foo' ); + $request->set_param( 'label', 'Foo' ); + $request->set_param( 'content', '' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Asserts that no icons can be deleted. + */ + public function test_delete_item() { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/icons/core/wordpress' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item() { + // see methods test_get_item_* + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // see methods test_get_items_* + } + + /** + * @covers WP_REST_Icons_Controller::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $icon = $data[0]; + $this->assertArrayHasKey( 'name', $icon ); + $this->assertArrayHasKey( 'content', $icon ); + $this->assertIsString( $icon['name'] ); + $this->assertIsString( $icon['content'] ); + $this->assertStringStartsWith( 'dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertIsArray( $data ); + $this->assertNotEmpty( $data, 'Icon registry should contain at least one icon' ); + } + + /** + * Test that GET /wp/v2/icons requires proper permissions. + */ + public function test_get_items_requires_edit_posts_capability() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 403 ); + } + + /** + * Test that administrators can access icons. + */ + public function test_get_items_admin_has_access() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that contributors can access icons. + */ + public function test_get_items_contributor_has_access() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that GET /wp/v2/icons/core/arrow-left returns specific icon data. + */ + public function test_get_item_returns_specific_icon() { + wp_set_current_user( self::$editor_id ); + + /* + * Intentionally avoid mocks or class reflection to register fake + * icons. Yes, this blurs the line between unit and integration + * testing, but as of now WP_Icons_Registry is closed for registration + * and really MUST contain our core icons. + */ + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons/core/arrow-left' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'name', $data ); + $this->assertArrayHasKey( 'label', $data ); + $this->assertArrayHasKey( 'content', $data ); + $this->assertSame( 'core/arrow-left', $data['name'] ); + $this->assertSame( 'Arrow Left', $data['label'] ); + $this->assertNotEmpty( $data['content'] ); + $this->assertStringStartsWith( + 'assertStringContainsStringIgnoringCase( 'arrow', $icon['name'] ); + } + + // Assert that 'core/arrow-left' is specifically included in the results + $icon_names = array_column( $data, 'name' ); + $this->assertContains( 'core/arrow-left', $icon_names, 'Search results should include core/arrow-left icon' ); + } + + /** + * Test that search is case-insensitive. + */ + public function test_get_items_search_case_insensitive() { + wp_set_current_user( self::$editor_id ); + + // Test with uppercase search term + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $request->set_param( 'search', 'ARROW' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + + // All returned icons should contain "arrow" in their name (case insensitive) + foreach ( $data as $icon ) { + $this->assertStringContainsStringIgnoringCase( 'arrow', $icon['name'] ); + } + } + + /** + * Test that search with no matches returns empty array. + */ + public function test_get_items_search_no_matches() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $request->set_param( 'search', 'nonexistenticon12345' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertIsArray( $data ); + $this->assertEmpty( $data ); + } + + /** + * Test that _fields parameter filters response fields. + */ + public function test_get_items_fields_parameter() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $request->set_param( '_fields', 'name' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + + // Each icon should only have the 'name' field + foreach ( $data as $icon ) { + $this->assertArrayHasKey( 'name', $icon ); + $this->assertArrayNotHasKey( 'content', $icon ); + } + } + + /** + * Test permissions for getting a specific icon. + */ + public function test_get_item_requires_permissions() { + // 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' ); + $list_response = rest_get_server()->dispatch( $list_request ); + + // Icons endpoint must be available + $this->assertSame( 200, $list_response->get_status(), 'Icons endpoint should be available and return 200' ); + + $all_icons = $list_response->get_data(); + + // Registry should contain at least our test icon + $this->assertIsArray( $all_icons, 'Icons endpoint should return an array' ); + $this->assertNotEmpty( $all_icons, 'Icon registry should contain at least one icon' ); + $this->assertArrayHasKey( 'name', $all_icons[0], 'Icons should have a name field' ); + + $test_icon_name = $all_icons[0]['name']; + + // Now test with subscriber (no permissions) + wp_set_current_user( self::$subscriber_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/icons/' . $test_icon_name ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 403 ); + } + + /** + * Test that unauthenticated users cannot access icons. + */ + public function test_get_items_requires_authentication() { + wp_set_current_user( 0 ); // No user + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 401 ); + } + + /** + * Test that unauthenticated users cannot access specific icons. + */ + public function test_get_item_requires_authentication() { + wp_set_current_user( 0 ); // No user + + $request = new WP_REST_Request( 'GET', '/wp/v2/icons/core/some-icon' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 401 ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index b5e9a177a7765..9c6c431e5ef35 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -195,6 +195,8 @@ 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/icons', + '/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)', '/wp-abilities/v1', '/wp-abilities/v1/categories', '/wp-abilities/v1/categories/(?P[a-z0-9]+(?:-[a-z0-9]+)*)', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 675e53b496673..1623eca0c0f47 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12608,6 +12608,90 @@ mockedApiResponse.Schema = { } } ] + }, + "/wp/v2/icons": { + "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/icons" + } + ] + } + }, + "/wp/v2/icons/(?P[a-z][a-z0-9-]*/[a-z][a-z0-9-]*)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "name": { + "description": "Icon name.", + "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 + } + } + } + ] } }, "site_logo": 0, diff --git a/tools/gutenberg/copy-gutenberg-build.js b/tools/gutenberg/copy-gutenberg-build.js index e5332f806f74a..fdd332c35eb24 100644 --- a/tools/gutenberg/copy-gutenberg-build.js +++ b/tools/gutenberg/copy-gutenberg-build.js @@ -12,6 +12,7 @@ const fs = require( 'fs' ); const path = require( 'path' ); const json2php = require( 'json2php' ); +const glob = require( 'glob' ); // Paths const rootDir = path.resolve( __dirname, '../..' ); @@ -98,6 +99,18 @@ const COPY_CONFIG = { { from: 'theme-i18n.json', to: 'theme-i18n.json' }, ], }, + + // Specific files to copy to wp-includes/$destination + wpIncludes: [ + { + files: [ 'packages/icons/src/manifest.php' ], + destination: 'icons', + }, + { + files: [ 'packages/icons/src/library/*.svg' ], + destination: 'icons/library', + }, + ], }; /** @@ -982,10 +995,7 @@ async function main() { } } } - } else if ( - entry.isFile() && - entry.name.endsWith( '.js' ) - ) { + } else if ( entry.isFile() && entry.name.endsWith( '.js' ) ) { // Copy root-level JS files const dest = path.join( scriptsDest, entry.name ); fs.mkdirSync( path.dirname( dest ), { recursive: true } ); @@ -1057,6 +1067,25 @@ async function main() { } } + // Copy remaining files to wp-includes + console.log( '\n📦 Copying remaining files to wp-includes...' ); + for ( const fileMap of COPY_CONFIG.wpIncludes ) { + const dest = path.join( wpIncludesDir, fileMap.destination ); + fs.mkdirSync( dest, { recursive: true } ); + for ( const src of fileMap.files ) { + const matches = glob.sync( path.join( gutenbergDir, src ) ); + if ( ! matches.length ) { + throw new Error( `No files found matching '${ src }'` ); + } + for ( const match of matches ) { + fs.copyFileSync( + match, + path.join( dest, path.basename( match ) ) + ); + } + } + } + // 7. Generate script-modules-packages.min.php from individual asset files console.log( '\n📦 Generating script-modules-packages.min.php...' ); generateScriptModulesPackages();