diff --git a/Gruntfile.js b/Gruntfile.js index b5c69553f3f0d..355a8989db3db 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -53,7 +53,6 @@ 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', ], @@ -588,7 +587,97 @@ 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: { @@ -1323,20 +1412,21 @@ module.exports = function(grunt) { }, { expand: true, - 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/' + cwd: BUILD_DIR + 'wp-includes/js/dist/', + src: [ '*.js' ], + dest: BUILD_DIR + 'wp-includes/js/dist/', }, { expand: true, - flatten: true, - src: [ - BUILD_DIR + 'wp-includes/js/dist/vendor/**/*.js' - ], - dest: BUILD_DIR + 'wp-includes/js/dist/vendor/' + cwd: BUILD_DIR + 'wp-includes/js/dist/vendor/', + src: [ '**/*.js' ], + dest: BUILD_DIR + 'wp-includes/js/dist/vendor/', + }, + { + expand: true, + cwd: BUILD_DIR + 'wp-includes/js/dist/script-modules/', + src: [ '**/*.js' ], + dest: BUILD_DIR + 'wp-includes/js/dist/script-modules/', } ] } @@ -1475,45 +1565,38 @@ module.exports = function(grunt) { } ); // Gutenberg integration tasks. - grunt.registerTask( 'gutenberg-checkout', 'Checks out the Gutenberg repository.', function() { + grunt.registerTask( 'gutenberg:verify', 'Verifies the installed Gutenberg version matches the expected SHA.', function() { const done = this.async(); grunt.util.spawn( { cmd: 'node', - args: [ 'tools/gutenberg/checkout-gutenberg.js' ], + args: [ 'tools/gutenberg/utils.js' ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); } ); } ); - grunt.registerTask( 'gutenberg-build', 'Builds the Gutenberg repository.', function() { + grunt.registerTask( 'gutenberg:download', 'Downloads the built Gutenberg artifact.', function() { const done = this.async(); + const args = [ 'tools/gutenberg/download.js' ]; + if ( grunt.option( 'force' ) ) { + args.push( '--force' ); + } grunt.util.spawn( { cmd: 'node', - args: [ 'tools/gutenberg/build-gutenberg.js' ], + args, opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); } ); } ); - grunt.registerTask( 'gutenberg-copy', 'Copies Gutenberg build output to WordPress Core.', function() { + grunt.registerTask( 'gutenberg:copy', 'Copies Gutenberg JS packages and block assets to WordPress Core.', function() { const done = this.async(); const buildDir = grunt.option( 'dev' ) ? 'src' : 'build'; grunt.util.spawn( { cmd: 'node', - 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' ], + args: [ 'tools/gutenberg/copy.js', `--build-dir=${ buildDir }` ], opts: { stdio: 'inherit' } }, function( error ) { done( ! error ); @@ -1956,26 +2039,35 @@ 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', - 'gutenberg-sync', - 'gutenberg-copy', + 'build:gutenberg', 'copy-vendor-scripts', 'build:certificates' ] ); } else { grunt.task.run( [ + 'gutenberg:verify', 'build:certificates', 'build:files', 'build:js', 'build:css', 'build:codemirror', - 'gutenberg-sync', - 'gutenberg-copy', + 'build:gutenberg', 'copy-vendor-scripts', 'replace:source-maps', 'verify:build' diff --git a/package.json b/package.json index c3150ce358e34..117cf46f5455f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "ref": "7b7fa2bc97a8029a302bd6511cf0d206b5953172" + "sha": "9b8144036fa5faf75de43d4502ff9809fcf689ad", + "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { "node": ">=20.10.0", @@ -111,9 +112,10 @@ "wicg-inert": "3.1.3" }, "scripts": { - "postinstall": "npm run gutenberg:sync && npm run gutenberg:copy -- --dev", + "postinstall": "npm run gutenberg:download", "build": "grunt build", "build:dev": "grunt build --dev", + "build:gutenberg": "grunt build:gutenberg", "dev": "grunt watch --dev", "test": "grunt test", "watch": "grunt watch", @@ -137,10 +139,8 @@ "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: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", + "gutenberg:copy": "node tools/gutenberg/copy.js", + "gutenberg:download": "node tools/gutenberg/download.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 347c2198ae953..318b9969a928d 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 sourced by wordpress/gutenberg in `tools/release/sync-stable-blocks.js`. + # These files are autogenerated by tools/gutenberg/copy.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 deleted file mode 100644 index 01cf4489de1fa..0000000000000 --- a/tools/gutenberg/build-gutenberg.js +++ /dev/null @@ -1,192 +0,0 @@ -#!/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 deleted file mode 100644 index 42e35a1967b78..0000000000000 --- a/tools/gutenberg/checkout-gutenberg.js +++ /dev/null @@ -1,239 +0,0 @@ -#!/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-gutenberg-build.js b/tools/gutenberg/copy.js similarity index 53% rename from tools/gutenberg/copy-gutenberg-build.js rename to tools/gutenberg/copy.js index 845a98d0d7d21..3a7fc67ad7485 100644 --- a/tools/gutenberg/copy-gutenberg-build.js +++ b/tools/gutenberg/copy.js @@ -9,19 +9,20 @@ * @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 @@ -37,62 +38,61 @@ 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, // Copy subdirectories - patterns: [ '*.js' ], - // Rename vendors/ to vendor/ when copying + copyDirectories: true, + // 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: 'block-library/src', + php: 'scripts/block-library', }, { - // Widget blocks + // Widget blocks. name: 'widgets', scripts: 'scripts/widgets/blocks', styles: 'styles/widgets', - php: 'widgets/src/blocks', + php: 'scripts/widgets/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,6 +114,42 @@ 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. * @@ -158,7 +194,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 ) ) { @@ -168,13 +204,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/' ) && @@ -183,14 +219,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( @@ -223,20 +259,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( gutenbergPackagesDir, source.php ); + const phpSrc = path.join( gutenbergBuildDir, 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, @@ -257,7 +293,7 @@ function copyBlockAssets( config ) { blockDest, { recursive: true, - // Skip PHP, copied from packages + // Skip PHP, copied from build in steps 3 & 4. filter: f => ! f.endsWith( '.php' ), } ); @@ -277,19 +313,18 @@ function copyBlockAssets( config ) { } } - // 3. Copy PHP from packages - const blockPhpSrc = path.join( phpSrc, blockName, 'index.php' ); + // 3. Copy PHP from build + const blockPhpSrc = path.join( phpSrc, `${ blockName }.php` ); + const phpDest = path.join( + wpIncludesDir, + config.destination, + `${ blockName }.php` + ); if ( fs.existsSync( blockPhpSrc ) ) { - const phpDest = path.join( - wpIncludesDir, - config.destination, - `${ blockName }.php` - ); - const content = fs.readFileSync( blockPhpSrc, 'utf8' ); - fs.writeFileSync( phpDest, content ); + fs.copyFileSync( blockPhpSrc, phpDest ); } - // 4. Copy PHP subdirectories from packages (e.g., shared/helpers.php) + // 4. Copy PHP subdirectories from build (e.g., navigation-link/shared/*.php) const blockPhpDir = path.join( phpSrc, blockName ); if ( fs.existsSync( blockPhpDir ) ) { const rootIndex = path.join( blockPhpDir, 'index.php' ); @@ -302,7 +337,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; }, } ); @@ -345,7 +380,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( '/' ); @@ -356,18 +391,9 @@ function generateScriptModulesPackages() { const jsPathRegular = jsPathMin.replace( /\.min\.js$/, '.js' ); try { - // Read and parse the PHP asset file - const phpContent = fs.readFileSync( fullPath, 'utf8' ); - // Extract the array from PHP: `\t'${ name }',` ).join( '\n' ) } @@ -645,7 +664,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( 'šŸ” Checking Gutenberg build...' ); - console.log( ` Build target: ${ buildTarget }/` ); + console.log( `šŸ“¦ Copying Gutenberg build to ${ buildTarget }/...` ); - // Verify Gutenberg build exists if ( ! fs.existsSync( gutenbergBuildDir ) ) { console.error( 'āŒ Gutenberg build directory not found' ); - console.error( ' Run: node tools/gutenberg/build-gutenberg.js' ); + console.error( ' Run: npm run grunt gutenberg:download' ); process.exit( 1 ); } - 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 + // 1. 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 } ); @@ -946,20 +709,22 @@ 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 } ); @@ -971,12 +736,7 @@ async function main() { const srcFile = path.join( src, file ); const destFile = path.join( dest, file ); - let content = fs.readFileSync( - srcFile, - 'utf8' - ); - content = removeSourceMaps( content ); - fs.writeFileSync( destFile, content ); + fs.copyFileSync( srcFile, destFile ); copiedCount++; } } @@ -984,15 +744,17 @@ async function main() { ` āœ… ${ entry.name }/ → ${ destName }/ (react-jsx-runtime only, ${ copiedCount } files)` ); } else { - // Copy other special directories normally - copyDirectory( src, dest, removeSourceMaps ); + // Copy other special directories normally. + copyDirectory( src, dest ); 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 ) { @@ -1000,7 +762,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 }.` @@ -1011,144 +773,45 @@ async function main() { recursive: true, } ); - // 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 ); - } + 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 } ); - - let content = fs.readFileSync( src, 'utf8' ); - content = removeSourceMaps( content ); - fs.writeFileSync( dest, content ); + fs.copyFileSync( src, dest ); } } console.log( ' āœ… JavaScript packages copied' ); } - // 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) + // 2. 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 ); - // 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...' ); + // 3. Generate script-modules-packages.php from individual asset files. + console.log( '\nšŸ“¦ Generating script-modules-packages.php...' ); generateScriptModulesPackages(); - // 8. Generate script-loader-packages.min.php - console.log( '\nšŸ“¦ Generating script-loader-packages.min.php...' ); + // 4. Generate script-loader-packages.php. + console.log( '\nšŸ“¦ Generating script-loader-packages.php...' ); generateScriptLoaderPackages(); - // 9. Generate require-dynamic-blocks.php and require-static-blocks.php + // 5. Generate require-dynamic-blocks.php and require-static-blocks.php. console.log( '\nšŸ“¦ Generating block registration files...' ); generateBlockRegistrationFiles(); - // 10. Generate blocks-json.php from block.json files + // 6. 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 new file mode 100644 index 0000000000000..f936136ffa25c --- /dev/null +++ b/tools/gutenberg/download.js @@ -0,0 +1,173 @@ +#!/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 deleted file mode 100644 index 814188d920cfa..0000000000000 --- a/tools/gutenberg/sync-gutenberg.js +++ /dev/null @@ -1,149 +0,0 @@ -#!/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 new file mode 100644 index 0000000000000..4a04210f699d7 --- /dev/null +++ b/tools/gutenberg/utils.js @@ -0,0 +1,82 @@ +#!/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 29ebbd696b875..2fbda4cf10165 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-gutenberg-build.js). + // the Gutenberg build (see tools/gutenberg/copy.js). // Note: developmentConfig returns an array of configs, so we spread it. const config = [ mediaConfig( env ),