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 0000000000000..694e710f77c52 Binary files /dev/null and b/test/emptytheme/img/1024x768_emptytheme_test_image.jpg differ 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": {