diff --git a/.github/workflows/test-old-branches.yml b/.github/workflows/test-old-branches.yml
index 74f9c2d43d54c..ae651290d9cd8 100644
--- a/.github/workflows/test-old-branches.yml
+++ b/.github/workflows/test-old-branches.yml
@@ -7,7 +7,7 @@ on:
- trunk
paths:
- '.github/workflows/test-old-branches.yml'
- - '.github/workflows/reusable-phpunit-tests-v[1-2].yml'
+ - '.github/workflows/reusable-phpunit-tests-v[1-3].yml'
# Run twice a month on the 1st and 15th at 00:00 UTC.
schedule:
- cron: '0 0 1 * *'
diff --git a/Gruntfile.js b/Gruntfile.js
index 2ce79d03bddc6..dae8c3e972e4c 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -41,18 +41,43 @@ module.exports = function(grunt) {
'wp-admin/css/colors/**/*.css',
],
- // Built js files, in /src or /build.
+ // Built JavaScript files that do not belong to a more specific group.
jsFiles = [
'wp-admin/js/',
- 'wp-includes/js/',
+ 'wp-includes/js/*',
+ /*
+ * This directory has shared responsibility and is managed through
+ * gutenbergUnversionedFiles, webpackFiles, and copy:vendor-js.
+ */
+ '!wp-includes/js/dist',
+ 'wp-includes/js/dist/vendor/*.js',
+ // Managed by the Gutenberg-related tasks.
+ '!wp-includes/js/dist/vendor/react-jsx-runtime*',
+ ],
+
+ // Files sourced from the Gutenberg repository built asset that are ignored by version control.
+ gutenbergUnversionedFiles = [
+ SOURCE_DIR + 'wp-includes/blocks/*/*.css',
+ SOURCE_DIR + 'wp-includes/css/dist',
+ SOURCE_DIR + 'wp-includes/js/dist/*.js',
+ SOURCE_DIR + 'wp-includes/js/dist/script-modules',
+ SOURCE_DIR + 'wp-includes/js/dist/vendor/react-jsx-runtime*',
],
- // All files copied from the Gutenberg repository excluded from version control.
- gutenbergFiles = [
- 'wp-includes/js/dist',
- 'wp-includes/css/dist',
- // Old location kept temporarily to ensure they are cleaned up.
- 'wp-includes/icons',
+ // Files sourced from the Gutenberg repository built asset that are managed through version control.
+ gutenbergVersionedFiles = [
+ // Block assets (block.json, top-level PHP, nested PHP helpers).
+ SOURCE_DIR + 'wp-includes/blocks/*',
+ '!' + SOURCE_DIR + 'wp-includes/blocks/index.php',
+ SOURCE_DIR + 'wp-includes/images/icon-library',
+ SOURCE_DIR + 'wp-includes/theme.json',
+ SOURCE_DIR + 'wp-includes/theme-i18n.json',
+ // Routes and pages.
+ SOURCE_DIR + 'wp-includes/build',
+ // PHP manifests generated by gutenberg:copy.
+ SOURCE_DIR + 'wp-includes/assets/icon-library-manifest.php',
+ SOURCE_DIR + 'wp-includes/assets/script-loader-packages.php',
+ SOURCE_DIR + 'wp-includes/assets/script-modules-packages.php',
],
// All files built by Webpack, in /src or /build.
@@ -241,10 +266,32 @@ module.exports = function(grunt) {
return setFilePath( WORKING_DIR, file );
} ),
- // Clean files built by the tools/gutenberg scripts.
- gutenberg: gutenbergFiles.map( function( file ) {
- return setFilePath( WORKING_DIR, file );
- }),
+ /*
+ * Clean files sourced from the downloaded zip file built by the Gutenberg repository.
+ *
+ * All files originating from the Gutenberg repository's built assets (both tracked and untracked by version
+ * control) are deleted when `clean:gutenberg` is explicitly called. This ensures that versioned files that
+ * have been deleted upstream are also removed from version control in this repository.
+ *
+ * When `clean:gutenberg` is not explicitly called and run through `grunt clean`, only ignored files are
+ * cleaned.
+ */
+ gutenberg: {
+ get src() {
+ const cli = grunt.cli.tasks;
+ // Preserve versioned files only when running bare `grunt clean`.
+ const isBareCleanSweep =
+ cli.includes( 'clean' ) &&
+ ! cli.includes( 'clean:gutenberg' );
+
+ if ( isBareCleanSweep ) {
+ return gutenbergUnversionedFiles;
+ } else {
+ return gutenbergUnversionedFiles.concat( gutenbergVersionedFiles );
+ }
+ },
+ },
+
dynamic: {
dot: true,
expand: true,
@@ -289,7 +336,6 @@ module.exports = function(grunt) {
expand: true,
cwd: SOURCE_DIR,
src: buildFiles.concat( [
- '!wp-includes/assets/**', // Assets is extracted into separate copy tasks.
'!js/**', // JavaScript is extracted into separate copy tasks.
'!.{svn,git}', // Exclude version control folders.
'!wp-includes/version.php', // Exclude version.php.
@@ -666,24 +712,18 @@ module.exports = function(grunt) {
'constants.php',
'pages/**/*.php',
],
- dest: WORKING_DIR + 'wp-includes/build/',
+ dest: SOURCE_DIR + 'wp-includes/build/',
} ],
},
/*
- * Only copy files relevant to the routes specified in the registry file.
- *
- * While the registry file does not contain any experimental routes, the `gutenberg/build/routes` directory
- * includes the files for all registered routes. Only the files related to the routes specified in the
- * registry should be included in the WordPress build.
- *
- * The `src` list is populated at task runtime by `routes:setup`, which reads the registry after
- * `gutenberg:download` has run. See the `routes:setup` task registration for implementation details.
+ * The list of route source files is populated from the contents of the registry.php file at task runtime by
+ * `routes:setup`.
*/
routes: {
expand: true,
cwd: 'gutenberg/build',
src: [],
- dest: WORKING_DIR + 'wp-includes/build/',
+ dest: SOURCE_DIR + 'wp-includes/build/',
},
'gutenberg-js': {
files: [ {
@@ -692,7 +732,7 @@ module.exports = function(grunt) {
src: [
'pages/**/*.js',
],
- dest: WORKING_DIR + 'wp-includes/build/',
+ dest: SOURCE_DIR + 'wp-includes/build/',
} ],
},
'gutenberg-modules': {
@@ -706,7 +746,7 @@ module.exports = function(grunt) {
// with no debugging value over the minified versions.
'!vips/!(*.min).js',
],
- dest: WORKING_DIR + 'wp-includes/js/dist/script-modules/',
+ dest: SOURCE_DIR + 'wp-includes/js/dist/script-modules/',
} ],
},
'gutenberg-styles': {
@@ -719,7 +759,7 @@ module.exports = function(grunt) {
// Per-block CSS is copied to wp-includes/blocks/ by tools/gutenberg/copy.js.
'!block-library/*/**',
],
- dest: WORKING_DIR + 'wp-includes/css/dist/',
+ dest: SOURCE_DIR + 'wp-includes/css/dist/',
} ],
},
'gutenberg-theme-json': {
@@ -738,11 +778,11 @@ module.exports = function(grunt) {
files: [
{
src: 'gutenberg/lib/theme.json',
- dest: WORKING_DIR + 'wp-includes/theme.json',
+ dest: SOURCE_DIR + 'wp-includes/theme.json',
},
{
src: 'gutenberg/lib/theme-i18n.json',
- dest: WORKING_DIR + 'wp-includes/theme-i18n.json',
+ dest: SOURCE_DIR + 'wp-includes/theme-i18n.json',
},
],
},
@@ -750,8 +790,8 @@ module.exports = function(grunt) {
files: [ {
expand: true,
cwd: 'gutenberg/packages/icons/src/library',
- src: '*.svg',
- dest: WORKING_DIR + 'wp-includes/images/icon-library',
+ src: [ '*.svg' ],
+ dest: SOURCE_DIR + 'wp-includes/images/icon-library',
} ],
},
'icon-library-manifest': {
@@ -773,7 +813,7 @@ module.exports = function(grunt) {
},
files: [ {
src: 'gutenberg/packages/icons/src/manifest.php',
- dest: WORKING_DIR + 'wp-includes/assets/icon-library-manifest.php',
+ dest: SOURCE_DIR + 'wp-includes/assets/icon-library-manifest.php',
} ],
},
},
@@ -1667,7 +1707,7 @@ module.exports = function(grunt) {
*/
grunt.util.spawn( {
grunt: true,
- args: [ 'build:gutenberg', '--dev' ],
+ args: [ 'build:gutenberg' ],
opts: { stdio: 'inherit' }
}, function( buildError ) {
done( ! buildError );
@@ -1677,10 +1717,9 @@ module.exports = function(grunt) {
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.js', `--build-dir=${ buildDir }` ],
+ args: [ 'tools/gutenberg/copy.js' ],
opts: { stdio: 'inherit' }
}, function( error ) {
done( ! error );
@@ -2164,10 +2203,23 @@ module.exports = function(grunt) {
} );
} );
- grunt.registerTask( 'build:gutenberg', [
- 'copy:gutenberg-php',
+ // Detects and copies stable routes.
+ grunt.registerTask( 'build:routes', [
'routes:setup',
'copy:routes',
+ ] );
+
+ /*
+ * Refresh the Gutenberg-sourced content in src/.
+ *
+ * clean:gutenberg must run first to ensure files removed upstream are purged.
+ *
+ * Because all of these tasks write to src/, the outcome is identical for build and build:dev.
+ */
+ grunt.registerTask( 'build:gutenberg', [
+ 'clean:gutenberg',
+ 'copy:gutenberg-php',
+ 'build:routes',
'copy:gutenberg-js',
'gutenberg:copy',
'copy:gutenberg-modules',
@@ -2181,21 +2233,21 @@ module.exports = function(grunt) {
if ( grunt.option( 'dev' ) ) {
grunt.task.run( [
'gutenberg:verify',
+ 'build:gutenberg',
'build:js',
'build:css',
'build:codemirror',
- 'build:gutenberg',
'build:certificates'
] );
} else {
grunt.task.run( [
'gutenberg:verify',
+ 'build:gutenberg',
'build:certificates',
'build:files',
'build:js',
'build:css',
'build:codemirror',
- 'build:gutenberg',
'replace:source-maps',
'verify:build'
] );
diff --git a/composer.json b/composer.json
index 5c016d37316c1..6500e7ccbf8af 100644
--- a/composer.json
+++ b/composer.json
@@ -16,7 +16,8 @@
"php": ">=7.4"
},
"suggest": {
- "ext-dom": "*"
+ "ext-dom": "*",
+ "ext-mysqli": "*"
},
"require-dev": {
"composer/ca-bundle": "1.5.12",
diff --git a/package.json b/package.json
index 430efdd2fba85..429e0469dd491 100644
--- a/package.json
+++ b/package.json
@@ -141,6 +141,6 @@
"typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan",
"gutenberg:copy": "node tools/gutenberg/copy.js",
"gutenberg:verify": "node tools/gutenberg/utils.js",
- "gutenberg:download": "node tools/gutenberg/download.js && grunt build:gutenberg --dev"
+ "gutenberg:download": "node tools/gutenberg/download.js && grunt build:gutenberg"
}
}
diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css
index f48b8048c76e5..53933f0ac28a2 100644
--- a/src/wp-admin/css/common.css
+++ b/src/wp-admin/css/common.css
@@ -934,6 +934,7 @@ a#remove-post-thumbnail:hover,
border-top: 1px solid #dcdcde;
background: #f6f7f7;
display: flex;
+ flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php
index 75e046ef8ffa7..71d3953179218 100644
--- a/src/wp-admin/includes/class-wp-site-health.php
+++ b/src/wp-admin/includes/class-wp-site-health.php
@@ -1357,7 +1357,7 @@ public function get_test_dotorg_communication() {
$result['description'] .= sprintf(
'
%s
',
sprintf(
- '%s %s',
+ '%s %s',
/* translators: Hidden accessibility text. */
__( 'Error' ),
sprintf(
diff --git a/src/wp-admin/includes/class-wp-upgrader.php b/src/wp-admin/includes/class-wp-upgrader.php
index 695ce50bf0d7e..ba27113ff73de 100644
--- a/src/wp-admin/includes/class-wp-upgrader.php
+++ b/src/wp-admin/includes/class-wp-upgrader.php
@@ -528,7 +528,7 @@ public function install_package( $args = array() ) {
/*
* Give the upgrade an additional 300 seconds (5 minutes) to ensure the install
* doesn't prematurely timeout having used up the maximum script execution time
- * upacking and downloading in WP_Upgrader->run().
+ * downloading and unpacking in WP_Upgrader->run().
*/
if ( function_exists( 'set_time_limit' ) ) {
set_time_limit( 300 );
diff --git a/src/wp-admin/includes/image.php b/src/wp-admin/includes/image.php
index 95084b1db0576..935c613d561e9 100644
--- a/src/wp-admin/includes/image.php
+++ b/src/wp-admin/includes/image.php
@@ -316,7 +316,7 @@ function wp_create_image_subsizes( $file, $attachment_id ) {
}
if ( $scale_down ) {
- // Resize the image. This will also convet it if needed.
+ // Resize the image. This will also convert it if needed.
$resized = $editor->resize( $threshold, $threshold );
} elseif ( $convert ) {
// The image will be converted (if possible) when saved.
diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php
index e5300e6d75122..e9d7f986d5801 100644
--- a/src/wp-includes/class-wpdb.php
+++ b/src/wp-includes/class-wpdb.php
@@ -2758,7 +2758,7 @@ public function update( $table, $data, $where, $format = null, $where_format = n
* @param string[]|string $where_format Optional. An array of formats to be mapped to each of the values in $where.
* If string, that format will be used for all of the items in $where.
* A format is one of '%d', '%f', '%s' (integer, float, string).
- * If omitted, all values in $data will be treated as strings unless otherwise
+ * If omitted, all values in $where will be treated as strings unless otherwise
* specified in wpdb::$field_types. Default null.
* @return int|false The number of rows deleted, or false on error.
*/
@@ -3019,12 +3019,16 @@ protected function process_field_lengths( $data, $table ) {
* the value in the column and row specified is returned. If $query is null,
* the value in the specified column and row from the previous SQL result is returned.
*
+ * Returns null both on failure and when the matched cell value is an empty
+ * string. To distinguish the two cases, check {@see self::$last_error}.
+ *
* @since 0.71
*
* @param string|null $query Optional. SQL query. Defaults to null, use the result from the previous query.
* @param int $x Optional. Column of value to return. Indexed from 0. Default 0.
* @param int $y Optional. Row of value to return. Indexed from 0. Default 0.
- * @return string|null Database query result (as string), or null on failure.
+ * @return string|null Database query result (as string), or null on failure or when the value is an empty string.
+ * @phpstan-return non-empty-string|null
*/
public function get_var( $query = null, $x = 0, $y = 0 ) {
$this->func_call = "\$db->get_var(\"$query\", $x, $y)";
@@ -3039,6 +3043,14 @@ public function get_var( $query = null, $x = 0, $y = 0 ) {
// Extract var out of cached results based on x,y vals.
if ( ! empty( $this->last_result[ $y ] ) ) {
+ /**
+ * Column values.
+ *
+ * These are returned from the database as strings, or null for SQL NULL, but get_object_vars() types the
+ * property values as mixed.
+ *
+ * @var list $values
+ */
$values = array_values( get_object_vars( $this->last_result[ $y ] ) );
}
@@ -3059,6 +3071,24 @@ public function get_var( $query = null, $x = 0, $y = 0 ) {
* respectively. Default OBJECT.
* @param int $y Optional. Row to return. Indexed from 0. Default 0.
* @return array|object|null Database query result in format specified by $output or null on failure.
+ * @phpstan-param 'OBJECT'|'ARRAY_A'|'ARRAY_N' $output
+ * @phpstan-return (
+ * $query is non-falsy-string
+ * ? (
+ * $output is 'OBJECT'
+ * ? stdClass|null
+ * : (
+ * $output is 'ARRAY_A'
+ * ? array|null
+ * : (
+ * $output is 'ARRAY_N'
+ * ? list|null
+ * : null
+ * )
+ * )
+ * )
+ * : null
+ * )
*/
public function get_row( $query = null, $output = OBJECT, $y = 0 ) {
$this->func_call = "\$db->get_row(\"$query\",$output,$y)";
@@ -3104,6 +3134,7 @@ public function get_row( $query = null, $output = OBJECT, $y = 0 ) {
* @param string|null $query Optional. SQL query. Defaults to previous query.
* @param int $x Optional. Column to return. Indexed from 0. Default 0.
* @return array Database query result. Array indexed from 0 by SQL result row number.
+ * @phpstan-return list
*/
public function get_col( $query = null, $x = 0 ) {
if ( $query ) {
@@ -3118,7 +3149,7 @@ public function get_col( $query = null, $x = 0 ) {
// Extract the column values.
if ( $this->last_result ) {
for ( $i = 0, $j = count( $this->last_result ); $i < $j; $i++ ) {
- $new_array[ $i ] = $this->get_var( null, $x, $i );
+ $new_array[] = $this->get_var( null, $x, $i );
}
}
return $new_array;
@@ -3129,18 +3160,47 @@ public function get_col( $query = null, $x = 0 ) {
*
* Executes a SQL query and returns the entire SQL result.
*
+ * Returns an empty array when no rows match or when the database
+ * reports an error for the query. Returns null when $query is empty,
+ * when $output is not one of the recognized constants, or when the
+ * query cannot run because the connection is not ready.
+ *
* @since 0.71
*
- * @param string $query SQL query.
- * @param string $output Optional. Any of ARRAY_A | ARRAY_N | OBJECT | OBJECT_K constants.
- * With one of the first three, return an array of rows indexed
- * from 0 by SQL result row number. Each row is an associative array
- * (column => value, ...), a numerically indexed array (0 => value, ...),
- * or an object ( ->column = value ), respectively. With OBJECT_K,
- * return an associative array of row objects keyed by the value
- * of each row's first column's value. Duplicate keys are discarded.
- * Default OBJECT.
- * @return array|object|null Database query results.
+ * @param string|null $query SQL query.
+ * @param string $output Optional. Any of ARRAY_A | ARRAY_N | OBJECT | OBJECT_K constants.
+ * With one of the first three, return an array of rows indexed
+ * from 0 by SQL result row number. Each row is an associative array
+ * (column => value, ...), a numerically indexed array (0 => value, ...),
+ * or an object ( ->column = value ), respectively. With OBJECT_K,
+ * return an associative array of row objects keyed by the value
+ * of each row's first column's value. Duplicate keys are discarded.
+ * Default OBJECT.
+ * @return array|null Database query results. Empty array when no rows match
+ * or on database error. Null when $query is empty, when
+ * $output is invalid, or when the connection is not ready.
+ * @phpstan-param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $output
+ * @phpstan-return (
+ * $query is non-falsy-string
+ * ? (
+ * $output is 'OBJECT'
+ * ? list|null
+ * : (
+ * $output is 'OBJECT_K'
+ * ? array
+ * : (
+ * $output is 'ARRAY_A'
+ * ? list>
+ * : (
+ * $output is 'ARRAY_N'
+ * ? list>
+ * : null
+ * )
+ * )
+ * )
+ * )
+ * : null
+ * )
*/
public function get_results( $query = null, $output = OBJECT ) {
$this->func_call = "\$db->get_results(\"$query\", $output)";
@@ -3167,7 +3227,15 @@ public function get_results( $query = null, $output = OBJECT ) {
if ( $this->last_result ) {
foreach ( $this->last_result as $row ) {
$var_by_ref = get_object_vars( $row );
- $key = array_shift( $var_by_ref );
+ /**
+ * The first column's value is used as the key.
+ *
+ * A SQL NULL value surfaces as null here, so coerce it to an empty string to avoid the deprecated
+ * use of null as an array offset (PHP 8.5+).
+ *
+ * @var array-key $key
+ */
+ $key = array_shift( $var_by_ref ) ?? '';
if ( ! isset( $new_array[ $key ] ) ) {
$new_array[ $key ] = $row;
}
diff --git a/src/wp-includes/compat-utf8.php b/src/wp-includes/compat-utf8.php
index e1cab36ea3244..5fa8cde158789 100644
--- a/src/wp-includes/compat-utf8.php
+++ b/src/wp-includes/compat-utf8.php
@@ -65,7 +65,7 @@ function _wp_scan_utf8( string $bytes, int &$at, int &$invalid_length, ?int $max
"\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" .
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f",
$i,
- $end - $i
+ min( $end - $i, $max_count - $count )
);
if ( $count + $ascii_byte_count >= $max_count ) {
diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php
index d7d2ff3fed89a..355d9f8a1ec37 100644
--- a/src/wp-includes/functions.php
+++ b/src/wp-includes/functions.php
@@ -5290,20 +5290,41 @@ function _wp_to_kebab_case( $input_string ) {
/**
* Determines if the variable is a numeric-indexed array.
*
+ * Note! This answers a different question than {@see array_is_list()} and is
+ * more flexible to handle situations where some numeric array indices
+ * have been removed. A numeric-indexed array is only a “list” when the
+ * array keys form a contiguous range from zero to the highest key.
+ *
+ * Example:
+ *
+ * true === wp_is_numeric_array( array( 1, 2, 3, 4 ) );
+ * false === wp_is_numeric_array( array( 'name' => 'WordPress' ) );
+ *
+ * // All-numeric keys vs. list.
+ * $above_two = array_filter( array( 1, 2, 8, 9 ), fn ( $v ) => $v > 2 );
+ * $above_two === array( '2' => 8, '3' => 9 );
+ * true === wp_is_numeric_array( $above_two );
+ * false === array_is_list( $above_two );
+ *
* @since 4.4.0
*
* @param mixed $data Variable to check.
* @return bool Whether the variable is a list.
+ *
+ * @phpstan-assert-if-true array $data
*/
-function wp_is_numeric_array( $data ) {
+function wp_is_numeric_array( $data ): bool {
if ( ! is_array( $data ) ) {
return false;
}
- $keys = array_keys( $data );
- $string_keys = array_filter( $keys, 'is_string' );
+ foreach ( $data as $key => $value ) {
+ if ( is_string( $key ) ) {
+ return false;
+ }
+ }
- return count( $string_keys ) === 0;
+ return true;
}
/**
diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php
index a45d1697ea40a..0edb36d9c80bb 100644
--- a/src/wp-includes/kses.php
+++ b/src/wp-includes/kses.php
@@ -109,6 +109,8 @@
),
'br' => array(),
'button' => array(
+ 'command' => true,
+ 'commandfor' => true,
'disabled' => true,
'name' => true,
'type' => true,
@@ -2579,6 +2581,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) {
* Filters the list of allowed CSS attributes.
*
* @since 2.8.1
+ * @since 7.1.0 Added support for SVG presentation attributes.
*
* @param string[] $attr Array of allowed CSS attributes.
*/
@@ -2737,6 +2740,71 @@ function safecss_filter_attr( $css, $deprecated = '' ) {
'aspect-ratio',
'container-type',
+ 'fill',
+ 'fill-opacity',
+ 'fill-rule',
+
+ 'stroke',
+ 'stroke-dasharray',
+ 'stroke-dashoffset',
+ 'stroke-linecap',
+ 'stroke-linejoin',
+ 'stroke-miterlimit',
+ 'stroke-opacity',
+ 'stroke-width',
+
+ 'color-interpolation',
+ 'color-interpolation-filters',
+ 'paint-order',
+ 'stop-color',
+ 'stop-opacity',
+ 'flood-color',
+ 'flood-opacity',
+ 'lighting-color',
+
+ 'marker',
+ 'marker-end',
+ 'marker-mid',
+ 'marker-start',
+
+ 'clip-path',
+ 'clip-rule',
+ 'mask',
+ 'mask-type',
+
+ 'cx',
+ 'cy',
+ 'r',
+ 'rx',
+ 'ry',
+ 'x',
+ 'y',
+ 'd',
+
+ 'alignment-baseline',
+ 'baseline-shift',
+ 'dominant-baseline',
+ 'glyph-orientation-horizontal',
+ 'glyph-orientation-vertical',
+ 'text-anchor',
+ 'unicode-bidi',
+ 'word-spacing',
+
+ 'font-size-adjust',
+ 'font-stretch',
+
+ 'color-rendering',
+ 'image-rendering',
+ 'shape-rendering',
+ 'text-rendering',
+ 'vector-effect',
+
+ 'transform',
+ 'transform-origin',
+
+ 'pointer-events',
+ 'visibility',
+
// Custom CSS properties.
'--*',
)
diff --git a/tests/phpunit/tests/compat/wpUtf8CodePointSpan.php b/tests/phpunit/tests/compat/wpUtf8CodePointSpan.php
new file mode 100644
index 0000000000000..4bb5e0c272223
--- /dev/null
+++ b/tests/phpunit/tests/compat/wpUtf8CodePointSpan.php
@@ -0,0 +1,103 @@
+assertSame(
+ $expected_span,
+ _wp_utf8_codepoint_span( $text, $byte_offset, $max_code_points, $found_code_points ),
+ 'Should have found the expected byte span.'
+ );
+
+ $this->assertSame(
+ $expected_found,
+ $found_code_points,
+ 'Should have reported the expected number of code points.'
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_codepoint_spans() {
+ $long_ascii_run = str_repeat( 'a', 1024 );
+
+ return array(
+ 'zero code point budget' => array(
+ 'abcdef',
+ 0,
+ 0,
+ 0,
+ 0,
+ ),
+ 'long ASCII run at start' => array(
+ $long_ascii_run,
+ 0,
+ 5,
+ 5,
+ 5,
+ ),
+ 'long ASCII run from non-zero offset' => array(
+ "zz{$long_ascii_run}",
+ 2,
+ 5,
+ 5,
+ 5,
+ ),
+ 'multibyte character before the boundary' => array(
+ "ab\u{1F170}cd",
+ 0,
+ 2,
+ 2,
+ 2,
+ ),
+ 'multibyte character at the boundary' => array(
+ "ab\u{1F170}cd",
+ 0,
+ 3,
+ strlen( "ab\u{1F170}" ),
+ 3,
+ ),
+ 'invalid span after the boundary' => array(
+ "ab\xF0\x9Fzz",
+ 0,
+ 2,
+ 2,
+ 2,
+ ),
+ 'invalid span at the boundary' => array(
+ "ab\xF0\x9Fzz",
+ 0,
+ 3,
+ 4,
+ 3,
+ ),
+ );
+ }
+}
diff --git a/tests/phpunit/tests/functions/wpIsNumericArray.php b/tests/phpunit/tests/functions/wpIsNumericArray.php
index 4eeab0af81f2a..4bf7b0cc1695b 100644
--- a/tests/phpunit/tests/functions/wpIsNumericArray.php
+++ b/tests/phpunit/tests/functions/wpIsNumericArray.php
@@ -26,27 +26,34 @@ public function test_wp_is_numeric_array( $input, $expected ) {
*/
public function data_wp_is_numeric_array() {
return array(
- 'no index' => array(
+ 'no index' => array(
'test_array' => array( 'www', 'eee' ),
'expected' => true,
),
- 'text index' => array(
+ 'text index' => array(
'test_array' => array( 'www' => 'eee' ),
'expected' => false,
),
- 'numeric index' => array(
+ 'numeric index' => array(
'test_array' => array( 99 => 'eee' ),
'expected' => true,
),
- '- numeric index' => array(
+ 'filtered list (missing numeric keys)' => array(
+ 'test_array' => array_filter(
+ array( 1, 12, 13, 15, 16, 17, 20 ),
+ fn ( $v ) => 0 === $v % 2
+ ),
+ 'expected' => true,
+ ),
+ '- numeric index' => array(
'test_array' => array( -11 => 'eee' ),
'expected' => true,
),
- 'numeric string index' => array(
+ 'numeric string index' => array(
'test_array' => array( '11' => 'eee' ),
'expected' => true,
),
- 'nested number index' => array(
+ 'nested number index' => array(
'test_array' => array(
'next' => array(
11 => 'vvv',
@@ -54,7 +61,7 @@ public function data_wp_is_numeric_array() {
),
'expected' => false,
),
- 'nested string index' => array(
+ 'nested string index' => array(
'test_array' => array(
'11' => array(
'eee' => 'vvv',
@@ -62,7 +69,7 @@ public function data_wp_is_numeric_array() {
),
'expected' => true,
),
- 'not an array' => array(
+ 'not an array' => array(
'test_array' => null,
'expected' => false,
),
diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php
index db507a6b26550..9ed3a45b2d90e 100644
--- a/tests/phpunit/tests/kses.php
+++ b/tests/phpunit/tests/kses.php
@@ -1000,6 +1000,7 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() {
* @ticket 58551
* @ticket 60132
* @ticket 64414
+ * @ticket 65457
*
* @dataProvider data_safecss_filter_attr
*
@@ -1473,6 +1474,43 @@ public function data_safecss_filter_attr() {
'css' => 'display: grid',
'expected' => 'display: grid',
),
+ // SVG presentation attributes introduced in 7.1.0.
+ array(
+ 'css' => 'fill: none',
+ 'expected' => 'fill: none',
+ ),
+ array(
+ 'css' => 'fill-rule: evenodd',
+ 'expected' => 'fill-rule: evenodd',
+ ),
+ array(
+ 'css' => 'stroke: red',
+ 'expected' => 'stroke: red',
+ ),
+ array(
+ 'css' => 'stroke-width: 2',
+ 'expected' => 'stroke-width: 2',
+ ),
+ array(
+ 'css' => 'stroke-linecap: round',
+ 'expected' => 'stroke-linecap: round',
+ ),
+ array(
+ 'css' => 'paint-order: stroke',
+ 'expected' => 'paint-order: stroke',
+ ),
+ array(
+ 'css' => 'vector-effect: non-scaling-stroke',
+ 'expected' => 'vector-effect: non-scaling-stroke',
+ ),
+ array(
+ 'css' => 'clip-rule: evenodd',
+ 'expected' => 'clip-rule: evenodd',
+ ),
+ array(
+ 'css' => 'text-anchor: middle',
+ 'expected' => 'text-anchor: middle',
+ ),
);
}
@@ -1890,6 +1928,17 @@ public function test_wp_kses_main_tag_standard_attributes() {
$this->assertEqualHTML( $html, wp_kses_post( $html ) );
}
+ /**
+ * Test that Invoker Commands API attributes are preserved on buttons in post content.
+ *
+ * @ticket 64576
+ */
+ public function test_wp_kses_button_invoker_command_attributes() {
+ $html = 'Content
';
+
+ $this->assertEqualHTML( $html, wp_kses_post( $html ) );
+ }
+
/**
* Test that object tags are allowed under limited circumstances.
*
diff --git a/tools/gutenberg/copy.js b/tools/gutenberg/copy.js
index 8589c9581bed1..3da78e4b14611 100644
--- a/tools/gutenberg/copy.js
+++ b/tools/gutenberg/copy.js
@@ -6,32 +6,59 @@
* This script copies and transforms Gutenberg's build output to WordPress Core.
* It handles path transformations from plugin structure to Core structure.
*
+ * Since a number of files sourced from the downloaded zip file are subject to
+ * version control, the `src/` directory is used as the destination for all
+ * outputs of this file (both versioned and unversioned).
+ *
+ * Grunt will copy the files appropriately when running `build` instead of
+ * `build:dev`, and the repository's configured ignore rules will manage what
+ * can be committed.
+ *
* @package WordPress
*/
const fs = require( 'fs' );
const path = require( 'path' );
-const json2php = require( 'json2php' );
+const json2php = /** @type {typeof import('json2php').default} */ (
+ /** @type {unknown} */ ( require( 'json2php' ) )
+);
const { fromString } = require( 'php-array-reader' );
-// Paths.
const rootDir = path.resolve( __dirname, '../..' );
const gutenbergDir = path.join( rootDir, 'gutenberg' );
const gutenbergBuildDir = path.join( gutenbergDir, 'build' );
+const wpIncludesDir = path.join( rootDir, 'src', 'wp-includes' );
+
+/**
+ * JS package copy configuration.
+ *
+ * @typedef ScriptsConfig
+ * @type {object}
+ * @property {string} source - Gutenberg-relative source directory (e.g. `'scripts'`).
+ * @property {string} destination - Subpath under `wp-includes/` where packages land (e.g. `'js/dist'`).
+ * @property {boolean} copyDirectories - Whether to copy whole directories (with optional renames) as-is.
+ * @property {Record} directoryRenames - Map of source directory name → destination directory name.
+ */
-/*
- * Determine build target from command line argument (--dev or --build-dir).
- * Default to 'src' for development.
+/**
+ * One block family entry — block library, widget blocks, etc.
+ *
+ * @typedef BlockConfigSource
+ * @type {object}
+ * @property {string} name - Human-readable label (e.g. `'block-library'`, `'widgets'`).
+ * @property {string} scripts - Gutenberg-relative path to the block scripts directory.
+ * @property {string} styles - Gutenberg-relative path to the block styles directory.
+ * @property {string} php - Gutenberg-relative path to the block PHP directory.
*/
-const args = process.argv.slice( 2 );
-const buildDirArg = args.find( ( arg ) => arg.startsWith( '--build-dir=' ) );
-const buildTarget = buildDirArg
- ? buildDirArg.split( '=' )[ 1 ]
- : args.includes( '--dev' )
- ? 'src'
- : 'build';
-const wpIncludesDir = path.join( rootDir, buildTarget, 'wp-includes' );
+/**
+ * Block copy configuration.
+ *
+ * @typedef BlockConfig
+ * @type {object}
+ * @property {string} destination - Subpath under `wp-includes/` where blocks land (e.g. `'blocks'`).
+ * @property {BlockConfigSource[]} sources - One entry per block family.
+ */
/**
* Copy configuration.
@@ -81,7 +108,7 @@ const COPY_CONFIG = {
* @throws Error when PHP source file unable to be read or parsed.
*
* @param {string} phpFilepath Absolute path of PHP file returning a single value.
- * @return {Object|Array} JavaScript representation of value from input file.
+ * @return {any} JavaScript representation of value from input file.
*/
function readReturnedValueFromPHPFile( phpFilepath ) {
const content = fs.readFileSync( phpFilepath, 'utf8' );
@@ -109,104 +136,244 @@ function isExperimentalBlock( blockJsonPath ) {
}
/**
- * Copy all assets for blocks from Gutenberg to Core.
- * Handles scripts, styles, PHP, and JSON for all block types in a unified way.
+ * Generate a list of stable blocks.
*
- * @param {Object} config - Block configuration from COPY_CONFIG.blocks
+ * Blocks marked as `"__experimental": true` in a `block.json` file are excluded.
+ *
+ * @param {string} scriptsSrc - Path to the Gutenberg scripts source (e.g. `scripts/block-library`).
+ * @return {string[]} Stable block directory names.
*/
-function copyBlockAssets( config ) {
- const blocksDest = path.join( wpIncludesDir, config.destination );
+function getStableBlocks( scriptsSrc ) {
+ if ( ! fs.existsSync( scriptsSrc ) ) {
+ return [];
+ }
+ return fs
+ .readdirSync( scriptsSrc, { withFileTypes: true } )
+ .filter( ( entry ) => entry.isDirectory() )
+ .map( ( entry ) => entry.name )
+ .filter( ( blockName ) => ! isExperimentalBlock(
+ path.join( scriptsSrc, blockName, 'block.json' )
+ ) );
+}
- 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 );
+/**
+ * Copy JavaScript files.
+ *
+ * @param {ScriptsConfig} config - Scripts configuration from `COPY_CONFIG.scripts`.
+ */
+function copyScripts( config ) {
+ const scriptsSrc = path.join( gutenbergBuildDir, config.source );
+ const scriptsDest = path.join( wpIncludesDir, config.destination );
- if ( ! fs.existsSync( scriptsSrc ) ) {
- continue;
- }
+ if ( ! fs.existsSync( scriptsSrc ) ) {
+ return;
+ }
- // 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.
- const blockJsonPath = path.join(
- scriptsSrc,
- blockName,
- 'block.json'
- );
- if ( isExperimentalBlock( blockJsonPath ) ) {
- continue;
+ const entries = fs.readdirSync( scriptsSrc, { withFileTypes: true } );
+
+ for ( const entry of entries ) {
+ const src = path.join( scriptsSrc, entry.name );
+
+ if ( entry.isDirectory() ) {
+ // Check if this should be copied as a directory (like vendors/).
+ if (
+ config.copyDirectories &&
+ config.directoryRenames &&
+ config.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).
+ */
+ const destName = config.directoryRenames[ entry.name ];
+ const dest = path.join( scriptsDest, destName );
+
+ if ( entry.name === 'vendors' ) {
+ // Only copy react-jsx-runtime files, skip react and react-dom.
+ const vendorFiles = fs.readdirSync( src );
+ let copiedCount = 0;
+ fs.mkdirSync( dest, { recursive: true } );
+ for ( const file of vendorFiles ) {
+ if (
+ file.startsWith( 'react-jsx-runtime' ) &&
+ file.endsWith( '.js' )
+ ) {
+ const srcFile = path.join( src, file );
+ const destFile = path.join( dest, file );
+
+ fs.copyFileSync( srcFile, destFile );
+ copiedCount++;
+ }
+ }
+ console.log(
+ ` ✅ ${ entry.name }/ → ${ destName }/ (react-jsx-runtime only, ${ copiedCount } files)`
+ );
+ }
+ } else {
+ /*
+ * 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 ) {
+ if ( /^index\.(js|min\.js)$/.test( file ) ) {
+ const srcFile = path.join( src, file );
+ // Replace 'index.' with 'package-name.'.
+ const destFile = file.replace(
+ /^index\./,
+ `${ entry.name }.`
+ );
+ const destPath = path.join( scriptsDest, destFile );
+
+ fs.mkdirSync( path.dirname( destPath ), {
+ recursive: true,
+ } );
+
+ fs.copyFileSync( srcFile, destPath );
+ }
+ }
}
+ } else if ( entry.isFile() && entry.name.endsWith( '.js' ) ) {
+ // Copy root-level JS files.
+ const dest = path.join( scriptsDest, entry.name );
+ fs.mkdirSync( path.dirname( dest ), { recursive: true } );
+ fs.copyFileSync( src, dest );
+ }
+ }
+
+ console.log( ' ✅ JavaScript packages copied' );
+}
+/**
+ * Copy `block.json` files for every stable block.
+ *
+ * @param {BlockConfig} config - Block configuration from `COPY_CONFIG.blocks`.
+ */
+function copyBlockJson( config ) {
+ const blocksDest = path.join( wpIncludesDir, config.destination );
+
+ for ( const source of config.sources ) {
+ const scriptsSrc = path.join( gutenbergBuildDir, source.scripts );
+ const blocks = getStableBlocks( scriptsSrc );
+
+ for ( const blockName of blocks ) {
+ const blockSrc = path.join( scriptsSrc, blockName );
const blockDest = path.join( blocksDest, blockName );
fs.mkdirSync( blockDest, { recursive: true } );
- // 1. Copy scripts/JSON (everything except PHP)
- const blockScriptsSrc = path.join( scriptsSrc, blockName );
- if ( fs.existsSync( blockScriptsSrc ) ) {
- fs.cpSync(
- blockScriptsSrc,
- blockDest,
- {
- recursive: true,
- // Skip PHP, copied from build in steps 3 & 4.
- filter: f => ! f.endsWith( '.php' ),
- }
+ const blockJsonSrc = path.join( blockSrc, 'block.json' );
+ if ( fs.existsSync( blockJsonSrc ) ) {
+ fs.copyFileSync(
+ blockJsonSrc,
+ path.join( blockDest, 'block.json' )
);
}
+ }
- // 2. Copy styles (if they exist in per-block directory)
- const blockStylesSrc = path.join( stylesSrc, blockName );
- if ( fs.existsSync( blockStylesSrc ) ) {
- const cssFiles = fs
- .readdirSync( blockStylesSrc )
- .filter( ( file ) => file.endsWith( '.css' ) );
- for ( const cssFile of cssFiles ) {
- fs.copyFileSync(
- path.join( blockStylesSrc, cssFile ),
- path.join( blockDest, cssFile )
- );
- }
- }
+ console.log(
+ ` ✅ ${ source.name } block.json copied (${ blocks.length } blocks)`
+ );
+ }
+}
- // 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 ) ) {
- fs.copyFileSync( blockPhpSrc, phpDest );
+/**
+ * Copy block PHP files for every stable block.
+ *
+ * Handles both the top-level `.php` dynamic block files and any nested
+ * `*.php` helpers under `/` (e.g. `navigation-link/shared/render-submenu-icon.php`).
+ *
+ * @param {BlockConfig} config - Block configuration from `COPY_CONFIG.blocks`.
+ */
+function copyBlockPhp( config ) {
+ const blocksDest = path.join( wpIncludesDir, config.destination );
+
+ for ( const source of config.sources ) {
+ const scriptsSrc = path.join( gutenbergBuildDir, source.scripts );
+ const phpSrc = path.join( gutenbergBuildDir, source.php );
+ const blocks = getStableBlocks( scriptsSrc );
+
+ for ( const blockName of blocks ) {
+ // Top-level .php (dynamic block file).
+ const topLevelPhpSrc = path.join( phpSrc, `${ blockName }.php` );
+ const topLevelPhpDest = path.join( blocksDest, `${ blockName }.php` );
+ if ( fs.existsSync( topLevelPhpSrc ) ) {
+ fs.mkdirSync( blocksDest, { recursive: true } );
+ fs.copyFileSync( topLevelPhpSrc, topLevelPhpDest );
}
- // 4. Copy PHP subdirectories from build (e.g., navigation-link/shared/*.php)
+ // Nested PHP helpers under /, excluding the block's own index.php.
const blockPhpDir = path.join( phpSrc, blockName );
if ( fs.existsSync( blockPhpDir ) ) {
+ const blockDest = path.join( blocksDest, blockName );
const rootIndex = path.join( blockPhpDir, 'index.php' );
+
+ /**
+ * @param {string} src
+ * @return {boolean}
+ */
+ function hasPhpFiles( src ) {
+ const stat = fs.statSync( src );
+ if ( stat.isDirectory() ) {
+ return fs.readdirSync( src, { withFileTypes: true } ).some(
+ ( entry ) => hasPhpFiles( path.join( src, entry.name ) )
+ );
+ }
+ return src.endsWith( '.php' ) && src !== rootIndex;
+ }
+
fs.cpSync( blockPhpDir, blockDest, {
recursive: true,
- filter: function hasPhpFiles( src ) {
- const stat = fs.statSync( src );
- if ( stat.isDirectory() ) {
- return fs.readdirSync( src, { withFileTypes: true } ).some(
- ( entry ) => hasPhpFiles( path.join( src, entry.name ) )
- );
- }
- // Copy PHP files, but skip root index.php (handled by step 3).
- return src.endsWith( '.php' ) && src !== rootIndex;
- },
+ filter: hasPhpFiles,
} );
}
}
console.log(
- ` ✅ ${ source.name } blocks copied (${ blockDirs.length } blocks)`
+ ` ✅ ${ source.name } block PHP copied (${ blocks.length } blocks)`
+ );
+ }
+}
+
+/**
+ * Copy per-block CSS files for every stable block.
+ *
+ * @param {BlockConfig} config - Block configuration from `COPY_CONFIG.blocks`.
+ */
+function copyBlockStyles( config ) {
+ const blocksDest = path.join( wpIncludesDir, config.destination );
+
+ for ( const source of config.sources ) {
+ const scriptsSrc = path.join( gutenbergBuildDir, source.scripts );
+ const stylesSrc = path.join( gutenbergBuildDir, source.styles );
+ const blocks = getStableBlocks( scriptsSrc );
+
+ let stylesCopied = 0;
+ for ( const blockName of blocks ) {
+ const blockStylesSrc = path.join( stylesSrc, blockName );
+ if ( ! fs.existsSync( blockStylesSrc ) ) {
+ continue;
+ }
+
+ const blockDest = path.join( blocksDest, blockName );
+ fs.mkdirSync( blockDest, { recursive: true } );
+
+ const cssFiles = fs
+ .readdirSync( blockStylesSrc )
+ .filter( ( file ) => file.endsWith( '.css' ) );
+ for ( const cssFile of cssFiles ) {
+ fs.copyFileSync(
+ path.join( blockStylesSrc, cssFile ),
+ path.join( blockDest, cssFile )
+ );
+ }
+ if ( cssFiles.length > 0 ) {
+ stylesCopied++;
+ }
+ }
+
+ console.log(
+ ` ✅ ${ source.name } block CSS copied (${ stylesCopied } blocks)`
);
}
}
@@ -218,6 +385,7 @@ function copyBlockAssets( config ) {
*/
function generateScriptModulesPackages() {
const modulesDir = path.join( gutenbergBuildDir, 'modules' );
+ /** @type {Record} */
const assets = {};
/**
@@ -254,7 +422,7 @@ function generateScriptModulesPackages() {
} catch ( error ) {
console.error(
` ⚠️ Error reading ${ relativePath }:`,
- error.message
+ error instanceof Error ? error.message : String( error )
);
}
}
@@ -291,6 +459,7 @@ function generateScriptModulesPackages() {
*/
function generateScriptLoaderPackages() {
const scriptsDir = path.join( gutenbergBuildDir, 'scripts' );
+ /** @type {Record} */
const assets = {};
if ( ! fs.existsSync( scriptsDir ) ) {
@@ -326,7 +495,7 @@ function generateScriptLoaderPackages() {
} catch ( error ) {
console.error(
` ⚠️ Error reading ${ entry.name }/index.min.asset.php:`,
- error.message
+ error instanceof Error ? error.message : String( error )
);
}
}
@@ -354,9 +523,10 @@ function generateScriptLoaderPackages() {
}
/**
- * Generate require-dynamic-blocks.php and require-static-blocks.php.
- * Reads all block.json files from wp-includes/blocks and categorizes them.
- * Only includes blocks from block-library, not widgets.
+ * Generate `require-*-blocks.php` files.
+ *
+ * Reads all `block.json` files from the block-library (widgets are ignored) and
+ * creates `require-dynamic-blocks.php` and `require-static-blocks.php` files.
*/
function generateBlockRegistrationFiles() {
const blocksDir = path.join( wpIncludesDir, 'blocks' );
@@ -447,12 +617,15 @@ ${ staticBlocks.map( ( name ) => `\t'${ name }',` ).join( '\n' ) }
}
/**
- * Generate blocks-json.php from all block.json files.
- * Reads all block.json files and combines them into a single PHP array.
- * Uses json2php to maintain consistency with Core's formatting.
+ * Generate a `blocks-json.php` file.
+ *
+ * Reads all `block.json` files and combines them into a single PHP array.
+ *
+ * This must run after `copyBlockJson` has populated `wp-includes/blocks/`.
*/
function generateBlocksJson() {
const blocksDir = path.join( wpIncludesDir, 'blocks' );
+ /** @type {Record} */
const blocks = {};
if ( ! fs.existsSync( blocksDir ) ) {
@@ -478,7 +651,7 @@ function generateBlocksJson() {
} catch ( error ) {
console.error(
` ⚠️ Error reading ${ entry.name }/block.json:`,
- error.message
+ error instanceof Error ? error.message : String( error )
);
}
}
@@ -508,7 +681,7 @@ function generateBlocksJson() {
* Main execution function.
*/
async function main() {
- console.log( `📦 Copying Gutenberg build to ${ buildTarget }/...` );
+ console.log( '📦 Copying Gutenberg build to src/...' );
if ( ! fs.existsSync( gutenbergBuildDir ) ) {
console.error( '❌ Gutenberg build directory not found' );
@@ -518,95 +691,18 @@ async function main() {
// 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 );
+ copyScripts( COPY_CONFIG.scripts );
- if ( fs.existsSync( scriptsSrc ) ) {
- const entries = fs.readdirSync( scriptsSrc, { withFileTypes: true } );
+ console.log( '\n📦 Copying block.json files...' );
+ copyBlockJson( COPY_CONFIG.blocks );
- for ( const entry of entries ) {
- const src = path.join( scriptsSrc, entry.name );
-
- if ( entry.isDirectory() ) {
- // 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).
- */
- 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.
- const vendorFiles = fs.readdirSync( src );
- let copiedCount = 0;
- fs.mkdirSync( dest, { recursive: true } );
- for ( const file of vendorFiles ) {
- if (
- file.startsWith( 'react-jsx-runtime' ) &&
- file.endsWith( '.js' )
- ) {
- const srcFile = path.join( src, file );
- const destFile = path.join( dest, file );
-
- fs.copyFileSync( srcFile, destFile );
- copiedCount++;
- }
- }
- console.log(
- ` ✅ ${ entry.name }/ → ${ destName }/ (react-jsx-runtime only, ${ copiedCount } files)`
- );
- }
- } else {
- /*
- * 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 ) {
- if (
- /^index\.(js|min\.js)$/.test( file )
- ) {
- const srcFile = path.join( src, file );
- // Replace 'index.' with 'package-name.'.
- const destFile = file.replace(
- /^index\./,
- `${ entry.name }.`
- );
- const destPath = path.join( scriptsDest, destFile );
-
- fs.mkdirSync( path.dirname( destPath ), {
- recursive: true,
- } );
-
- fs.copyFileSync( srcFile, destPath );
- }
- }
- }
- } else if ( entry.isFile() && entry.name.endsWith( '.js' ) ) {
- // Copy root-level JS files.
- const dest = path.join( scriptsDest, entry.name );
- fs.mkdirSync( path.dirname( dest ), { recursive: true } );
- fs.copyFileSync( src, dest );
- }
- }
-
- console.log( ' ✅ JavaScript packages copied' );
- }
+ console.log( '\n📦 Copying block PHP files...' );
+ copyBlockPhp( COPY_CONFIG.blocks );
- // 2. Copy blocks (unified: scripts, styles, PHP, JSON).
- console.log( '\n📦 Copying blocks...' );
- copyBlockAssets( COPY_CONFIG.blocks );
+ console.log( '\n📦 Copying block CSS files...' );
+ copyBlockStyles( COPY_CONFIG.blocks );
- // 3. Generate script-modules-packages.php from individual asset files.
+ // 3. Generate script-modules-packages.php.
console.log( '\n📦 Generating script-modules-packages.php...' );
generateScriptModulesPackages();
diff --git a/tools/gutenberg/utils.js b/tools/gutenberg/utils.js
index 43047b5ee5dd7..3ba95199578b4 100644
--- a/tools/gutenberg/utils.js
+++ b/tools/gutenberg/utils.js
@@ -139,7 +139,7 @@ async function resolveExpectedSha( { ref, ghcrRepo, isMutable } ) {
/**
* Trigger a fresh download of the Gutenberg artifact by spawning download.js,
- * then run `grunt build:gutenberg --dev` to copy the build to src/.
+ * then run `grunt build:gutenberg` to copy the build into src/.
* Exits the process if either step fails.
*/
function downloadGutenberg() {
@@ -148,7 +148,7 @@ function downloadGutenberg() {
process.exit( downloadResult.status ?? 1 );
}
- const buildResult = spawnSync( 'grunt', [ 'build:gutenberg', '--dev' ], { stdio: 'inherit', shell: true } );
+ const buildResult = spawnSync( 'grunt', [ 'build:gutenberg' ], { stdio: 'inherit', shell: true } );
if ( buildResult.status !== 0 ) {
process.exit( buildResult.status ?? 1 );
}
diff --git a/tsconfig.json b/tsconfig.json
index 87abe9fb7a42b..e9f36c374ac89 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,6 +30,7 @@
"src/js/_enqueues/wp/code-editor.js",
"src/js/_enqueues/lib/codemirror/javascript-lint.js",
"src/js/_enqueues/lib/codemirror/htmlhint-kses.js",
+ "tools/gutenberg/copy.js",
"tools/gutenberg/download.js",
"tools/gutenberg/utils.js"
]