From ee1559ea118924b3eddffacbdbff9cb52e2e3f02 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 11 Mar 2026 17:54:02 +0000 Subject: [PATCH 1/5] Connectors: Add API key source detection and refactor REST dispatch Add `_wp_connectors_get_api_key_source()` to detect whether an API key comes from an environment variable, PHP constant, or the database. This enables the UI to show the key source and hide the remove button for externally configured keys. Refactor API key validation and masking from `sanitize_callback` and `option_` filters into a single `rest_post_dispatch` handler (`_wp_connectors_rest_settings_dispatch`). This ensures raw keys are never exposed via the REST API and simplifies the validation flow. Enrich `_wp_connectors_get_connector_settings()` with plugin installation/activation status and static memoization. Update `_wp_connectors_get_connector_script_module_data()` to expose `keySource`, `isConnected`, `logoUrl`, and plugin status to the admin. Backports https://github.com/WordPress/gutenberg/pull/76266 Backports https://github.com/WordPress/gutenberg/pull/76327 updates include ref update --- package.json | 2 +- src/wp-includes/connectors.php | 171 +++++++++++++++++++++------------ 2 files changed, 111 insertions(+), 62 deletions(-) diff --git a/package.json b/package.json index 117cf46f5455f..a8ba7e94cdbc3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "9b8144036fa5faf75de43d4502ff9809fcf689ad", + "sha": "74a4f254a45f7a303bd27b8f8e104786380e8103", "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 60f97839dabb6..e6cac74416aea 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -266,6 +266,50 @@ function _wp_connectors_mask_api_key( string $key ): string { return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); } +/** + * Determines the source of an API key for a given provider. + * + * Checks in order: environment variable, PHP constant, database. + * Uses the same naming convention as the WP AI Client ProviderRegistry. + * + * @since 7.0.0 + * @access private + * + * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). + * @return string The key source: 'env', 'constant', 'database', or 'none'. + */ +function _wp_connectors_get_api_key_source( string $provider_id ): string { + // Convert provider ID to CONSTANT_CASE for env var name. + // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'. + $constant_case_id = strtoupper( + preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) ) + ); + $env_var_name = "{$constant_case_id}_API_KEY"; + + // Check environment variable first. + $env_value = getenv( $env_var_name ); + if ( false !== $env_value && '' !== $env_value ) { + return 'env'; + } + + // Check PHP constant. + if ( defined( $env_var_name ) ) { + $const_value = constant( $env_var_name ); + if ( is_string( $const_value ) && '' !== $const_value ) { + return 'constant'; + } + } + + // Check database. + $setting_name = "connectors_ai_{$provider_id}_api_key"; + $db_value = get_option( $setting_name, '' ); + if ( '' !== $db_value ) { + return 'database'; + } + + return 'none'; +} + /** * Checks whether an API key is valid for a given provider. * @@ -305,25 +349,6 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): } } -/** - * Retrieves the real (unmasked) value of a connector API key. - * - * Temporarily removes the masking filter, reads the option, then re-adds it. - * - * @since 7.0.0 - * @access private - * - * @param string $option_name The option name for the API key. - * @param callable $mask_callback The mask filter function. - * @return string The real API key value. - */ -function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { - remove_filter( "option_{$option_name}", $mask_callback ); - $value = get_option( $option_name, '' ); - add_filter( "option_{$option_name}", $mask_callback ); - return (string) $value; -} - /** * Gets the registered connector settings. * @@ -360,12 +385,13 @@ function _wp_connectors_get_connector_settings(): array { } /** - * Validates connector API keys in the REST response when explicitly requested. + * Masks and validates connector API keys in REST responses. + * + * On every `/wp/v2/settings` response, masks connector API key values so raw + * keys are never exposed via the REST API. * - * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector - * fields via `_fields`. For each requested connector field, it validates the unmasked - * key against the provider and replaces the response value with `invalid_key` if - * validation fails. + * On POST or PUT requests, validates each updated key against the provider + * before masking. If validation fails, the key is reverted to an empty string. * * @since 7.0.0 * @access private @@ -373,57 +399,55 @@ function _wp_connectors_get_connector_settings(): array { * @param WP_REST_Response $response The response object. * @param WP_REST_Server $server The server instance. * @param WP_REST_Request $request The request object. - * @return WP_REST_Response The potentially modified response. + * @return WP_REST_Response The modified response with masked/validated keys. */ -function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { +function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { if ( '/wp/v2/settings' !== $request->get_route() ) { return $response; } - $fields = $request->get_param( '_fields' ); - if ( ! $fields ) { - return $response; - } - - if ( is_array( $fields ) ) { - $requested = $fields; - } else { - $requested = array_map( 'trim', explode( ',', $fields ) ); - } - $data = $response->get_data(); if ( ! is_array( $data ) ) { return $response; } + $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method(); + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } $setting_name = $auth['setting_name']; - if ( ! in_array( $setting_name, $requested, true ) ) { + if ( ! array_key_exists( $setting_name, $data ) ) { continue; } - $real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); - if ( '' === $real_key ) { - continue; + $value = $data[ $setting_name ]; + + // On update, validate the key before masking. + if ( $is_update && is_string( $value ) && '' !== $value ) { + if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) { + update_option( $setting_name, '' ); + $data[ $setting_name ] = ''; + continue; + } } - if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) { - $data[ $setting_name ] = 'invalid_key'; + // Mask the key in the response. + if ( is_string( $value ) && '' !== $value ) { + $data[ $setting_name ] = _wp_connectors_mask_api_key( $value ); } } $response->set_data( $data ); return $response; } -add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 ); +add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 ); /** - * Registers default connector settings and mask/sanitize filters. + * Registers default connector settings. * * @since 7.0.0 * @access private @@ -442,10 +466,9 @@ function _wp_register_default_connector_settings(): void { continue; } - $setting_name = $auth['setting_name']; register_setting( 'connectors', - $setting_name, + $auth['setting_name'], array( 'type' => 'string', 'label' => sprintf( @@ -460,18 +483,9 @@ function _wp_register_default_connector_settings(): void { ), 'default' => '', 'show_in_rest' => true, - 'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string { - $value = sanitize_text_field( $value ); - if ( '' === $value ) { - return $value; - } - - $valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id ); - return true === $valid ? $value : ''; - }, + 'sanitize_callback' => 'sanitize_text_field', ) ); - add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); } } add_action( 'init', '_wp_register_default_connector_settings', 20 ); @@ -499,7 +513,13 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); + // Skip if the key is already provided via env var or constant. + $key_source = _wp_connectors_get_api_key_source( $connector_id ); + if ( 'env' === $key_source || 'constant' === $key_source ) { + continue; + } + + $api_key = get_option( $auth['setting_name'], '' ); if ( '' === $api_key ) { continue; } @@ -525,6 +545,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { * @return array Script module data with connectors added. */ function _wp_connectors_get_connector_script_module_data( array $data ): array { + $registry = AiClient::defaultRegistry(); + + // Build a slug-to-file map for plugin installation status. + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $plugin_files_by_slug = array(); + foreach ( array_keys( get_plugins() ) as $plugin_file ) { + $slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file ); + $plugin_files_by_slug[ $slug ] = $plugin_file; + } + $connectors = array(); foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; @@ -533,17 +565,34 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { if ( 'api_key' === $auth['method'] ) { $auth_out['settingName'] = $auth['setting_name'] ?? ''; $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; + $auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id ); + try { + $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); + } catch ( Exception $e ) { + $auth_out['isConnected'] = false; + } } $connector_out = array( 'name' => $connector_data['name'], 'description' => $connector_data['description'], + 'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null, 'type' => $connector_data['type'], 'authentication' => $auth_out, ); - if ( ! empty( $connector_data['plugin'] ) ) { - $connector_out['plugin'] = $connector_data['plugin']; + if ( ! empty( $connector_data['plugin']['slug'] ) ) { + $plugin_slug = $connector_data['plugin']['slug']; + $plugin_file = $plugin_files_by_slug[ $plugin_slug ] ?? null; + + $is_installed = null !== $plugin_file; + $is_activated = $is_installed && is_plugin_active( $plugin_file ); + + $connector_out['plugin'] = array( + 'slug' => $plugin_slug, + 'isInstalled' => $is_installed, + 'isActivated' => $is_activated, + ); } $connectors[ $connector_id ] = $connector_out; From 2687469dbebf2263dce80509beec45195660cff6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 11 Mar 2026 22:25:28 +0000 Subject: [PATCH 2/5] Revert "Build/Test Tools: Test against MySQL 9.6 & MariaDB 12.1." This reverts commit d32670ef84738da9e4264fbde4d88f1bef0e611b. --- .github/workflows/install-testing.yml | 4 ++-- .github/workflows/phpunit-tests.yml | 6 +++--- .github/workflows/upgrade-testing.yml | 4 ++-- .version-support-mysql.json | 1 - 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/install-testing.yml b/.github/workflows/install-testing.yml index dd9e674174541..648ff0485be94 100644 --- a/.github/workflows/install-testing.yml +++ b/.github/workflows/install-testing.yml @@ -101,9 +101,9 @@ jobs: - db-version: '9.4' # MySQL 9.0+ will not work on PHP 7.2 & 7.3. See https://core.trac.wordpress.org/ticket/61218. - php: '7.2' - db-version: '9.6' + db-version: '9.5' - php: '7.3' - db-version: '9.6' + db-version: '9.5' services: database: diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index de36d5a505187..de2de9091677c 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -203,7 +203,7 @@ jobs: os: [ ubuntu-24.04 ] php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] db-type: [ 'mysql', 'mariadb' ] - db-version: [ '9.6', '12.1' ] + db-version: [ '9.5', '12.0' ] multisite: [ false, true ] memcached: [ false ] db-innovation: [ true ] @@ -211,9 +211,9 @@ jobs: exclude: # Exclude version combinations that don't exist. - db-type: 'mariadb' - db-version: '9.6' + db-version: '9.5' - db-type: 'mysql' - db-version: '12.1' + db-version: '12.0' with: os: ${{ matrix.os }} php: ${{ matrix.php }} diff --git a/.github/workflows/upgrade-testing.yml b/.github/workflows/upgrade-testing.yml index 0370c8770bd58..4f6025e054580 100644 --- a/.github/workflows/upgrade-testing.yml +++ b/.github/workflows/upgrade-testing.yml @@ -70,7 +70,7 @@ jobs: os: [ 'ubuntu-24.04' ] php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] db-type: [ 'mysql' ] - db-version: [ '5.7', '8.0', '8.4', '9.6' ] + db-version: [ '5.7', '8.0', '8.4', '9.5' ] wp: [ '6.7', '6.8' ] multisite: [ false, true ] with: @@ -179,7 +179,7 @@ jobs: os: [ 'ubuntu-24.04' ] php: [ '7.4' ] db-type: [ 'mysql' ] - db-version: [ '5.7', '8.0', '8.4', '9.6' ] + db-version: [ '5.7', '8.0', '8.4', '9.5' ] wp: [ '4.7' ] multisite: [ false, true ] with: diff --git a/.version-support-mysql.json b/.version-support-mysql.json index 6a3385cf13e28..826942c5785b3 100644 --- a/.version-support-mysql.json +++ b/.version-support-mysql.json @@ -1,6 +1,5 @@ { "7-0": [ - "9.6", "9.5", "9.4", "9.3", From 5078e32607c8de4eea4a3ddda2b45a9cfd38a765 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 11 Mar 2026 22:25:34 +0000 Subject: [PATCH 3/5] Revert "Build/Test Tools: Revert [61836]." This reverts commit 3c7becf3cab98a24ff31e88d7c0b43ec289bafeb. --- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 45198c20f5e52..4088afbfa7c00 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -120,7 +120,7 @@ jobs: phpunit-tests: name: ${{ ( inputs.phpunit-test-groups || inputs.coverage-report ) && format( 'PHP {0} with ', inputs.php ) || '' }} ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.multisite && ' multisite' || '' }}${{ inputs.db-innovation && ' (innovation release)' || '' }}${{ inputs.memcached && ' with memcached' || '' }}${{ inputs.report && ' (test reporting enabled)' || '' }} ${{ 'example.org' != inputs.tests-domain && inputs.tests-domain || '' }} runs-on: ${{ inputs.os }} - timeout-minutes: ${{ inputs.coverage-report && 120 || inputs.php == '8.4' && 30 || 20 }} + timeout-minutes: ${{ inputs.coverage-report && 120 || 40 }} permissions: contents: read From 40e2ce0963946af6de3f75d530701335032ae1b8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 11 Mar 2026 22:26:43 +0000 Subject: [PATCH 4/5] Revert "Build/Test Tools: Remove the requirement to clone/build Gutenberg." This reverts commit 49d8c1137c1cf1de8c175d2479f1c2d5044c43fb. --- Gruntfile.js | 160 +---- package.json | 12 +- tests/phpstan/base.neon | 2 +- tools/gutenberg/build-gutenberg.js | 192 ++++++ tools/gutenberg/checkout-gutenberg.js | 239 +++++++ .../{copy.js => copy-gutenberg-build.js} | 603 ++++++++++++++---- tools/gutenberg/download.js | 173 ----- tools/gutenberg/sync-gutenberg.js | 149 +++++ tools/gutenberg/utils.js | 82 --- webpack.config.js | 2 +- 10 files changed, 1092 insertions(+), 522 deletions(-) create mode 100644 tools/gutenberg/build-gutenberg.js create mode 100644 tools/gutenberg/checkout-gutenberg.js rename tools/gutenberg/{copy.js => copy-gutenberg-build.js} (53%) delete mode 100644 tools/gutenberg/download.js create mode 100644 tools/gutenberg/sync-gutenberg.js delete mode 100644 tools/gutenberg/utils.js diff --git a/Gruntfile.js b/Gruntfile.js index 355a8989db3db..b5c69553f3f0d 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -53,6 +53,7 @@ module.exports = function(grunt) { webpackFiles = [ 'wp-includes/assets/*', 'wp-includes/css/dist', + 'wp-includes/blocks/**/*.css', '!wp-includes/assets/script-loader-packages.min.php', '!wp-includes/assets/script-modules-packages.min.php', ], @@ -587,97 +588,7 @@ module.exports = function(grunt) { certificates: { src: 'vendor/composer/ca-bundle/res/cacert.pem', dest: SOURCE_DIR + 'wp-includes/certificates/ca-bundle.crt' - }, - // Gutenberg PHP infrastructure files (routes.php, pages.php, constants.php, pages/, routes/). - 'gutenberg-php': { - options: { - process: function( content ) { - // Fix boot module asset file path for Core's different directory structure. - return content.replace( - /__DIR__\s*\.\s*(['"])\/..\/\..\/modules\/boot\/index\.min\.asset\.php\1/g, - 'ABSPATH . WPINC . \'/js/dist/script-modules/boot/index.min.asset.php\'' - ); - } - }, - files: [ { - expand: true, - cwd: 'gutenberg/build', - src: [ - 'routes.php', - 'pages.php', - 'constants.php', - 'pages/**/*.php', - 'routes/**/*.php', - ], - dest: WORKING_DIR + 'wp-includes/build/', - } ], - }, - 'gutenberg-modules': { - files: [ { - expand: true, - cwd: 'gutenberg/build/modules', - src: [ '**/*', '!**/*.map' ], - dest: WORKING_DIR + 'wp-includes/js/dist/script-modules/', - } ], - }, - 'gutenberg-styles': { - files: [ { - expand: true, - cwd: 'gutenberg/build/styles', - src: [ '**/*', '!**/*.map' ], - dest: WORKING_DIR + 'wp-includes/css/dist/', - } ], - }, - 'gutenberg-theme-json': { - options: { - process: function( content, srcpath ) { - // Replace the local schema URL with the canonical public URL for Core. - if ( path.basename( srcpath ) === 'theme.json' ) { - return content.replace( - '"$schema": "../schemas/json/theme.json"', - '"$schema": "https://schemas.wp.org/trunk/theme.json"' - ); - } - return content; - } - }, - files: [ - { - src: 'gutenberg/lib/theme.json', - dest: WORKING_DIR + 'wp-includes/theme.json', - }, - { - src: 'gutenberg/lib/theme-i18n.json', - dest: WORKING_DIR + 'wp-includes/theme-i18n.json', - }, - ], - }, - 'gutenberg-icons': { - options: { - process: function( content, srcpath ) { - // Remove the 'gutenberg' text domain from _x() calls in manifest.php. - if ( path.basename( srcpath ) === 'manifest.php' ) { - return content.replace( - /_x\(\s*([^,]+),\s*([^,]+),\s*['"]gutenberg['"]\s*\)/g, - '_x( $1, $2 )' - ); - } - return content; - } - }, - files: [ - { - src: 'gutenberg/packages/icons/src/manifest.php', - dest: WORKING_DIR + 'wp-includes/icons/manifest.php', - }, - { - expand: true, - cwd: 'gutenberg/packages/icons/src/library', - src: '*.svg', - dest: WORKING_DIR + 'wp-includes/icons/library/', - }, - ], - }, + } }, sass: { colors: { @@ -1412,21 +1323,20 @@ module.exports = function(grunt) { }, { expand: true, - cwd: BUILD_DIR + 'wp-includes/js/dist/', - src: [ '*.js' ], - dest: BUILD_DIR + 'wp-includes/js/dist/', - }, - { - expand: true, - cwd: BUILD_DIR + 'wp-includes/js/dist/vendor/', - src: [ '**/*.js' ], - dest: BUILD_DIR + 'wp-includes/js/dist/vendor/', + flatten: true, + src: [ + BUILD_DIR + 'wp-includes/js/dist/block-editor.js', + BUILD_DIR + 'wp-includes/js/dist/commands.js', + ], + dest: BUILD_DIR + 'wp-includes/js/dist/' }, { expand: true, - cwd: BUILD_DIR + 'wp-includes/js/dist/script-modules/', - src: [ '**/*.js' ], - dest: BUILD_DIR + 'wp-includes/js/dist/script-modules/', + flatten: true, + src: [ + BUILD_DIR + 'wp-includes/js/dist/vendor/**/*.js' + ], + dest: BUILD_DIR + 'wp-includes/js/dist/vendor/' } ] } @@ -1565,38 +1475,45 @@ module.exports = function(grunt) { } ); // Gutenberg integration tasks. - grunt.registerTask( 'gutenberg:verify', 'Verifies the installed Gutenberg version matches the expected SHA.', function() { + grunt.registerTask( 'gutenberg-checkout', 'Checks out the Gutenberg repository.', function() { const done = this.async(); grunt.util.spawn( { cmd: 'node', - args: [ 'tools/gutenberg/utils.js' ], + args: [ 'tools/gutenberg/checkout-gutenberg.js' ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); } ); } ); - grunt.registerTask( 'gutenberg:download', 'Downloads the built Gutenberg artifact.', function() { + grunt.registerTask( 'gutenberg-build', 'Builds the Gutenberg repository.', function() { const done = this.async(); - const args = [ 'tools/gutenberg/download.js' ]; - if ( grunt.option( 'force' ) ) { - args.push( '--force' ); - } grunt.util.spawn( { cmd: 'node', - args, + args: [ 'tools/gutenberg/build-gutenberg.js' ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); } ); } ); - grunt.registerTask( 'gutenberg:copy', 'Copies Gutenberg JS packages and block assets to WordPress Core.', function() { + grunt.registerTask( 'gutenberg-copy', 'Copies Gutenberg build output to WordPress Core.', function() { const done = this.async(); const buildDir = grunt.option( 'dev' ) ? 'src' : 'build'; grunt.util.spawn( { cmd: 'node', - args: [ 'tools/gutenberg/copy.js', `--build-dir=${ buildDir }` ], + args: [ 'tools/gutenberg/copy-gutenberg-build.js', `--build-dir=${ buildDir }` ], + opts: { stdio: 'inherit' } + }, function( error ) { + done( ! error ); + } ); + } ); + + grunt.registerTask( 'gutenberg-sync', 'Syncs Gutenberg checkout and build if ref has changed.', function() { + const done = this.async(); + grunt.util.spawn( { + cmd: 'node', + args: [ 'tools/gutenberg/sync-gutenberg.js' ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); @@ -2039,35 +1956,26 @@ module.exports = function(grunt) { } ); } ); - grunt.registerTask( 'build:gutenberg', [ - 'copy:gutenberg-php', - 'gutenberg:copy', - 'copy:gutenberg-modules', - 'copy:gutenberg-styles', - 'copy:gutenberg-theme-json', - 'copy:gutenberg-icons', - ] ); - grunt.registerTask( 'build', function() { if ( grunt.option( 'dev' ) ) { grunt.task.run( [ - 'gutenberg:verify', 'build:js', 'build:css', 'build:codemirror', - 'build:gutenberg', + 'gutenberg-sync', + 'gutenberg-copy', 'copy-vendor-scripts', 'build:certificates' ] ); } else { grunt.task.run( [ - 'gutenberg:verify', 'build:certificates', 'build:files', 'build:js', 'build:css', 'build:codemirror', - 'build:gutenberg', + 'gutenberg-sync', + 'gutenberg-copy', 'copy-vendor-scripts', 'replace:source-maps', 'verify:build' diff --git a/package.json b/package.json index a8ba7e94cdbc3..e7bed0238e9a8 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "74a4f254a45f7a303bd27b8f8e104786380e8103", - "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" + "ref": "9b8144036fa5faf75de43d4502ff9809fcf689ad" }, "engines": { "node": ">=20.10.0", @@ -112,10 +111,9 @@ "wicg-inert": "3.1.3" }, "scripts": { - "postinstall": "npm run gutenberg:download", + "postinstall": "npm run gutenberg:sync && npm run gutenberg:copy -- --dev", "build": "grunt build", "build:dev": "grunt build --dev", - "build:gutenberg": "grunt build:gutenberg", "dev": "grunt watch --dev", "test": "grunt test", "watch": "grunt watch", @@ -139,8 +137,10 @@ "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan", - "gutenberg:copy": "node tools/gutenberg/copy.js", - "gutenberg:download": "node tools/gutenberg/download.js", + "gutenberg:checkout": "node tools/gutenberg/checkout-gutenberg.js", + "gutenberg:build": "node tools/gutenberg/build-gutenberg.js", + "gutenberg:copy": "node tools/gutenberg/copy-gutenberg-build.js", + "gutenberg:sync": "node tools/gutenberg/sync-gutenberg.js", "vendor:copy": "node tools/vendors/copy-vendors.js", "sync-gutenberg-packages": "grunt sync-gutenberg-packages", "postsync-gutenberg-packages": "grunt wp-packages:sync-stable-blocks && grunt build --dev && grunt build" diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 318b9969a928d..347c2198ae953 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -105,7 +105,7 @@ parameters: - ../../src/wp-includes/deprecated.php - ../../src/wp-includes/ms-deprecated.php - ../../src/wp-includes/pluggable-deprecated.php - # These files are autogenerated by tools/gutenberg/copy.js. + # These files are sourced by wordpress/gutenberg in `tools/release/sync-stable-blocks.js`. - ../../src/wp-includes/blocks # Third-party libraries. - ../../src/wp-admin/includes/class-ftp-pure.php diff --git a/tools/gutenberg/build-gutenberg.js b/tools/gutenberg/build-gutenberg.js new file mode 100644 index 0000000000000..01cf4489de1fa --- /dev/null +++ b/tools/gutenberg/build-gutenberg.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +/** + * Build Gutenberg Script + * + * This script builds the Gutenberg repository using its build command + * as specified in the root package.json's "gutenberg" configuration. + * + * @package WordPress + */ + +const { spawn } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +// Paths +const rootDir = path.resolve( __dirname, '../..' ); +const gutenbergDir = path.join( rootDir, 'gutenberg' ); + +/** + * Execute a command and return a promise. + * Captures output and only displays it on failure for cleaner logs. + * + * @param {string} command - Command to execute. + * @param {string[]} args - Command arguments. + * @param {Object} options - Spawn options. + * @return {Promise} Promise that resolves when command completes. + */ +function exec( command, args, options = {} ) { + return new Promise( ( resolve, reject ) => { + let stdout = ''; + let stderr = ''; + + const child = spawn( command, args, { + cwd: options.cwd || rootDir, + stdio: [ 'ignore', 'pipe', 'pipe' ], + shell: process.platform === 'win32', // Use shell on Windows to find .cmd files + ...options, + } ); + + // Capture output + if ( child.stdout ) { + child.stdout.on( 'data', ( data ) => { + stdout += data.toString(); + } ); + } + + if ( child.stderr ) { + child.stderr.on( 'data', ( data ) => { + stderr += data.toString(); + } ); + } + + child.on( 'close', ( code ) => { + if ( code !== 0 ) { + // Show output only on failure + if ( stdout ) { + console.error( '\nCommand output:' ); + console.error( stdout ); + } + if ( stderr ) { + console.error( '\nCommand errors:' ); + console.error( stderr ); + } + reject( + new Error( + `${ command } ${ args.join( + ' ' + ) } failed with code ${ code }` + ) + ); + } else { + resolve(); + } + } ); + + child.on( 'error', reject ); + } ); +} + +/** + * Main execution function. + */ +async function main() { + console.log( 'šŸ” Checking Gutenberg setup...' ); + + // Verify Gutenberg directory exists + if ( ! fs.existsSync( gutenbergDir ) ) { + console.error( 'āŒ Gutenberg directory not found at:', gutenbergDir ); + console.error( ' Run: node tools/gutenberg/checkout-gutenberg.js' ); + process.exit( 1 ); + } + + // Verify node_modules exists + const nodeModulesPath = path.join( gutenbergDir, 'node_modules' ); + if ( ! fs.existsSync( nodeModulesPath ) ) { + console.error( 'āŒ Gutenberg dependencies not installed' ); + console.error( ' Run: node tools/gutenberg/checkout-gutenberg.js' ); + process.exit( 1 ); + } + + console.log( 'āœ… Gutenberg directory found' ); + + // Modify Gutenberg's package.json for Core build + console.log( '\nāš™ļø Configuring build for WordPress Core...' ); + const gutenbergPackageJsonPath = path.join( gutenbergDir, 'package.json' ); + + try { + const content = fs.readFileSync( gutenbergPackageJsonPath, 'utf8' ); + const gutenbergPackageJson = JSON.parse( content ); + + // Set Core environment variables + gutenbergPackageJson.config = gutenbergPackageJson.config || {}; + gutenbergPackageJson.config.IS_GUTENBERG_PLUGIN = false; + gutenbergPackageJson.config.IS_WORDPRESS_CORE = true; + + // Set wpPlugin.name for Core naming convention + gutenbergPackageJson.wpPlugin = gutenbergPackageJson.wpPlugin || {}; + gutenbergPackageJson.wpPlugin.name = 'wp'; + + fs.writeFileSync( + gutenbergPackageJsonPath, + JSON.stringify( gutenbergPackageJson, null, '\t' ) + '\n' + ); + + console.log( ' āœ… IS_GUTENBERG_PLUGIN = false' ); + console.log( ' āœ… IS_WORDPRESS_CORE = true' ); + console.log( ' āœ… wpPlugin.name = wp' ); + } catch ( error ) { + console.error( + 'āŒ Error modifying Gutenberg package.json:', + error.message + ); + process.exit( 1 ); + } + + // Build Gutenberg + console.log( '\nšŸ”Ø Building Gutenberg for WordPress Core...' ); + console.log( ' (This may take a few minutes)' ); + + const startTime = Date.now(); + + try { + // Invoke the build script directly with node instead of going through + // `npm run build --` to avoid shell argument mangling of the base-url + // value (which contains spaces, parentheses, and single quotes). + // The PATH is extended with node_modules/.bin so that bin commands + // like `wp-build` are found, matching what npm would normally provide. + const binPath = path.join( gutenbergDir, 'node_modules', '.bin' ); + await exec( 'node', [ + 'bin/build.mjs', + '--skip-types', + "--base-url=includes_url( 'build/' )", + ], { + cwd: gutenbergDir, + env: { + ...process.env, + PATH: binPath + path.delimiter + process.env.PATH, + }, + } ); + + const duration = Math.round( ( Date.now() - startTime ) / 1000 ); + console.log( `āœ… Build completed in ${ duration }s` ); + } catch ( error ) { + console.error( 'āŒ Build failed:', error.message ); + throw error; + } finally { + // Restore Gutenberg's package.json regardless of success or failure + await restorePackageJson(); + } +} + +/** + * Restore Gutenberg's package.json to its original state. + */ +async function restorePackageJson() { + console.log( '\nšŸ”„ Restoring Gutenberg package.json...' ); + try { + await exec( 'git', [ 'checkout', '--', 'package.json' ], { + cwd: gutenbergDir, + } ); + console.log( 'āœ… package.json restored' ); + } catch ( error ) { + console.warn( 'āš ļø Could not restore package.json:', error.message ); + } +} + +// Run main function +main().catch( ( error ) => { + console.error( 'āŒ Unexpected error:', error ); + process.exit( 1 ); +} ); diff --git a/tools/gutenberg/checkout-gutenberg.js b/tools/gutenberg/checkout-gutenberg.js new file mode 100644 index 0000000000000..42e35a1967b78 --- /dev/null +++ b/tools/gutenberg/checkout-gutenberg.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +/** + * Checkout Gutenberg Repository Script + * + * This script checks out the Gutenberg repository at a specific commit/branch/tag + * as specified in the root package.json's "gutenberg" configuration. + * + * It handles: + * - Initial clone if directory doesn't exist + * - Updating existing checkout to correct ref + * - Installing dependencies with npm ci + * - Idempotent operation (safe to run multiple times) + * + * @package WordPress + */ + +const { spawn } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +// Constants +const GUTENBERG_REPO = 'https://github.com/WordPress/gutenberg.git'; + +// Paths +const rootDir = path.resolve( __dirname, '../..' ); +const gutenbergDir = path.join( rootDir, 'gutenberg' ); +const packageJsonPath = path.join( rootDir, 'package.json' ); + +/** + * Execute a command and return a promise. + * Captures output and only displays it on failure for cleaner logs. + * + * @param {string} command - Command to execute. + * @param {string[]} args - Command arguments. + * @param {Object} options - Spawn options. + * @return {Promise} Promise that resolves when command completes. + */ +function exec( command, args, options = {} ) { + return new Promise( ( resolve, reject ) => { + let stdout = ''; + let stderr = ''; + + const child = spawn( command, args, { + cwd: options.cwd || rootDir, + stdio: [ 'ignore', 'pipe', 'pipe' ], + shell: process.platform === 'win32', // Use shell on Windows to find .cmd files + ...options, + } ); + + // Capture output + if ( child.stdout ) { + child.stdout.on( 'data', ( data ) => { + stdout += data.toString(); + } ); + } + + if ( child.stderr ) { + child.stderr.on( 'data', ( data ) => { + stderr += data.toString(); + } ); + } + + child.on( 'close', ( code ) => { + if ( code !== 0 ) { + // Show output only on failure + if ( stdout ) { + console.error( '\nCommand output:' ); + console.error( stdout ); + } + if ( stderr ) { + console.error( '\nCommand errors:' ); + console.error( stderr ); + } + reject( + new Error( + `${ command } ${ args.join( + ' ' + ) } failed with code ${ code }` + ) + ); + } else { + resolve(); + } + } ); + + child.on( 'error', reject ); + } ); +} + +/** + * Execute a command and capture its output. + * + * @param {string} command - Command to execute. + * @param {string[]} args - Command arguments. + * @param {Object} options - Spawn options. + * @return {Promise} Promise that resolves with command output. + */ +function execOutput( command, args, options = {} ) { + return new Promise( ( resolve, reject ) => { + const child = spawn( command, args, { + cwd: options.cwd || rootDir, + shell: process.platform === 'win32', // Use shell on Windows to find .cmd files + ...options, + } ); + + let stdout = ''; + let stderr = ''; + + if ( child.stdout ) { + child.stdout.on( 'data', ( data ) => { + stdout += data.toString(); + } ); + } + + if ( child.stderr ) { + child.stderr.on( 'data', ( data ) => { + stderr += data.toString(); + } ); + } + + child.on( 'close', ( code ) => { + if ( code !== 0 ) { + reject( new Error( `${ command } failed: ${ stderr }` ) ); + } else { + resolve( stdout.trim() ); + } + } ); + + child.on( 'error', reject ); + } ); +} + +/** + * Main execution function. + */ +async function main() { + console.log( 'šŸ” Checking Gutenberg configuration...' ); + + // Read Gutenberg ref from package.json + let ref; + try { + const packageJson = JSON.parse( + fs.readFileSync( packageJsonPath, 'utf8' ) + ); + ref = packageJson.gutenberg?.ref; + + if ( ! ref ) { + throw new Error( 'Missing "gutenberg.ref" in package.json' ); + } + + console.log( ` Repository: ${ GUTENBERG_REPO }` ); + console.log( ` Reference: ${ ref }` ); + } catch ( error ) { + console.error( 'āŒ Error reading package.json:', error.message ); + process.exit( 1 ); + } + + // Check if Gutenberg directory exists + const gutenbergExists = fs.existsSync( gutenbergDir ); + + if ( ! gutenbergExists ) { + console.log( '\nšŸ“„ Cloning Gutenberg repository (shallow clone)...' ); + try { + // Generic shallow clone approach that works for both branches and commit hashes + // 1. Clone with no checkout and shallow depth + await exec( 'git', [ + 'clone', + '--depth', + '1', + '--no-checkout', + GUTENBERG_REPO, + 'gutenberg', + ] ); + + // 2. Fetch the specific ref with depth 1 (works for branches, tags, and commits) + await exec( 'git', [ 'fetch', '--depth', '1', 'origin', ref ], { + cwd: gutenbergDir, + } ); + + // 3. Checkout FETCH_HEAD + await exec( 'git', [ 'checkout', 'FETCH_HEAD' ], { + cwd: gutenbergDir, + } ); + + console.log( 'āœ… Cloned successfully' ); + } catch ( error ) { + console.error( 'āŒ Clone failed:', error.message ); + process.exit( 1 ); + } + } else { + console.log( '\nāœ… Gutenberg directory already exists' ); + } + + // Fetch and checkout target ref + console.log( `\nšŸ“” Fetching and checking out: ${ ref }` ); + try { + // Fetch the specific ref (works for branches, tags, and commit hashes) + await exec( 'git', [ 'fetch', '--depth', '1', 'origin', ref ], { + cwd: gutenbergDir, + } ); + + // Checkout what was just fetched + await exec( 'git', [ 'checkout', 'FETCH_HEAD' ], { + cwd: gutenbergDir, + } ); + + console.log( 'āœ… Checked out successfully' ); + } catch ( error ) { + console.error( 'āŒ Fetch/checkout failed:', error.message ); + process.exit( 1 ); + } + + // Install dependencies + console.log( '\nšŸ“¦ Installing dependencies...' ); + const nodeModulesExists = fs.existsSync( + path.join( gutenbergDir, 'node_modules' ) + ); + + if ( ! nodeModulesExists ) { + console.log( ' (This may take a few minutes on first run)' ); + } + + try { + await exec( 'npm', [ 'ci' ], { cwd: gutenbergDir } ); + console.log( 'āœ… Dependencies installed' ); + } catch ( error ) { + console.error( 'āŒ npm ci failed:', error.message ); + process.exit( 1 ); + } + + console.log( '\nāœ… Gutenberg checkout complete!' ); +} + +// Run main function +main().catch( ( error ) => { + console.error( 'āŒ Unexpected error:', error ); + process.exit( 1 ); +} ); diff --git a/tools/gutenberg/copy.js b/tools/gutenberg/copy-gutenberg-build.js similarity index 53% rename from tools/gutenberg/copy.js rename to tools/gutenberg/copy-gutenberg-build.js index 3a7fc67ad7485..845a98d0d7d21 100644 --- a/tools/gutenberg/copy.js +++ b/tools/gutenberg/copy-gutenberg-build.js @@ -9,20 +9,19 @@ * @package WordPress */ -const child_process = require( 'child_process' ); const fs = require( 'fs' ); const path = require( 'path' ); const json2php = require( 'json2php' ); +const glob = require( 'glob' ); -// Paths. +// Paths const rootDir = path.resolve( __dirname, '../..' ); const gutenbergDir = path.join( rootDir, 'gutenberg' ); const gutenbergBuildDir = path.join( gutenbergDir, 'build' ); +const gutenbergPackagesDir = path.join( gutenbergDir, 'packages' ); -/* - * Determine build target from command line argument (--dev or --build-dir). - * Default to 'src' for development. - */ +// Determine build target from command line argument (--dev or --build-dir) +// Default to 'src' for development const args = process.argv.slice( 2 ); const buildDirArg = args.find( ( arg ) => arg.startsWith( '--build-dir=' ) ); const buildTarget = buildDirArg @@ -38,61 +37,62 @@ const wpIncludesDir = path.join( rootDir, buildTarget, 'wp-includes' ); * Defines what to copy from Gutenberg build and where it goes in Core. */ const COPY_CONFIG = { - // PHP infrastructure files (to wp-includes/build/). + // PHP infrastructure files (to wp-includes/build/) phpInfrastructure: { destination: 'build', files: [ 'routes.php', 'pages.php', 'constants.php' ], directories: [ 'pages', 'routes' ], }, - // JavaScript packages (to wp-includes/js/dist/). + // JavaScript packages (to wp-includes/js/dist/) scripts: { source: 'scripts', destination: 'js/dist', - copyDirectories: true, - // Rename vendors/ to vendor/ when copying. + copyDirectories: true, // Copy subdirectories + patterns: [ '*.js' ], + // Rename vendors/ to vendor/ when copying directoryRenames: { vendors: 'vendor', }, }, - // Script modules (to wp-includes/js/dist/script-modules/). + // Script modules (to wp-includes/js/dist/script-modules/) modules: { source: 'modules', destination: 'js/dist/script-modules', + copyAll: true, }, - // Styles (to wp-includes/css/dist/). + // Styles (to wp-includes/css/dist/) styles: { source: 'styles', destination: 'css/dist', + copyAll: true, }, - /* - * Blocks (to wp-includes/blocks/). - * Unified configuration for all block types. - */ + // Blocks (to wp-includes/blocks/) + // Unified configuration for all block types blocks: { destination: 'blocks', sources: [ { - // Block library blocks. + // Block library blocks name: 'block-library', scripts: 'scripts/block-library', styles: 'styles/block-library', - php: 'scripts/block-library', + php: 'block-library/src', }, { - // Widget blocks. + // Widget blocks name: 'widgets', scripts: 'scripts/widgets/blocks', styles: 'styles/widgets', - php: 'scripts/widgets/blocks', + php: 'widgets/src/blocks', }, ], }, - // Theme JSON files (from Gutenberg lib directory). + // Theme JSON files (from Gutenberg lib directory) themeJson: { files: [ { from: 'theme.json', to: 'theme.json' }, @@ -101,7 +101,7 @@ const COPY_CONFIG = { transform: true, }, - // Specific files to copy to wp-includes/$destination. + // Specific files to copy to wp-includes/$destination wpIncludes: [ { files: [ 'packages/icons/src/manifest.php' ], @@ -114,42 +114,6 @@ const COPY_CONFIG = { ], }; -/** - * Given a path to a PHP file which returns a single value, converts that - * value into a native JavaScript value (limited by JSON serialization). - * - * @throws Error when PHP source file unable to be read, or PHP is unavailable. - * - * @param {string} phpFilepath Absolute path of PHP file returning a single value. - * @return {Object|Array} JavaScript representation of value from input file. - */ -function readReturnedValueFromPHPFile( phpFilepath ) { - const results = child_process.spawnSync( - 'php', - [ '-r', '$path = file_get_contents( "php://stdin" ); if ( ! is_file( $path ) ) { die( 1 ); } try { $data = require $path; } catch ( \\Throwable $e ) { die( 2 ); } $json = json_encode( $data ); if ( ! is_string( $json ) ) { die( 3 ); } echo $json;' ], - { - encoding: 'utf8', - input: phpFilepath, - } - ); - - switch ( results.status ) { - case 0: - return JSON.parse( results.stdout ); - - case 1: - throw new Error( `Could not read PHP source file: '${ phpFilepath }'` ); - - case 2: - throw new Error( `PHP source file did not return value when imported: '${ phpFilepath }'` ); - - case 3: - throw new Error( `Could not serialize PHP source value into JSON: '${ phpFilepath }'` ); - } - - throw new Error( `Unknown error while reading PHP source file: '${ phpFilepath }'` ); -} - /** * Check if a block is experimental by reading its block.json. * @@ -194,7 +158,7 @@ function copyDirectory( src, dest, transform = null, options = {} ) { const destPath = path.join( dest, entry.name ); if ( entry.isDirectory() ) { - // Check if this directory is an experimental block. + // Check if this directory is an experimental block if ( options.excludeExperimental ) { const blockJsonPath = path.join( srcPath, 'block.json' ); if ( isExperimentalBlock( blockJsonPath ) ) { @@ -204,13 +168,13 @@ function copyDirectory( src, dest, transform = null, options = {} ) { copyDirectory( srcPath, destPath, transform, options ); } else { - // Skip source map files (.map) — these are not useful in Core, + // Skip source map files (.map) — these are not useful in Core // and the sourceMappingURL references are already stripped from JS files. if ( /\.map$/.test( entry.name ) ) { continue; } - // Skip non-minified VIPS files — they are ~10MB of inlined WASM, + // Skip non-minified VIPS files — they are ~10MB of inlined WASM // with no debugging value over the minified versions. if ( srcPath.includes( '/vips/' ) && @@ -219,14 +183,14 @@ function copyDirectory( src, dest, transform = null, options = {} ) { continue; } - // Skip PHP files if excludePHP is true. + // Skip PHP files if excludePHP is true if ( options.excludePHP && /\.php$/.test( entry.name ) ) { continue; } let content = fs.readFileSync( srcPath ); - // Apply transformation if provided and file is text. + // Apply transformation if provided and file is text if ( transform && /\.(php|js|css)$/.test( entry.name ) ) { try { content = transform( @@ -259,20 +223,20 @@ function copyBlockAssets( config ) { for ( const source of config.sources ) { const scriptsSrc = path.join( gutenbergBuildDir, source.scripts ); const stylesSrc = path.join( gutenbergBuildDir, source.styles ); - const phpSrc = path.join( gutenbergBuildDir, source.php ); + const phpSrc = path.join( gutenbergPackagesDir, source.php ); if ( ! fs.existsSync( scriptsSrc ) ) { continue; } - // Get all block directories from the scripts source. + // Get all block directories from the scripts source const blockDirs = fs .readdirSync( scriptsSrc, { withFileTypes: true } ) .filter( ( entry ) => entry.isDirectory() ) .map( ( entry ) => entry.name ); for ( const blockName of blockDirs ) { - // Skip experimental blocks. + // Skip experimental blocks const blockJsonPath = path.join( scriptsSrc, blockName, @@ -293,7 +257,7 @@ function copyBlockAssets( config ) { blockDest, { recursive: true, - // Skip PHP, copied from build in steps 3 & 4. + // Skip PHP, copied from packages filter: f => ! f.endsWith( '.php' ), } ); @@ -313,18 +277,19 @@ function copyBlockAssets( config ) { } } - // 3. Copy PHP from build - const blockPhpSrc = path.join( phpSrc, `${ blockName }.php` ); - const phpDest = path.join( - wpIncludesDir, - config.destination, - `${ blockName }.php` - ); + // 3. Copy PHP from packages + const blockPhpSrc = path.join( phpSrc, blockName, 'index.php' ); if ( fs.existsSync( blockPhpSrc ) ) { - fs.copyFileSync( blockPhpSrc, phpDest ); + const phpDest = path.join( + wpIncludesDir, + config.destination, + `${ blockName }.php` + ); + const content = fs.readFileSync( blockPhpSrc, 'utf8' ); + fs.writeFileSync( phpDest, content ); } - // 4. Copy PHP subdirectories from build (e.g., navigation-link/shared/*.php) + // 4. Copy PHP subdirectories from packages (e.g., shared/helpers.php) const blockPhpDir = path.join( phpSrc, blockName ); if ( fs.existsSync( blockPhpDir ) ) { const rootIndex = path.join( blockPhpDir, 'index.php' ); @@ -337,7 +302,7 @@ function copyBlockAssets( config ) { ( entry ) => hasPhpFiles( path.join( src, entry.name ) ) ); } - // Copy PHP files, but skip root index.php (handled by step 3). + // Copy PHP files, but skip root index.php (handled by step 3) return src.endsWith( '.php' ) && src !== rootIndex; }, } ); @@ -380,7 +345,7 @@ function generateScriptModulesPackages() { processDirectory( fullPath, baseDir ); } else if ( entry.name.endsWith( '.min.asset.php' ) ) { const relativePath = path.relative( baseDir, fullPath ); - // Normalize path separators to forward slashes for cross-platform consistency. + // Normalize path separators to forward slashes for cross-platform consistency const normalizedPath = relativePath .split( path.sep ) .join( '/' ); @@ -391,9 +356,18 @@ function generateScriptModulesPackages() { const jsPathRegular = jsPathMin.replace( /\.min\.js$/, '.js' ); try { - const assetData = readReturnedValueFromPHPFile( fullPath ); - assetsMin[ jsPathMin ] = assetData; - assetsRegular[ jsPathRegular ] = assetData; + // Read and parse the PHP asset file + const phpContent = fs.readFileSync( fullPath, 'utf8' ); + // Extract the array from PHP: `\t'${ name }',` ).join( '\n' ) } @@ -664,7 +645,7 @@ function generateBlocksJson() { } } - // Generate the PHP file content using json2php for consistent formatting. + // Generate the PHP file content using json2php for consistent formatting const phpContent = ' 1 ) { + currentArray += 'array('; + } + i += 5; // Skip 'array(' + continue; + } + + if ( depth > 0 ) { + if ( char === '(' ) { + depth++; + currentArray += char; + } else if ( char === ')' ) { + depth--; + if ( depth === 0 ) { + // Found complete nested array + const placeholder = `__ARRAY_${ nestedArrays.length }__`; + nestedArrays.push( currentArray ); + content = + content.substring( 0, arrayStart ) + + placeholder + + content.substring( i + 1 ); + i = arrayStart + placeholder.length - 1; + currentArray = ''; + } else { + currentArray += char; + } + } else { + currentArray += char; + } + } + } else if ( depth > 0 ) { + currentArray += char; + } + } + + // Now parse the simplified content + const result = {}; + const values = []; + let isAssociative = false; + + // Split by top-level commas + const parts = []; + depth = 0; + inString = false; + let currentPart = ''; + + for ( let i = 0; i < content.length; i++ ) { + const char = content[ i ]; + + if ( + ( char === "'" || char === '"' ) && + ( i === 0 || content[ i - 1 ] !== '\\' ) + ) { + inString = ! inString; + } + + if ( ! inString && char === ',' && depth === 0 ) { + parts.push( currentPart.trim() ); + currentPart = ''; + } else { + currentPart += char; + if ( ! inString ) { + if ( char === '(' ) depth++; + if ( char === ')' ) depth--; + } + } + } + if ( currentPart.trim() ) { + parts.push( currentPart.trim() ); + } + + // Parse each part + for ( const part of parts ) { + const arrowMatch = part.match( /^(.+?)\s*=>\s*(.+)$/ ); + + if ( arrowMatch ) { + isAssociative = true; + let key = arrowMatch[ 1 ].trim().replace( /^['"]|['"]$/g, '' ); + let value = arrowMatch[ 2 ].trim(); + + // Replace placeholders + while ( value.match( /__ARRAY_(\d+)__/ ) ) { + value = value.replace( /__ARRAY_(\d+)__/, ( match, index ) => { + return 'array(' + nestedArrays[ parseInt( index ) ] + ')'; + } ); + } + + result[ key ] = parseValue( value ); + } else { + // No arrow, indexed array + let value = part; + + // Replace placeholders + while ( value.match( /__ARRAY_(\d+)__/ ) ) { + value = value.replace( /__ARRAY_(\d+)__/, ( match, index ) => { + return 'array(' + nestedArrays[ parseInt( index ) ] + ')'; + } ); + } + + values.push( parseValue( value ) ); + } + } + + return isAssociative ? result : values; + + /** + * Parse a single value. + * + * @param {string} value - The value string to parse. + * @return {*} Parsed value. + */ + function parseValue( value ) { + value = value.trim(); + + if ( value.startsWith( 'array(' ) && value.endsWith( ')' ) ) { + return parsePHPArray( value.substring( 6, value.length - 1 ) ); + } else if ( value.match( /^['"].*['"]$/ ) ) { + return value.substring( 1, value.length - 1 ); + } else if ( value === 'true' ) { + return true; + } else if ( value === 'false' ) { + return false; + } else if ( ! isNaN( value ) && value !== '' ) { + return parseInt( value, 10 ); + } + return value; + } +} + +/** + * Transform PHP file contents to work in Core. + * + * @param {string} content - File content. + * @return {string} Transformed content. + */ +function transformPHPContent( content ) { + let transformed = content; + + // Fix boot module asset file path for Core's different directory structure + // FROM: __DIR__ . '/../../modules/boot/index.min.asset.php' + // TO: ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php' + // This is needed because Core copies modules to a different location than the plugin structure + transformed = transformed.replace( + /__DIR__\s*\.\s*['"]\/\.\.\/\.\.\/modules\/boot\/index\.min\.asset\.php['"]/g, + "ABSPATH . WPINC . '/js/dist/script-modules/boot/index.min.asset.php'" + ); + + return transformed; +} + +/** + * Transform manifest.php to remove gutenberg text domain. + * + * @param {string} content - File content. + * @return {string} Transformed content. + */ +function transformManifestPHP( content ) { + // Remove 'gutenberg' text domain from _x() calls + // FROM: _x( '...', 'icon label', 'gutenberg' ) + // TO: _x( '...', 'icon label' ) + const transformedContent = content.replace( + /_x\(\s*([^,]+),\s*([^,]+),\s*['"]gutenberg['"]\s*\)/g, + '_x( $1, $2 )' + ); + return transformedContent; +} + /** * Main execution function. */ async function main() { - console.log( `šŸ“¦ Copying Gutenberg build to ${ buildTarget }/...` ); + console.log( 'šŸ” Checking Gutenberg build...' ); + console.log( ` Build target: ${ buildTarget }/` ); + // Verify Gutenberg build exists if ( ! fs.existsSync( gutenbergBuildDir ) ) { console.error( 'āŒ Gutenberg build directory not found' ); - console.error( ' Run: npm run grunt gutenberg:download' ); + console.error( ' Run: node tools/gutenberg/build-gutenberg.js' ); process.exit( 1 ); } - // 1. Copy JavaScript packages. + console.log( 'āœ… Gutenberg build found' ); + + // 1. Copy PHP infrastructure + console.log( '\nšŸ“¦ Copying PHP infrastructure...' ); + const phpConfig = COPY_CONFIG.phpInfrastructure; + const phpDest = path.join( wpIncludesDir, phpConfig.destination ); + + // Copy PHP files + for ( const file of phpConfig.files ) { + const src = path.join( gutenbergBuildDir, file ); + const dest = path.join( phpDest, file ); + + if ( fs.existsSync( src ) ) { + fs.mkdirSync( path.dirname( dest ), { recursive: true } ); + let content = fs.readFileSync( src, 'utf8' ); + content = transformPHPContent( content ); + fs.writeFileSync( dest, content ); + console.log( ` āœ… ${ file }` ); + } else { + console.log( + ` āš ļø ${ file } not found (may not exist in this Gutenberg version)` + ); + } + } + + // Copy PHP directories + for ( const dir of phpConfig.directories ) { + const src = path.join( gutenbergBuildDir, dir ); + const dest = path.join( phpDest, dir ); + + if ( fs.existsSync( src ) ) { + console.log( ` šŸ“ Copying ${ dir }/...` ); + copyDirectory( src, dest, transformPHPContent ); + console.log( ` āœ… ${ dir }/ copied` ); + } + } + + // 2. Copy JavaScript packages console.log( '\nšŸ“¦ Copying JavaScript packages...' ); const scriptsConfig = COPY_CONFIG.scripts; const scriptsSrc = path.join( gutenbergBuildDir, scriptsConfig.source ); const scriptsDest = path.join( wpIncludesDir, scriptsConfig.destination ); + // Transform function to remove source map comments from all JS files. + // Only match actual source map comments at the start of a line (possibly + // with whitespace), not occurrences inside string literals. + const removeSourceMaps = ( content ) => { + return content.replace( /^\s*\/\/# sourceMappingURL=.*$/gm, '' ).trimEnd(); + }; + if ( fs.existsSync( scriptsSrc ) ) { const entries = fs.readdirSync( scriptsSrc, { withFileTypes: true } ); @@ -709,22 +946,20 @@ async function main() { const src = path.join( scriptsSrc, entry.name ); if ( entry.isDirectory() ) { - // Check if this should be copied as a directory (like vendors/). + // Check if this should be copied as a directory (like vendors/) if ( scriptsConfig.copyDirectories && scriptsConfig.directoryRenames && scriptsConfig.directoryRenames[ entry.name ] ) { - /* - * Copy special directories with rename (vendors/ → vendor/). - * Only copy react-jsx-runtime from vendors (react and react-dom come from Core's node_modules). - */ + // Copy special directories with rename (vendors/ → vendor/) + // Only copy react-jsx-runtime from vendors (react and react-dom come from Core's node_modules) const destName = scriptsConfig.directoryRenames[ entry.name ]; const dest = path.join( scriptsDest, destName ); if ( entry.name === 'vendors' ) { - // Only copy react-jsx-runtime files, skip react and react-dom. + // Only copy react-jsx-runtime files, skip react and react-dom const vendorFiles = fs.readdirSync( src ); let copiedCount = 0; fs.mkdirSync( dest, { recursive: true } ); @@ -736,7 +971,12 @@ async function main() { const srcFile = path.join( src, file ); const destFile = path.join( dest, file ); - fs.copyFileSync( srcFile, destFile ); + let content = fs.readFileSync( + srcFile, + 'utf8' + ); + content = removeSourceMaps( content ); + fs.writeFileSync( destFile, content ); copiedCount++; } } @@ -744,17 +984,15 @@ async function main() { ` āœ… ${ entry.name }/ → ${ destName }/ (react-jsx-runtime only, ${ copiedCount } files)` ); } else { - // Copy other special directories normally. - copyDirectory( src, dest ); + // Copy other special directories normally + copyDirectory( src, dest, removeSourceMaps ); console.log( ` āœ… ${ entry.name }/ → ${ destName }/` ); } } else { - /* - * Flatten package structure: package-name/index.js → package-name.js. - * This matches Core's expected file structure. - */ + // Flatten package structure: package-name/index.js → package-name.js + // This matches Core's expected file structure const packageFiles = fs.readdirSync( src ); for ( const file of packageFiles ) { @@ -762,7 +1000,7 @@ async function main() { /^index\.(js|min\.js|min\.asset\.php)$/.test( file ) ) { const srcFile = path.join( src, file ); - // Replace 'index.' with 'package-name.'. + // Replace 'index.' with 'package-name.' const destFile = file.replace( /^index\./, `${ entry.name }.` @@ -773,45 +1011,144 @@ async function main() { recursive: true, } ); - fs.copyFileSync( srcFile, destPath ); + // Apply source map removal for .js files + if ( file.endsWith( '.js' ) ) { + let content = fs.readFileSync( + srcFile, + 'utf8' + ); + content = removeSourceMaps( content ); + fs.writeFileSync( destPath, content ); + } else { + // Copy other files as-is (.min.asset.php) + fs.copyFileSync( srcFile, destPath ); + } } } } } else if ( entry.isFile() && entry.name.endsWith( '.js' ) ) { - // Copy root-level JS files. + // Copy root-level JS files const dest = path.join( scriptsDest, entry.name ); fs.mkdirSync( path.dirname( dest ), { recursive: true } ); - fs.copyFileSync( src, dest ); + + let content = fs.readFileSync( src, 'utf8' ); + content = removeSourceMaps( content ); + fs.writeFileSync( dest, content ); } } console.log( ' āœ… JavaScript packages copied' ); } - // 2. Copy blocks (unified: scripts, styles, PHP, JSON). + // 3. Copy script modules + console.log( '\nšŸ“¦ Copying script modules...' ); + const modulesConfig = COPY_CONFIG.modules; + const modulesSrc = path.join( gutenbergBuildDir, modulesConfig.source ); + const modulesDest = path.join( wpIncludesDir, modulesConfig.destination ); + + if ( fs.existsSync( modulesSrc ) ) { + // Use the same source map removal transform + copyDirectory( modulesSrc, modulesDest, removeSourceMaps ); + console.log( ' āœ… Script modules copied' ); + } + + // 4. Copy styles + console.log( '\nšŸ“¦ Copying styles...' ); + const stylesConfig = COPY_CONFIG.styles; + const stylesSrc = path.join( gutenbergBuildDir, stylesConfig.source ); + const stylesDest = path.join( wpIncludesDir, stylesConfig.destination ); + + if ( fs.existsSync( stylesSrc ) ) { + copyDirectory( stylesSrc, stylesDest ); + console.log( ' āœ… Styles copied' ); + } + + // 5. Copy blocks (unified: scripts, styles, PHP, JSON) console.log( '\nšŸ“¦ Copying blocks...' ); + const blocksDest = path.join( + wpIncludesDir, + COPY_CONFIG.blocks.destination + ); copyBlockAssets( COPY_CONFIG.blocks ); - // 3. Generate script-modules-packages.php from individual asset files. - console.log( '\nšŸ“¦ Generating script-modules-packages.php...' ); + // 6. Copy theme JSON files (from Gutenberg lib directory) + console.log( '\nšŸ“¦ Copying theme JSON files...' ); + const themeJsonConfig = COPY_CONFIG.themeJson; + const gutenbergLibDir = path.join( gutenbergDir, 'lib' ); + + for ( const fileMap of themeJsonConfig.files ) { + const src = path.join( gutenbergLibDir, fileMap.from ); + const dest = path.join( wpIncludesDir, fileMap.to ); + + if ( fs.existsSync( src ) ) { + let content = fs.readFileSync( src, 'utf8' ); + + if ( themeJsonConfig.transform && fileMap.from === 'theme.json' ) { + // Transform schema URL for Core + content = content.replace( + '"$schema": "../schemas/json/theme.json"', + '"$schema": "https://schemas.wp.org/trunk/theme.json"' + ); + } + + fs.writeFileSync( dest, content ); + console.log( ` āœ… ${ fileMap.to }` ); + } else { + console.log( ` āš ļø Not found: ${ fileMap.from }` ); + } + } + + // Copy remaining files to wp-includes + console.log( '\nšŸ“¦ Copying remaining files to wp-includes...' ); + for ( const fileMap of COPY_CONFIG.wpIncludes ) { + const dest = path.join( wpIncludesDir, fileMap.destination ); + fs.mkdirSync( dest, { recursive: true } ); + for ( const src of fileMap.files ) { + const matches = glob.sync( path.join( gutenbergDir, src ) ); + if ( ! matches.length ) { + throw new Error( `No files found matching '${ src }'` ); + } + for ( const match of matches ) { + const destPath = path.join( dest, path.basename( match ) ); + // Apply transformation for manifest.php to remove gutenberg text domain + if ( path.basename( match ) === 'manifest.php' ) { + let content = fs.readFileSync( match, 'utf8' ); + content = transformManifestPHP( content ); + fs.writeFileSync( destPath, content ); + } else { + fs.copyFileSync( match, destPath ); + } + } + } + } + + // 7. Generate script-modules-packages.min.php from individual asset files + console.log( '\nšŸ“¦ Generating script-modules-packages.min.php...' ); generateScriptModulesPackages(); - // 4. Generate script-loader-packages.php. - console.log( '\nšŸ“¦ Generating script-loader-packages.php...' ); + // 8. Generate script-loader-packages.min.php + console.log( '\nšŸ“¦ Generating script-loader-packages.min.php...' ); generateScriptLoaderPackages(); - // 5. Generate require-dynamic-blocks.php and require-static-blocks.php. + // 9. Generate require-dynamic-blocks.php and require-static-blocks.php console.log( '\nšŸ“¦ Generating block registration files...' ); generateBlockRegistrationFiles(); - // 6. Generate blocks-json.php from block.json files. + // 10. Generate blocks-json.php from block.json files console.log( '\nšŸ“¦ Generating blocks-json.php...' ); generateBlocksJson(); + // Summary console.log( '\nāœ… Copy complete!' ); + console.log( '\nšŸ“Š Summary:' ); + console.log( ` PHP infrastructure: ${ phpDest }` ); + console.log( ` JavaScript: ${ scriptsDest }` ); + console.log( ` Script modules: ${ modulesDest }` ); + console.log( ` Styles: ${ stylesDest }` ); + console.log( ` Blocks: ${ blocksDest }` ); } -// Run main function. +// Run main function main().catch( ( error ) => { console.error( 'āŒ Unexpected error:', error ); process.exit( 1 ); diff --git a/tools/gutenberg/download.js b/tools/gutenberg/download.js deleted file mode 100644 index f936136ffa25c..0000000000000 --- a/tools/gutenberg/download.js +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node - -/** - * Download Gutenberg Repository Script. - * - * This script downloads a pre-built Gutenberg tar.gz artifact from the GitHub - * Container Registry and extracts it into the ./gutenberg directory. - * - * The artifact is identified by the "gutenberg.sha" value in the root - * package.json, which is used as the OCI image tag for the gutenberg-build - * package on GitHub Container Registry. - * - * @package WordPress - */ - -const { spawn } = require( 'child_process' ); -const fs = require( 'fs' ); -const { Writable } = require( 'stream' ); -const { pipeline } = require( 'stream/promises' ); -const path = require( 'path' ); -const zlib = require( 'zlib' ); -const { gutenbergDir, readGutenbergConfig, verifyGutenbergVersion } = require( './utils' ); - -/** - * Main execution function. - * - * @param {boolean} force - Whether to force a fresh download even if the gutenberg directory exists. - */ -async function main( force ) { - console.log( 'šŸ” Checking Gutenberg configuration...' ); - - /* - * Read Gutenberg configuration from package.json. - * - * Note: ghcr stands for GitHub Container Registry where wordpress-develop ready builds of the Gutenberg plugin - * are published on every repository push event. - */ - let sha, ghcrRepo; - try { - ( { sha, ghcrRepo } = readGutenbergConfig() ); - console.log( ` SHA: ${ sha }` ); - console.log( ` GHCR repository: ${ ghcrRepo }` ); - } catch ( error ) { - console.error( 'āŒ Error reading package.json:', error.message ); - process.exit( 1 ); - } - - // Skip download if the gutenberg directory already exists and --force is not set. - let downloaded = false; - if ( ! force && fs.existsSync( gutenbergDir ) ) { - console.log( '\nā„¹ļø The `gutenberg` directory already exists. Use `npm run grunt gutenberg:download -- --force` to download a fresh copy.' ); - } else { - downloaded = true; - - // Step 1: Get an anonymous GHCR token for pulling. - console.log( '\nšŸ”‘ Fetching GHCR token...' ); - let token; - try { - const response = await fetch( `https://ghcr.io/token?scope=repository:${ ghcrRepo }:pull&service=ghcr.io` ); - if ( ! response.ok ) { - throw new Error( `Failed to fetch token: ${ response.status } ${ response.statusText }` ); - } - const data = await response.json(); - token = data.token; - if ( ! token ) { - throw new Error( 'No token in response' ); - } - console.log( 'āœ… Token acquired' ); - } catch ( error ) { - console.error( 'āŒ Failed to fetch token:', error.message ); - process.exit( 1 ); - } - - // Step 2: Get the manifest to find the blob digest. - console.log( `\nšŸ“‹ Fetching manifest for ${ sha }...` ); - let digest; - try { - const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/manifests/${ sha }`, { - headers: { - Authorization: `Bearer ${ token }`, - Accept: 'application/vnd.oci.image.manifest.v1+json', - }, - } ); - if ( ! response.ok ) { - throw new Error( `Failed to fetch manifest: ${ response.status } ${ response.statusText }` ); - } - const manifest = await response.json(); - digest = manifest?.layers?.[ 0 ]?.digest; - if ( ! digest ) { - throw new Error( 'No layer digest found in manifest' ); - } - console.log( `āœ… Blob digest: ${ digest }` ); - } catch ( error ) { - console.error( 'āŒ Failed to fetch manifest:', error.message ); - process.exit( 1 ); - } - - // Remove existing gutenberg directory so the extraction is clean. - if ( fs.existsSync( gutenbergDir ) ) { - console.log( '\nšŸ—‘ļø Removing existing gutenberg directory...' ); - fs.rmSync( gutenbergDir, { recursive: true, force: true } ); - } - - fs.mkdirSync( gutenbergDir, { recursive: true } ); - - /* - * Step 3: Stream the blob directly through gunzip into tar, writing - * into ./gutenberg with no temporary file on disk. - */ - console.log( `\nšŸ“„ Downloading and extracting artifact...` ); - try { - const response = await fetch( `https://ghcr.io/v2/${ ghcrRepo }/blobs/${ digest }`, { - headers: { - Authorization: `Bearer ${ token }`, - }, - } ); - if ( ! response.ok ) { - throw new Error( `Failed to download blob: ${ response.status } ${ response.statusText }` ); - } - - /* - * Spawn tar to read from stdin and extract into gutenbergDir. - * `tar` is available on macOS, Linux, and Windows 10+. - */ - const tar = spawn( 'tar', [ '-x', '-C', gutenbergDir ], { - stdio: [ 'pipe', 'inherit', 'inherit' ], - } ); - - const tarDone = new Promise( ( resolve, reject ) => { - tar.on( 'close', ( code ) => { - if ( code !== 0 ) { - reject( new Error( `tar exited with code ${ code }` ) ); - } else { - resolve(); - } - } ); - tar.on( 'error', reject ); - } ); - - /* - * Pipe: fetch body → gunzip → tar stdin. - * Decompressing in Node keeps the pipeline error handling - * consistent and means tar only sees plain tar data on stdin. - */ - await pipeline( - response.body, - zlib.createGunzip(), - Writable.toWeb( tar.stdin ), - ); - - await tarDone; - - console.log( 'āœ… Download and extraction complete' ); - } catch ( error ) { - console.error( 'āŒ Download/extraction failed:', error.message ); - process.exit( 1 ); - } - } - - // Verify the downloaded version matches the expected SHA. - verifyGutenbergVersion(); - - if ( downloaded ) { - console.log( '\nāœ… Gutenberg download complete!' ); - } -} - -// Run main function. -const force = process.argv.includes( '--force' ); -main( force ).catch( ( error ) => { - console.error( 'āŒ Unexpected error:', error ); - process.exit( 1 ); -} ); diff --git a/tools/gutenberg/sync-gutenberg.js b/tools/gutenberg/sync-gutenberg.js new file mode 100644 index 0000000000000..814188d920cfa --- /dev/null +++ b/tools/gutenberg/sync-gutenberg.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +/** + * Sync Gutenberg Script + * + * This script ensures Gutenberg is checked out and built for the correct ref. + * It follows the same pattern as install-changed: + * - Stores the built ref in .gutenberg-hash + * - Compares current package.json ref with stored hash + * - Only runs checkout + build when they differ + * + * @package WordPress + */ + +const { spawn } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +// Paths +const rootDir = path.resolve( __dirname, '../..' ); +const gutenbergDir = path.join( rootDir, 'gutenberg' ); +const gutenbergBuildDir = path.join( gutenbergDir, 'build' ); +const packageJsonPath = path.join( rootDir, 'package.json' ); +const hashFilePath = path.join( rootDir, '.gutenberg-hash' ); + +/** + * Execute a command and return a promise. + * + * @param {string} command - Command to execute. + * @param {string[]} args - Command arguments. + * @param {Object} options - Spawn options. + * @return {Promise} Promise that resolves when command completes. + */ +function exec( command, args, options = {} ) { + return new Promise( ( resolve, reject ) => { + const child = spawn( command, args, { + cwd: options.cwd || rootDir, + stdio: 'inherit', + shell: process.platform === 'win32', + ...options, + } ); + + child.on( 'close', ( code ) => { + if ( code !== 0 ) { + reject( + new Error( + `${ command } ${ args.join( ' ' ) } failed with code ${ code }` + ) + ); + } else { + resolve(); + } + } ); + + child.on( 'error', reject ); + } ); +} + +/** + * Read the expected Gutenberg ref from package.json. + * + * @return {string} The Gutenberg ref. + */ +function getExpectedRef() { + const packageJson = JSON.parse( fs.readFileSync( packageJsonPath, 'utf8' ) ); + const ref = packageJson.gutenberg?.ref; + + if ( ! ref ) { + throw new Error( 'Missing "gutenberg.ref" in package.json' ); + } + + return ref; +} + +/** + * Read the stored hash from .gutenberg-hash file. + * + * @return {string|null} The stored ref, or null if file doesn't exist. + */ +function getStoredHash() { + try { + return fs.readFileSync( hashFilePath, 'utf8' ).trim(); + } catch ( error ) { + return null; + } +} + +/** + * Write the ref to .gutenberg-hash file. + * + * @param {string} ref - The ref to store. + */ +function writeHash( ref ) { + fs.writeFileSync( hashFilePath, ref + '\n' ); +} + +/** + * Check if Gutenberg build exists. + * + * @return {boolean} True if build directory exists. + */ +function hasBuild() { + return fs.existsSync( gutenbergBuildDir ); +} + +/** + * Main execution function. + */ +async function main() { + console.log( 'šŸ” Checking Gutenberg sync status...' ); + + const expectedRef = getExpectedRef(); + const storedHash = getStoredHash(); + + console.log( ` Expected ref: ${ expectedRef }` ); + console.log( ` Stored hash: ${ storedHash || '(none)' }` ); + + // Check if we need to rebuild + if ( storedHash === expectedRef && hasBuild() ) { + console.log( 'āœ… Gutenberg is already synced and built' ); + return; + } + + if ( storedHash !== expectedRef ) { + console.log( '\nšŸ“¦ Gutenberg ref has changed, rebuilding...' ); + } else { + console.log( '\nšŸ“¦ Gutenberg build not found, building...' ); + } + + // Run checkout + console.log( '\nšŸ”„ Running gutenberg:checkout...' ); + await exec( 'node', [ 'tools/gutenberg/checkout-gutenberg.js' ] ); + + // Run build + console.log( '\nšŸ”„ Running gutenberg:build...' ); + await exec( 'node', [ 'tools/gutenberg/build-gutenberg.js' ] ); + + // Write the hash after successful build + writeHash( expectedRef ); + console.log( `\nāœ… Updated .gutenberg-hash to ${ expectedRef }` ); + + console.log( '\nāœ… Gutenberg sync complete!' ); +} + +// Run main function +main().catch( ( error ) => { + console.error( 'āŒ Sync failed:', error.message ); + process.exit( 1 ); +} ); diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js deleted file mode 100644 index 4a04210f699d7..0000000000000 --- a/tools/gutenberg/utils.js +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node - -/** - * Gutenberg build utilities. - * - * Shared helpers used by the Gutenberg download script. When run directly, - * verifies that the installed Gutenberg build matches the SHA in package.json. - * - * @package WordPress - */ - -const fs = require( 'fs' ); -const path = require( 'path' ); - -// Paths. -const rootDir = path.resolve( __dirname, '../..' ); -const gutenbergDir = path.join( rootDir, 'gutenberg' ); - -/** - * Read Gutenberg configuration from package.json. - * - * @return {{ sha: string, ghcrRepo: string }} The Gutenberg configuration. - * @throws {Error} If the configuration is missing or invalid. - */ -function readGutenbergConfig() { - const packageJson = require( path.join( rootDir, 'package.json' ) ); - const sha = packageJson.gutenberg?.sha; - const ghcrRepo = packageJson.gutenberg?.ghcrRepo; - - if ( ! sha ) { - throw new Error( 'Missing "gutenberg.sha" in package.json' ); - } - - if ( ! ghcrRepo ) { - throw new Error( 'Missing "gutenberg.ghcrRepo" in package.json' ); - } - - return { sha, ghcrRepo }; -} - -/** - * Verify that the installed Gutenberg version matches the expected SHA in - * package.json. Logs progress to the console and exits with a non-zero code - * on failure. - */ -function verifyGutenbergVersion() { - console.log( '\nšŸ” Verifying Gutenberg version...' ); - - let sha; - try { - ( { sha } = readGutenbergConfig() ); - } catch ( error ) { - console.error( 'āŒ Error reading package.json:', error.message ); - process.exit( 1 ); - } - - const hashFilePath = path.join( gutenbergDir, '.gutenberg-hash' ); - try { - const installedHash = fs.readFileSync( hashFilePath, 'utf8' ).trim(); - if ( installedHash !== sha ) { - console.error( - `āŒ SHA mismatch: expected ${ sha } but found ${ installedHash }. Run \`npm run grunt gutenberg:download -- --force\` to download the correct version.` - ); - process.exit( 1 ); - } - } catch ( error ) { - if ( error.code === 'ENOENT' ) { - console.error( `āŒ .gutenberg-hash not found. Run \`npm run grunt gutenberg:download\` to download Gutenberg.` ); - } else { - console.error( `āŒ ${ error.message }` ); - } - process.exit( 1 ); - } - - console.log( 'āœ… Version verified' ); -} - -module.exports = { rootDir, gutenbergDir, readGutenbergConfig, verifyGutenbergVersion }; - -if ( require.main === module ) { - verifyGutenbergVersion(); -} diff --git a/webpack.config.js b/webpack.config.js index 2fbda4cf10165..29ebbd696b875 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,7 +14,7 @@ module.exports = function ( // Only building Core-specific media files and development scripts. // Blocks, packages, script modules, and vendors are now sourced from - // the Gutenberg build (see tools/gutenberg/copy.js). + // the Gutenberg build (see tools/gutenberg/copy-gutenberg-build.js). // Note: developmentConfig returns an array of configs, so we spread it. const config = [ mediaConfig( env ), From 2ff6199ee6ee8e5320cb8257bbb91d283c7c939f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 11 Mar 2026 23:51:36 +0000 Subject: [PATCH 5/5] ref update --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e7bed0238e9a8..9dd23f264f982 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "ref": "9b8144036fa5faf75de43d4502ff9809fcf689ad" + "ref": "74a4f254a45f7a303bd27b8f8e104786380e8103" }, "engines": { "node": ">=20.10.0",