From 692ddc7834cdae8ec3ec7066e53370466d153e25 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 20 May 2024 09:42:10 -0700 Subject: [PATCH] Background image: add support for relative theme path URLs in top-level theme.json styles (#61271) * This initial commit: - removes requirement for a `source` property in theme.json as the assumption is that, for now, all paths are paths to image files, whether absolute or relative - checks for existence of "host" in URL and then tries to resolve background image url using get_theme_file_uri - Adds a new public method WP_Theme_JSON_Gutenberg::resolve_theme_file_uris to allow theme devs to optionally resolve relative paths in theme.json to a theme. * For testing purposes, resolve in get_merged_data - should it be optional? That is, done in the global themes controller and wherever a stylesheet is generated? * Rollback test of imperative method in resolver * Moves resolution of file paths back to theme_json resolver * Backend resolution of theme file URIs for global styles. * Working on revisions Backend resolution of theme file URIs for global styles revisions Ensuring links are preserved when updating global styles. * So my linter is working again * Changed the relative link to `wp:theme-file-uris` Always adding path to the link object so that it can dynamically resolved. * Added some explanatory TODOs * Adding valid link attributes to the _link object. Updated tests * Added some unit tests for utils * Switching to using file: prefix, which is an established theme.json convention for relative paths to theme assets. E.g., web fonts Adding test image to empty theme Added theme JSON schema update Unit tests for JS helper Using response methods to add links to response collection * Fix linting * Remove TODO * Update tests * dump var_dump * Check for $theme_json before resolving * be explicit about the background value file:./ * Remove unnecessary empty check * Abstracting getting any resolved URI to separate hook Updating comments * Update lib/class-wp-theme-json-resolver-gutenberg.php * Update lib/class-wp-theme-json-resolver-gutenberg.php * Revert useGlobalStyle changes - no longer required given the new hook * Bad revert * Rename wp:theme-file-uris to wp:theme-file Rename utils file --------- Co-authored-by: ramonjd Co-authored-by: andrewserong Co-authored-by: noisysocks Co-authored-by: tellthemachines Co-authored-by: oandregal Co-authored-by: TimothyBJacobs Co-authored-by: creativecoder --- ...est-global-styles-controller-gutenberg.php | 45 ++++++-- ...class-wp-theme-json-resolver-gutenberg.php | 74 +++++++++++++ lib/compat/wordpress-6.5/rest-api.php | 10 -- ...global-styles-revisions-controller-6-6.php | 97 ++++++++++++++++++ lib/compat/wordpress-6.6/rest-api.php | 11 ++ lib/global-styles-and-settings.php | 1 + lib/load.php | 1 + .../global-styles/background-panel.js | 13 ++- .../src/components/global-styles/hooks.js | 5 + .../src/components/global-styles/index.js | 1 + .../test/theme-file-uri-utils.js | 66 ++++++++++++ .../global-styles/theme-file-uri-utils.js | 77 ++++++++++++++ .../global-styles/use-global-styles-output.js | 5 + .../global-styles/background-panel.js | 3 + .../global-styles/screen-revisions/index.js | 6 ++ .../use-global-styles-revisions.js | 1 + .../global-styles/variations/variation.js | 2 + .../global-styles-provider/index.js | 11 +- ...lobal-styles-controller-gutenberg-test.php | 26 ++++- phpunit/class-wp-theme-json-resolver-test.php | 87 ++++++++++++++++ schemas/json/theme.json | 2 +- .../img/1024x768_emptytheme_test_image.jpg | Bin 0 -> 22133 bytes test/emptytheme/styles/variation.json | 5 + 23 files changed, 525 insertions(+), 24 deletions(-) create mode 100644 lib/compat/wordpress-6.6/class-gutenberg-rest-global-styles-revisions-controller-6-6.php create mode 100644 packages/block-editor/src/components/global-styles/test/theme-file-uri-utils.js create mode 100644 packages/block-editor/src/components/global-styles/theme-file-uri-utils.js create mode 100644 test/emptytheme/img/1024x768_emptytheme_test_image.jpg diff --git a/lib/class-wp-rest-global-styles-controller-gutenberg.php b/lib/class-wp-rest-global-styles-controller-gutenberg.php index 79cb8af59df4f..3c960564a8fe0 100644 --- a/lib/class-wp-rest-global-styles-controller-gutenberg.php +++ b/lib/class-wp-rest-global-styles-controller-gutenberg.php @@ -357,6 +357,7 @@ protected function prepare_item_for_database( $request ) { * * @since 5.9.0 * @since 6.2.0 Handling of style.css was added to WP_Theme_JSON. + * @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. @@ -366,8 +367,10 @@ public function prepare_item_for_response( $post, $request ) { // phpcs:ignore V $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_Gutenberg( $raw_config, 'custom' ) )->get_raw_data(); + $theme_json = new WP_Theme_JSON_Gutenberg( $raw_config, 'custom' ); + $config = $theme_json->get_raw_data(); } // Base fields for every post. @@ -409,6 +412,13 @@ public function prepare_item_for_response( $post, $request ) { // phpcs:ignore V 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_Gutenberg::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(); @@ -620,18 +630,22 @@ public function get_theme_item( $request ) { $data['styles'] = isset( $raw_data['styles'] ) ? $raw_data['styles'] : array(); } - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - $data = $this->add_additional_fields_to_object( $data, $request ); - $data = $this->filter_response_by_context( $data, $context ); - + $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 ); 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_Gutenberg::get_resolved_theme_uris( $theme ); + if ( ! empty( $resolved_theme_uris ) ) { + $links['https://api.w.org/theme-file'] = $resolved_theme_uris; + } + $response->add_links( $links ); } @@ -671,6 +685,7 @@ public function get_theme_items_permissions_check( $request ) { // phpcs:ignore * @since 6.0.0 * @since 6.2.0 Returns parent theme variations, if they exist. * @since 6.4.0 Removed unnecessary local variable. + * @since 6.6.0 Added custom relative theme file URIs to `_links` for each item. * * @param WP_REST_Request $request The request instance. * @@ -686,9 +701,25 @@ public function get_theme_items( $request ) { ); } + $response = array(); $variations = WP_Theme_JSON_Resolver_Gutenberg::get_style_variations(); - return rest_ensure_response( $variations ); + // Add resolved theme asset links. + foreach ( $variations as $variation ) { + $variation_theme_json = new WP_Theme_JSON_Gutenberg( $variation ); + $resolved_theme_uris = WP_Theme_JSON_Resolver_Gutenberg::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/lib/class-wp-theme-json-resolver-gutenberg.php b/lib/class-wp-theme-json-resolver-gutenberg.php index dcc0bf8b099c3..5aaa2ea7e3eac 100644 --- a/lib/class-wp-theme-json-resolver-gutenberg.php +++ b/lib/class-wp-theme-json-resolver-gutenberg.php @@ -760,4 +760,78 @@ 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_Gutenberg $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_Gutenberg ) { + return $resolved_theme_uris; + } + + $theme_json_data = $theme_json->get_raw_data(); + + // Top level styles. + $background_image_url = $theme_json_data['styles']['background']['backgroundImage']['url'] ?? null; + // Using 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_Gutenberg $theme_json A theme json instance. + * @return WP_Theme_JSON_Gutenberg 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_Gutenberg::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_Gutenberg( $resolved_theme_json_data ) ); + + return $theme_json; + } } diff --git a/lib/compat/wordpress-6.5/rest-api.php b/lib/compat/wordpress-6.5/rest-api.php index 12d789fb58b86..d18756844cc91 100644 --- a/lib/compat/wordpress-6.5/rest-api.php +++ b/lib/compat/wordpress-6.5/rest-api.php @@ -10,16 +10,6 @@ die( 'Silence is golden.' ); } -/** - * Registers the Global Styles Revisions REST API routes. - */ -function gutenberg_register_global_styles_revisions_endpoints() { - $global_styles_revisions_controller = new Gutenberg_REST_Global_Styles_Revisions_Controller_6_5(); - $global_styles_revisions_controller->register_routes(); -} - -add_action( 'rest_api_init', 'gutenberg_register_global_styles_revisions_endpoints' ); - /** * Registers additional fields for wp_template and wp_template_part rest api. * diff --git a/lib/compat/wordpress-6.6/class-gutenberg-rest-global-styles-revisions-controller-6-6.php b/lib/compat/wordpress-6.6/class-gutenberg-rest-global-styles-revisions-controller-6-6.php new file mode 100644 index 0000000000000..f725366c33cfb --- /dev/null +++ b/lib/compat/wordpress-6.6/class-gutenberg-rest-global-styles-revisions-controller-6-6.php @@ -0,0 +1,97 @@ +get_parent( $request['parent'] ); + $global_styles_config = $this->get_decoded_global_styles_json( $post->post_content ); + + if ( is_wp_error( $global_styles_config ) ) { + return $global_styles_config; + } + + $fields = $this->get_fields_for_response( $request ); + $data = array(); + $theme_json = array(); + + if ( ! empty( $global_styles_config['styles'] ) || ! empty( $global_styles_config['settings'] ) ) { + $theme_json = new WP_Theme_JSON_Gutenberg( $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(); + } + if ( rest_is_field_included( 'styles', $fields ) ) { + $data['styles'] = ! empty( $global_styles_config['styles'] ) ? $global_styles_config['styles'] : new stdClass(); + } + } + + if ( rest_is_field_included( 'author', $fields ) ) { + $data['author'] = (int) $post->post_author; + } + + if ( rest_is_field_included( 'date', $fields ) ) { + $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date ); + } + + if ( rest_is_field_included( 'date_gmt', $fields ) ) { + $data['date_gmt'] = $this->prepare_date_response( $post->post_date_gmt ); + } + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = (int) $post->ID; + } + + if ( rest_is_field_included( 'modified', $fields ) ) { + $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified ); + } + + if ( rest_is_field_included( 'modified_gmt', $fields ) ) { + $data['modified_gmt'] = $this->prepare_date_response( $post->post_modified_gmt ); + } + + if ( rest_is_field_included( 'parent', $fields ) ) { + $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 ); + + $response = rest_ensure_response( $data ); + + // Add resolved URIs to the response. + $links = array(); + $resolved_theme_uris = WP_Theme_JSON_Resolver_Gutenberg::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 ); + + return $response; + } +} diff --git a/lib/compat/wordpress-6.6/rest-api.php b/lib/compat/wordpress-6.6/rest-api.php index 8526093dc99dd..54796685f45ab 100644 --- a/lib/compat/wordpress-6.6/rest-api.php +++ b/lib/compat/wordpress-6.6/rest-api.php @@ -76,3 +76,14 @@ function gutenberg_add_class_list_to_public_post_types() { } } add_action( 'rest_api_init', 'gutenberg_add_class_list_to_public_post_types' ); + + +/** + * Registers the Global Styles Revisions REST API routes. + */ +function gutenberg_register_global_styles_revisions_endpoints() { + $global_styles_revisions_controller = new Gutenberg_REST_Global_Styles_Revisions_Controller_6_6(); + $global_styles_revisions_controller->register_routes(); +} + +add_action( 'rest_api_init', 'gutenberg_register_global_styles_revisions_endpoints' ); diff --git a/lib/global-styles-and-settings.php b/lib/global-styles-and-settings.php index 70b3f7078e62a..4ceade6c7125b 100644 --- a/lib/global-styles-and-settings.php +++ b/lib/global-styles-and-settings.php @@ -28,6 +28,7 @@ function gutenberg_get_global_stylesheet( $types = array() ) { } } $tree = WP_Theme_JSON_Resolver_Gutenberg::get_merged_data(); + $tree = WP_Theme_JSON_Resolver_Gutenberg::resolve_theme_file_uris( $tree ); $supports_theme_json = wp_theme_has_theme_json(); if ( empty( $types ) && ! $supports_theme_json ) { diff --git a/lib/load.php b/lib/load.php index 3450dbf2b2402..b00c024778b5f 100644 --- a/lib/load.php +++ b/lib/load.php @@ -47,6 +47,7 @@ function gutenberg_is_experiment_enabled( $name ) { require_once __DIR__ . '/compat/wordpress-6.5/rest-api.php'; // WordPress 6.6 compat. + require __DIR__ . '/compat/wordpress-6.6/class-gutenberg-rest-global-styles-revisions-controller-6-6.php'; require __DIR__ . '/compat/wordpress-6.6/class-gutenberg-rest-templates-controller-6-6.php'; require __DIR__ . '/compat/wordpress-6.6/rest-api.php'; diff --git a/packages/block-editor/src/components/global-styles/background-panel.js b/packages/block-editor/src/components/global-styles/background-panel.js index a975de53a99d9..b8c3b6358ab5f 100644 --- a/packages/block-editor/src/components/global-styles/background-panel.js +++ b/packages/block-editor/src/components/global-styles/background-panel.js @@ -38,6 +38,7 @@ import { TOOLSPANEL_DROPDOWNMENU_PROPS } from './utils'; import { setImmutably } from '../../utils/object'; import MediaReplaceFlow from '../media-replace-flow'; import { store as blockEditorStore } from '../../store'; +import { getResolvedThemeFilePath } from './theme-file-uri-utils'; const IMAGE_BACKGROUND_TYPE = 'image'; const DEFAULT_CONTROLS = { @@ -191,6 +192,7 @@ function BackgroundImageToolsPanelItem( { onChange, style, inheritedValue, + themeFileURIs, } ) { const mediaUpload = useSelect( ( select ) => select( blockEditorStore ).getSettings().mediaUpload, @@ -301,7 +303,10 @@ function BackgroundImageToolsPanelItem( { } variant="secondary" @@ -340,6 +345,7 @@ function BackgroundSizeToolsPanelItem( { style, inheritedValue, defaultValues, + themeFileURIs, } ) { const sizeValue = style?.background?.backgroundSize || @@ -468,7 +474,7 @@ function BackgroundSizeToolsPanelItem( { @@ -553,6 +559,7 @@ export default function BackgroundPanel( { defaultControls = DEFAULT_CONTROLS, defaultValues = {}, headerLabel = __( 'Background image' ), + themeFileURIs, } ) { const resetAllFilter = useCallback( ( previousValue ) => { return { @@ -577,6 +584,7 @@ export default function BackgroundPanel( { isShownByDefault={ defaultControls.backgroundImage } style={ value } inheritedValue={ inheritedValue } + themeFileURIs={ themeFileURIs } /> { shouldShowBackgroundSizeControls && ( ) } diff --git a/packages/block-editor/src/components/global-styles/hooks.js b/packages/block-editor/src/components/global-styles/hooks.js index e0de34cf2280e..5c1e87001ca84 100644 --- a/packages/block-editor/src/components/global-styles/hooks.js +++ b/packages/block-editor/src/components/global-styles/hooks.js @@ -206,6 +206,11 @@ export function useGlobalStyle( return [ result, setStyle ]; } +export function useGlobalStyleLinks() { + const { merged: mergedConfig } = useContext( GlobalStylesContext ); + return mergedConfig?._links; +} + /** * React hook that overrides a global settings object with block and element specific settings. * diff --git a/packages/block-editor/src/components/global-styles/index.js b/packages/block-editor/src/components/global-styles/index.js index 7ad192fac9b4f..0e9aeb4c9c84e 100644 --- a/packages/block-editor/src/components/global-styles/index.js +++ b/packages/block-editor/src/components/global-styles/index.js @@ -3,6 +3,7 @@ export { useGlobalSetting, useGlobalStyle, useSettingsForBlockElement, + useGlobalStyleLinks, } from './hooks'; export { getBlockCSSSelector } from './get-block-css-selector'; export { diff --git a/packages/block-editor/src/components/global-styles/test/theme-file-uri-utils.js b/packages/block-editor/src/components/global-styles/test/theme-file-uri-utils.js new file mode 100644 index 0000000000000..06c482b67826b --- /dev/null +++ b/packages/block-editor/src/components/global-styles/test/theme-file-uri-utils.js @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import { + setThemeFileUris, + getResolvedThemeFilePath, +} from '../theme-file-uri-utils'; + +const themeFileURIs = [ + { + name: 'file:./assets/image.jpg', + href: 'https://wordpress.org/assets/image.jpg', + target: 'styles.background.backgroundImage.url', + }, + { + name: 'file:./assets/other/image.jpg', + href: 'https://wordpress.org/assets/other/image.jpg', + target: "styles.blocks.['core/group].background.backgroundImage.url", + }, +]; + +describe( 'setThemeFileUris()', () => { + const themeJson = { + styles: { + background: { + backgroundImage: { + url: 'file:./assets/image.jpg', + }, + }, + }, + }; + + it( 'should replace relative paths with resolved URIs if found in themeFileURIs', () => { + const newThemeJson = setThemeFileUris( themeJson, themeFileURIs ); + expect( + newThemeJson.styles.background.backgroundImage.url === + 'https://wordpress.org/assets/image.jpg' + ).toBe( true ); + // Object reference should be the same as the function is mutating the object. + expect( newThemeJson ).toEqual( themeJson ); + } ); +} ); + +describe( 'getResolvedThemeFilePath()', () => { + it.each( [ + [ + 'file:./assets/image.jpg', + 'https://wordpress.org/assets/image.jpg', + 'Should return absolute URL if found in themeFileURIs', + ], + [ + 'file:./misc/image.jpg', + 'file:./misc/image.jpg', + 'Should return value if not found in themeFileURIs', + ], + [ + 'https://wordpress.org/assets/image.jpg', + 'https://wordpress.org/assets/image.jpg', + 'Should not match absolute URLs', + ], + ] )( 'Given file %s and return value %s: %s', ( file, returnedValue ) => { + expect( + getResolvedThemeFilePath( file, themeFileURIs ) === returnedValue + ).toBe( true ); + } ); +} ); diff --git a/packages/block-editor/src/components/global-styles/theme-file-uri-utils.js b/packages/block-editor/src/components/global-styles/theme-file-uri-utils.js new file mode 100644 index 0000000000000..1ab05a45f0d54 --- /dev/null +++ b/packages/block-editor/src/components/global-styles/theme-file-uri-utils.js @@ -0,0 +1,77 @@ +/** + * Internal dependencies + */ +import { getValueFromObjectPath } from '../../utils/object'; + +/** + * Looks up a theme file URI based on a relative path. + * + * @param {string} file A relative path. + * @param {Array} themeFileURIs A collection of absolute theme file URIs and their corresponding file paths. + * @return {string?} A resolved theme file URI, if one is found in the themeFileURIs collection. + */ +export function getResolvedThemeFilePath( file, themeFileURIs = [] ) { + const uri = themeFileURIs.find( + ( themeFileUri ) => themeFileUri.name === file + ); + + if ( ! uri?.href ) { + return file; + } + + return uri?.href; +} + +/** + * Mutates an object by settings a value at the provided path. + * + * @param {Object} object Object to set a value in. + * @param {number|string|Array} path Path in the object to modify. + * @param {*} value New value to set. + * @return {Object} Object with the new value set. + */ +function setMutably( object, path, value ) { + path = path.split( '.' ); + const finalValueKey = path.pop(); + let prev = object; + + for ( const key of path ) { + const current = prev[ key ]; + prev = current; + } + + prev[ finalValueKey ] = value; + + return object; +} + +/** + * Resolves any relative paths if a corresponding theme file URI is available. + * Note: this function mutates the object and is specifically to be used in + * an async styles build context in useGlobalStylesOutput + * + * @param {Object} themeJson Theme.json/Global styles tree. + * @param {Array} themeFileURIs A collection of absolute theme file URIs and their corresponding file paths. + * @return {Object} Returns mutated object. + */ +export function setThemeFileUris( themeJson, themeFileURIs ) { + if ( ! themeJson?.styles || ! themeFileURIs ) { + return themeJson; + } + + themeFileURIs.forEach( ( { name, href, target } ) => { + const value = getValueFromObjectPath( themeJson, target ); + if ( value === name ) { + /* + * The object must not be updated immutably here because the + * themeJson is a reference to the global styles tree used as a dependency in the + * useGlobalStylesOutputWithConfig() hook. If we don't mutate the object, + * the hook will detect the change and re-render the component, resulting + * in a maximum depth exceeded error. + */ + themeJson = setMutably( themeJson, target, href ); + } + } ); + + return themeJson; +} diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/use-global-styles-output.js index d9be99efa86ef..14fdf06daa72b 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/use-global-styles-output.js @@ -35,6 +35,7 @@ import { LAYOUT_DEFINITIONS } from '../../layouts/definitions'; import { getValueFromObjectPath, setImmutably } from '../../utils/object'; import BlockContext from '../block-context'; import { unlock } from '../../lock-unlock'; +import { setThemeFileUris } from './theme-file-uri-utils'; // List of block support features that can have their related styles // generated under their own feature level selector rather than the block's. @@ -1212,6 +1213,10 @@ export function processCSSNesting( css, blockSelector ) { */ export function useGlobalStylesOutputWithConfig( mergedConfig = {} ) { const [ blockGap ] = useGlobalSetting( 'spacing.blockGap' ); + mergedConfig = setThemeFileUris( + mergedConfig, + mergedConfig?._links?.[ 'wp:theme-file' ] + ); const hasBlockGapSupport = blockGap !== null; const hasFallbackGapSupport = ! hasBlockGapSupport; // This setting isn't useful yet: it exists as a placeholder for a future explicit fallback styles support. const disableLayoutStyles = useSelect( ( select ) => { diff --git a/packages/edit-site/src/components/global-styles/background-panel.js b/packages/edit-site/src/components/global-styles/background-panel.js index 65dd9738b2b4f..44a9fccaa15ed 100644 --- a/packages/edit-site/src/components/global-styles/background-panel.js +++ b/packages/edit-site/src/components/global-styles/background-panel.js @@ -17,6 +17,7 @@ const BACKGROUND_DEFAULT_VALUES = { const { useGlobalStyle, useGlobalSetting, + useGlobalStyleLinks, BackgroundPanel: StylesBackgroundPanel, } = unlock( blockEditorPrivateApis ); @@ -42,6 +43,7 @@ export default function BackgroundPanel() { const [ inheritedStyle, setStyle ] = useGlobalStyle( '', undefined, 'all', { shouldDecodeEncode: false, } ); + const _links = useGlobalStyleLinks(); const [ settings ] = useGlobalSetting( '' ); const defaultControls = { @@ -60,6 +62,7 @@ export default function BackgroundPanel() { headerLabel={ __( 'Image' ) } defaultValues={ BACKGROUND_DEFAULT_VALUES } defaultControls={ defaultControls } + themeFileURIs={ _links?.[ 'wp:theme-file' ] } /> ); } diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/index.js b/packages/edit-site/src/components/global-styles/screen-revisions/index.js index 6a20e23c91674..e4ffb20996418 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/index.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/index.js @@ -84,6 +84,7 @@ function ScreenRevisions() { setUserConfig( () => ( { styles: revision?.styles, settings: revision?.settings, + _links: revision?._links, } ) ); setIsLoadingRevisionWithUnsavedChanges( false ); onCloseRevisions(); @@ -91,8 +92,13 @@ function ScreenRevisions() { const selectRevision = ( revision ) => { setCurrentlySelectedRevision( { + /* + * The default must be an empty object so that + * `mergeBaseAndUserConfigs()` can merge them correctly. + */ styles: revision?.styles || {}, settings: revision?.settings || {}, + _links: revision?._links || {}, id: revision?.id, } ); }; diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js index 2cd0b3b7bdea6..024fb74ef97cf 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js @@ -119,6 +119,7 @@ export default function useGlobalStylesRevisions( { query } = {} ) { id: 'unsaved', styles: userConfig?.styles, settings: userConfig?.settings, + _links: userConfig?._links, author: { name: currentUser?.name, avatar_urls: currentUser?.avatar_urls, diff --git a/packages/edit-site/src/components/global-styles/variations/variation.js b/packages/edit-site/src/components/global-styles/variations/variation.js index 26f5235dece6f..a3214149edec3 100644 --- a/packages/edit-site/src/components/global-styles/variations/variation.js +++ b/packages/edit-site/src/components/global-styles/variations/variation.js @@ -30,6 +30,7 @@ export default function Variation( { variation, children, isPill } ) { user: { settings: variation.settings ?? {}, styles: variation.styles ?? {}, + _links: variation._links ?? {}, }, base, merged: mergeBaseAndUserConfigs( base, variation ), @@ -42,6 +43,7 @@ export default function Variation( { variation, children, isPill } ) { setUserConfig( () => ( { settings: variation.settings, styles: variation.styles, + _links: variation._links, } ) ); }; diff --git a/packages/editor/src/components/global-styles-provider/index.js b/packages/editor/src/components/global-styles-provider/index.js index 566e390b26a57..9e4ba24e7311f 100644 --- a/packages/editor/src/components/global-styles-provider/index.js +++ b/packages/editor/src/components/global-styles-provider/index.js @@ -31,7 +31,7 @@ export function mergeBaseAndUserConfigs( base, user ) { } function useGlobalStylesUserConfig() { - const { globalStylesId, isReady, settings, styles } = useSelect( + const { globalStylesId, isReady, settings, styles, _links } = useSelect( ( select ) => { const { getEditedEntityRecord, hasFinishedResolution } = select( coreStore ); @@ -65,6 +65,7 @@ function useGlobalStylesUserConfig() { isReady: hasResolved, settings: record?.settings, styles: record?.styles, + _links: record?._links, }; }, [] @@ -76,8 +77,9 @@ function useGlobalStylesUserConfig() { return { settings: settings ?? {}, styles: styles ?? {}, + _links: _links ?? {}, }; - }, [ settings, styles ] ); + }, [ settings, styles, _links ] ); const setConfig = useCallback( ( callback, options = {} ) => { @@ -86,11 +88,14 @@ function useGlobalStylesUserConfig() { 'globalStyles', globalStylesId ); + const currentConfig = { styles: record?.styles ?? {}, settings: record?.settings ?? {}, + _links: record?._links ?? {}, }; const updatedConfig = callback( currentConfig ); + editEntityRecord( 'root', 'globalStyles', @@ -98,6 +103,7 @@ function useGlobalStylesUserConfig() { { styles: cleanEmptyObject( updatedConfig.styles ) || {}, settings: cleanEmptyObject( updatedConfig.settings ) || {}, + _links: cleanEmptyObject( updatedConfig._links ) || {}, }, options ); @@ -128,6 +134,7 @@ export function useGlobalStylesContext() { } return mergeBaseAndUserConfigs( baseConfig, userConfig ); }, [ userConfig, baseConfig ] ); + const context = useMemo( () => { return { isReady: isUserConfigReady && isBaseConfigReady, diff --git a/phpunit/class-wp-rest-global-styles-controller-gutenberg-test.php b/phpunit/class-wp-rest-global-styles-controller-gutenberg-test.php index 563037f41db9d..f5b216a084a4c 100644 --- a/phpunit/class-wp-rest-global-styles-controller-gutenberg-test.php +++ b/phpunit/class-wp-rest-global-styles-controller-gutenberg-test.php @@ -122,6 +122,23 @@ public function test_get_theme_items() { $data = $response->get_data(); $expected = array( array( + '_links' => array( + 'curies' => array( + array( + 'name' => 'wp', + 'href' => 'https://api.w.org/{rel}', + 'templated' => true, + ), + ), + 'wp:theme-file' => array( + array( + 'href' => 'http://localhost:8889/wp-content/themes/emptytheme/img/1024x768_emptytheme_test_image.jpg', + 'name' => 'file:./img/1024x768_emptytheme_test_image.jpg', + 'target' => 'styles.background.backgroundImage.url', + 'type' => 'image/jpeg', + ), + ), + ), 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, 'settings' => array( 'color' => array( @@ -137,7 +154,12 @@ public function test_get_theme_items() { ), ), 'styles' => array( - 'blocks' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./img/1024x768_emptytheme_test_image.jpg', + ), + ), + 'blocks' => array( 'core/post-title' => array( 'typography' => array( 'fontWeight' => '700', @@ -152,7 +174,7 @@ public function test_get_theme_items() { wp_recursive_ksort( $data ); wp_recursive_ksort( $expected ); - $this->assertSameSets( $expected, $data ); + $this->assertSameSets( $expected, $data, 'Theme item should match expected schema' ); } /** diff --git a/phpunit/class-wp-theme-json-resolver-test.php b/phpunit/class-wp-theme-json-resolver-test.php index c842376b53e30..9ba170cd785d2 100644 --- a/phpunit/class-wp-theme-json-resolver-test.php +++ b/phpunit/class-wp-theme-json-resolver-test.php @@ -1139,4 +1139,91 @@ 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. + */ + public function test_resolve_theme_file_uris() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./example/img/image.png', + ), + ), + ), + ) + ); + + $expected_data = array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'https://example.org/wp-content/themes/example-theme/example/img/image.png', + ), + ), + ), + ); + + /* + * 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., + * /wordpress-phpunit/includes/../data/themedir1/default/example/img/image.png + */ + $filter_theme_file_uri_callback = function ( $file ) { + return 'https://example.org/wp-content/themes/example-theme/example/' . explode( 'example/', $file )[1]; + }; + add_filter( 'theme_file_uri', $filter_theme_file_uri_callback ); + $actual = WP_Theme_JSON_Resolver_Gutenberg::resolve_theme_file_uris( $theme_json ); + remove_filter( 'theme_file_uri', $filter_theme_file_uri_callback ); + + $this->assertSame( $expected_data, $actual->get_raw_data() ); + } + + /** + * Tests that them uris are resolved and bundled with other metadata + * in an array. + */ + public function test_get_resolved_theme_uris() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'background' => array( + 'backgroundImage' => array( + 'url' => 'file:./example/img/image.png', + ), + ), + ), + ) + ); + + $expected_data = array( + array( + 'name' => 'file:./example/img/image.png', + 'href' => 'https://example.org/wp-content/themes/example-theme/example/img/image.png', + 'target' => 'styles.background.backgroundImage.url', + 'type' => 'image/png', + ), + ); + + /* + * 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., + * /wordpress-phpunit/includes/../data/themedir1/default/example/img/image.png + */ + $filter_theme_file_uri_callback = function ( $file ) { + return 'https://example.org/wp-content/themes/example-theme/example/' . explode( 'example/', $file )[1]; + }; + add_filter( 'theme_file_uri', $filter_theme_file_uri_callback ); + $actual = WP_Theme_JSON_Resolver_Gutenberg::get_resolved_theme_uris( $theme_json ); + remove_filter( 'theme_file_uri', $filter_theme_file_uri_callback ); + + $this->assertSame( $expected_data, $actual ); + } } diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 46f0c671fdd65..1443685ff83cb 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -2351,7 +2351,7 @@ "description": "Sets the `background-image` CSS property.", "oneOf": [ { - "description": "A valid CSS value for the background-image property.", + "description": "A valid CSS value for the background-image property, or a path to a file relative to the theme root directory, and prefixed with `file:`, e.g., 'file:./path/to/file.png'.", "type": "string" }, { diff --git a/test/emptytheme/img/1024x768_emptytheme_test_image.jpg b/test/emptytheme/img/1024x768_emptytheme_test_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..694e710f77c5270a8acfbe813af4934a2bbbe233 GIT binary patch literal 22133 zcmeIzH&7Kp9LMqh-tN7m1xGS84i6BtBN!V7&?meOjMxDt?7)CJpAU(GC<- zC5obU7zvFC#tO{Roc9D}#>x@P@Bijzi`)6n{C4;D<94|(BrX~$j)=096nT%hy;3M@ zoE2*_&K63!x5No(F`|_tmZP+z+;*8JqU@30GFr;g#`gIG!O#S6LTj8@N^8q#+cw6V zz1{mR#MivznM;9+yx@`H1vXzyqRSo%Xx;I_^Js_~`MIr_Y{ub@%kW>>qgb z`pw&S?}t8o{Pg+D*Kgm4W4V-<^H+@o`y-dP2+A@>8-Fa9vTD6oJH}4Q^2O(u_{*yk z!r66!#Da##=C)vJPU&D$MNMZYIW4y@eJB>~XR^NwR{u}Qeg_-N)gu$N@-|O9k|(a@ z4NIn+vUY?RSOQJ~KLU3F*C8UHAsV6~8loW@q9GchAsV6~8loW@q9GchAsV6~8loW@ zq9GchAsV6~8loW@q9GchAsV6~8loW@q9GchAsV6~8loW@q9GchAsV6~8loW@q9Gch nAsV6~8loW@q9GchAsV6~8loW@q9GchAsV6~8vcI`#qIt9PFk%E literal 0 HcmV?d00001 diff --git a/test/emptytheme/styles/variation.json b/test/emptytheme/styles/variation.json index 06f672f6fd25d..7210001d22e82 100644 --- a/test/emptytheme/styles/variation.json +++ b/test/emptytheme/styles/variation.json @@ -12,6 +12,11 @@ } }, "styles": { + "background": { + "backgroundImage": { + "url": "file:./img/1024x768_emptytheme_test_image.jpg" + } + }, "blocks": { "core/post-title": { "typography": {