From e7f8082eaaf1035984c48422776ef7f9aa74c65c Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 9 Oct 2025 22:57:57 +0900 Subject: [PATCH 1/2] [WIP] add icons API --- backport-changelog/6.9/TODO.md | 1 + lib/class-wp-icons-registry-gutenberg.php | 229 +++++++++++++++++ ...lass-wp-rest-icon-controller-gutenberg.php | 241 ++++++++++++++++++ lib/load.php | 2 + lib/rest-api.php | 9 + 5 files changed, 482 insertions(+) create mode 100644 backport-changelog/6.9/TODO.md create mode 100644 lib/class-wp-icons-registry-gutenberg.php create mode 100644 lib/class-wp-rest-icon-controller-gutenberg.php diff --git a/backport-changelog/6.9/TODO.md b/backport-changelog/6.9/TODO.md new file mode 100644 index 00000000000000..8b48a321841719 --- /dev/null +++ b/backport-changelog/6.9/TODO.md @@ -0,0 +1 @@ +Submit a PR to add the WP_REST_Icon_Controller and WP_Icons_Registry classes to the core. diff --git a/lib/class-wp-icons-registry-gutenberg.php b/lib/class-wp-icons-registry-gutenberg.php new file mode 100644 index 00000000000000..722b24f1e6a129 --- /dev/null +++ b/lib/class-wp-icons-registry-gutenberg.php @@ -0,0 +1,229 @@ +register( + 'core/' . $icon_name, + array( + 'name' => $icon_name, + 'content' => $svg_content, + ) + ); + } + } + + /** + * Registers an icon. + * + * @param string $icon_name Icon name including namespace. + * @param array $icon_properties { + * List of properties for the icon. + * + * @type string $title Required. A human-readable title 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' ), + '6.9.0' + ); + return false; + } + + if ( ! isset( $icon_properties['content'] ) || ! is_string( $icon_properties['content'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Icon content must be a string.', 'gutenberg' ), + '6.9.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' ), + '6.9.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. + * + * @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 The content of the icon. + * @since 6.9.0 + */ + private function get_content( $icon_name ) { + 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_name ) { + if ( ! $this->is_registered( $icon_name ) ) { + return null; + } + + $icon = $this->registered_icons[ $icon_name ]; + $content = $this->get_content( $icon_name ); + $icon['content'] = $content; + + return $icon; + } + + /** + * Retrieves all registered icons. + * + * @return array[] Array of arrays containing the registered icon properties. + */ + public function get_all_registered() { + $icons = $this->registered_icons; + + foreach ( $icons as $index => $icon ) { + $content = $this->get_content( $icon['name'] ); + $icons[ $index ]['content'] = $content; + } + + return array_values( $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 ] ); + } + + /** + * Magic method for object serialization. + * + */ + public function __wakeup() { + if ( ! $this->registered_icons ) { + return; + } + if ( ! is_array( $this->registered_icons ) ) { + throw new UnexpectedValueException(); + } + foreach ( $this->registered_icons as $value ) { + if ( ! is_array( $value ) ) { + throw new UnexpectedValueException(); + } + } + } + + /** + * 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/lib/class-wp-rest-icon-controller-gutenberg.php b/lib/class-wp-rest-icon-controller-gutenberg.php new file mode 100644 index 00000000000000..ff156af17887d8 --- /dev/null +++ b/lib/class-wp-rest-icon-controller-gutenberg.php @@ -0,0 +1,241 @@ +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; + } + + $icon = $this->get_icon( $request['name'] ); + if ( is_wp_error( $icon ) ) { + return $icon; + } + + 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(); + $icons = WP_Icons_Registry_Gutenberg::get_instance()->get_all_registered(); + $search = $request->get_param( 'search' ); + foreach ( $icons as $icon ) { + // Filter by search query if provided + if ( ! empty( $search ) && false === stripos( $icon['name'], $search ) ) { + continue; + } + $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_Gutenberg::get_instance(); + $icon = $registry->get_registered( $name ); + + if ( null === $icon ) { + return new WP_Error( + 'rest_icon_invalid_name', + __( 'Invalid icon name.', 'gutenberg' ), + 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', + 'title' => 'title', + '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' ), + ), + '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/lib/load.php b/lib/load.php index b8d91fdcf57a85..25387c2e65f512 100644 --- a/lib/load.php +++ b/lib/load.php @@ -62,6 +62,7 @@ function gutenberg_is_experiment_enabled( $name ) { // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; require_once __DIR__ . '/class-wp-rest-edit-site-export-controller-gutenberg.php'; + require_once __DIR__ . '/class-wp-rest-icon-controller-gutenberg.php'; require_once __DIR__ . '/rest-api.php'; require_once __DIR__ . '/experimental/rest-api.php'; @@ -141,6 +142,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/class-wp-theme-json-resolver-gutenberg.php'; require __DIR__ . '/class-wp-theme-json-schema-gutenberg.php'; require __DIR__ . '/class-wp-duotone-gutenberg.php'; +require __DIR__ . '/class-wp-icons-registry-gutenberg.php'; require __DIR__ . '/blocks.php'; require __DIR__ . '/block-editor-settings.php'; require __DIR__ . '/client-assets.php'; diff --git a/lib/rest-api.php b/lib/rest-api.php index 783abc24d3ee38..d0578ac166f2d0 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -36,3 +36,12 @@ function gutenberg_register_edit_site_export_controller_endpoints() { $edit_site_export_controller->register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_edit_site_export_controller_endpoints' ); + +/** + * Registers the Icons Registry REST API routes. + */ +function gutenberg_register_icon_controller_endpoints() { + $icons_registry = new WP_REST_Icon_Controller_Gutenberg(); + $icons_registry->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_icon_controller_endpoints' ); From ff278b2ffd05049cdcd527e05a5fe881e81b242e Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 15 Nov 2025 12:33:08 +0900 Subject: [PATCH 2/2] Make feature experimental --- backport-changelog/6.9/TODO.md | 1 - lib/class-wp-icons-registry-gutenberg.php | 229 ----------------- ...lass-wp-rest-icon-controller-gutenberg.php | 241 ------------------ lib/experimental/class-wp-icons-registry.php | 230 +++++++++++++++++ .../class-wp-rest-icon-controller.php | 238 +++++++++++++++++ lib/experiments-page.php | 12 + lib/load.php | 7 +- lib/rest-api.php | 10 +- 8 files changed, 491 insertions(+), 477 deletions(-) delete mode 100644 backport-changelog/6.9/TODO.md delete mode 100644 lib/class-wp-icons-registry-gutenberg.php delete mode 100644 lib/class-wp-rest-icon-controller-gutenberg.php create mode 100644 lib/experimental/class-wp-icons-registry.php create mode 100644 lib/experimental/class-wp-rest-icon-controller.php diff --git a/backport-changelog/6.9/TODO.md b/backport-changelog/6.9/TODO.md deleted file mode 100644 index 8b48a321841719..00000000000000 --- a/backport-changelog/6.9/TODO.md +++ /dev/null @@ -1 +0,0 @@ -Submit a PR to add the WP_REST_Icon_Controller and WP_Icons_Registry classes to the core. diff --git a/lib/class-wp-icons-registry-gutenberg.php b/lib/class-wp-icons-registry-gutenberg.php deleted file mode 100644 index 722b24f1e6a129..00000000000000 --- a/lib/class-wp-icons-registry-gutenberg.php +++ /dev/null @@ -1,229 +0,0 @@ -register( - 'core/' . $icon_name, - array( - 'name' => $icon_name, - 'content' => $svg_content, - ) - ); - } - } - - /** - * Registers an icon. - * - * @param string $icon_name Icon name including namespace. - * @param array $icon_properties { - * List of properties for the icon. - * - * @type string $title Required. A human-readable title 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' ), - '6.9.0' - ); - return false; - } - - if ( ! isset( $icon_properties['content'] ) || ! is_string( $icon_properties['content'] ) ) { - _doing_it_wrong( - __METHOD__, - __( 'Icon content must be a string.', 'gutenberg' ), - '6.9.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' ), - '6.9.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. - * - * @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 The content of the icon. - * @since 6.9.0 - */ - private function get_content( $icon_name ) { - 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_name ) { - if ( ! $this->is_registered( $icon_name ) ) { - return null; - } - - $icon = $this->registered_icons[ $icon_name ]; - $content = $this->get_content( $icon_name ); - $icon['content'] = $content; - - return $icon; - } - - /** - * Retrieves all registered icons. - * - * @return array[] Array of arrays containing the registered icon properties. - */ - public function get_all_registered() { - $icons = $this->registered_icons; - - foreach ( $icons as $index => $icon ) { - $content = $this->get_content( $icon['name'] ); - $icons[ $index ]['content'] = $content; - } - - return array_values( $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 ] ); - } - - /** - * Magic method for object serialization. - * - */ - public function __wakeup() { - if ( ! $this->registered_icons ) { - return; - } - if ( ! is_array( $this->registered_icons ) ) { - throw new UnexpectedValueException(); - } - foreach ( $this->registered_icons as $value ) { - if ( ! is_array( $value ) ) { - throw new UnexpectedValueException(); - } - } - } - - /** - * 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/lib/class-wp-rest-icon-controller-gutenberg.php b/lib/class-wp-rest-icon-controller-gutenberg.php deleted file mode 100644 index ff156af17887d8..00000000000000 --- a/lib/class-wp-rest-icon-controller-gutenberg.php +++ /dev/null @@ -1,241 +0,0 @@ -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; - } - - $icon = $this->get_icon( $request['name'] ); - if ( is_wp_error( $icon ) ) { - return $icon; - } - - 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(); - $icons = WP_Icons_Registry_Gutenberg::get_instance()->get_all_registered(); - $search = $request->get_param( 'search' ); - foreach ( $icons as $icon ) { - // Filter by search query if provided - if ( ! empty( $search ) && false === stripos( $icon['name'], $search ) ) { - continue; - } - $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_Gutenberg::get_instance(); - $icon = $registry->get_registered( $name ); - - if ( null === $icon ) { - return new WP_Error( - 'rest_icon_invalid_name', - __( 'Invalid icon name.', 'gutenberg' ), - 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', - 'title' => 'title', - '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' ), - ), - '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/lib/experimental/class-wp-icons-registry.php b/lib/experimental/class-wp-icons-registry.php new file mode 100644 index 00000000000000..3d5957bc065d74 --- /dev/null +++ b/lib/experimental/class-wp-icons-registry.php @@ -0,0 +1,230 @@ +register( + 'core/' . $icon_name, + array( + 'name' => $icon_name, + 'content' => $svg_content, + ) + ); + } + } + + /** + * Registers an icon. + * + * @param string $icon_name Icon name including namespace. + * @param array $icon_properties { + * List of properties for the icon. + * + * @type string $title Required. A human-readable title 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; + } + + if ( ! isset( $icon_properties['content'] ) || ! 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. + * + * @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 The content of the icon. + */ + private function get_content( $icon_name ) { + 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_name ) { + if ( ! $this->is_registered( $icon_name ) ) { + return null; + } + + $icon = $this->registered_icons[ $icon_name ]; + $content = $this->get_content( $icon_name ); + $icon['content'] = $content; + + return $icon; + } + + /** + * Retrieves all registered icons. + * + * @return array[] Array of arrays containing the registered icon properties. + */ + public function get_all_registered() { + $icons = $this->registered_icons; + + foreach ( $icons as $index => $icon ) { + $content = $this->get_content( $icon['name'] ); + $icons[ $index ]['content'] = $content; + } + + return array_values( $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 ] ); + } + + /** + * Magic method for object serialization. + * + */ + public function __wakeup() { + if ( ! $this->registered_icons ) { + return; + } + if ( ! is_array( $this->registered_icons ) ) { + throw new UnexpectedValueException(); + } + foreach ( $this->registered_icons as $value ) { + if ( ! is_array( $value ) ) { + throw new UnexpectedValueException(); + } + } + } + + /** + * 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/lib/experimental/class-wp-rest-icon-controller.php b/lib/experimental/class-wp-rest-icon-controller.php new file mode 100644 index 00000000000000..0049500c3ede2a --- /dev/null +++ b/lib/experimental/class-wp-rest-icon-controller.php @@ -0,0 +1,238 @@ +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; + } + + $icon = $this->get_icon( $request['name'] ); + if ( is_wp_error( $icon ) ) { + return $icon; + } + + 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(); + $icons = WP_Icons_Registry::get_instance()->get_all_registered(); + $search = $request->get_param( 'search' ); + foreach ( $icons as $icon ) { + // Filter by search query if provided + if ( ! empty( $search ) && false === stripos( $icon['name'], $search ) ) { + continue; + } + $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( $name ); + + if ( null === $icon ) { + return new WP_Error( + 'rest_icon_invalid_name', + __( 'Invalid icon name.', 'gutenberg' ), + 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', + 'title' => 'title', + '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' ), + ), + '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/lib/experiments-page.php b/lib/experiments-page.php index d615f79bee1dee..2ba3eb6593273a 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -199,6 +199,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-svg-icon-registry', + __( 'Icons registry', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Enables the SVG Icon registarion API.', 'gutenberg' ), + 'id' => 'gutenberg-svg-icon-registry', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/lib/load.php b/lib/load.php index 25387c2e65f512..d0c3750d9b4cb3 100644 --- a/lib/load.php +++ b/lib/load.php @@ -62,7 +62,6 @@ function gutenberg_is_experiment_enabled( $name ) { // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; require_once __DIR__ . '/class-wp-rest-edit-site-export-controller-gutenberg.php'; - require_once __DIR__ . '/class-wp-rest-icon-controller-gutenberg.php'; require_once __DIR__ . '/rest-api.php'; require_once __DIR__ . '/experimental/rest-api.php'; @@ -142,7 +141,6 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/class-wp-theme-json-resolver-gutenberg.php'; require __DIR__ . '/class-wp-theme-json-schema-gutenberg.php'; require __DIR__ . '/class-wp-duotone-gutenberg.php'; -require __DIR__ . '/class-wp-icons-registry-gutenberg.php'; require __DIR__ . '/blocks.php'; require __DIR__ . '/block-editor-settings.php'; require __DIR__ . '/client-assets.php'; @@ -189,3 +187,8 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/interactivity-api/class-gutenberg-interactivity-api-full-page-navigation.php'; Gutenberg_Interactivity_API_Full_Page_Navigation::instance(); } + +if ( gutenberg_is_experiment_enabled( 'gutenberg-svg-icon-registry' ) ) { + require __DIR__ . '/experimental/class-wp-icons-registry.php'; + require __DIR__ . '/experimental/class-wp-rest-icon-controller.php'; +} diff --git a/lib/rest-api.php b/lib/rest-api.php index d0578ac166f2d0..aad6a1fe29f6e4 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -40,8 +40,10 @@ function gutenberg_register_edit_site_export_controller_endpoints() { /** * Registers the Icons Registry REST API routes. */ -function gutenberg_register_icon_controller_endpoints() { - $icons_registry = new WP_REST_Icon_Controller_Gutenberg(); - $icons_registry->register_routes(); +if ( gutenberg_is_experiment_enabled( 'gutenberg-svg-icon-registry' ) ) { + function gutenberg_register_icon_controller_endpoints() { + $icons_registry = new WP_REST_Icon_Controller(); + $icons_registry->register_routes(); + } + add_action( 'rest_api_init', 'gutenberg_register_icon_controller_endpoints' ); } -add_action( 'rest_api_init', 'gutenberg_register_icon_controller_endpoints' );