diff --git a/src/wp-includes/class-wp-theme-json-resolver.php b/src/wp-includes/class-wp-theme-json-resolver.php index 59c5f54b96e45..db3a48c5c82f5 100644 --- a/src/wp-includes/class-wp-theme-json-resolver.php +++ b/src/wp-includes/class-wp-theme-json-resolver.php @@ -744,4 +744,82 @@ public static function get_style_variations() { } return $variations; } + + /** + * Resolves relative paths in theme.json styles to theme absolute paths + * and returns them in an array that can be embedded + * as the value of `_link` object in REST API responses. + * + * @since 6.6.0 + * + * @param WP_Theme_JSON $theme_json A theme json instance. + * @return array An array of resolved paths. + */ + public static function get_resolved_theme_uris( $theme_json ) { + $resolved_theme_uris = array(); + + if ( ! $theme_json instanceof WP_Theme_JSON ) { + return $resolved_theme_uris; + } + + $theme_json_data = $theme_json->get_raw_data(); + + // Top level styles. + $background_image_url = isset( $theme_json_data['styles']['background']['backgroundImage']['url'] ) ? $theme_json_data['styles']['background']['backgroundImage']['url'] : null; + + /* + * The same file convention when registering web fonts. + * See: WP_Font_Face_Resolver:: to_theme_file_uri. + */ + $placeholder = 'file:./'; + if ( + isset( $background_image_url ) && + is_string( $background_image_url ) && + // Skip if the src doesn't start with the placeholder, as there's nothing to replace. + str_starts_with( $background_image_url, $placeholder ) + ) { + $file_type = wp_check_filetype( $background_image_url ); + $src_url = str_replace( $placeholder, '', $background_image_url ); + $resolved_theme_uri = array( + 'name' => $background_image_url, + 'href' => sanitize_url( get_theme_file_uri( $src_url ) ), + 'target' => 'styles.background.backgroundImage.url', + ); + if ( isset( $file_type['type'] ) ) { + $resolved_theme_uri['type'] = $file_type['type']; + } + $resolved_theme_uris[] = $resolved_theme_uri; + } + + return $resolved_theme_uris; + } + + /** + * Resolves relative paths in theme.json styles to theme absolute paths + * and merges them with incoming theme JSON. + * + * @since 6.6.0 + * + * @param WP_Theme_JSON $theme_json A theme json instance. + * @return WP_Theme_JSON Theme merged with resolved paths, if any found. + */ + public static function resolve_theme_file_uris( $theme_json ) { + $resolved_urls = static::get_resolved_theme_uris( $theme_json ); + if ( empty( $resolved_urls ) ) { + return $theme_json; + } + + $resolved_theme_json_data = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + ); + + foreach ( $resolved_urls as $resolved_url ) { + $path = explode( '.', $resolved_url['target'] ); + _wp_array_set( $resolved_theme_json_data, $path, $resolved_url['href'] ); + } + + $theme_json->merge( new WP_Theme_JSON( $resolved_theme_json_data ) ); + + return $theme_json; + } } diff --git a/src/wp-includes/global-styles-and-settings.php b/src/wp-includes/global-styles-and-settings.php index fd1890c53aba2..fbf4fe2c52c3e 100644 --- a/src/wp-includes/global-styles-and-settings.php +++ b/src/wp-includes/global-styles-and-settings.php @@ -139,6 +139,7 @@ function wp_get_global_styles( $path = array(), $context = array() ) { * * @since 5.9.0 * @since 6.1.0 Added 'base-layout-styles' support. + * @since 6.6.0 Resolves relative paths in theme.json styles to theme absolute paths. * * @param array $types Optional. Types of styles to load. * It accepts as values 'variables', 'presets', 'styles', 'base-layout-styles'. @@ -179,9 +180,9 @@ function wp_get_global_stylesheet( $types = array() ) { } } - $tree = WP_Theme_JSON_Resolver::get_merged_data(); - + $tree = WP_Theme_JSON_Resolver::resolve_theme_file_uris( WP_Theme_JSON_Resolver::get_merged_data() ); $supports_theme_json = wp_theme_has_theme_json(); + if ( empty( $types ) && ! $supports_theme_json ) { $types = array( 'variables', 'presets', 'base-layout-styles' ); } elseif ( empty( $types ) ) { diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php index e9d5006d53b14..3b2caf88fe65b 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php @@ -289,6 +289,7 @@ protected function prepare_item_for_database( $request ) { * Prepare a global styles config output for response. * * @since 5.9.0 + * @since 6.6.0 Added custom relative theme file URIs to `_links`. * * @param WP_Post $post Global Styles post object. * @param WP_REST_Request $request Request object. @@ -298,8 +299,10 @@ public function prepare_item_for_response( $post, $request ) { $raw_config = json_decode( $post->post_content, true ); $is_global_styles_user_theme_json = isset( $raw_config['isGlobalStylesUserThemeJSON'] ) && true === $raw_config['isGlobalStylesUserThemeJSON']; $config = array(); + $theme_json = null; if ( $is_global_styles_user_theme_json ) { - $config = ( new WP_Theme_JSON( $raw_config, 'custom' ) )->get_raw_data(); + $theme_json = new WP_Theme_JSON( $raw_config, 'custom' ); + $config = $theme_json->get_raw_data(); } // Base fields for every post. @@ -341,6 +344,15 @@ public function prepare_item_for_response( $post, $request ) { if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { $links = $this->prepare_links( $post->ID ); + + // Only return resolved URIs for get requests to user theme JSON. + if ( $theme_json ) { + $resolved_theme_uris = WP_Theme_JSON_Resolver::get_resolved_theme_uris( $theme_json ); + if ( ! empty( $resolved_theme_uris ) ) { + $links['https://api.w.org/theme-file'] = $resolved_theme_uris; + } + } + $response->add_links( $links ); if ( ! empty( $links['self']['href'] ) ) { $actions = $this->get_available_actions( $post, $request ); @@ -515,6 +527,7 @@ public function get_theme_item_permissions_check( $request ) { * Returns the given theme global styles config. * * @since 5.9.0 + * @since 6.6.0 Added custom relative theme file URIs to `_links`. * * @param WP_REST_Request $request The request instance. * @return WP_REST_Response|WP_Error @@ -549,11 +562,15 @@ public function get_theme_item( $request ) { $response = rest_ensure_response( $data ); if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { - $links = array( + $links = array( 'self' => array( 'href' => rest_url( sprintf( '%s/%s/themes/%s', $this->namespace, $this->rest_base, $request['stylesheet'] ) ), ), ); + $resolved_theme_uris = WP_Theme_JSON_Resolver::get_resolved_theme_uris( $theme ); + if ( ! empty( $resolved_theme_uris ) ) { + $links['https://api.w.org/theme-file'] = $resolved_theme_uris; + } $response->add_links( $links ); } @@ -591,6 +608,7 @@ public function get_theme_items_permissions_check( $request ) { * * @since 6.0.0 * @since 6.2.0 Returns parent theme variations, if they exist. + * @since 6.6.0 Added custom relative theme file URIs to `_links` for each item. * * @param WP_REST_Request $request The request instance. * @@ -606,9 +624,24 @@ public function get_theme_items( $request ) { ); } + $response = array(); $variations = WP_Theme_JSON_Resolver::get_style_variations(); - return rest_ensure_response( $variations ); + foreach ( $variations as $variation ) { + $variation_theme_json = new WP_Theme_JSON( $variation ); + $resolved_theme_uris = WP_Theme_JSON_Resolver::get_resolved_theme_uris( $variation_theme_json ); + $data = rest_ensure_response( $variation ); + if ( ! empty( $resolved_theme_uris ) ) { + $data->add_links( + array( + 'https://api.w.org/theme-file' => $resolved_theme_uris, + ) + ); + } + $response[] = $this->prepare_response_for_collection( $data ); + } + + return rest_ensure_response( $response ); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php index 4a37f28d370a4..54285fa560bee 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-revisions-controller.php @@ -268,6 +268,7 @@ public function get_items( $request ) { * Prepares the revision for the REST response. * * @since 6.3.0 + * @since 6.6.0 Added resolved URI links to the response. * * @param WP_Post $post Post revision object. * @param WP_REST_Request $request Request object. @@ -281,11 +282,13 @@ public function prepare_item_for_response( $post, $request ) { return $global_styles_config; } - $fields = $this->get_fields_for_response( $request ); - $data = array(); + $fields = $this->get_fields_for_response( $request ); + $data = array(); + $theme_json = null; if ( ! empty( $global_styles_config['styles'] ) || ! empty( $global_styles_config['settings'] ) ) { - $global_styles_config = ( new WP_Theme_JSON( $global_styles_config, 'custom' ) )->get_raw_data(); + $theme_json = new WP_Theme_JSON( $global_styles_config, 'custom' ); + $global_styles_config = $theme_json->get_raw_data(); if ( rest_is_field_included( 'settings', $fields ) ) { $data['settings'] = ! empty( $global_styles_config['settings'] ) ? $global_styles_config['settings'] : new stdClass(); } @@ -322,11 +325,21 @@ public function prepare_item_for_response( $post, $request ) { $data['parent'] = (int) $parent->ID; } - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + $response = rest_ensure_response( $data ); + $resolved_theme_uris = WP_Theme_JSON_Resolver::get_resolved_theme_uris( $theme_json ); - return rest_ensure_response( $data ); + if ( ! empty( $resolved_theme_uris ) ) { + $response->add_links( + array( + 'https://api.w.org/theme-file' => $resolved_theme_uris, + ) + ); + } + + return $response; } /** diff --git a/tests/phpunit/data/themedir1/block-theme/styles/variation-b.json b/tests/phpunit/data/themedir1/block-theme/styles/variation-b.json index 340198ffe0b65..23e9cb2d63391 100644 --- a/tests/phpunit/data/themedir1/block-theme/styles/variation-b.json +++ b/tests/phpunit/data/themedir1/block-theme/styles/variation-b.json @@ -14,5 +14,12 @@ } } } + }, + "styles": { + "background": { + "backgroundImage": { + "url": "file:./assets/sugarloaf-mountain.jpg" + } + } } } diff --git a/tests/phpunit/tests/rest-api/rest-global-styles-controller.php b/tests/phpunit/tests/rest-api/rest-global-styles-controller.php index efc4fdb463d36..6f920c9152bb4 100644 --- a/tests/phpunit/tests/rest-api/rest-global-styles-controller.php +++ b/tests/phpunit/tests/rest-api/rest-global-styles-controller.php @@ -34,6 +34,12 @@ class WP_REST_Global_Styles_Controller_Test extends WP_Test_REST_Controller_Test public function set_up() { parent::set_up(); switch_theme( 'tt1-blocks' ); + add_filter( 'theme_file_uri', array( $this, 'filter_theme_file_uri' ) ); + } + + public function tear_down() { + remove_filter( 'theme_file_uri', array( $this, 'filter_theme_file_uri' ) ); + parent::tear_down(); } /** @@ -79,6 +85,17 @@ public static function wpTearDownAfterClass() { self::delete_user( self::$subscriber_id ); } + /* + * This filter callback normalizes the return value from `get_theme_file_uri` + * to guard against changes in test environments. + * The test suite otherwise returns full system dir path, e.g., + * /var/www/tests/phpunit/includes/../data/themedir1/block-theme/assets/sugarloaf-mountain.jpg + */ + public function filter_theme_file_uri( $file ) { + $file_name = substr( strrchr( $file, '/' ), 1 ); + return 'https://example.org/wp-content/themes/example-theme/assets/' . $file_name; + } + /** * @covers WP_REST_Global_Styles_Controller::register_routes * @ticket 54596 @@ -119,6 +136,12 @@ public function test_context_param() { // Controller does not use get_context_param(). } + /** + * Tests a GET request to the global styles variations endpoint. + * + * @covers WP_REST_Global_Styles_Controller::get_theme_items + * @ticket 61273 + */ public function test_get_theme_items() { wp_set_current_user( self::$admin_id ); switch_theme( 'block-theme' ); @@ -128,7 +151,6 @@ public function test_get_theme_items() { $expected = array( array( 'version' => 2, - 'title' => 'variation-a', 'settings' => array( 'blocks' => array( 'core/paragraph' => array( @@ -146,10 +168,10 @@ public function test_get_theme_items() { ), ), ), + 'title' => 'variation-a', ), array( 'version' => 2, - 'title' => 'variation-b', 'settings' => array( 'blocks' => array( 'core/post-title' => array( @@ -167,6 +189,31 @@ public function test_get_theme_items() { ), ), ), + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./assets/sugarloaf-mountain.jpg', + ), + ), + ), + 'title' => 'variation-b', + '_links' => array( + 'curies' => array( + array( + 'name' => 'wp', + 'href' => 'https://api.w.org/{rel}', + 'templated' => true, + ), + ), + 'wp:theme-file' => array( + array( + 'href' => 'https://example.org/wp-content/themes/example-theme/assets/sugarloaf-mountain.jpg', + 'name' => 'file:./assets/sugarloaf-mountain.jpg', + 'target' => 'styles.background.backgroundImage.url', + 'type' => 'image/jpeg', + ), + ), + ), ), array( 'version' => 2, diff --git a/tests/phpunit/tests/theme/wpThemeJsonResolver.php b/tests/phpunit/tests/theme/wpThemeJsonResolver.php index 15e3a9a71dea6..52cd20641f45e 100644 --- a/tests/phpunit/tests/theme/wpThemeJsonResolver.php +++ b/tests/phpunit/tests/theme/wpThemeJsonResolver.php @@ -103,6 +103,7 @@ public function set_up() { add_filter( 'theme_root', array( $this, 'filter_set_theme_root' ) ); add_filter( 'stylesheet_root', array( $this, 'filter_set_theme_root' ) ); add_filter( 'template_root', array( $this, 'filter_set_theme_root' ) ); + add_filter( 'theme_file_uri', array( $this, 'filter_theme_file_uri' ) ); $this->queries = array(); // Clear caches. wp_clean_themes_cache(); @@ -113,12 +114,24 @@ public function tear_down() { $GLOBALS['wp_theme_directories'] = $this->orig_theme_dir; wp_clean_themes_cache(); unset( $GLOBALS['wp_themes'] ); + remove_filter( 'theme_file_uri', array( $this, 'filter_theme_file_uri' ) ); // Reset data between tests. wp_clean_theme_json_cache(); parent::tear_down(); } + /* + * This filter callback normalizes the return value from `get_theme_file_uri` + * to guard against changes in test environments. + * The test suite otherwise returns full system dir path, e.g., + * /var/www/tests/phpunit/includes/../data/themedir1/block-theme/assets/sugarloaf-mountain.jpg + */ + public function filter_theme_file_uri( $file ) { + $file_name = substr( strrchr( $file, '/' ), 1 ); + return 'https://example.org/wp-content/themes/example-theme/assets/' . $file_name; + } + public function filter_set_theme_root() { return $this->theme_root; } @@ -1176,4 +1189,74 @@ public function test_shadow_default_presets_value_for_block_and_classic_themes() $default_presets_for_block = $theme_json->get_settings()['shadow']['defaultPresets']; $this->assertTrue( $default_presets_for_block ); } + + /** + * Tests that relative paths are resolved and merged into the theme.json data. + * + * @covers WP_Theme_JSON_Resolver::resolve_theme_file_uris + * @ticket 61273 + */ + public function test_resolve_theme_file_uris() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./assets/image.png', + ), + ), + ), + ) + ); + + $expected_data = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'https://example.org/wp-content/themes/example-theme/assets/image.png', + ), + ), + ), + ); + + $actual = WP_Theme_JSON_Resolver::resolve_theme_file_uris( $theme_json ); + + $this->assertSame( $expected_data, $actual->get_raw_data() ); + } + + /** + * Tests that them uris are resolved and bundled with other metadata in an array. + * + * @covers WP_Theme_JSON_Resolver::get_resolved_theme_uris + * @ticket 61273 + */ + public function test_get_resolved_theme_uris() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./assets/image.png', + ), + ), + ), + ) + ); + + $expected_data = array( + array( + 'name' => 'file:./assets/image.png', + 'href' => 'https://example.org/wp-content/themes/example-theme/assets/image.png', + 'target' => 'styles.background.backgroundImage.url', + 'type' => 'image/png', + ), + ); + + $actual = WP_Theme_JSON_Resolver::get_resolved_theme_uris( $theme_json ); + + $this->assertSame( $expected_data, $actual ); + } }