diff --git a/.gitignore b/.gitignore index 210dc00a..152f1095 100644 --- a/.gitignore +++ b/.gitignore @@ -188,12 +188,10 @@ local.properties ## Production Build Products /android/Gutenberg/src/main/assets/assets /android/Gutenberg/src/main/assets/index.html -/android/Gutenberg/src/main/assets/remote.html # Disabled removing these files until this is published like Android in CI. # /ios/Sources/GutenbergKit/Gutenberg/assets # /ios/Sources/GutenbergKit/Gutenberg/index.html -# /ios/Sources/GutenbergKit/Gutenberg/remote.html # Translation files src/translations/ diff --git a/CLAUDE.md b/CLAUDE.md index f0f3021c..51e533e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,15 +71,6 @@ make local-android-library make test-android ``` -### Remote Editor Development - -```bash -# Start development server for remote editor -make dev-server-remote -# or -npm run dev:remote -``` - ## Architecture ### Web Editor Structure @@ -87,8 +78,7 @@ npm run dev:remote The web editor is built with React and WordPress packages: - **Entry Points**: - - `src/index.js` - Bundled editor entry - - `src/remote.js` - Remote editor entry (supports plugins) + - `src/index.js` - Main editor entry point (supports plugins via bundled code) - **Core Components**: - `src/components/editor/` - Main editor component with host bridge integration - `src/components/visual-editor/` - Visual editing interface @@ -123,7 +113,7 @@ The editor uses a bidirectional bridge pattern: ### Build System -- **Vite**: Handles web bundling with separate configs for local and remote editors +- **Vite**: Handles web bundling and development server - **Translations**: Automated translation preparation from WordPress packages - **Asset Distribution**: Built assets are copied to platform-specific directories diff --git a/Makefile b/Makefile index 31151bc4..0b97f26a 100644 --- a/Makefile +++ b/Makefile @@ -35,9 +35,6 @@ build: npm-dependencies prep-translations dev-server: npm-dependencies npm run dev -dev-server-remote: npm-dependencies - npm run dev:remote - fmt-js: npm-dependencies npm run format diff --git a/README.md b/README.md index 9993c759..9fdbb1e7 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Install the GutenbergKit dependencies and start the development server by runnin make dev-server ``` -Once finished, the web app can now be accessed in your browser by visiting the URL logged in your terminal. However, it is recommended to use a native host app for testing the changes made to the editor for a more realistic experience. A demo app is included in the GutenbergKit package, along with instructions on how to use it below. +Once finished, the web app can now be accessed in your browser by visiting the URL logged in your terminal. However, it is **recommended to use a native host app for testing** changes made to the editor for a more realistic experience. A demo app is included in the GutenbergKit project, along with instructions on how to use it below. ### Demo App diff --git a/android/Gutenberg/build.gradle.kts b/android/Gutenberg/build.gradle.kts index 5cae1a16..d5b35ae5 100644 --- a/android/Gutenberg/build.gradle.kts +++ b/android/Gutenberg/build.gradle.kts @@ -22,12 +22,6 @@ android { "\"${rootProject.ext["gutenbergEditorUrl"] ?: ""}\"" ) - buildConfigField( - "String", - "GUTENBERG_EDITOR_REMOTE_URL", - "\"${rootProject.ext["gutenbergEditorRemoteUrl"] ?: ""}\"" - ) - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } 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 95ede2c9..14aa75f7 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -29,7 +29,6 @@ import org.json.JSONObject import java.util.Locale const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" -const val ASSET_URL_REMOTE = "https://appassets.androidplatform.net/assets/remote.html" class GutenbergView : WebView { private var isEditorLoaded = false @@ -170,7 +169,7 @@ class GutenbergView : WebView { } // Allow asset URLs - if (url.host == Uri.parse(ASSET_URL).host || url.host == Uri.parse(ASSET_URL_REMOTE).host) { + if (url.host == Uri.parse(ASSET_URL).host) { return false } @@ -194,14 +193,6 @@ class GutenbergView : WebView { } } - // Allow remote editor server if configured - if (BuildConfig.GUTENBERG_EDITOR_REMOTE_URL.isNotEmpty()) { - val remoteEditorUrl = Uri.parse(BuildConfig.GUTENBERG_EDITOR_REMOTE_URL) - if (url.host == remoteEditorUrl.host) { - return false - } - } - // For all other URLs, open in external browser val intent = Intent(Intent.ACTION_VIEW, url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index 08dc848b..ab40035a 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -7,15 +7,22 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Inventory2 import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -37,6 +44,7 @@ import com.example.gutenbergkit.ui.dialogs.AddConfigurationDialog import com.example.gutenbergkit.ui.dialogs.DeleteConfigurationDialog import com.example.gutenbergkit.ui.dialogs.DiscoveringSiteDialog import com.example.gutenbergkit.ui.theme.AppTheme +import org.wordpress.gutenberg.BuildConfig import org.wordpress.gutenberg.EditorConfiguration class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCallback { @@ -106,7 +114,7 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa private fun createRemoteConfiguration(config: ConfigurationItem.RemoteEditor): EditorConfiguration = createCommonConfigurationBuilder() - .setPlugins(true) // Enable plugins for remote editor + .setPlugins(true) .setSiteURL(config.siteUrl) .setSiteApiRoot(config.siteApiRoot) .setNamespaceExcludedPaths(arrayOf()) @@ -152,6 +160,10 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa } } +private fun isDevServerRunning(): Boolean { + return BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty() +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( @@ -192,16 +204,65 @@ fun MainScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(configurations) { config -> + // Bundled editor + item { ConfigurationCard( - configuration = config, - onClick = { onConfigurationClick(config) }, - onLongClick = { - if (config is ConfigurationItem.RemoteEditor) { + configuration = ConfigurationItem.BundledEditor, + onClick = { onConfigurationClick(ConfigurationItem.BundledEditor) }, + onLongClick = { } + ) + } + + // Remote editors section + val remoteEditors = configurations.filterIsInstance() + if (remoteEditors.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.remote_editors_section).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + items(remoteEditors) { config -> + ConfigurationCard( + configuration = config, + onClick = { onConfigurationClick(config) }, + onLongClick = { showDeleteDialog.value = config } - } - ) + ) + } + } + + // Editor source note at bottom with info icon + item { + Spacer(modifier = Modifier.height(16.dp)) + val editorSourceNote = if (isDevServerRunning()) { + stringResource(R.string.editor_source_dev_server) + } else { + stringResource(R.string.editor_source_built) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = editorSourceNote, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } @@ -266,7 +327,7 @@ fun ConfigurationCard( Text( when (configuration) { is ConfigurationItem.BundledEditor -> stringResource(R.string.bundled_editor_subtitle) - is ConfigurationItem.RemoteEditor -> configuration.siteUrl + is ConfigurationItem.RemoteEditor -> stringResource(R.string.remote_editor_subtitle) } ) }, @@ -282,4 +343,4 @@ fun ConfigurationCard( } ) } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/ui/dialogs/ConfigurationDialogs.kt b/android/app/src/main/java/com/example/gutenbergkit/ui/dialogs/ConfigurationDialogs.kt index 739fdcd2..4eb10df8 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/ui/dialogs/ConfigurationDialogs.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/ui/dialogs/ConfigurationDialogs.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.AlertDialog @@ -15,6 +16,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.example.gutenbergkit.R @@ -34,6 +36,9 @@ fun AddConfigurationDialog( onValueChange = onSiteUrlChange, label = { Text(stringResource(R.string.site_url)) }, singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri + ), modifier = Modifier.fillMaxWidth() ) }, diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6df69395..3f1fd28b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,8 +3,12 @@ GutenbergKit Demo + Note: Editors are using the dev server started with `make dev-server`. + Note: Editors are using the compiled web app built with `make build`. Bundled editor Local editor without plugin support + Remote editors + Site-specific editor with plugins Add remote editor diff --git a/android/build.gradle.kts b/android/build.gradle.kts index caad2fb9..3b1878a7 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -7,7 +7,6 @@ if (localPropertiesFile.exists()) { ext { set("gutenbergEditorUrl", localProperties.getProperty("GUTENBERG_EDITOR_URL") ?: "") - set("gutenbergEditorRemoteUrl", localProperties.getProperty("GUTENBERG_EDITOR_REMOTE_URL") ?: "") } plugins { diff --git a/docs/architecture.md b/docs/architecture.md index 66234332..ff100d15 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,8 +19,7 @@ GutenbergKit/ │ │ └── text-editor/ # HTML text editing interface │ ├── utils/ # Utility functions │ │ └── bridge.js # Native-to-web communication -│ ├── index.js # Bundled editor entry point -│ └── remote.js # Remote editor entry point +│ └── index.js # Main editor entry point ├── ios/ # iOS Swift package │ └── Sources/ │ └── GutenbergKit/ @@ -64,11 +63,10 @@ Development mode (`?dev_mode` query parameter) enables debugging features and by - **Mock GBKit global is provided** - If the native bridge (`window.GBKit`) is not available, a mock object is automatically provided to allow the editor to load without native integration - **Development notice is displayed** - A warning notice appears to inform developers that they're running without a native bridge -Add the `?dev_mode` query parameter to any editor URL: +Add the `?dev_mode` query parameter to the editor URL: ``` http://localhost:3000/?dev_mode -http://localhost:3000/remote.html?dev_mode ``` ## Logging Configuration @@ -80,38 +78,28 @@ The logger utility (`src/utils/logger.js`) supports different log levels that ca - `?log_level=warn` - Show only warnings and errors - `?log_level=error` - Show only errors -## Editor Variants +## Plugin Support -By default, GutenbergKit utilizes local `@wordpress` modules. This approach is similar to most modern web applications, where the `@wordpress` modules are bundled with the application. -To enable support for non-core blocks, GutenbergKit can be configured to use remote `@wordpress` modules, where the `@wordpress` modules and plugin-provided editor assets are fetched from a site's remote server. At this time, this functionality is partially implemented and may not work as expected. +GutenbergKit bundles `@wordpress` modules locally for offline capability and fast load times. The editor can optionally load plugin-provided blocks and custom editor assets from a remote server when configured. -The `make build` command builds both the local and remote editors by default. To load the remote editor, you must enable the `plugins` configuration option within the Demo app. +### How Plugin Loading Works -Additionally, a `make dev-server-remote` command is available for serving the latest remote editor changes through a development server. To load the development server in the Demo app, add an environment variable named `GUTENBERG_EDITOR_REMOTE_URL` with the URL of the development server plus `/remote.html`—i.e., `http://:5173/remote.html`. +When the `plugins` configuration option is enabled: -> [!TIP] -> The remote editor redirects to the bundled editor when loading fails. If you need to debug the failure, disable redirects via the `?dev_mode` query parameter.. +1. Core `@wordpress` packages are loaded from bundled code +2. Plugin scripts and styles are fetched from the site's editor assets endpoint +3. Custom blocks and editor extensions are registered dynamically +4. The editor integrates both core and custom functionality -### Bundled Editor (`index.html`) +This approach provides: -The bundled editor relies upon local `@wordpress` packages. This variant: +- **Offline-first**: Core editor works without network connectivity +- **Extensibility**: Supports custom blocks and plugins when connected +- **Performance**: Core packages load instantly from local bundle -- Provides offline capability -- Has faster initial load times -- Limited to core blocks only -- No plugin support +### Configuration -**Entry point:** `src/index.js` - -### Remote Editor (`remote.html`) - -The remote editor loads `@wordpress` packages and plugins from a remote server. This variant: - -- Supports custom blocks and plugins -- Requires network connectivity -- Used in production environments with custom implementations - -**Entry point:** `src/remote.js` +Enable plugins by setting the `plugins` configuration option. The editor will fetch assets from the configured `editorAssetsEndpoint` or fall back to the default Jetpack endpoint. The demo app UI allows adding site-specific editor configurations, which enables the `plugins` configuration option. ## Testing @@ -132,3 +120,11 @@ The remote editor loads `@wordpress` packages and plugins from a remote server. - JUnit framework - Run: `make test-android` + +## Frequently Asked Questions + +### Why do I encounter a `GBKit global not available after timeout` error when opening the GutenbergKit editor in a browser? + +This error occurs when the editor is unable to communicate with the native bridge. This is expected when opening the editor in a browser, as the native bridge is not available. GutenbergKit is designed to be used in a native host app that provides the native bridge. The GutenbergKit project includes a demo app that can be used to test the editor. + +It is possible to circumvent this this error by adding the `?dev_mode` query parameter to the editor URL in your browser. This will bypass the native bridge requirement and allow the editor to load without the native bridge. However, some features may not work as expected when using this mode. diff --git a/docs/gutenberg-kit-preview.png b/docs/gutenberg-kit-preview.png index 312ccb53..66ea2230 100644 Binary files a/docs/gutenberg-kit-preview.png and b/docs/gutenberg-kit-preview.png differ diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme index ad7c7cec..f9e1ca51 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme @@ -56,11 +56,6 @@ value = "http://localhost:5173/" isEnabled = "YES"> - - - - - - - Gutenberg - - -
- -
- - diff --git a/src/remote.js b/src/remote.js deleted file mode 100644 index 9e6ac12a..00000000 --- a/src/remote.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Internal dependencies - */ -import { initializeRemoteEditor } from './utils/remote-editor'; -import './index.scss'; - -initializeRemoteEditor(); diff --git a/src/utils/bundled-editor.jsx b/src/utils/editor-environment.js similarity index 97% rename from src/utils/bundled-editor.jsx rename to src/utils/editor-environment.js index 051768d0..113851cf 100644 --- a/src/utils/bundled-editor.jsx +++ b/src/utils/editor-environment.js @@ -6,7 +6,7 @@ import { loadEditorAssets } from './editor-loader'; import { initializeVideoPressAjaxBridge } from './videopress-bridge'; import EditorLoadError from '../components/editor-load-error'; import { error } from './logger'; -import './editor-styles.js'; +import './editor-styles'; /** * Initialize the bundled editor by loading assets and configuring modules @@ -14,7 +14,7 @@ import './editor-styles.js'; * * @return {Promise} Promise that resolves when initialization is complete */ -export function initializeBundledEditor() { +export function setUpEditorEnvironment() { // Rely upon promises rather than async/await to avoid timeouts caused by // circular dependencies. Addressing the circular dependencies is quite // challenging due to Vite's preload helpers and bugs in `manualChunks` diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js new file mode 100644 index 00000000..bef3a608 --- /dev/null +++ b/src/utils/editor-environment.test.js @@ -0,0 +1,200 @@ +/** + * External dependencies + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * Internal dependencies + */ +import { setUpEditorEnvironment } from './editor-environment'; +import { awaitGBKitGlobal, getGBKit, editorLoaded } from './bridge.js'; +import { loadEditorAssets } from './editor-loader.js'; +import EditorLoadError from '../components/editor-load-error/index.jsx'; +import { error } from './logger.js'; +import { initializeVideoPressAjaxBridge } from './videopress-bridge.js'; +import { configureLocale } from './localization.js'; +import { initializeApiFetch } from './api-fetch.js'; +import { initializeEditor } from './editor.jsx'; + +vi.mock( './bridge.js' ); +vi.mock( './logger.js' ); +vi.mock( './editor-styles.js' ); +vi.mock( './videopress-bridge.js' ); +vi.mock( './wordpress-globals.js' ); + +vi.mock( '../components/editor-load-error/index.jsx', () => ( { + default: vi.fn(), +} ) ); + +vi.mock( './editor-loader.js', () => ( { + loadEditorAssets: vi.fn(), +} ) ); + +vi.mock( './localization.js', () => ( { + configureLocale: vi.fn(), +} ) ); + +vi.mock( './api-fetch.js', () => ( { + initializeApiFetch: vi.fn(), +} ) ); + +vi.mock( './editor.jsx', () => ( { + initializeEditor: vi.fn(), +} ) ); + +describe( 'setUpEditorEnvironment', () => { + beforeEach( () => { + vi.clearAllMocks(); + + awaitGBKitGlobal.mockResolvedValue( undefined ); + getGBKit.mockReturnValue( { plugins: false } ); + configureLocale.mockResolvedValue( undefined ); + initializeApiFetch.mockImplementation( () => {} ); + initializeVideoPressAjaxBridge.mockImplementation( () => {} ); + initializeEditor.mockImplementation( () => {} ); + EditorLoadError.mockReturnValue( '
Error
' ); + loadEditorAssets.mockResolvedValue( { + allowedBlockTypes: [ 'core/paragraph' ], + } ); + } ); + + it( 'executes initialization sequence in correct order', async () => { + const callOrder = []; + + awaitGBKitGlobal.mockImplementation( () => { + callOrder.push( 'awaitGBKitGlobal' ); + return Promise.resolve(); + } ); + + configureLocale.mockImplementation( () => { + callOrder.push( 'configureLocale' ); + return Promise.resolve(); + } ); + + vi.doMock( './wordpress-globals.js', () => { + callOrder.push( 'loadRemainingGlobals' ); + return {}; + } ); + + initializeApiFetch.mockImplementation( () => { + callOrder.push( 'initializeApiFetch' ); + } ); + + initializeVideoPressAjaxBridge.mockImplementation( () => { + callOrder.push( 'initializeVideoPress' ); + } ); + + initializeEditor.mockImplementation( () => { + callOrder.push( 'initializeEditor' ); + } ); + + await setUpEditorEnvironment(); + + expect( callOrder ).toEqual( [ + 'awaitGBKitGlobal', + 'configureLocale', + 'loadRemainingGlobals', + 'initializeApiFetch', + 'initializeVideoPress', + 'initializeEditor', + ] ); + } ); + + it( 'loads plugins when plugins enabled', async () => { + getGBKit.mockReturnValue( { plugins: true } ); + + const allowedTypes = [ 'core/paragraph', 'core/heading', 'core/image' ]; + loadEditorAssets.mockResolvedValue( { + allowedBlockTypes: allowedTypes, + } ); + + await setUpEditorEnvironment(); + + expect( loadEditorAssets ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'skips plugin loading when plugins configuration is disabled', async () => { + getGBKit.mockReturnValue( { plugins: false } ); + + await setUpEditorEnvironment(); + + expect( loadEditorAssets ).not.toHaveBeenCalled(); + } ); + + it( 'skips plugin loading when plugins configuration is undefined', async () => { + getGBKit.mockReturnValue( {} ); + + await setUpEditorEnvironment(); + + expect( loadEditorAssets ).not.toHaveBeenCalled(); + } ); + + it( 'handles errors during initialization', async () => { + const testError = new Error( 'Initialization failed' ); + awaitGBKitGlobal.mockRejectedValue( testError ); + + await setUpEditorEnvironment(); + + expect( error ).toHaveBeenCalledWith( + 'Error initializing editor', + testError + ); + expect( EditorLoadError ).toHaveBeenCalledWith( { + error: testError, + } ); + expect( document.body.innerHTML ).toBe( '
Error
' ); + expect( editorLoaded ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'handles errors during locale configuration', async () => { + const testError = new Error( 'Locale configuration failed' ); + configureLocale.mockRejectedValue( testError ); + + await setUpEditorEnvironment(); + + expect( error ).toHaveBeenCalledWith( + 'Error initializing editor', + testError + ); + expect( editorLoaded ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'handles errors during editor initialization', async () => { + const testError = new Error( 'Editor initialization failed' ); + initializeEditor.mockImplementation( () => { + throw testError; + } ); + + await setUpEditorEnvironment(); + + expect( error ).toHaveBeenCalledWith( + 'Error initializing editor', + testError + ); + expect( editorLoaded ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'handles errors during plugin loading', async () => { + getGBKit.mockReturnValue( { plugins: true } ); + + const testError = new Error( 'Plugin loading failed' ); + initializeEditor.mockImplementation( () => { + throw testError; + } ); + + await setUpEditorEnvironment(); + + expect( error ).toHaveBeenCalledWith( + 'Error initializing editor', + testError + ); + expect( editorLoaded ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'returns a promise that resolves when initialization completes', async () => { + const result = setUpEditorEnvironment(); + + expect( result ).toBeInstanceOf( Promise ); + await expect( result ).resolves.toBeUndefined(); + } ); +} ); diff --git a/src/utils/editor-loader.js b/src/utils/editor-loader.js index 6903c1b6..5fbfe140 100644 --- a/src/utils/editor-loader.js +++ b/src/utils/editor-loader.js @@ -19,23 +19,13 @@ let editorAssetsCache = null; /** * Fetch editor assets and return select WordPress dependencies. * - * @param {Object} [options] Options for the fetch. - * @param {Array} [options.allowedPackages] Array of allowed package names to load. - * @param {Array} [options.disallowedPackages] Array of disallowed package names to load. - * * @return {EditorAssetConfig} Editor configuration provided by the API. */ -export async function loadEditorAssets( { - allowedPackages = [], - disallowedPackages = [], -} = {} ) { +export async function loadEditorAssets() { try { // Return cached response if available if ( editorAssetsCache ) { - return processEditorAssets( editorAssetsCache, { - allowedPackages, - disallowedPackages, - } ); + return processEditorAssets( editorAssetsCache ); } const response = await fetchEditorAssets(); @@ -43,10 +33,7 @@ export async function loadEditorAssets( { // Cache the response editorAssetsCache = response; - return processEditorAssets( response, { - allowedPackages, - disallowedPackages, - } ); + return processEditorAssets( response ); } catch ( err ) { error( 'Error loading editor assets', err ); throw err; @@ -56,33 +43,17 @@ export async function loadEditorAssets( { /** * Process editor assets and return the configuration * - * @param {Object} assets The assets to process - * @param {string[]} assets.styles Array of style assets - * @param {string[]} assets.scripts Array of script assets - * @param {string[]} assets.allowedBlockTypes Array of allowed block types - * @param {Object} options Processing options - * @param {string[]} options.allowedPackages Array of allowed package names - * @param {string[]} options.disallowedPackages Array of disallowed package names + * @param {Object} assets The assets to process + * @param {string[]} assets.styles Array of style assets + * @param {string[]} assets.scripts Array of script assets + * @param {string[]} assets.allowedBlockTypes Array of allowed block types * * @return {EditorAssetConfig} Processed editor configuration */ -async function processEditorAssets( - assets, - { allowedPackages = [], disallowedPackages = [] } = {} -) { +async function processEditorAssets( assets ) { const { styles, scripts, allowed_block_types: allowedBlockTypes } = assets; - if ( allowedPackages.length > 0 ) { - await loadAssets( [ ...styles, ...scripts ].join( '' ), { - allowedPackages, - } ); - - return { allowedBlockTypes }; - } - - await loadAssets( [ ...styles, ...scripts ].join( '' ), { - disallowedPackages, - } ); + await loadAssets( [ ...styles, ...scripts ].join( '' ) ); return { allowedBlockTypes }; } @@ -90,46 +61,14 @@ async function processEditorAssets( /** * Load the asset files for a block * - * @param {string} html The HTML content to parse for assets. - * @param {Object} [options] Options for the load. - * @param {string[]} [options.allowedPackages] Array of allowed package names to load. - * @param {string[]} [options.disallowedPackages] Array of disallowed package names to load. + * @param {string} html The HTML content to parse for assets. */ -async function loadAssets( - html, - { allowedPackages = [], disallowedPackages = [] } = {} -) { - const excludedScriptIDs = disallowedPackages.length - ? new RegExp( - disallowedPackages - .map( ( script ) => `wp-${ script }-js` ) - .join( '|' ) - ) - : null; - - const allowedScriptIDs = allowedPackages.length - ? new RegExp( - allowedPackages.map( ( pkg ) => `wp-${ pkg }-js` ).join( '|' ) - ) - : null; - +async function loadAssets( html ) { const doc = new window.DOMParser().parseFromString( html, 'text/html' ); const newAssets = Array.from( doc.querySelectorAll( 'link[rel="stylesheet"],script' ) ).filter( ( asset ) => { - if ( ! asset.id ) { - return false; - } - - if ( allowedScriptIDs ) { - return allowedScriptIDs.test( asset.id ); - } - - if ( excludedScriptIDs ) { - return ! excludedScriptIDs.test( asset.id ); - } - /** * TODO: Remove this once the relevant Jetpack plugin release is available. * @@ -149,7 +88,7 @@ async function loadAssets( return false; } - return true; + return !! asset.id; } ); /* diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 22a82e5b..ac387639 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -15,10 +15,6 @@ import { setLogLevel } from './logger'; /** * Configure editor settings and styles, and render the editor. * - * Dependency injection is used for various `@wordpress` package functions so - * that this utility can be used in both the local and remote editor, which - * rely upon ES modules and global variables, respectively. - * * @param {Object} [options] * @param {Array} [options.allowedBlockTypes] Array of allowed block types * @param {boolean} [options.pluginLoadFailed] Whether plugin loading failed diff --git a/src/utils/remote-editor.jsx b/src/utils/remote-editor.jsx deleted file mode 100644 index 480abe20..00000000 --- a/src/utils/remote-editor.jsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Internal dependencies - */ -import { awaitGBKitGlobal } from './bridge'; -import { loadEditorAssets } from './editor-loader'; -import { initializeVideoPressAjaxBridge } from './videopress-bridge'; -import { error, warn } from './logger'; -import { isDevMode } from './dev-mode'; -import './editor-styles.js'; - -const I18N_PACKAGES = [ 'i18n', 'hooks' ]; -const API_FETCH_PACKAGES = [ 'api-fetch', 'url' ]; - -/** - * Initialize the remote editor by loading assets and configuring modules - * in the correct sequence. - * - * @return {Promise} Promise that resolves when initialization is complete - */ -export function initializeRemoteEditor() { - // Rely upon promises rather than async/await to avoid timeouts caused by - // circular dependencies. Addressing the circular dependencies is quite - // challenging due to Vite's preload helpers and bugs in `manualChunks` - // configuration. - // - // See: - // - https://github.com/vitejs/vite/issues/18551 - // - https://github.com/vitejs/vite/issues/13952 - // - https://github.com/vitejs/vite/issues/5189#issuecomment-2175410148 - return awaitGBKitGlobal() - .then( initializeApiAndLoadI18n ) - .then( importL10n ) - .then( configureLocale ) // Configure locale before loading modules with strings - .then( loadApiFetch ) - .then( initializeApiFetch ) // Configure API fetch before loading remaining modules - .then( loadRemainingAssets ) - .then( initializeEditor ) - .catch( handleError ); -} - -function initializeApiAndLoadI18n() { - return loadEditorAssets( { allowedPackages: I18N_PACKAGES } ); -} - -function importL10n() { - return import( './localization' ); -} - -function configureLocale( localeModule ) { - const { configureLocale: _configureLocale } = localeModule; - return _configureLocale(); -} - -function loadApiFetch() { - return loadEditorAssets( { allowedPackages: API_FETCH_PACKAGES } ); -} - -function loadRemainingAssets() { - return loadEditorAssets( { - disallowedPackages: [ ...I18N_PACKAGES, ...API_FETCH_PACKAGES ], - } ); -} - -function initializeApiFetch( assetsResult ) { - return import( './api-fetch' ).then( - ( { initializeApiFetch: _initializeApiFetch } ) => { - _initializeApiFetch(); - return assetsResult; - } - ); -} - -function initializeEditor( assetsResult ) { - initializeVideoPressAjaxBridge(); - - const { allowedBlockTypes } = assetsResult; - return import( './editor' ).then( - ( { initializeEditor: _initializeEditor } ) => { - _initializeEditor( { allowedBlockTypes } ); - } - ); -} - -function handleError( err ) { - error( 'Error initializing editor', err ); - if ( isDevMode() ) { - warn( 'Dev mode disabled automatic redirect to the local editor.' ); - } else { - // Fallback to the local editor and display a notice. Because the remote - // editor loading failed, it is more practical to rely upon the local - // editor's scripts and styles for displaying the notice. - window.location.href = 'index.html?error=gbkit_global_unavailable'; - } -} diff --git a/src/utils/remote-editor.test.jsx b/src/utils/remote-editor.test.jsx deleted file mode 100644 index cd2b1efa..00000000 --- a/src/utils/remote-editor.test.jsx +++ /dev/null @@ -1,300 +0,0 @@ -/** - * External dependencies - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -/** - * Internal dependencies - */ -import { initializeRemoteEditor } from './remote-editor.jsx'; -import { awaitGBKitGlobal } from './bridge'; -import { loadEditorAssets } from './editor-loader.js'; -import { configureLocale } from './localization'; -import { initializeApiFetch } from './api-fetch'; -import { initializeEditor } from './editor'; -import { initializeVideoPressAjaxBridge } from './videopress-bridge'; -import { isDevMode } from './dev-mode'; -import { error, warn } from './logger'; - -vi.mock( './bridge', () => ( { - awaitGBKitGlobal: vi.fn(), -} ) ); - -vi.mock( './editor-loader', () => ( { - loadEditorAssets: vi.fn(), -} ) ); - -vi.mock( './localization', () => ( { - configureLocale: vi.fn(), -} ) ); - -vi.mock( './api-fetch', () => ( { - initializeApiFetch: vi.fn(), -} ) ); - -vi.mock( './editor', () => ( { - initializeEditor: vi.fn(), -} ) ); - -vi.mock( './videopress-bridge', () => ( { - initializeVideoPressAjaxBridge: vi.fn(), -} ) ); - -vi.mock( './dev-mode', () => ( { - isDevMode: vi.fn(), -} ) ); - -vi.mock( './logger', () => ( { - error: vi.fn(), - warn: vi.fn(), -} ) ); - -const I18N_PACKAGES = [ 'i18n', 'hooks' ]; -const API_FETCH_PACKAGES = [ 'api-fetch', 'url' ]; - -function arrayEquals( a, b ) { - return ( - a.length === b.length && - a.every( ( value, index ) => value === b[ index ] ) - ); -} - -describe( 'Remote Editor Loading Sequence', () => { - let loadSequence; - let originalLocation; - - beforeEach( () => { - vi.clearAllMocks(); - loadSequence = []; - originalLocation = window.location; - - // Mock window.location for redirect testing - delete window.location; - window.location = { href: '' }; - - awaitGBKitGlobal.mockResolvedValue( {} ); - - loadEditorAssets.mockImplementation( - ( { allowedPackages = [], disallowedPackages = [] } = {} ) => { - loadSequence.push( { - action: 'loadEditorAssets', - allowedPackages: [ ...allowedPackages ], - disallowedPackages: [ ...disallowedPackages ], - } ); - return Promise.resolve( { - allowedBlockTypes: [ 'core/paragraph', 'core/heading' ], - } ); - } - ); - - configureLocale.mockImplementation( () => { - loadSequence.push( { action: 'configureLocale' } ); - return Promise.resolve(); - } ); - - initializeApiFetch.mockImplementation( () => { - loadSequence.push( { action: 'initializeApiFetch' } ); - } ); - - initializeEditor.mockImplementation( () => { - loadSequence.push( { action: 'initializeEditor' } ); - } ); - - initializeVideoPressAjaxBridge.mockImplementation( () => { - loadSequence.push( { action: 'initializeVideoPressAjaxBridge' } ); - } ); - - isDevMode.mockReturnValue( false ); - } ); - - afterEach( () => { - window.location = originalLocation; - } ); - - it( 'should configure locale before loading api-fetch modules', async () => { - await initializeRemoteEditor(); - - const i18nLoadIndex = loadSequence.findIndex( - ( item ) => - item.action === 'loadEditorAssets' && - arrayEquals( item.allowedPackages, I18N_PACKAGES ) - ); - const localeIndex = loadSequence.findIndex( - ( item ) => item.action === 'configureLocale' - ); - const apiFetchLoadIndex = loadSequence.findIndex( - ( item ) => - item.action === 'loadEditorAssets' && - arrayEquals( item.allowedPackages, API_FETCH_PACKAGES ) - ); - - expect( i18nLoadIndex ).toBeLessThan( localeIndex ); - expect( localeIndex ).toBeLessThan( apiFetchLoadIndex ); - } ); - - it( 'should configure api-fetch before loading remaining modules', async () => { - await initializeRemoteEditor(); - - const apiFetchLoadIndex = loadSequence.findIndex( - ( item ) => - item.action === 'loadEditorAssets' && - arrayEquals( item.allowedPackages, API_FETCH_PACKAGES ) - ); - const apiFetchInitIndex = loadSequence.findIndex( - ( item ) => item.action === 'initializeApiFetch' - ); - const remainingLoadIndex = loadSequence.findIndex( - ( item ) => - item.action === 'loadEditorAssets' && - arrayEquals( item.disallowedPackages, [ - ...I18N_PACKAGES, - ...API_FETCH_PACKAGES, - ] ) - ); - - expect( apiFetchLoadIndex ).toBeLessThan( apiFetchInitIndex ); - expect( apiFetchInitIndex ).toBeLessThan( remainingLoadIndex ); - } ); - - it( 'should exclude strategically loaded modules when loading remaining assets', async () => { - await initializeRemoteEditor(); - - const finalLoad = loadSequence.find( - ( item ) => - item.action === 'loadEditorAssets' && - item.disallowedPackages.length > 0 - ); - - expect( finalLoad ).toBeDefined(); - expect( - arrayEquals( finalLoad.disallowedPackages, [ - ...I18N_PACKAGES, - ...API_FETCH_PACKAGES, - ] ) - ); - } ); - - it( 'should maintain correct sequence even with async delays', async () => { - // Add delays to simulate real network conditions - loadEditorAssets.mockImplementation( - ( { allowedPackages = [], disallowedPackages = [] } = {} ) => { - return new Promise( ( resolve ) => { - setTimeout( () => { - loadSequence.push( { - action: 'loadEditorAssets', - allowedPackages: [ ...allowedPackages ], - disallowedPackages: [ ...disallowedPackages ], - } ); - resolve( { allowedBlockTypes: [] } ); - }, Math.random() * 10 ); - } ); - } - ); - - await initializeRemoteEditor(); - - const apiFetchInitIndex = loadSequence.findIndex( - ( item ) => item.action === 'initializeApiFetch' - ); - const remainingLoadIndex = loadSequence.findIndex( - ( item ) => - item.action === 'loadEditorAssets' && - arrayEquals( item.disallowedPackages, [ - ...I18N_PACKAGES, - ...API_FETCH_PACKAGES, - ] ) - ); - - expect( apiFetchInitIndex ).toBeLessThan( remainingLoadIndex ); - } ); - - it( 'should pass allowedBlockTypes to initializeEditor', async () => { - const mockAllowedBlockTypes = [ - 'core/paragraph', - 'core/heading', - 'core/image', - ]; - loadEditorAssets.mockResolvedValue( { - allowedBlockTypes: mockAllowedBlockTypes, - } ); - - await initializeRemoteEditor(); - - expect( initializeEditor ).toHaveBeenCalledWith( { - allowedBlockTypes: mockAllowedBlockTypes, - } ); - } ); - - it( 'should handle errors and redirect to local editor in production', async () => { - awaitGBKitGlobal.mockRejectedValue( - new Error( 'GBKit not available' ) - ); - isDevMode.mockReturnValue( false ); - - await initializeRemoteEditor(); - - expect( error ).toHaveBeenCalledWith( - 'Error initializing editor', - expect.any( Error ) - ); - expect( window.location.href ).toBe( - 'index.html?error=gbkit_global_unavailable' - ); - } ); - - it( 'should not redirect in dev mode when error occurs', async () => { - awaitGBKitGlobal.mockRejectedValue( - new Error( 'GBKit not available' ) - ); - isDevMode.mockReturnValue( true ); - - await initializeRemoteEditor(); - - expect( error ).toHaveBeenCalledWith( - 'Error initializing editor', - expect.any( Error ) - ); - expect( warn ).toHaveBeenCalledWith( - 'Dev mode disabled automatic redirect to the local editor.' - ); - expect( window.location.href ).toBe( '' ); - } ); - - it( 'should handle errors during api-fetch loading', async () => { - let callCount = 0; - loadEditorAssets.mockImplementation( () => { - callCount++; - if ( callCount === 2 ) { - // Fail on api-fetch loading (second call) - return Promise.reject( - new Error( 'Failed to load api-fetch' ) - ); - } - return Promise.resolve( { allowedBlockTypes: [] } ); - } ); - isDevMode.mockReturnValue( false ); - - await initializeRemoteEditor(); - - expect( error ).toHaveBeenCalledWith( - 'Error initializing editor', - expect.any( Error ) - ); - expect( window.location.href ).toBe( - 'index.html?error=gbkit_global_unavailable' - ); - } ); - - it( 'should initialize VideoPress bridge before editor', async () => { - await initializeRemoteEditor(); - - const videoPressIndex = loadSequence.findIndex( - ( item ) => item.action === 'initializeVideoPressAjaxBridge' - ); - const editorIndex = loadSequence.findIndex( - ( item ) => item.action === 'initializeEditor' - ); - - expect( videoPressIndex ).toBeLessThan( editorIndex ); - } ); -} ); diff --git a/vite.config.remote.js b/vite.config.remote.js deleted file mode 100644 index b398498c..00000000 --- a/vite.config.remote.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * WordPress dependencies - */ -import { defaultRequestToExternal } from '@wordpress/dependency-extraction-webpack-plugin/lib/util'; -import { nodePolyfills } from 'vite-plugin-node-polyfills'; - -/** - * External dependencies - */ -import { resolve } from 'path'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import MagicString from 'magic-string'; - -export default defineConfig( { - base: '', - build: { - outDir: '../dist', - rollupOptions: { - input: resolve( __dirname, 'src/remote.html' ), - external, - }, - target: 'esnext', - }, - plugins: [ nodePolyfills(), react(), wordPressExternals() ], - root: 'src', - css: { - preprocessorOptions: { - scss: { - quietDeps: true, - }, - }, - }, -} ); - -function external( id ) { - const hasExternal = defaultRequestToExternal( id ) !== undefined; - const isInlineCss = id.match( /\.css\?inline$/ ); - - return hasExternal && ! isInlineCss; -} - -/** - * Transform code by replacing WordPress imports with global definitions. - * E.g., `import { __ } from '@wordpress/i18n';` becomes `const { __ } = window.wp.i18n;` - * Also transforms dynamic imports: `await import('@wordpress/blocks')` becomes `Promise.resolve(window.wp.blocks)` - * This replicates Gutenberg's behavior in a browser environment, which relies upon - * the `@wordpress/dependency-extraction-webpack-plugin` module. - * - * See: https://github.com/WordPress/gutenberg/tree/d2fce222ebbbef8dbc56eee1badcfe4ae0df04b0/packages/dependency-extraction-webpack-plugin - * - * @return {Object} The transformed code and map. - */ -function wordPressExternals() { - return { - name: 'wordpress-externals-plugin', - transform( code ) { - const magicString = new MagicString( code ); - let hasReplacements = false; - - // Match static WordPress and React JSX runtime import statements - const staticImportRegex = - /import\s*(?:(?:(\w+)|{([^}]+)})\s*from\s*)?['"](@wordpress\/[^'"]+|react\/jsx-runtime)['"];/g; - let match; - - while ( ( match = staticImportRegex.exec( code ) ) !== null ) { - const [ fullMatch, defaultImport, namedImports, module ] = - match; - const imports = defaultImport || namedImports; - - if ( module.match( /\.css\?inline$/ ) ) { - continue; // Exclude inlined CSS files from externalization - } - - const externalDefinition = defaultRequestToExternal( module ); - - if ( ! externalDefinition ) { - continue; // Exclude the module from externalization - } - - hasReplacements = true; - - if ( ! imports ) { - // Remove the side effect import entirely - magicString.remove( - match.index, - match.index + fullMatch.length - ); - continue; - } - - const definitionArray = Array.isArray( externalDefinition ) - ? externalDefinition - : [ externalDefinition ]; - - let replacement; - if ( defaultImport ) { - // Handle default import - replacement = `const ${ defaultImport } = window.${ definitionArray.join( - '.' - ) };`; - } else { - // Handle named imports - const importList = imports.split( ',' ).map( ( i ) => { - const parts = i.trim().split( /\s+as\s+/ ); - if ( parts.length === 2 ) { - // Convert import "as" syntax to destructuring assignment - return `${ parts[ 0 ] }: ${ parts[ 1 ] }`; - } - return i.trim(); - } ); - - replacement = `const { ${ importList.join( - ', ' - ) } } = window.${ definitionArray.join( '.' ) };`; - } - magicString.overwrite( - match.index, - match.index + fullMatch.length, - replacement - ); - } - - // Match dynamic WordPress imports - const dynamicImportRegex = - /import\s*\(\s*['"](@wordpress\/[^'"]+)['"]\s*\)/g; - - while ( ( match = dynamicImportRegex.exec( code ) ) !== null ) { - const [ fullMatch, module ] = match; - - const externalDefinition = defaultRequestToExternal( module ); - - if ( ! externalDefinition ) { - continue; // Exclude the module from externalization - } - - hasReplacements = true; - - const definitionArray = Array.isArray( externalDefinition ) - ? externalDefinition - : [ externalDefinition ]; - - // Transform to Promise that resolves with the global - const replacement = `Promise.resolve(window.${ definitionArray.join( - '.' - ) })`; - - magicString.overwrite( - match.index, - match.index + fullMatch.length, - replacement - ); - } - - if ( ! hasReplacements ) { - return null; - } - - return { - code: magicString.toString(), - map: magicString.generateMap( { hires: true } ), - }; - }, - }; -}