diff --git a/.eslintignore b/.eslintignore index 55e50dca1..9cd8ab950 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ android/ build/ dist/ ios/ +vendor/ diff --git a/.prettierignore b/.prettierignore index 6a48be6f6..f04cf3685 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ android ios package-lock.json .github/**/*.md +vendor diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 88bd94fb4..7ecba6bf3 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -47,8 +47,8 @@ import org.wordpress.gutenberg.services.EditorService import java.util.Collections import java.util.Locale -private const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" -private const val ASSET_URL_HTTP = "http://appassets.androidplatform.net/assets/index.html" +const val DEFAULT_ASSET_DOMAIN = "appassets.androidplatform.net" +const val ASSET_PATH_INDEX = "/assets/index.html" /** * A WebView-based Gutenberg block editor for Android. @@ -92,6 +92,7 @@ class GutenbergView : WebView { private var isEditorLoaded = false private var didFireEditorLoaded = false private lateinit var assetLoader: WebViewAssetLoader + private lateinit var assetDomain: String private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -243,10 +244,18 @@ class GutenbergView : WebView { ): WebResourceResponse? { if (request.url == null) { return super.shouldInterceptRequest(view, request) - } else if (request.url.host?.contains("appassets.androidplatform.net") == true) { + } + + // Check the cache interceptor first — it handles JS/CSS + // assets from any allowed host, which may include the asset + // domain when it matches the site domain. + if (requestInterceptor.canIntercept(request)) { + val response = requestInterceptor.handleRequest(request) + if (response != null) return response + } + + if (request.url.host == assetDomain) { return assetLoader.shouldInterceptRequest(request.url) - } else if (requestInterceptor.canIntercept(request)) { - return requestInterceptor.handleRequest(request) } return super.shouldInterceptRequest(view, request) @@ -275,8 +284,10 @@ class GutenbergView : WebView { return false } - // Allow asset URLs - if (url.host == "appassets.androidplatform.net") { + // Allow asset URLs (restrict to the asset path prefix so that + // arbitrary site pages don't load inside the WebView when the + // asset domain matches the site domain) + if (url.host == assetDomain && url.path?.startsWith("/assets/") == true) { return false } @@ -394,6 +405,11 @@ class GutenbergView : WebView { private fun loadEditor(dependencies: EditorDependencies) { this.dependencies = dependencies + // Derive the asset loader domain from the site URL so that the editor + // document shares the site's origin, making REST API and AJAX requests + // same-origin and eliminating CORS restrictions. + assetDomain = Uri.parse(configuration.siteURL).host ?: DEFAULT_ASSET_DOMAIN + // Set up asset caching requestInterceptor = CachedAssetRequestInterceptor( dependencies.assetBundle, @@ -407,6 +423,7 @@ class GutenbergView : WebView { val siteUri = Uri.parse(configuration.siteURL) val isLocalHttpSite = siteUri.scheme == "http" && siteUri.host in LOCAL_HOSTS assetLoader = WebViewAssetLoader.Builder() + .setDomain(assetDomain) .setHttpAllowed(isLocalHttpSite) .addPathHandler("/assets/", AssetsPathHandler(this.context)) .build() @@ -416,7 +433,8 @@ class GutenbergView : WebView { initializeWebView() - val assetUrl = if (isLocalHttpSite) ASSET_URL_HTTP else ASSET_URL + val scheme = if (isLocalHttpSite) "http" else "https" + val assetUrl = "$scheme://$assetDomain$ASSET_PATH_INDEX" val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { assetUrl } @@ -424,7 +442,7 @@ class GutenbergView : WebView { WebStorage.getInstance().deleteAllData() this.clearCache(true) // All cookies are third-party cookies because the root of this document - // lives under `appassets.androidplatform.net` + // lives under the configured asset domain (e.g., `https://appassets.androidplatform.net`) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) // Erase all local cookies before loading the URL – we don't want to persist diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 2e44cbaef..196111bb3 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -28,7 +28,7 @@ data class EditorConfiguration( val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, val enableNetworkLogging: Boolean = false, - var enableOfflineMode: Boolean = false, + var enableOfflineMode: Boolean = false ): Parcelable { /** diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index b4e15c0c4..64a85132d 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.Looper import android.webkit.ValueCallback import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest import android.webkit.WebView import kotlinx.coroutines.test.TestScope import org.junit.Before @@ -12,6 +13,7 @@ import org.junit.Test import org.junit.Rule import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner @@ -19,6 +21,7 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -171,4 +174,38 @@ class GutenbergViewTest { assertTrue("User agent should contain version number", userAgent.contains("GutenbergKit/${GutenbergKitVersion.VERSION}")) } + + @Test + fun `shouldOverrideUrlLoading allows asset path URLs on site domain`() { + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val request = mock(WebResourceRequest::class.java) + `when`(request.url).thenReturn(Uri.parse("https://example.com/assets/index.html")) + + val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request) + assertFalse("Asset path URLs on the site domain should load in the WebView", result) + } + + @Test + fun `shouldOverrideUrlLoading blocks non-asset URLs on site domain`() { + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val request = mock(WebResourceRequest::class.java) + `when`(request.url).thenReturn(Uri.parse("https://example.com/some-page")) + + val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request) + assertTrue("Non-asset URLs on the site domain should open externally", result) + } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt index 2ed2d3c74..86b94b00f 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt @@ -31,7 +31,7 @@ sealed class ConfigurationItem { is Account.WpCom -> ConfiguredEditor( accountId = account.id, name = account.username, - siteUrl = account.username, + siteUrl = "https://${account.username}", siteApiRoot = account.siteApiRoot, authHeader = "Bearer ${account.token}" ) diff --git a/docs/code/troubleshooting.md b/docs/code/troubleshooting.md index 300ac055f..3ff4f70dc 100644 --- a/docs/code/troubleshooting.md +++ b/docs/code/troubleshooting.md @@ -28,3 +28,11 @@ The file does not exist at "[path]" which is in the optimize deps directory. The - Deleting the `node_modules/.vite` directory (or `node_modules` entirely) and restarting the development server via `make dev-server`. You may also need to clear your browser cache to ensure no stale files are used. + +## AJAX requests fail with CORS errors + +**Error:** `Access to XMLHttpRequest at 'https://example.com/wp-admin/admin-ajax.php' from origin 'http://localhost:5173' has been blocked by CORS policy` + +This error occurs when the editor makes AJAX requests (e.g., from blocks that use `admin-ajax.php`) while running on the development server. The browser blocks these cross-origin requests because the editor runs on `localhost` while AJAX targets your WordPress site. + +**Solution:** AJAX functionality requires a production bundle. Build the editor assets with `make build` and test AJAX features using the demo apps without using the `GUTENBERG_EDITOR_URL` environment variable. See the [AJAX Support section](../integration.md#ajax-support) in the Integration Guide for complete configuration details. diff --git a/docs/integration.md b/docs/integration.md index bd606add6..f445b704a 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -291,3 +291,39 @@ val configuration = EditorConfiguration.builder() .setEditorSettings(editorSettingsJSON) .build() ``` + +### AJAX Support + +Some Gutenberg blocks and features use WordPress AJAX (`admin-ajax.php`) for functionality like form submissions. GutenbergKit supports AJAX requests when properly configured. + +**Requirements:** + +1. **Production bundle required**: AJAX requests fail with CORS errors when using the development server because the editor runs on `localhost` while AJAX requests target your WordPress site. You must use a production bundle built with `make build`. + +2. **Configure `siteURL`**: The `siteURL` configuration option must be set to your WordPress site URL. This is used to construct the AJAX endpoint (`{siteURL}/wp-admin/admin-ajax.php`). On Android, the editor is served from the site's domain so that AJAX requests are same-origin. + +3. **Set authentication header**: The `authHeader` configuration must be set. GutenbergKit injects this header into all AJAX requests since the WebView lacks WordPress authentication cookies. + +**Configuration examples:** + +```swift +// iOS +let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://example.com")!, + siteApiRoot: URL(string: "https://example.com/wp-json")! +) + .setAuthHeader("Bearer your-token") + .build() +``` + +```kotlin +// Android +val configuration = EditorConfiguration.builder( + siteURL = "https://example.com", + siteApiRoot = "https://example.com/wp-json" +) + .setPostType("post") + .setAuthHeader("Bearer your-token") + .build() +``` diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index b2aab0be4..68d6e0b8a 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -425,7 +425,7 @@ class SitePreparationViewModel { return EditorConfigurationBuilder( postType: selectedPostTypeDetails, - siteURL: URL(string: apiRoot.siteUrlString())!, + siteURL: URL(string: apiRoot.homeUrlString())!, siteApiRoot: siteApiRoot ) .setShouldUseThemeStyles(canUseEditorStyles) diff --git a/src/utils/ajax.js b/src/utils/ajax.js new file mode 100644 index 000000000..22c669113 --- /dev/null +++ b/src/utils/ajax.js @@ -0,0 +1,123 @@ +/** + * Internal dependencies + */ +import { getGBKit } from './bridge'; +import { warn, debug } from './logger'; + +/** + * Configure AJAX for use without authentication cookies. + * + * GutenbergKit runs in a WebView without WordPress session cookies, + * so AJAX requests need explicit URL and token-based authentication. + * Additionally, WordPress core media globals (`wp.media.ajax`, + * `wp.media.post`) are normally set by wp-includes/js/media-models.js, + * which GutenbergKit doesn't load — so we alias them here. + * + * @return {void} + */ +export function configureAjax() { + window.wp = window.wp || {}; + window.wp.ajax = window.wp.ajax || {}; + window.wp.ajax.settings = window.wp.ajax.settings || {}; + + const { siteURL: rawSiteURL, authHeader } = getGBKit(); + const siteURL = rawSiteURL?.replace( /\/+$/, '' ); + configureAjaxUrl( siteURL ); + configureAjaxAuth( siteURL, authHeader ); + configureMediaAjax(); +} + +function configureAjaxUrl( siteURL ) { + if ( ! siteURL ) { + warn( 'Unable to configure AJAX URL without siteURL' ); + return; + } + + const ajaxUrl = `${ siteURL }/wp-admin/admin-ajax.php`; + // Global used within WordPress admin pages + window.ajaxurl = ajaxUrl; + // Global used by WordPress' JavaScript API + window.wp.ajax.settings.url = ajaxUrl; + + debug( 'AJAX URL configured' ); +} + +function configureAjaxAuth( siteURL, authHeader ) { + if ( ! siteURL ) { + warn( 'Unable to configure AJAX auth without siteURL' ); + return; + } + + if ( ! authHeader ) { + warn( 'Unable to configure AJAX auth without authHeader' ); + return; + } + + if ( ! window.jQuery?.ajaxPrefilter ) { + warn( 'Unable to configure AJAX auth: jQuery not available' ); + return; + } + + let siteOrigin; + try { + siteOrigin = new URL( siteURL ).origin; + } catch { + warn( 'Unable to configure AJAX auth: invalid siteURL' ); + return; + } + + window.jQuery.ajaxPrefilter( function ( options ) { + if ( ! isSameOrigin( options.url, siteOrigin ) ) { + return; + } + + const originalBeforeSend = options.beforeSend; + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + } ); + + debug( 'AJAX auth configured' ); +} + +/** + * Check whether a request URL shares the same origin as the site. + * + * Uses `URL.origin` so that scheme, host, and port must all match exactly, + * preventing credential leakage to lookalike domains (e.g. + * `https://example.com.evil.com`). + * + * @param {string} requestUrl The URL of the outgoing request. + * @param {string} siteOrigin The origin derived from `siteURL`. + * @return {boolean} Whether the request targets the same origin. + */ +function isSameOrigin( requestUrl, siteOrigin ) { + try { + return new URL( requestUrl ).origin === siteOrigin; + } catch { + return false; + } +} + +/** + * Alias `wp.media.ajax` and `wp.media.post` to the (now-authenticated) + * `wp.ajax.send` and `wp.ajax.post`. WordPress core normally sets these + * in `wp-includes/js/media-models.js`, which GutenbergKit doesn't load. + * + * @see https://github.com/WordPress/wordpress-develop/blob/117af7e/src/js/_enqueues/wp/media/models.js#L134 + */ +function configureMediaAjax() { + if ( ! window.wp.ajax.send || ! window.wp.ajax.post ) { + warn( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + return; + } + + window.wp.media = window.wp.media || {}; + window.wp.media.ajax = window.wp.ajax.send; + window.wp.media.post = window.wp.ajax.post; +} diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js new file mode 100644 index 000000000..73d6abc04 --- /dev/null +++ b/src/utils/ajax.test.js @@ -0,0 +1,517 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { configureAjax } from './ajax'; +import * as bridge from './bridge'; +import * as logger from './logger'; + +vi.mock( './bridge' ); +vi.mock( './logger' ); + +describe( 'configureAjax', () => { + let originalWindow; + let mockJQueryAjaxPrefilter; + + beforeEach( () => { + vi.clearAllMocks(); + + // Store original window state + originalWindow = { + wp: global.window.wp, + ajaxurl: global.window.ajaxurl, + jQuery: global.window.jQuery, + }; + + // Reset window.wp + global.window.wp = undefined; + global.window.ajaxurl = undefined; + + // Mock jQuery + mockJQueryAjaxPrefilter = vi.fn(); + global.window.jQuery = { + ajaxPrefilter: mockJQueryAjaxPrefilter, + }; + } ); + + afterEach( () => { + // Restore original window state + global.window.wp = originalWindow.wp; + global.window.ajaxurl = originalWindow.ajaxurl; + global.window.jQuery = originalWindow.jQuery; + } ); + + describe( 'URL configuration', () => { + it( 'should configure ajax URLs when siteURL is provided', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + configureAjax(); + + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + } ); + + it( 'should strip trailing slash from siteURL', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com/', + authHeader: null, + } ); + + configureAjax(); + + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + + it( 'should log warning when siteURL is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without siteURL' + ); + expect( global.window.ajaxurl ).toBeUndefined(); + } ); + + it( 'should handle undefined siteURL', () => { + bridge.getGBKit.mockReturnValue( { + authHeader: 'Bearer token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without siteURL' + ); + expect( global.window.ajaxurl ).toBeUndefined(); + } ); + + it( 'should properly initialize window.wp.ajax hierarchy', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Ensure window.wp doesn't exist initially + expect( global.window.wp ).toBeUndefined(); + + configureAjax(); + + expect( global.window.wp ).toBeDefined(); + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings ).toBeDefined(); + } ); + } ); + + describe( 'Auth configuration', () => { + it( 'should register a jQuery ajaxPrefilter', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + expect( mockJQueryAjaxPrefilter ).toHaveBeenCalledWith( + expect.any( Function ) + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should inject auth header for same-site requests', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { + url: 'https://example.com/wp-admin/admin-ajax.php', + }; + prefilter( options ); + + const mockXhr = { setRequestHeader: vi.fn() }; + options.beforeSend( mockXhr ); + + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer test-token' + ); + } ); + + it( 'should not inject auth header for cross-origin requests', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { url: 'https://evil.com/steal' }; + prefilter( options ); + + expect( options.beforeSend ).toBeUndefined(); + } ); + + it( 'should not inject auth header for lookalike subdomain prefixes', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { url: 'https://example.com.evil.com/steal' }; + prefilter( options ); + + expect( options.beforeSend ).toBeUndefined(); + } ); + + it( 'should preserve original beforeSend', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const originalBeforeSend = vi.fn(); + const options = { + url: 'https://example.com/wp-admin/admin-ajax.php', + beforeSend: originalBeforeSend, + }; + prefilter( options ); + + const mockXhr = { setRequestHeader: vi.fn() }; + options.beforeSend( mockXhr ); + + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer test-token' + ); + expect( originalBeforeSend ).toHaveBeenCalledWith( mockXhr ); + } ); + + it( 'should log warning when authHeader is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); + } ); + + it( 'should handle undefined authHeader', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'Integration tests', () => { + it( 'should configure both URL and auth when both are provided', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer full-token', + } ); + + configureAjax(); + + // Check URL configuration + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + + // Check auth configuration + expect( mockJQueryAjaxPrefilter ).toHaveBeenCalledWith( + expect.any( Function ) + ); + + // Check debug logs + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle empty configuration object', () => { + bridge.getGBKit.mockReturnValue( {} ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without siteURL' + ); + } ); + } ); + + describe( 'Edge cases', () => { + it( 'should warn when jQuery is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-jquery', + } ); + + delete global.window.jQuery; + + expect( () => configureAjax() ).not.toThrow(); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: jQuery not available' + ); + expect( logger.debug ).not.toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should warn when jQuery is undefined', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer undefined-jquery', + } ); + + global.window.jQuery = undefined; + + expect( () => configureAjax() ).not.toThrow(); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: jQuery not available' + ); + expect( logger.debug ).not.toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle missing wp.ajax entirely', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-ajax', + } ); + + global.window.wp = {}; + + expect( () => configureAjax() ).not.toThrow(); + + // Should create ajax object + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings ).toBeDefined(); + } ); + + it( 'should work with window.wp already partially initialized', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Pre-existing wp object with other properties + global.window.wp = { + data: { someData: 'test' }, + }; + + configureAjax(); + + // Should preserve existing properties + expect( global.window.wp.data ).toEqual( { someData: 'test' } ); + + // Should add ajax properties + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + + it( 'should work when wp.ajax is partially initialized', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Pre-existing wp.ajax object without settings + global.window.wp = { + ajax: { + someMethod: vi.fn(), + }, + }; + + configureAjax(); + + // Should preserve existing methods + expect( global.window.wp.ajax.someMethod ).toBeDefined(); + + // Should add settings + expect( global.window.wp.ajax.settings ).toBeDefined(); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + + it( 'should not modify options when URL is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = {}; + prefilter( options ); + + expect( options.beforeSend ).toBeUndefined(); + } ); + } ); + + describe( 'Media AJAX configuration', () => { + it( 'should alias wp.media.ajax to wp.ajax.send', () => { + const mockSend = vi.fn(); + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + global.window.wp = { + ajax: { + send: mockSend, + post: vi.fn(), + settings: {}, + }, + }; + + configureAjax(); + + expect( global.window.wp.media.ajax ).toBe( mockSend ); + } ); + + it( 'should alias wp.media.post to wp.ajax.post', () => { + const mockPost = vi.fn(); + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + global.window.wp = { + ajax: { + send: vi.fn(), + post: mockPost, + settings: {}, + }, + }; + + configureAjax(); + + expect( global.window.wp.media.post ).toBe( mockPost ); + } ); + + it( 'should not initialize wp.media when wp.ajax.send is unavailable', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + global.window.wp = {}; + + configureAjax(); + + expect( global.window.wp.media ).toBeUndefined(); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + } ); + + it( 'should warn when wp.ajax.send or wp.ajax.post are not available', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // wp.ajax exists but without send/post (e.g., wp-util.js failed to load) + global.window.wp = { + ajax: { + settings: {}, + }, + }; + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + expect( global.window.wp.media ).toBeUndefined(); + } ); + } ); + + describe( 'Invalid siteURL handling', () => { + it( 'should warn when siteURL is not a valid URL for auth config', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'not-a-url', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: invalid siteURL' + ); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 4470638a6..339f8b6a7 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -9,6 +9,7 @@ import { } from './bridge'; import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; +import { configureAjax } from './ajax'; import { initializeVideoPressAjaxBridge } from './videopress-bridge'; import { initializeFetchInterceptor } from './fetch-interceptor'; import EditorLoadError from '../components/editor-load-error'; @@ -33,7 +34,6 @@ export async function setUpEditorEnvironment() { await configureLocale(); await initializeWordPressGlobals(); await configureApiFetch(); - initializeVideoPressAjaxBridge(); const pluginLoadResult = await loadPluginsIfEnabled(); await initializeEditor( pluginLoadResult ); } catch ( err ) { @@ -84,7 +84,7 @@ function setLogLevelFromGBKit() { async function initializeWordPressGlobals() { const { initializeWordPressGlobals: _initializeWordPressGlobals } = await import( './wordpress-globals' ); - _initializeWordPressGlobals(); + await _initializeWordPressGlobals(); } /** @@ -132,15 +132,26 @@ async function loadPluginsIfEnabled() { /** * Initialize the editor module. Lazy-loaded to ensure WordPress globals are - * before importing the editor module and referencing `window.wp` globals. + * available before importing the editor module and referencing `window.wp` + * globals. * * @param {Object} pluginLoadResult - Results from plugin loading * @return {Promise} Promise that resolves when the editor is initialized */ async function initializeEditor( pluginLoadResult = {} ) { const { initializeEditor: _initializeEditor } = await import( './editor' ); + const { allowedBlockTypes } = pluginLoadResult; + + configureAjax(); + + if ( ! allowedBlockTypes?.includes( 'videopress/video' ) ) { + // The VideoPress block isn't available, so initialize the bridge to handle + // any `core/video` blocks extended to rely upon VideoPress upload services. + initializeVideoPressAjaxBridge(); + } + _initializeEditor( { - allowedBlockTypes: pluginLoadResult.allowedBlockTypes, + allowedBlockTypes, pluginLoadFailed: pluginLoadResult.pluginLoadFailed, } ); } diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index 834fb13da..ea6c77008 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -16,6 +16,7 @@ import { import { loadEditorAssets } from './editor-loader.js'; import EditorLoadError from '../components/editor-load-error/index.jsx'; import { error } from './logger.js'; +import { configureAjax } from './ajax.js'; import { initializeVideoPressAjaxBridge } from './videopress-bridge.js'; import { initializeWordPressGlobals } from './wordpress-globals.js'; import { configureLocale } from './localization.js'; @@ -27,6 +28,7 @@ vi.mock( './bridge.js' ); vi.mock( './fetch-interceptor.js' ); vi.mock( './logger.js' ); vi.mock( './editor-styles.js' ); +vi.mock( './ajax.js' ); vi.mock( './videopress-bridge.js' ); vi.mock( './wordpress-globals.js', () => ( { @@ -63,6 +65,7 @@ describe( 'setUpEditorEnvironment', () => { initializeWordPressGlobals.mockImplementation( () => {} ); configureApiFetch.mockImplementation( () => {} ); initializeFetchInterceptor.mockImplementation( () => {} ); + configureAjax.mockImplementation( () => {} ); initializeVideoPressAjaxBridge.mockImplementation( () => {} ); initializeEditor.mockImplementation( () => {} ); EditorLoadError.mockReturnValue( '
Error
' ); @@ -96,8 +99,12 @@ describe( 'setUpEditorEnvironment', () => { callOrder.push( 'configureApiFetch' ); } ); + configureAjax.mockImplementation( () => { + callOrder.push( 'configureAjax' ); + } ); + initializeVideoPressAjaxBridge.mockImplementation( () => { - callOrder.push( 'initializeVideoPress' ); + callOrder.push( 'initializeVideoPressAjaxBridge' ); } ); initializeEditor.mockImplementation( () => { @@ -112,7 +119,8 @@ describe( 'setUpEditorEnvironment', () => { 'configureLocale', 'loadRemainingGlobals', 'configureApiFetch', - 'initializeVideoPress', + 'configureAjax', + 'initializeVideoPressAjaxBridge', 'initializeEditor', ] ); } ); @@ -224,6 +232,39 @@ describe( 'setUpEditorEnvironment', () => { expect( editorLoaded ).toHaveBeenCalledTimes( 1 ); } ); + it( 'initializes VideoPress bridge when videopress/video is not in allowed block types', async () => { + getGBKit.mockReturnValue( { plugins: true } ); + loadEditorAssets.mockResolvedValue( { + allowedBlockTypes: [ 'core/paragraph', 'core/heading' ], + } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'skips VideoPress bridge when videopress/video is in allowed block types', async () => { + getGBKit.mockReturnValue( { plugins: true } ); + loadEditorAssets.mockResolvedValue( { + allowedBlockTypes: [ 'core/paragraph', 'videopress/video' ], + } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).not.toHaveBeenCalled(); + } ); + + it( 'initializes VideoPress bridge when allowed block types is undefined', async () => { + getGBKit.mockReturnValue( { plugins: false } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'returns a promise that resolves when initialization completes', async () => { const result = setUpEditorEnvironment(); diff --git a/src/utils/editor-loader.js b/src/utils/editor-loader.js index 3edfb8e17..18d4a6daf 100644 --- a/src/utils/editor-loader.js +++ b/src/utils/editor-loader.js @@ -61,6 +61,13 @@ async function processEditorAssets( assets ) { * Load the asset files for a block * * @param {string} html The HTML content to parse for assets. + * + * @todo Remove the core and Gutenberg asset filtering once the relevant Jetpack + * plugin release is available that excludes these assets from the editor assets + * endpoint response. See: https://github.com/Automattic/jetpack/pull/45715 + * + * @todo Replace the client `lodash-js-after` script filtering with more robust + * server-side filtering in the editor assets endpoint. */ async function loadAssets( html ) { const doc = new window.DOMParser().parseFromString( html, 'text/html' ); @@ -69,8 +76,6 @@ async function loadAssets( html ) { doc.querySelectorAll( 'link[rel="stylesheet"],script' ) ).filter( ( asset ) => { /** - * TODO: Remove this once the relevant Jetpack plugin release is available. - * * Exclude WordPress core and Gutenberg assets to avoid loading duplicate * assets, which causes editor loading failures. * @@ -87,6 +92,16 @@ async function loadAssets( html ) { return false; } + /** + * WordPress's lodash-js-after inline script calls _.noConflict() to + * restore window._ to Underscore.js. GutenbergKit doesn't load + * Underscore, so this wipes window._ to undefined. Ideally, this filtering + * occurs in the editor assets endpoint. + */ + if ( asset.id === 'lodash-js-after' ) { + return false; + } + return !! asset.id; } ); diff --git a/src/utils/videopress-bridge.js b/src/utils/videopress-bridge.js index 7bed2ee2b..23418c8ad 100644 --- a/src/utils/videopress-bridge.js +++ b/src/utils/videopress-bridge.js @@ -1,7 +1,6 @@ /** * Internal dependencies */ -import { getGBKit } from './bridge'; import { warn, debug, error } from './logger'; /** @@ -18,6 +17,8 @@ import { warn, debug, error } from './logger'; * This function overrides wp.media.ajax to intercept VideoPress-specific * AJAX requests and redirect them to the appropriate REST API endpoints. * + * @todo Remove this bridge once the `videopress/video` block type is allowed and stable. + * * @return {void} */ export function initializeVideoPressAjaxBridge() { @@ -27,16 +28,6 @@ export function initializeVideoPressAjaxBridge() { return; } - // Initialize wp.ajax if not already present - window.wp.ajax = window.wp.ajax || {}; - window.wp.ajax.settings = window.wp.ajax.settings || {}; - - // Set up AJAX settings with site URL - const { siteURL } = getGBKit(); - if ( siteURL ) { - window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; - } - // Store original wp.media.ajax function if it exists const originalMediaAjax = window.wp.media?.ajax; diff --git a/src/utils/wordpress-globals.js b/src/utils/wordpress-globals.js index 5bdb8e1d8..87f6e6a52 100644 --- a/src/utils/wordpress-globals.js +++ b/src/utils/wordpress-globals.js @@ -67,7 +67,7 @@ import * as wordcount from '@wordpress/wordcount'; * * @return {void} */ -export function initializeWordPressGlobals() { +export async function initializeWordPressGlobals() { window.jQuery = jquery; // Expose jQuery for plugins // Initialize the wp namespace if it doesn't exist @@ -139,4 +139,7 @@ export function initializeWordPressGlobals() { // React JSX runtime for plugin compatibility window.ReactJSXRuntime = ReactJSXRuntime; + + // Load wp-util after jQuery and lodash are on window + await import( '../../vendor/wp-util.js' ); } diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 000000000..3ca63cd99 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,10 @@ +# Vendored Files + +This directory contains vendored third-party files that GutenbergKit loads directly, since it does not include the full set of WordPress core assets. + +## wp-util.js + +- **Source**: [`wp-includes/js/wp-util.js`](https://github.com/WordPress/wordpress-develop/blob/117af7e9a37c02ee17ac8f143cb46b6b0f4cde15/src/js/_enqueues/wp/util.js) +- **Commit**: `117af7e9a37c02ee17ac8f143cb46b6b0f4cde15` + +Provides `wp.ajax` (authenticated AJAX utilities) and `wp.template` (JavaScript templating). WordPress normally enqueues this as the `wp-util` script handle. GutenbergKit vendors it because the editor assets endpoint excludes core WordPress scripts, and the IIFE captures jQuery via closure at execution time — so it must be loaded after jQuery is on `window`. \ No newline at end of file diff --git a/vendor/wp-util.js b/vendor/wp-util.js new file mode 100644 index 000000000..f28653af7 --- /dev/null +++ b/vendor/wp-util.js @@ -0,0 +1,156 @@ +/** + * @output wp-includes/js/wp-util.js + */ + +/* global _wpUtilSettings */ + +/** @namespace wp */ +window.wp = window.wp || {}; + +(function ($) { + // Check for the utility settings. + var settings = typeof _wpUtilSettings === 'undefined' ? {} : _wpUtilSettings; + + /** + * wp.template( id ) + * + * Fetch a JavaScript template for an id, and return a templating function for it. + * + * @param {string} id A string that corresponds to a DOM element with an id prefixed with "tmpl-". + * For example, "attachment" maps to "tmpl-attachment". + * @return {function} A function that lazily-compiles the template requested. + */ + wp.template = _.memoize(function ( id ) { + var compiled, + /* + * Underscore's default ERB-style templates are incompatible with PHP + * when asp_tags is enabled, so WordPress uses Mustache-inspired templating syntax. + * + * @see trac ticket #22344. + */ + options = { + evaluate: /<#([\s\S]+?)#>/g, + interpolate: /\{\{\{([\s\S]+?)\}\}\}/g, + escape: /\{\{([^\}]+?)\}\}(?!\})/g, + variable: 'data' + }; + + return function ( data ) { + if ( ! document.getElementById( 'tmpl-' + id ) ) { + throw new Error( 'Template not found: ' + '#tmpl-' + id ); + } + compiled = compiled || _.template( $( '#tmpl-' + id ).html(), options ); + return compiled( data ); + }; + }); + + /* + * wp.ajax + * ------ + * + * Tools for sending ajax requests with JSON responses and built in error handling. + * Mirrors and wraps jQuery's ajax APIs. + */ + wp.ajax = { + settings: settings.ajax || {}, + + /** + * wp.ajax.post( [action], [data] ) + * + * Sends a POST request to WordPress. + * + * @param {(string|Object)} action The slug of the action to fire in WordPress or options passed + * to jQuery.ajax. + * @param {Object=} data Optional. The data to populate $_POST with. + * @return {$.promise} A jQuery promise that represents the request, + * decorated with an abort() method. + */ + post: function( action, data ) { + return wp.ajax.send({ + data: _.isObject( action ) ? action : _.extend( data || {}, { action: action }) + }); + }, + + /** + * wp.ajax.send( [action], [options] ) + * + * Sends a POST request to WordPress. + * + * @param {(string|Object)} action The slug of the action to fire in WordPress or options passed + * to jQuery.ajax. + * @param {Object=} options Optional. The options passed to jQuery.ajax. + * @return {$.promise} A jQuery promise that represents the request, + * decorated with an abort() method. + */ + send: function( action, options ) { + var promise, deferred; + if ( _.isObject( action ) ) { + options = action; + } else { + options = options || {}; + options.data = _.extend( options.data || {}, { action: action }); + } + + options = _.defaults( options || {}, { + type: 'POST', + url: wp.ajax.settings.url, + context: this + }); + + deferred = $.Deferred( function( deferred ) { + // Transfer success/error callbacks. + if ( options.success ) { + deferred.done( options.success ); + } + + if ( options.error ) { + deferred.fail( options.error ); + } + + delete options.success; + delete options.error; + + // Use with PHP's wp_send_json_success() and wp_send_json_error(). + deferred.jqXHR = $.ajax( options ).done( function( response ) { + // Treat a response of 1 as successful for backward compatibility with existing handlers. + if ( response === '1' || response === 1 ) { + response = { success: true }; + } + + if ( _.isObject( response ) && ! _.isUndefined( response.success ) ) { + + // When handling a media attachments request, get the total attachments from response headers. + var context = this; + deferred.done( function() { + if ( + action && + action.data && + 'query-attachments' === action.data.action && + deferred.jqXHR.hasOwnProperty( 'getResponseHeader' ) && + deferred.jqXHR.getResponseHeader( 'X-WP-Total' ) + ) { + context.totalAttachments = parseInt( deferred.jqXHR.getResponseHeader( 'X-WP-Total' ), 10 ); + } else { + context.totalAttachments = 0; + } + } ); + deferred[ response.success ? 'resolveWith' : 'rejectWith' ]( this, [response.data] ); + } else { + deferred.rejectWith( this, [response] ); + } + }).fail( function() { + deferred.rejectWith( this, arguments ); + }); + }); + + promise = deferred.promise(); + promise.abort = function() { + deferred.jqXHR.abort(); + return this; + }; + + return promise; + } + }; + +}(jQuery)); \ No newline at end of file