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/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 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", 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 117cf46f5455f..9dd23f264f982 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "9b8144036fa5faf75de43d4502ff9809fcf689ad", - "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" + "ref": "74a4f254a45f7a303bd27b8f8e104786380e8103" }, "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/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; 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 ),