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