From f738d85a9befe04e4c61255a1d9a22b97b7d34da Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 9 Nov 2026 18:59:51 -0800 Subject: [PATCH 1/7] Build/Test Tools: Make the file watcher pick up changes reliably `npm run dev` often missed changes entirely, such as edits to `src/js/_enqueues/lib/emoji-loader.js`. grunt-contrib-watch is replaced by a watch task built on chokidar, in `tools/watch/task.js`, because of several problems that the Gruntfile could not work around: - gaze, which it uses, finds every file below a pattern before filtering out the excluded ones. For `src/**`, that includes every plugin in `src/wp-content/plugins` and every `node_modules` directory of a bundled theme: 1.8 million files in a development checkout, read synchronously, blocking the watcher for minutes. - It then polls each watched file every two seconds. - With `spawn: false`, it closes and recreates every watcher after each run, missing the changes made in the meantime. - A change for one target seen while another target's tasks run is left queued until the next change. The new task watches with the native file system events of each platform, skipping the ignored directories, and keeps one watcher for the whole session. Changes made while tasks run are run as soon as they finish. A failing task is logged without stopping the watcher, and a change to the Gruntfile restarts it. Polling remains available through `CHOKIDAR_USEPOLLING=1`, for network drives and Windows drives accessed from WSL. The `all` target, which copies changed files to `build/`, is no longer watched with `--dev`, as there is nothing for it to copy. The `phpunit` target watches `src/` and `tests/phpunit/` instead of every PHP file in the repository, which included caches and worktrees. The changes of a target are now collected for all the files changed together, instead of each overwriting the configuration of the `:dynamic` tasks, so that only the last file was rebuilt. The configuration is also reset on each run: the `clean:dynamic` file list was kept from the last deletion, so that a later run could delete that path again. `wp-emoji-loader.js` is minified as a module, as in the build, and a vendor script is mapped to the copy pattern it matches instead of always the first. `npm run build:dev -- --symlink` symlinks the scripts that are copied unchanged from `src/js/_enqueues` into `src/wp-admin/js` and `src/wp-includes/js`, so that edits to them take effect without the watcher. It is opt-in because Docker Desktop's file sharing can fail to read a path that is replaced by a symlink until the containers are restarted. Where symlinks cannot be created, as on Windows without Developer Mode, the files are copied. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1 --- Gruntfile.js | 374 +++++++++++++------ README.md | 10 + package-lock.json | 874 ++++++-------------------------------------- package.json | 2 +- tools/watch/task.js | 313 ++++++++++++++++ 5 files changed, 706 insertions(+), 867 deletions(-) create mode 100644 tools/watch/task.js diff --git a/Gruntfile.js b/Gruntfile.js index f96eda8ce5f61..dce9b116883c8 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -16,6 +16,10 @@ module.exports = function(grunt) { autoprefixer = require( 'autoprefixer' ), sass = require( 'sass' ), phpUnitWatchGroup = grunt.option( 'group' ), + // Scripts that are loaded as modules, which are minified by their own uglify tasks. + uglifyModules = [ + 'wp-includes/js/wp-emoji-loader.js' + ], buildFiles = [ '*.php', '*.txt', @@ -139,8 +143,7 @@ module.exports = function(grunt) { 'copy', 'cssmin', 'jshint', - 'uglify', - 'watch' + 'uglify' ], 'standard': [ 'banner', @@ -1216,8 +1219,9 @@ module.exports = function(grunt) { '!{wp-admin,wp-includes}/**/*.min.js', '!wp-admin/js/custom-header.js', // Why? We should minify this. '!wp-admin/js/farbtastic.js', - '!wp-includes/js/wp-emoji-loader.js', // This is a module. See the emoji-loader task below. - ] + ].concat( uglifyModules.map( function( file ) { + return '!' + file; // This is a module. See the emoji-loader task below. + } ) ) }, 'emoji-loader': { options: { @@ -1266,6 +1270,18 @@ module.exports = function(grunt) { dest: WORKING_DIR, ext: '.min.js', src: [] + }, + // Changed files that are loaded as modules, minified with the options of the emoji-loader task. + 'dynamic-module': { + options: { + module: true, + toplevel: true, + }, + expand: true, + cwd: WORKING_DIR, + dest: WORKING_DIR, + ext: '.min.js', + src: [] } }, webpack: { @@ -1646,29 +1662,35 @@ module.exports = function(grunt) { }, _watch: { options: { - interval: 2000 + // Directories that are never watched, so that they are not searched. + ignored: [ + '**/.{svn,git}/**', + '**/node_modules/**', + 'vendor/**', + BUILD_DIR + '**', + // Third-party plugins. + SOURCE_DIR + 'wp-content/plugins/**' + ] }, + /* + * Copies changed files from /src to /build. When building into /src with --dev, there is nothing to + * copy, so this target is removed below. + */ all: { files: [ SOURCE_DIR + '**', - '!' + SOURCE_DIR + 'js/**/*.js', - // Ignore version control directories. - '!' + SOURCE_DIR + '**/.{svn,git}/**', - // Ignore third-party plugins. - '!' + SOURCE_DIR + 'wp-content/plugins/**' + '!' + SOURCE_DIR + 'js/**/*.js' ], - tasks: ['clean:dynamic', 'copy:dynamic'], + tasks: ['watch-dynamic:all', 'clean:dynamic', 'copy:dynamic'], options: { - dot: true, - spawn: false + dot: true } }, 'js-enqueues': { files: [SOURCE_DIR + 'js/_enqueues/**/*.js'], - tasks: ['clean:dynamic', 'copy:dynamic-js', 'uglify:dynamic'], + tasks: ['watch-dynamic:js-enqueues', 'clean:dynamic', 'copy:dynamic-js', 'uglify:dynamic', 'uglify:dynamic-module'], options: { - dot: true, - spawn: false + dot: true } }, 'js-webpack': { @@ -1677,17 +1699,19 @@ module.exports = function(grunt) { '!' + SOURCE_DIR + 'js/_enqueues/**/*.js', 'webpack-dev.config.js' ], - tasks: ['clean:dynamic', 'webpack:dev', 'uglify:dynamic'], + tasks: ['watch-dynamic:js-webpack', 'clean:dynamic', 'webpack:dev'], options: { - dot: true, - spawn: false + dot: true } }, config: { files: [ 'Gruntfile.js', 'webpack.config.js' - ] + ], + options: { + reload: true + } }, colors: { files: [SOURCE_DIR + 'wp-admin/css/colors/**'], @@ -1698,10 +1722,7 @@ module.exports = function(grunt) { SOURCE_DIR + 'wp-admin/css/*.css', SOURCE_DIR + 'wp-includes/css/*.css' ], - tasks: ['rtlcss:dynamic'], - options: { - spawn: false - } + tasks: ['watch-dynamic:rtl', 'rtlcss:dynamic'] }, test: { files: [ @@ -1712,6 +1733,13 @@ module.exports = function(grunt) { } }); + /* + * When building into /src, there is nothing for the `all` watch target to copy, so its files are not watched. + */ + if ( WORKING_DIR === SOURCE_DIR ) { + delete grunt.config.data._watch.all; + } + // Allow builds to be minimal. if( grunt.option( 'minimal-copy' ) ) { var copyFilesOptions = grunt.config.get( 'copy.files.files' ); @@ -1798,7 +1826,7 @@ module.exports = function(grunt) { } ); } ); - grunt.renameTask( 'watch', '_watch' ); + grunt.registerTask( '_watch', 'Runs the tasks of the watch targets whose files change.', require( './tools/watch/task.js' )( grunt ) ); grunt.registerTask( 'watch', 'Watches files for changes and runs the related tasks. Use watch:phpunit or --phpunit to also run PHPUnit tests.', function() { if ( ! this.args.length || this.args.indexOf( 'webpack' ) > -1 ) { @@ -1807,7 +1835,10 @@ module.exports = function(grunt) { if ( 'watch:phpunit' === grunt.cli.tasks[ 0 ] || 'undefined' !== typeof grunt.option( 'phpunit' ) ) { grunt.config.data._watch.phpunit = { - files: [ '**/*.php' ], + files: [ + SOURCE_DIR + '**/*.php', + 'tests/phpunit/**/*.php' + ], tasks: [ 'phpunit:default' ] }; } @@ -1983,13 +2014,103 @@ module.exports = function(grunt) { } } ); + /* + * With --symlink, when building into /src, the wp-admin and wp-includes scripts that are copied unchanged from + * /src/js/_enqueues are symlinked instead, so that edits to them take effect without a rebuild. + * + * This is opt-in because Docker Desktop's file sharing can keep serving the previous entry for a path whose file is + * replaced by a symlink, failing to read it until the containers are restarted (`npm run env:restart`). As the + * build cleans the scripts first, this can happen after every build. + */ + var linkScripts = WORKING_DIR === SOURCE_DIR && grunt.option( 'symlink' ) === true; + grunt.registerTask( 'copy:js', 'Copies JavaScript files from npm packages, vendor libraries, wp-admin, and wp-includes.', [ 'copy:npm-packages', 'copy:vendor-js', - 'copy:admin-js', - 'copy:includes-js' + linkScripts ? 'link:admin-js' : 'copy:admin-js', + linkScripts ? 'link:includes-js' : 'copy:includes-js' ] ); + /** + * Checks whether a path is a symlink, including one whose target no longer exists. + * + * @param {string} filepath Path to check. + * @return {boolean} Whether the path is a symlink. + */ + function isSymlink( filepath ) { + try { + return fs.lstatSync( filepath ).isSymbolicLink(); + } catch ( e ) { + return false; + } + } + + grunt.registerTask( 'link', 'Symlinks the files of a copy task to their sources, falling back to copying them where symlinks cannot be created.', function( target ) { + var files = grunt.config( [ 'copy', target, 'files' ] ); + var linked = 0; + var unchanged = 0; + var copied = 0; + var canLink = true; + + if ( ! files ) { + grunt.fail.warn( 'The copy:' + target + ' task has no files to link.' ); + return; + } + + Object.keys( files ).forEach( function( dest ) { + var src = files[ dest ][ 0 ]; + var linkTarget = path.relative( path.dirname( dest ), src ); + + /* + * Keep a link that is already in place. Docker Desktop's file sharing can keep serving the previous + * entry for a path that is replaced, failing to read it until the containers are restarted. + */ + if ( isSymlink( dest ) && fs.readlinkSync( dest ) === linkTarget && grunt.file.exists( src ) ) { + unchanged++; + return; + } + + // Remove the previous copy or link, so that neither is written through. + if ( isSymlink( dest ) || grunt.file.exists( dest ) ) { + fs.unlinkSync( dest ); + } + + // Like the copy task, skip a source that does not exist. + if ( ! grunt.file.exists( src ) ) { + grunt.verbose.writeln( 'Source file ' + src + ' not found.' ); + return; + } + grunt.file.mkdir( path.dirname( dest ) ); + + if ( canLink ) { + try { + // A relative link also resolves inside the Docker containers, which mount the repository elsewhere. + fs.symlinkSync( linkTarget, dest, 'file' ); + linked++; + return; + } catch ( e ) { + // Creating symlinks on Windows requires Developer Mode or administrator rights. + if ( [ 'EPERM', 'EACCES', 'ENOTSUP', 'ENOSYS' ].indexOf( e.code ) === -1 ) { + throw e; + } + canLink = false; + grunt.log.writeln( 'Symlinks cannot be created (' + e.code + '), so the files are copied instead.' ); + } + } + + grunt.file.copy( src, dest ); + copied++; + } ); + + grunt.log.ok( 'Linked ' + linked + ' ' + grunt.util.pluralize( linked, 'file/files' ) + + ( unchanged ? ', kept ' + unchanged + ' existing ' + grunt.util.pluralize( unchanged, 'link/links' ) : '' ) + + ( copied ? ', copied ' + copied + ' ' + grunt.util.pluralize( copied, 'file/files' ) : '' ) + '.' ); + + if ( linked ) { + grunt.log.writeln( 'If the local environment cannot read the linked files, restart it with `npm run env:restart`.' ); + } + } ); + grunt.registerTask( 'uglify:all', 'Minifies all JavaScript files.', [ 'uglify:core', 'uglify:emoji-loader', @@ -2496,104 +2617,137 @@ module.exports = function(grunt) { grunt.registerTask('default', 'Runs the build task.', ['build']); /* - * Automatically updates the `:dynamic` configurations - * so that only the changed files are updated. + * Changes seen by each watch target since its tasks last ran, keyed by file path. + * + * The watch task runs the tasks of a target once for all the changes made within its debounce delay, so the + * changes are collected here and the `:dynamic` configurations are set from all of them when the tasks run, + * by the `watch-dynamic` task, which each target runs first. */ - grunt.event.on( 'watch', function( action, filepath, target ) { - var src; + var watchedChanges = {}; - // Only configure the dynamic tasks based on known targets. - if ( [ 'all', 'rtl', 'webpack', 'js-enqueues', 'js-webpack' ].indexOf( target ) === -1 ) { + grunt.event.on( 'watch', function( action, filepath, target ) { + if ( [ 'all', 'rtl', 'js-enqueues', 'js-webpack' ].indexOf( target ) === -1 ) { return; } + watchedChanges[ target ] = watchedChanges[ target ] || {}; + // Normalize filepath for Windows. - filepath = filepath.replace( /\\/g, '/' ); - - // If the target is a file in the restructured js src. - if ( target === 'js-enqueues' ) { - var files = {}; - var configs, dest; - - // If it's a vendor file which are configured with glob matchers. - if ( filepath.indexOf( SOURCE_DIR + 'js/_enqueues/vendor/' ) === 0 ) { - // Grab the glob matchers from the copy task. - configs = grunt.config( [ 'copy', 'vendor-js', 'files' ] ); - - // For each glob matcher check if it matches and if so set the variables for our dynamic tasks. - for ( var i = 0; i < configs.length; i++ ) { - var config = configs[ i ]; - var relative = path.relative( config.cwd, filepath ); - var minimatch = require('minimatch'); - - if ( minimatch.match( config.src, relative, {} ) ) { - dest = config.dest + relative; - src = [ path.relative( WORKING_DIR, dest ) ]; - files[ dest ] = [ filepath ]; - break; - } - } - // Or if it's another file which has a straight mapping. - } else { - configs = Object.assign( {}, - grunt.config( [ 'copy', 'admin-js', 'files' ] ), - grunt.config( [ 'copy', 'includes-js', 'files' ] ) - ); + watchedChanges[ target ][ filepath.replace( /\\/g, '/' ) ] = action; + } ); - for ( dest in configs ) { - // If a file in the mapping matches then set the variables for our dynamic tasks. - if ( dest && configs.hasOwnProperty( dest ) && configs[ dest ][0] === './' + filepath ) { - files[ dest ] = configs[ dest ]; - src = [ path.relative( WORKING_DIR, dest ) ]; - break; - } + /** + * Gets the destination of a file in the restructured js src, relative to the working directory. + * + * @param {string} filepath Path of the source file. + * @return {?{dest: string, src: string}} The destination and source of the copy, or null if the file is not copied. + */ + function getEnqueuedScriptCopy( filepath ) { + var configs, i, dest, relative; + + // If it's a vendor file which are configured with glob matchers. + if ( filepath.indexOf( SOURCE_DIR + 'js/_enqueues/vendor/' ) === 0 ) { + configs = grunt.config( [ 'copy', 'vendor-js', 'files' ] ); + + // For each glob matcher check if it matches. + for ( i = 0; i < configs.length; i++ ) { + relative = path.relative( configs[ i ].cwd, filepath ); + + if ( require( 'minimatch' ).match( configs[ i ].src, relative, {} ).length ) { + return { dest: configs[ i ].dest + relative, src: filepath }; } } + return null; + } - // Configure our dynamic-js copy task which uses a file mapping rather than simply copying from src to build. - if ( action !== 'deleted' ) { - grunt.config( [ 'copy', 'dynamic-js', 'files' ], files ); + // Or if it's another file which has a straight mapping. + configs = Object.assign( {}, + grunt.config( [ 'copy', 'admin-js', 'files' ] ), + grunt.config( [ 'copy', 'includes-js', 'files' ] ) + ); + + for ( dest in configs ) { + if ( configs.hasOwnProperty( dest ) && configs[ dest ][ 0 ] === './' + filepath ) { + return { dest: dest, src: configs[ dest ][ 0 ] }; } - // For the webpack builds configure the task to only check those files built by webpack. - } else if ( target === 'js-webpack' ) { - src = [ - 'wp-includes/js/media-audiovideo.js', - 'wp-includes/js/media-grid.js', - 'wp-includes/js/media-models.js', - 'wp-includes/js/media-views.js' - ]; - // Else simply use the path relative to the source directory. - } else { - src = [ path.relative( SOURCE_DIR, filepath ) ]; } + return null; + } - if ( ! src ) { - grunt.warn( 'Failed to determine the destination file.' ); - return; - } + /* + * Updates the `:dynamic` configurations so that only the changed files are updated. + * + * Every configuration a target uses is set each time, so that none is left over from an earlier run. + */ + grunt.registerTask( 'watch-dynamic', 'Configures the dynamic tasks of a watch target for the files changed since they last ran.', function( target ) { + var changes = watchedChanges[ target ] || {}; + var deleted = []; + var copyFiles = {}; + var minify = []; + var minifyModules = []; + var changed = []; - if ( action === 'deleted' ) { - // Clean up only those files that were deleted. - grunt.config( [ 'clean', 'dynamic', 'src' ], src ); - } else { - if ( ! grunt.option( 'dev' ) ) { - // Otherwise copy over only the changed file. - grunt.config(['copy', 'dynamic', 'src'], src); - } + delete watchedChanges[ target ]; + + Object.keys( changes ).forEach( function( filepath ) { + var action = changes[ filepath ]; + var copy, dest; - // For javascript also minify and validate the changed file. if ( target === 'js-enqueues' ) { - grunt.config( [ 'uglify', 'dynamic', 'src' ], src ); - grunt.config( [ 'dynamic', 'files', 'src' ], src.map( function( dir ) { return WORKING_DIR + dir; } ) ); - } - // For webpack only validate the file, minification is handled by webpack itself. - if ( target === 'js-webpack' ) { - grunt.config( [ 'dynamic', 'files', 'src' ], src.map( function( dir ) { return WORKING_DIR + dir; } ) ); - } - // For css run the rtl task on just the changed file. - if ( target === 'rtl' ) { - grunt.config( [ 'rtlcss', 'dynamic', 'src' ], src ); + copy = getEnqueuedScriptCopy( filepath ); + if ( ! copy ) { + grunt.log.writeln( 'Not built: ' + filepath ); + return; + } + dest = path.relative( WORKING_DIR, copy.dest ); + + if ( action === 'deleted' ) { + // A symlink to the deleted file no longer exists for the clean task, so it is removed here. + if ( isSymlink( copy.dest ) ) { + fs.unlinkSync( copy.dest ); + } else { + deleted.push( dest ); + } + return; + } + + // A symlink already has the changes, and copying onto it would write the file onto itself. + if ( ! isSymlink( copy.dest ) ) { + copyFiles[ copy.dest ] = [ copy.src ]; + } + + if ( uglifyModules.indexOf( dest ) !== -1 ) { + minifyModules.push( dest ); + } else { + minify.push( dest ); + } + } else if ( target === 'js-webpack' ) { + // Check only the files built by webpack. + if ( action === 'deleted' ) { + deleted = [ + 'wp-includes/js/media-audiovideo.js', + 'wp-includes/js/media-grid.js', + 'wp-includes/js/media-models.js', + 'wp-includes/js/media-views.js' + ]; + } + } else if ( action === 'deleted' ) { + deleted.push( path.relative( SOURCE_DIR, filepath ) ); + } else { + changed.push( path.relative( SOURCE_DIR, filepath ) ); } + } ); + + grunt.config( [ 'clean', 'dynamic', 'src' ], deleted ); + + if ( target === 'all' ) { + grunt.config( [ 'copy', 'dynamic', 'src' ], changed ); + } else if ( target === 'rtl' ) { + grunt.config( [ 'rtlcss', 'dynamic', 'src' ], changed ); + } else if ( target === 'js-enqueues' ) { + grunt.config( [ 'copy', 'dynamic-js', 'files' ], copyFiles ); + grunt.config( [ 'uglify', 'dynamic', 'src' ], minify ); + grunt.config( [ 'uglify', 'dynamic-module', 'src' ], minifyModules ); } - }); + } ); }; diff --git a/README.md b/README.md index 0f41da84643bd..282200584ae8f 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,16 @@ npm run dev To stop the watcher, press `ctrl+c`. +The watcher relies on the file system events of your operating system. Where these are not delivered, such as on a network drive or for a Windows drive accessed from WSL, set the `CHOKIDAR_USEPOLLING=1` environment variable to check the files for changes periodically instead. + +The JavaScript files in `src/wp-admin/js` and `src/wp-includes/js` that are copied unchanged from `src/js/_enqueues` can be symlinked instead, so that changes to them take effect without the watcher: + +``` +npm run build:dev -- --symlink +``` + +If the local environment then cannot load these files, which can happen with Docker Desktop, restart it with `npm run env:restart`. + #### To run a [WP-CLI](https://make.wordpress.org/cli/handbook/) command ``` diff --git a/package-lock.json b/package-lock.json index 5b497251c419d..c327ba65167fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "@wordpress/prettier-config": "4.51.0", "@wordpress/scripts": "33.0.0", "autoprefixer": "10.5.3", + "chokidar": "4.0.3", "dotenv": "17.4.2", "dotenv-expand": "13.0.0", "grunt": "1.6.2", @@ -67,7 +68,6 @@ "grunt-contrib-cssmin": "~5.0.0", "grunt-contrib-jshint": "3.2.0", "grunt-contrib-uglify": "~5.2.2", - "grunt-contrib-watch": "~1.1.0", "grunt-file-append": "0.0.7", "grunt-jsdoc": "2.4.1", "grunt-legacy-util": "^2.0.2", @@ -3013,18 +3013,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/core/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/core/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3059,18 +3047,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/@jest/core/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/core/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3080,15 +3056,6 @@ "node": ">=8" } }, - "node_modules/@jest/core/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/@jest/core/node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3127,18 +3094,6 @@ "node": ">=8" } }, - "node_modules/@jest/core/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -3802,18 +3757,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/transform/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/transform/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3854,18 +3797,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/@jest/transform/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/transform/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3875,15 +3806,6 @@ "node": ">=8" } }, - "node_modules/@jest/transform/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/@jest/transform/node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3910,18 +3832,6 @@ "node": ">=8" } }, - "node_modules/@jest/transform/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", @@ -4789,45 +4699,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@parcel/watcher/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@parcel/watcher/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/@parcel/watcher/node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4843,20 +4714,6 @@ "node": ">=8.6" } }, - "node_modules/@parcel/watcher/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/@paulirish/trace_engine": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.39.tgz", @@ -9316,15 +9173,6 @@ "node": ">=8" } }, - "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dev": true, - "dependencies": { - "lodash": "^4.17.14" - } - }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -9958,12 +9806,16 @@ } }, "node_modules/binary-extensions": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", - "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/bluebird": { @@ -9972,18 +9824,6 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true }, - "node_modules/body": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/body/-/body-5.1.0.tgz", - "integrity": "sha1-5LoM5BCkaTYyM2dgnstOZVMSUGk=", - "dev": true, - "dependencies": { - "continuable-cache": "^0.3.1", - "error": "^7.0.0", - "raw-body": "~1.1.0", - "safe-json-parse": "~1.0.1" - } - }, "node_modules/body-parser": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", @@ -10070,33 +9910,6 @@ "node": ">= 0.8" } }, - "node_modules/body/node_modules/bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz", - "integrity": "sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ==", - "dev": true - }, - "node_modules/body/node_modules/raw-body": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz", - "integrity": "sha512-WmJJU2e9Y6M5UzTOkHaM7xJGAPQD8PNzx3bAd2+uhZAim6wDk6dAZxPVYLF67XhbR4hmKGh33Lpmh4XWrCH5Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "1", - "string_decoder": "0.10" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/body/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true, - "license": "MIT" - }, "node_modules/bonjour-service": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", @@ -10124,6 +9937,19 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.6", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", @@ -10521,73 +10347,19 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chokidar/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chokidar/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/chokidar/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" } }, "node_modules/chrome-launcher": { @@ -11033,12 +10805,6 @@ "node": ">= 0.6" } }, - "node_modules/continuable-cache": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/continuable-cache/-/continuable-cache-0.3.1.tgz", - "integrity": "sha1-vXJ6f67XfnH/OYWskzUakSczrQ8=", - "dev": true - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -12289,15 +12055,6 @@ "node": ">=4" } }, - "node_modules/error": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/error/-/error-7.2.1.tgz", - "integrity": "sha512-fo9HBvWnx3NGUKMvMwB/CBCMMrfEJgbDTVDEkPygA3Bdd3lM1OyCd+rbQ8BwnpF6GdVeOLDNmyL4N5Bg80ZvdA==", - "dev": true, - "dependencies": { - "string-template": "~0.2.1" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -13679,63 +13436,18 @@ "node": ">=8.6.0" } }, - "node_modules/fast-glob/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/fast-glob/node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/fast-glob/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fast-glob/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/fast-glob/node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/fast-glob/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" + "node": ">=8.6" } }, "node_modules/fast-json-stable-stringify": { @@ -13786,19 +13498,6 @@ "reusify": "^1.0.4" } }, - "node_modules/faye-websocket": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz", - "integrity": "sha512-Xhj93RXbMSq8urNCUq4p9l0P6hnySJ/7YNRhYNug0bLOuii7pKO7xQFb5mx9xZXWCar88pLPb805PvUkwrLZpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -13876,6 +13575,19 @@ "integrity": "sha1-peeo/7+kk7Q7kju9TKiaU7Y7YSs=", "dev": true }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", @@ -14065,18 +13777,6 @@ "node": ">= 10.13.0" } }, - "node_modules/findup-sync/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/findup-sync/node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -14089,18 +13789,6 @@ "node": ">=0.10.0" } }, - "node_modules/findup-sync/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/findup-sync/node_modules/global-modules": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", @@ -14131,15 +13819,6 @@ "node": ">=0.10.0" } }, - "node_modules/findup-sync/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/findup-sync/node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -14167,18 +13846,6 @@ "node": ">=0.10.0" } }, - "node_modules/findup-sync/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/findup-sync/node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -14505,18 +14172,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaze": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", - "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", - "dev": true, - "dependencies": { - "globule": "^1.0.0" - }, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -14831,33 +14486,6 @@ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", "dev": true }, - "node_modules/globule": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.4.tgz", - "integrity": "sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "~7.1.1", - "lodash": "^4.17.21", - "minimatch": "~3.0.2" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/globule/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/good-listener": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", @@ -15421,21 +15049,6 @@ "node": ">=8" } }, - "node_modules/grunt-contrib-watch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/grunt-contrib-watch/-/grunt-contrib-watch-1.1.0.tgz", - "integrity": "sha512-yGweN+0DW5yM+oo58fRu/XIRrPcn3r4tQx+nL7eMRwjpvk+rQY6R8o94BPK0i2UhTg9FN21hS+m8vR8v9vXfeg==", - "dev": true, - "dependencies": { - "async": "^2.6.0", - "gaze": "^1.1.0", - "lodash": "^4.17.10", - "tiny-lr": "^1.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/grunt-file-append": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/grunt-file-append/-/grunt-file-append-0.0.7.tgz", @@ -16578,39 +16191,6 @@ } } }, - "node_modules/http-proxy-middleware/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/http-proxy-middleware/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/http-proxy-middleware/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", @@ -16637,18 +16217,6 @@ "node": ">=8.6" } }, - "node_modules/http-proxy-middleware/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -17079,6 +16647,7 @@ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, + "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" }, @@ -17284,6 +16853,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -18139,18 +17718,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-config/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-config/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -18185,18 +17752,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/jest-config/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-config/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -18206,15 +17761,6 @@ "node": ">=8" } }, - "node_modules/jest-config/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/jest-config/node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -18241,18 +17787,6 @@ "node": ">=8" } }, - "node_modules/jest-config/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -18780,30 +18314,6 @@ "fsevents": "^2.3.2" } }, - "node_modules/jest-haste-map/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-haste-map/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-haste-map/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -18813,15 +18323,6 @@ "node": ">=8" } }, - "node_modules/jest-haste-map/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/jest-haste-map/node_modules/jest-worker": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", @@ -18866,18 +18367,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jest-haste-map/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/jest-leak-detector": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", @@ -19011,18 +18500,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-message-util/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-message-util/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -19057,18 +18534,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/jest-message-util/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-message-util/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -19078,15 +18543,6 @@ "node": ">=8" } }, - "node_modules/jest-message-util/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/jest-message-util/node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -19113,18 +18569,6 @@ "node": ">=8" } }, - "node_modules/jest-message-util/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/jest-mock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", @@ -20506,18 +19950,6 @@ "node": ">=10" } }, - "node_modules/liftup/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/liftup/node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -20531,18 +19963,6 @@ "node": ">=0.10.0" } }, - "node_modules/liftup/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/liftup/node_modules/findup-sync": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", @@ -20589,15 +20009,6 @@ "node": ">=0.10.0" } }, - "node_modules/liftup/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/liftup/node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -20638,18 +20049,6 @@ "node": ">=0.10.0" } }, - "node_modules/liftup/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/liftup/node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", @@ -20774,12 +20173,6 @@ "uc.micro": "^1.0.1" } }, - "node_modules/livereload-js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.4.0.tgz", - "integrity": "sha512-XPQH8Z2GDP/Hwz2PCDrh2mth4yFejwA1OZ/81Ti3LgKyhDcEjsSsqFWZojHG0va/duGd+WyosY7eXLDoOyqcPw==", - "dev": true - }, "node_modules/loader-runner": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", @@ -24284,15 +23677,17 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, + "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/rechoir": { @@ -24876,12 +24271,6 @@ } ] }, - "node_modules/safe-json-parse": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-1.0.1.tgz", - "integrity": "sha1-PnZyPjjf3aE8mx0poeB//uSzC1c=", - "dev": true - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -25939,12 +25328,6 @@ "node": ">=8" } }, - "node_modules/string-template": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", - "integrity": "sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=", - "dev": true - }, "node_modules/string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -26347,19 +25730,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stylelint/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/stylelint/node_modules/cosmiconfig": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", @@ -26418,19 +25788,6 @@ "flat-cache": "^6.1.23" } }, - "node_modules/stylelint/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/stylelint/node_modules/flat-cache": { "version": "6.1.23", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", @@ -26463,16 +25820,6 @@ "node": ">=8" } }, - "node_modules/stylelint/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/stylelint/node_modules/js-yaml": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", @@ -26595,19 +25942,6 @@ "node": ">=8" } }, - "node_modules/stylelint/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/stylelint/node_modules/write-file-atomic": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", @@ -27089,29 +26423,6 @@ "globrex": "^0.1.2" } }, - "node_modules/tiny-lr": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz", - "integrity": "sha512-44yhA3tsaRoMOjQQ+5v5mVdqef+kH6Qze9jTpqtVufgYjYt08zyZAwNwwVBj3i1rJMnR52IxOW0LK0vBzgAkuA==", - "dev": true, - "dependencies": { - "body": "^5.1.0", - "debug": "^3.1.0", - "faye-websocket": "~0.10.0", - "livereload-js": "^2.3.0", - "object-assign": "^4.1.0", - "qs": "^6.4.0" - } - }, - "node_modules/tiny-lr/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -27189,6 +26500,19 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -28326,12 +27650,50 @@ "ajv": "^8.8.2" } }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/webpack-dev-server/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", diff --git a/package.json b/package.json index d8e955f0d9809..640a29820c838 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@wordpress/prettier-config": "4.51.0", "@wordpress/scripts": "33.0.0", "autoprefixer": "10.5.3", + "chokidar": "4.0.3", "dotenv": "17.4.2", "dotenv-expand": "13.0.0", "grunt": "1.6.2", @@ -65,7 +66,6 @@ "grunt-contrib-cssmin": "~5.0.0", "grunt-contrib-jshint": "3.2.0", "grunt-contrib-uglify": "~5.2.2", - "grunt-contrib-watch": "~1.1.0", "grunt-file-append": "0.0.7", "grunt-jsdoc": "2.4.1", "grunt-legacy-util": "^2.0.2", diff --git a/tools/watch/task.js b/tools/watch/task.js new file mode 100644 index 0000000000000..8ab62007b7222 --- /dev/null +++ b/tools/watch/task.js @@ -0,0 +1,313 @@ +/** + * Watch Task + * + * Runs the tasks of the `_watch` targets whose files change. It replaces + * grunt-contrib-watch, which missed changes: it polls each watched file through + * gaze, which finds every file in the directories that a target's patterns + * exclude before filtering them out; it recreates its watchers after each run, + * missing the changes made meanwhile; and it leaves the changes seen while + * another target's tasks run waiting for the next change. + * + * Files are watched with chokidar, through the native file system events of + * each platform. Set `CHOKIDAR_USEPOLLING=1` to poll instead, where the events + * are not delivered, as on network drives or for Windows drives under WSL. + * + * Each target takes `files` patterns, the `tasks` to run when one of them + * changes, and the options `dot`, to match files whose names begin with a dot, + * and `reload`, to restart the watch task, as for a change to the Gruntfile. + * The `ignored` option of the task lists the patterns of the directories that + * are never watched. + * + * The tasks run in the watch process, and a `watch` event is emitted for each + * change of a target before they run, so that a listener can configure them for + * the changed files. + * + * @package WordPress + */ + +const path = require( 'path' ); +const { spawn } = require( 'child_process' ); +const chokidar = require( 'chokidar' ); + +/** + * Time to wait for more changes before running the tasks, in milliseconds. + * + * An editor or a version control checkout often changes several files at once. + */ +const DEBOUNCE_DELAY = 250; + +/** + * Change actions, keyed by the chokidar event names. + */ +const ACTIONS = { + add: 'added', + change: 'changed', + unlink: 'deleted', +}; + +/** + * Gets the directory that a pattern searches, before any part with glob syntax. + * + * @param {string} pattern The pattern. + * + * @return {string} The directory, or the file when the pattern has no glob syntax. + */ +function getPatternBase( pattern ) { + const segments = []; + + for ( const segment of pattern.split( '/' ) ) { + if ( /[*?[\]{}()!+@]/.test( segment ) ) { + break; + } + segments.push( segment ); + } + + return segments.join( '/' ) || '.'; +} + +/** + * Gets the paths to watch for the patterns, without those inside another one. + * + * @param {string[]} patterns The patterns. + * + * @return {string[]} The paths to watch. + */ +function getWatchPaths( patterns ) { + const bases = [ ...new Set( + patterns + .filter( ( pattern ) => ! pattern.startsWith( '!' ) ) + .map( getPatternBase ) + ) ]; + const isInside = ( child, parent ) => parent === '.' || child.startsWith( parent + '/' ); + + return bases.filter( ( base ) => ! bases.some( ( other ) => other !== base && isInside( base, other ) ) ); +} + +/** + * Builds the watch task. + * + * @param {Object} grunt The grunt instance. + * + * @return {Function} The task function. + */ +module.exports = ( grunt ) => { + let watcher = null; + let taskName = '_watch'; + let targetNames = []; + let nameArgs = ''; + let resume = null; + let timer = null; + let reload = false; + let startedAt = null; + + // Changed files since the tasks last ran, keyed by path, and the targets to run for them. + const changes = new Map(); + const queuedTargets = new Set(); + + /** + * Gets the configuration of a target. + * + * @param {string} name The target name. + * + * @return {{files: string[], tasks: string[], options: Object}} The target configuration. + */ + function getTarget( name ) { + const target = grunt.config( [ taskName, name ] ); + + return { + files: [].concat( target.files || [] ).map( ( pattern ) => grunt.config.process( pattern ) ), + tasks: [].concat( target.tasks || [] ), + options: target.options || {}, + }; + } + + /** + * Records a change, for each target whose patterns match the file. + * + * @param {string} event The chokidar event name. + * @param {string} filepath The path of the changed file. + */ + function recordChange( event, filepath ) { + const action = ACTIONS[ event ]; + let matched = false; + + if ( ! action ) { + return; + } + + filepath = path.relative( process.cwd(), filepath ).replace( /\\/g, '/' ); + + targetNames.forEach( ( name ) => { + const target = getTarget( name ); + + if ( ! grunt.file.isMatch( { dot: !! target.options.dot }, target.files, filepath ) ) { + return; + } + + matched = true; + queuedTargets.add( name ); + if ( target.options.reload ) { + reload = true; + } + + grunt.event.emit( 'watch', action, filepath, name ); + } ); + + if ( matched ) { + changes.set( filepath, action ); + clearTimeout( timer ); + timer = setTimeout( runQueued, DEBOUNCE_DELAY ); + } + } + + /** + * Runs the tasks of the targets that have changes, unless tasks are running. + * + * The changes are then kept until the tasks finish and the watch task resumes. + */ + function runQueued() { + const done = resume; + const tasks = []; + + timer = null; + if ( ! done ) { + return; + } + + changes.forEach( ( action, filepath ) => { + grunt.log.ok( 'File "' + filepath + '" ' + action + '.' ); + } ); + + if ( reload ) { + restart(); + return; + } + + targetNames.forEach( ( name ) => { + if ( queuedTargets.has( name ) ) { + tasks.push( ...getTarget( name ).tasks ); + } + } ); + + changes.clear(); + queuedTargets.clear(); + resume = null; + startedAt = Date.now(); + + // Run the watch task again after the tasks, to wait for the next changes. + grunt.task.run( tasks.concat( nameArgs ) ); + done(); + } + + /** + * Restarts the watch task in a new process, so that it loads the changed configuration. + */ + function restart() { + grunt.log.writeln( 'Reloading watch config...'.cyan ); + watcher.close(); + + const child = spawn( + process.execPath, + process.execArgv.concat( process.argv[ 1 ], nameArgs, grunt.option.flags() ), + { stdio: 'inherit' } + ); + child.on( 'exit', ( code ) => process.exit( code === null ? 1 : code ) ); + + // Stop the new process with this one, which only waits for it. + [ 'SIGINT', 'SIGTERM', 'SIGHUP' ].forEach( ( signal ) => { + process.on( signal, () => child.kill( signal ) ); + } ); + } + + /** + * Keeps the watch task running when a task fails, instead of exiting. + * + * The failure is logged, and the watch task resumes, waiting for the next changes. + */ + function keepWatchingOnFailure() { + /* + * Exit once the output can no longer be written, as when the terminal is closed. Otherwise, the error + * of each attempt to log would be reported as another failure, without end. + */ + [ process.stdout, process.stderr ].forEach( ( stream ) => { + stream.on( 'error', ( error ) => { + if ( error.code === 'EPIPE' ) { + process.exit( 1 ); + } + } ); + } ); + + function rerun() { + grunt.task.clearQueue(); + grunt.task.run( nameArgs ); + } + + grunt.warn = grunt.fail.warn = ( error ) => { + grunt.log.writeln( ( 'Warning: ' + ( typeof error === 'string' ? error : error.message ) ).yellow ); + if ( ! grunt.option( 'force' ) ) { + rerun(); + } + }; + grunt.fatal = grunt.fail.fatal = ( error ) => { + grunt.log.writeln( ( 'Fatal error: ' + ( typeof error === 'string' ? error : error.message ) ).red ); + rerun(); + }; + } + + /** + * Starts watching the files of the targets. + * + * @param {Function} onReady Called once the initial scan is complete. + */ + function startWatching( onReady ) { + const ignored = ( grunt.config( [ taskName, 'options', 'ignored' ] ) || [] ).flatMap( + // Match each ignored directory itself, so that it is not searched. + ( pattern ) => pattern.endsWith( '/**' ) ? [ pattern, pattern.slice( 0, -3 ) ] : [ pattern ] + ); + const patterns = targetNames.flatMap( ( name ) => getTarget( name ).files ); + + watcher = chokidar.watch( getWatchPaths( patterns ), { + ignoreInitial: true, + ignored: ( filepath ) => grunt.file.isMatch( + { dot: true }, + ignored, + path.relative( process.cwd(), filepath ).replace( /\\/g, '/' ) + ), + } ); + + watcher.on( 'all', recordChange ); + watcher.on( 'error', ( error ) => grunt.log.error( 'Watch error: ' + error.message ) ); + watcher.once( 'ready', onReady ); + } + + return function( target ) { + const config = grunt.config( this.name ) || {}; + + taskName = this.name; + nameArgs = this.nameArgs; + resume = this.async(); + + if ( watcher ) { + if ( startedAt ) { + grunt.log.writeln( ( 'Completed in ' + ( ( Date.now() - startedAt ) / 1000 ).toFixed( 3 ) + 's at ' + new Date().toString() ).cyan + ' - Waiting...' ); + startedAt = null; + } + + // Run the changes made while the tasks ran. + if ( changes.size && ! timer ) { + runQueued(); + } + return; + } + + targetNames = target ? [ target ] : Object.keys( config ).filter( ( name ) => name !== 'options' ); + const missing = targetNames.filter( ( name ) => ! config[ name ] ); + if ( missing.length ) { + grunt.fail.warn( 'No "' + this.name + '" targets found: ' + missing.join( ', ' ) ); + return; + } + + keepWatchingOnFailure(); + startWatching( () => grunt.log.writeln( 'Waiting...' ) ); + }; +}; From c7e8db465e1376c6cebafe759b6bd6128f6fd311 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 9 Nov 2026 19:27:26 -0800 Subject: [PATCH 2/7] Build/Test Tools: Watch only the files that the build copies Third-party themes, uploads, translations, and the rest of `wp-content` other than the default themes are no longer watched, as they are not part of the build. Ignoring them also keeps the watcher from searching their directories. When building into `build/`, the `all` target now watches the files that the `copy:files` task copies, after any `--minimal-copy` exclusions, instead of everything in `src/`. Changes were otherwise copied that the build does not copy: those to third-party themes and uploads, and those to `src/index.php`, `src/wp-admin/index.php`, and `wp-includes/version.php`, whose versions in `build/` the build creates from `_index.php` or processes. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1 --- Gruntfile.js | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index dce9b116883c8..26f7110e85929 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1669,18 +1669,20 @@ module.exports = function(grunt) { 'vendor/**', BUILD_DIR + '**', // Third-party plugins. - SOURCE_DIR + 'wp-content/plugins/**' + SOURCE_DIR + 'wp-content/plugins/**', + // Third-party themes. + SOURCE_DIR + 'wp-content/themes/!(twenty*|ipsum|index.php)/**', + // Uploads, translations, and other content that is not part of the build. + SOURCE_DIR + 'wp-content/!(index.php|plugins|themes)/**' ] }, /* - * Copies changed files from /src to /build. When building into /src with --dev, there is nothing to - * copy, so this target is removed below. + * Copies changed files from /src to /build. Its files are those of the copy:files task, set below, so + * that the same files are copied as by the build. When building into /src with --dev, there is nothing to + * copy, so this target is removed. */ all: { - files: [ - SOURCE_DIR + '**', - '!' + SOURCE_DIR + 'js/**/*.js' - ], + files: [], tasks: ['watch-dynamic:all', 'clean:dynamic', 'copy:dynamic'], options: { dot: true @@ -1733,13 +1735,6 @@ module.exports = function(grunt) { } }); - /* - * When building into /src, there is nothing for the `all` watch target to copy, so its files are not watched. - */ - if ( WORKING_DIR === SOURCE_DIR ) { - delete grunt.config.data._watch.all; - } - // Allow builds to be minimal. if( grunt.option( 'minimal-copy' ) ) { var copyFilesOptions = grunt.config.get( 'copy.files.files' ); @@ -1748,6 +1743,16 @@ module.exports = function(grunt) { grunt.config.set( 'copy.files.files', copyFilesOptions ); } + if ( WORKING_DIR === SOURCE_DIR ) { + // When building into /src, there is nothing for the `all` watch target to copy, so its files are not watched. + delete grunt.config.data._watch.all; + } else { + // Watch the files that the copy:files task copies from /src, so that a change is copied as by the build. + grunt.config( [ '_watch', 'all', 'files' ], grunt.config( 'copy.files.files' )[0].src.map( function( pattern ) { + return pattern.charAt( 0 ) === '!' ? '!' + SOURCE_DIR + pattern.slice( 1 ) : SOURCE_DIR + pattern; + } ) ); + } + // Register tasks. From ad25aad59f66dcca12b8ab7ad5a01ed9620ffd99 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 9 Nov 2026 19:45:38 -0800 Subject: [PATCH 3/7] Build/Test Tools: Keep script symlinks in place across `--symlink` builds Each build cleaned the built scripts first, so `--symlink` deleted every link and created it again, even when it already pointed to its source. Docker Desktop's file sharing can fail to read a path that is replaced this way until the containers are restarted, so the scripts would fail to load after every build, including the one that `npm run dev -- --symlink` runs when it starts. With `--symlink`, the `clean:js` and `clean:webpack-assets` tasks now keep the links that point to their sources, and the `link` task leaves them as they are. The other built files in `src/wp-admin/js` are cleaned one by one instead of with the directory. Without the option, the links are cleaned and replaced with copies, as before. The README now mentions `npm run dev -- --symlink` and that switching between links and copies can require `npm run env:restart`. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1 --- Gruntfile.js | 64 ++++++++++++++++++++++++++++++++++++++++------------ README.md | 4 +++- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 26f7110e85929..b92010908bce0 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -12,6 +12,15 @@ module.exports = function(grunt) { SOURCE_DIR = 'src/', BUILD_DIR = 'build/', WORKING_DIR = grunt.option( 'dev' ) ? SOURCE_DIR : BUILD_DIR, + /* + * With --symlink, when building into /src, the wp-admin and wp-includes scripts that are copied unchanged from + * /src/js/_enqueues are symlinked instead, so that edits to them take effect without a rebuild. + * + * This is opt-in because Docker Desktop's file sharing can keep serving the previous entry for a path whose + * file is replaced, by a symlink or by a file in place of one, failing to read it until the containers are + * restarted (`npm run env:restart`). For the same reason, the links in place are kept by later builds. + */ + linkScripts = WORKING_DIR === SOURCE_DIR && grunt.option( 'symlink' ) === true, BANNER_TEXT = '/*! This file is auto-generated */', autoprefixer = require( 'autoprefixer' ), sass = require( 'sass' ), @@ -263,14 +272,28 @@ module.exports = function(grunt) { css: cssFiles.map( function( file ) { return setFilePath( WORKING_DIR, file ); } ), - js: jsFiles.map( function( file ) { + js: linkScripts ? { + // Clean the files in wp-admin/js one by one, so that the links in place are kept. See the link task. + src: jsFiles.map( function( file ) { + return setFilePath( WORKING_DIR, 'wp-admin/js/' === file ? 'wp-admin/js/**' : file ); + } ), + filter: function( filepath ) { + return ! isLinkedScriptPath( filepath ); + } + } : jsFiles.map( function( file ) { return setFilePath( WORKING_DIR, file ); } ), // Clean files built by Webpack. - 'webpack-assets': webpackFiles.map( function( file ) { - return setFilePath( WORKING_DIR, file ); - } ), + 'webpack-assets': { + src: webpackFiles.map( function( file ) { + return setFilePath( WORKING_DIR, file ); + } ), + // Keep the link to media-editor.js, which media-*.js matches. See the link task. + filter: function( filepath ) { + return ! linkScripts || ! isLinkedScriptPath( filepath ); + } + }, /* * Clean files sourced from the downloaded zip file built by the Gutenberg repository. @@ -2019,16 +2042,6 @@ module.exports = function(grunt) { } } ); - /* - * With --symlink, when building into /src, the wp-admin and wp-includes scripts that are copied unchanged from - * /src/js/_enqueues are symlinked instead, so that edits to them take effect without a rebuild. - * - * This is opt-in because Docker Desktop's file sharing can keep serving the previous entry for a path whose file is - * replaced by a symlink, failing to read it until the containers are restarted (`npm run env:restart`). As the - * build cleans the scripts first, this can happen after every build. - */ - var linkScripts = WORKING_DIR === SOURCE_DIR && grunt.option( 'symlink' ) === true; - grunt.registerTask( 'copy:js', 'Copies JavaScript files from npm packages, vendor libraries, wp-admin, and wp-includes.', [ 'copy:npm-packages', 'copy:vendor-js', @@ -2050,6 +2063,29 @@ module.exports = function(grunt) { } } + /** + * Checks whether a path is a script's symlink as created by the link task, or a directory that holds one. + * + * @param {string} filepath Path to check. + * @return {boolean} Whether the path is or holds a script's symlink. + */ + function isLinkedScriptPath( filepath ) { + var files = Object.assign( {}, + grunt.config( [ 'copy', 'admin-js', 'files' ] ), + grunt.config( [ 'copy', 'includes-js', 'files' ] ) + ); + + filepath = filepath.replace( /\/$/, '' ); + + return Object.keys( files ).some( function( dest ) { + if ( dest.indexOf( filepath + '/' ) === 0 ) { + return true; + } + return dest === filepath && isSymlink( dest ) && + fs.readlinkSync( dest ) === path.relative( path.dirname( dest ), files[ dest ][ 0 ] ); + } ); + } + grunt.registerTask( 'link', 'Symlinks the files of a copy task to their sources, falling back to copying them where symlinks cannot be created.', function( target ) { var files = grunt.config( [ 'copy', target, 'files' ] ); var linked = 0; diff --git a/README.md b/README.md index 282200584ae8f..a0fd93673885f 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,9 @@ The JavaScript files in `src/wp-admin/js` and `src/wp-includes/js` that are copi npm run build:dev -- --symlink ``` -If the local environment then cannot load these files, which can happen with Docker Desktop, restart it with `npm run env:restart`. +The option also applies to the build that the watcher runs when it starts, as in `npm run dev -- --symlink`. Later builds with the option keep the links in place, while a build without it replaces them with copies again. + +If the local environment then cannot load these files, which can happen with Docker Desktop after switching between links and copies, restart it with `npm run env:restart`. #### To run a [WP-CLI](https://make.wordpress.org/cli/handbook/) command From 2dcaf60c66964567b570a9612b1c2ad7d5bd73ca Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 9 Nov 2026 20:37:21 -0800 Subject: [PATCH 4/7] Build/Test Tools: Fix watch issues found in code review - With `--dev`, the `colors` and `rtl` targets ran again for the files their own tasks wrote into `src/`, without end, and `rtlcss` turned `common-rtl.css` into `common-rtl-rtl.css` and so on. The `colors` target now watches only the `.scss` sources, and the `rtl` target excludes the built `-rtl` and `.min` stylesheets. - Editing a vendor script in `src/js/_enqueues/vendor` threw: the arguments to `minimatch.match()` were reversed, so no glob matched and the lodash entry, which has no `cwd`, was reached. Each entry is now matched with `grunt.file.isMatch()`, skipping those without a `cwd`. - A changed script is minified by the uglify target that minifies it in the build, instead of always by `uglify:dynamic`: the jQuery UI scripts with the comments kept, `wp-emoji-loader.js` as a module, and `farbtastic.js` and `iris.min.js` not at all. This replaces the `uglifyModules` list and the `uglify:dynamic-module` target. - A reload for a change to the Gruntfile now happens after the tasks for the other changes made with it, which the restarted watcher would not see, and the `phpunit` target is defined when the Gruntfile loads, so that the restarted watch task still has it. - When a task fails, only the rest of its own target's tasks are skipped, instead of those of every other target in the same run. - The `all` target watches `index.php`, `hello.php`, and Akismet in `wp-content/plugins`, which the build copies, and copies `_index.php` to `index.php` as the build does. - The ignored patterns are compiled once instead of for every path found, and the clean tasks collect the scripts' links once instead of for every path they check. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1 --- Gruntfile.js | 198 ++++++++++++++++++++++++++++++-------------- tools/watch/task.js | 85 ++++++++++++++----- 2 files changed, 202 insertions(+), 81 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index b92010908bce0..f257c252b5c0a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -25,10 +25,6 @@ module.exports = function(grunt) { autoprefixer = require( 'autoprefixer' ), sass = require( 'sass' ), phpUnitWatchGroup = grunt.option( 'group' ), - // Scripts that are loaded as modules, which are minified by their own uglify tasks. - uglifyModules = [ - 'wp-includes/js/wp-emoji-loader.js' - ], buildFiles = [ '*.php', '*.txt', @@ -1242,9 +1238,8 @@ module.exports = function(grunt) { '!{wp-admin,wp-includes}/**/*.min.js', '!wp-admin/js/custom-header.js', // Why? We should minify this. '!wp-admin/js/farbtastic.js', - ].concat( uglifyModules.map( function( file ) { - return '!' + file; // This is a module. See the emoji-loader task below. - } ) ) + '!wp-includes/js/wp-emoji-loader.js', // This is a module. See the emoji-loader task below. + ] }, 'emoji-loader': { options: { @@ -1293,18 +1288,6 @@ module.exports = function(grunt) { dest: WORKING_DIR, ext: '.min.js', src: [] - }, - // Changed files that are loaded as modules, minified with the options of the emoji-loader task. - 'dynamic-module': { - options: { - module: true, - toplevel: true, - }, - expand: true, - cwd: WORKING_DIR, - dest: WORKING_DIR, - ext: '.min.js', - src: [] } }, webpack: { @@ -1691,8 +1674,8 @@ module.exports = function(grunt) { '**/node_modules/**', 'vendor/**', BUILD_DIR + '**', - // Third-party plugins. - SOURCE_DIR + 'wp-content/plugins/**', + // Third-party plugins, other than those that the build copies. + SOURCE_DIR + 'wp-content/plugins/!(index.php|hello.php|akismet)/**', // Third-party themes. SOURCE_DIR + 'wp-content/themes/!(twenty*|ipsum|index.php)/**', // Uploads, translations, and other content that is not part of the build. @@ -1706,14 +1689,14 @@ module.exports = function(grunt) { */ all: { files: [], - tasks: ['watch-dynamic:all', 'clean:dynamic', 'copy:dynamic'], + tasks: ['watch-dynamic:all', 'clean:dynamic', 'copy:dynamic', 'copy:dynamic-js'], options: { dot: true } }, 'js-enqueues': { files: [SOURCE_DIR + 'js/_enqueues/**/*.js'], - tasks: ['watch-dynamic:js-enqueues', 'clean:dynamic', 'copy:dynamic-js', 'uglify:dynamic', 'uglify:dynamic-module'], + tasks: ['watch-dynamic:js-enqueues', 'clean:dynamic', 'copy:dynamic-js', 'uglify:dynamic', 'watch-uglify'], options: { dot: true } @@ -1739,13 +1722,16 @@ module.exports = function(grunt) { } }, colors: { - files: [SOURCE_DIR + 'wp-admin/css/colors/**'], + files: [SOURCE_DIR + 'wp-admin/css/colors/**/*.scss'], tasks: ['sass:colors'] }, rtl: { files: [ SOURCE_DIR + 'wp-admin/css/*.css', - SOURCE_DIR + 'wp-includes/css/*.css' + SOURCE_DIR + 'wp-includes/css/*.css', + // Built stylesheets, written next to the sources with --dev. + '!' + SOURCE_DIR + '{wp-admin,wp-includes}/css/*.min.css', + '!' + SOURCE_DIR + '{wp-admin,wp-includes}/css/*-rtl*.css' ], tasks: ['watch-dynamic:rtl', 'rtlcss:dynamic'] }, @@ -1770,10 +1756,29 @@ module.exports = function(grunt) { // When building into /src, there is nothing for the `all` watch target to copy, so its files are not watched. delete grunt.config.data._watch.all; } else { - // Watch the files that the copy:files task copies from /src, so that a change is copied as by the build. + /* + * Watch the files that the copy:files task copies from /src, so that a change is copied as by the build, + * including the sources it copies to other paths, such as _index.php. + */ grunt.config( [ '_watch', 'all', 'files' ], grunt.config( 'copy.files.files' )[0].src.map( function( pattern ) { return pattern.charAt( 0 ) === '!' ? '!' + SOURCE_DIR + pattern.slice( 1 ) : SOURCE_DIR + pattern; - } ) ); + } ).concat( Object.keys( getMappedBuildFiles() ).map( function( dest ) { + return getMappedBuildFiles()[ dest ]; + } ) ) ); + } + + /* + * The `phpunit` watch target, also added here rather than only by the `watch` task, so that the watch task has + * it after restarting for a change to this file. + */ + if ( /^_?watch:phpunit$/.test( grunt.cli.tasks[ 0 ] ) || 'undefined' !== typeof grunt.option( 'phpunit' ) ) { + grunt.config( [ '_watch', 'phpunit' ], { + files: [ + SOURCE_DIR + '**/*.php', + 'tests/phpunit/**/*.php' + ], + tasks: [ 'phpunit:default' ] + } ); } @@ -1861,16 +1866,6 @@ module.exports = function(grunt) { grunt.task.run( 'build' ); } - if ( 'watch:phpunit' === grunt.cli.tasks[ 0 ] || 'undefined' !== typeof grunt.option( 'phpunit' ) ) { - grunt.config.data._watch.phpunit = { - files: [ - SOURCE_DIR + '**/*.php', - 'tests/phpunit/**/*.php' - ], - tasks: [ 'phpunit:default' ] - }; - } - grunt.task.run( '_' + this.nameArgs ); } ); @@ -2063,6 +2058,9 @@ module.exports = function(grunt) { } } + // The scripts' symlinks in place before the build, and the directories that hold them, keyed by path. + var linkedScriptPaths = null; + /** * Checks whether a path is a script's symlink as created by the link task, or a directory that holds one. * @@ -2070,20 +2068,27 @@ module.exports = function(grunt) { * @return {boolean} Whether the path is or holds a script's symlink. */ function isLinkedScriptPath( filepath ) { - var files = Object.assign( {}, - grunt.config( [ 'copy', 'admin-js', 'files' ] ), - grunt.config( [ 'copy', 'includes-js', 'files' ] ) - ); - - filepath = filepath.replace( /\/$/, '' ); + var files, dest; + + // Collect the links and the directories that hold them once, as the clean tasks check each path. + if ( ! linkedScriptPaths ) { + linkedScriptPaths = {}; + files = Object.assign( {}, + grunt.config( [ 'copy', 'admin-js', 'files' ] ), + grunt.config( [ 'copy', 'includes-js', 'files' ] ) + ); + Object.keys( files ).forEach( function( linkPath ) { + if ( ! isSymlink( linkPath ) || fs.readlinkSync( linkPath ) !== path.relative( path.dirname( linkPath ), files[ linkPath ][ 0 ] ) ) { + return; + } + linkedScriptPaths[ linkPath ] = true; + for ( dest = path.dirname( linkPath ); dest !== '.' && ! linkedScriptPaths[ dest ]; dest = path.dirname( dest ) ) { + linkedScriptPaths[ dest ] = true; + } + } ); + } - return Object.keys( files ).some( function( dest ) { - if ( dest.indexOf( filepath + '/' ) === 0 ) { - return true; - } - return dest === filepath && isSymlink( dest ) && - fs.readlinkSync( dest ) === path.relative( path.dirname( dest ), files[ dest ][ 0 ] ); - } ); + return !! linkedScriptPaths[ filepath.replace( /\/$/, '' ) ]; } grunt.registerTask( 'link', 'Symlinks the files of a copy task to their sources, falling back to copying them where symlinks cannot be created.', function( target ) { @@ -2666,6 +2671,9 @@ module.exports = function(grunt) { */ var watchedChanges = {}; + // The uglify targets to run for the changed scripts that the uglify:core task does not minify. + var watchedUglifyTargets = []; + grunt.event.on( 'watch', function( action, filepath, target ) { if ( [ 'all', 'rtl', 'js-enqueues', 'js-webpack' ].indexOf( target ) === -1 ) { return; @@ -2690,11 +2698,14 @@ module.exports = function(grunt) { if ( filepath.indexOf( SOURCE_DIR + 'js/_enqueues/vendor/' ) === 0 ) { configs = grunt.config( [ 'copy', 'vendor-js', 'files' ] ); - // For each glob matcher check if it matches. + // Find the glob matcher that copies it. The other entries map files from node_modules. for ( i = 0; i < configs.length; i++ ) { - relative = path.relative( configs[ i ].cwd, filepath ); + if ( ! configs[ i ].cwd ) { + continue; + } + relative = path.relative( configs[ i ].cwd, filepath ).replace( /\\/g, '/' ); - if ( require( 'minimatch' ).match( configs[ i ].src, relative, {} ).length ) { + if ( relative.indexOf( '../' ) !== 0 && grunt.file.isMatch( configs[ i ].src, relative ) ) { return { dest: configs[ i ].dest + relative, src: filepath }; } } @@ -2715,6 +2726,53 @@ module.exports = function(grunt) { return null; } + /** + * Gets the files in /src that the copy:files task copies to another path, such as _index.php to index.php. + * + * @return {Object} Source paths, keyed by their destinations. + */ + function getMappedBuildFiles() { + var mapped = {}; + + grunt.config( 'copy.files.files' ).forEach( function( files ) { + // Skip the entries of glob patterns, which have a src or cwd. + if ( files.src || files.cwd ) { + return; + } + Object.keys( files ).forEach( function( dest ) { + if ( 0 === files[ dest ][ 0 ].indexOf( SOURCE_DIR ) ) { + mapped[ dest ] = files[ dest ][ 0 ]; + } + } ); + } ); + + return mapped; + } + + /** + * Gets the uglify target that minifies a script in the build. + * + * @param {string} dest Path of the script, relative to the working directory. + * @return {?string} The name of the uglify target, or null if the script is not minified. + */ + function getUglifyTarget( dest ) { + var targets = grunt.config( 'uglify' ); + + return Object.keys( targets ).filter( function( name ) { + var target = targets[ name ]; + var relative; + + if ( 'options' === name || 'dynamic' === name ) { + return false; + } + if ( ! target.expand ) { + return target.src === WORKING_DIR + dest; + } + relative = path.relative( target.cwd, WORKING_DIR + dest ).replace( /\\/g, '/' ); + return relative.indexOf( '../' ) !== 0 && grunt.file.isMatch( target.src, relative ); + } )[ 0 ] || null; + } + /* * Updates the `:dynamic` configurations so that only the changed files are updated. * @@ -2722,17 +2780,18 @@ module.exports = function(grunt) { */ grunt.registerTask( 'watch-dynamic', 'Configures the dynamic tasks of a watch target for the files changed since they last ran.', function( target ) { var changes = watchedChanges[ target ] || {}; + var mappedBuildFiles = getMappedBuildFiles(); var deleted = []; var copyFiles = {}; var minify = []; - var minifyModules = []; + var uglifyTargets = []; var changed = []; delete watchedChanges[ target ]; Object.keys( changes ).forEach( function( filepath ) { var action = changes[ filepath ]; - var copy, dest; + var copy, dest, uglifyTarget; if ( target === 'js-enqueues' ) { copy = getEnqueuedScriptCopy( filepath ); @@ -2740,7 +2799,7 @@ module.exports = function(grunt) { grunt.log.writeln( 'Not built: ' + filepath ); return; } - dest = path.relative( WORKING_DIR, copy.dest ); + dest = path.relative( WORKING_DIR, copy.dest ).replace( /\\/g, '/' ); if ( action === 'deleted' ) { // A symlink to the deleted file no longer exists for the clean task, so it is removed here. @@ -2757,10 +2816,12 @@ module.exports = function(grunt) { copyFiles[ copy.dest ] = [ copy.src ]; } - if ( uglifyModules.indexOf( dest ) !== -1 ) { - minifyModules.push( dest ); - } else { + // Minify the script as the build does: with uglify:core, or by running the target that minifies it. + uglifyTarget = getUglifyTarget( dest ); + if ( 'core' === uglifyTarget ) { minify.push( dest ); + } else if ( uglifyTarget && uglifyTargets.indexOf( uglifyTarget ) === -1 ) { + uglifyTargets.push( uglifyTarget ); } } else if ( target === 'js-webpack' ) { // Check only the files built by webpack. @@ -2772,6 +2833,15 @@ module.exports = function(grunt) { 'wp-includes/js/media-views.js' ]; } + } else if ( target === 'all' && Object.keys( mappedBuildFiles ).some( function( mappedDest ) { + return mappedBuildFiles[ mappedDest ] === filepath; + } ) ) { + // Copy a file that the build copies to another path, such as _index.php to index.php. + Object.keys( mappedBuildFiles ).forEach( function( mappedDest ) { + if ( action !== 'deleted' && mappedBuildFiles[ mappedDest ] === filepath ) { + copyFiles[ mappedDest ] = [ filepath ]; + } + } ); } else if ( action === 'deleted' ) { deleted.push( path.relative( SOURCE_DIR, filepath ) ); } else { @@ -2783,12 +2853,20 @@ module.exports = function(grunt) { if ( target === 'all' ) { grunt.config( [ 'copy', 'dynamic', 'src' ], changed ); + grunt.config( [ 'copy', 'dynamic-js', 'files' ], copyFiles ); } else if ( target === 'rtl' ) { grunt.config( [ 'rtlcss', 'dynamic', 'src' ], changed ); } else if ( target === 'js-enqueues' ) { grunt.config( [ 'copy', 'dynamic-js', 'files' ], copyFiles ); grunt.config( [ 'uglify', 'dynamic', 'src' ], minify ); - grunt.config( [ 'uglify', 'dynamic-module', 'src' ], minifyModules ); + watchedUglifyTargets = uglifyTargets; } } ); + + grunt.registerTask( 'watch-uglify', 'Runs the uglify targets for the changed scripts that uglify:core does not minify.', function() { + grunt.task.run( watchedUglifyTargets.map( function( name ) { + return 'uglify:' + name; + } ) ); + watchedUglifyTargets = []; + } ); }; diff --git a/tools/watch/task.js b/tools/watch/task.js index 8ab62007b7222..13b62dfd680e8 100644 --- a/tools/watch/task.js +++ b/tools/watch/task.js @@ -20,7 +20,13 @@ * * The tasks run in the watch process, and a `watch` event is emitted for each * change of a target before they run, so that a listener can configure them for - * the changed files. + * the changed files. Changes made while tasks run are run once they finish. When + * a task fails, the rest of its target's tasks are skipped, and those of the + * other targets still run. A reload happens after the tasks of the other + * changes made with it have run. + * + * A target's tasks must not write files that match its own patterns, which + * would run them again without end. * * @package WordPress */ @@ -28,6 +34,7 @@ const path = require( 'path' ); const { spawn } = require( 'child_process' ); const chokidar = require( 'chokidar' ); +const { Minimatch } = require( 'minimatch' ); /** * Time to wait for more changes before running the tasks, in milliseconds. @@ -98,12 +105,26 @@ module.exports = ( grunt ) => { let resume = null; let timer = null; let reload = false; + let restartPending = false; let startedAt = null; + let currentTarget = null; // Changed files since the tasks last ran, keyed by path, and the targets to run for them. const changes = new Map(); const queuedTargets = new Set(); + // The targets whose tasks are queued in the current run, until each one's tasks finish. + let runningTargets = []; + + grunt.registerTask( '_watch-target', 'Marks the tasks of a watch target as started or finished.', ( step, name ) => { + if ( 'start' === step ) { + currentTarget = name; + } else { + currentTarget = null; + runningTargets = runningTargets.filter( ( running ) => running !== name ); + } + } ); + /** * Gets the configuration of a target. * @@ -121,6 +142,20 @@ module.exports = ( grunt ) => { }; } + /** + * Gets the tasks to run for targets, each between the tasks that mark it as started and finished. + * + * @param {string[]} names The target names. + * + * @return {string[]} The tasks. + */ + function getTasks( names ) { + return names.flatMap( ( name ) => [ '_watch-target:start:' + name ].concat( + getTarget( name ).tasks, + '_watch-target:done:' + name + ) ); + } + /** * Records a change, for each target whose patterns match the file. * @@ -167,7 +202,6 @@ module.exports = ( grunt ) => { */ function runQueued() { const done = resume; - const tasks = []; timer = null; if ( ! done ) { @@ -178,24 +212,20 @@ module.exports = ( grunt ) => { grunt.log.ok( 'File "' + filepath + '" ' + action + '.' ); } ); + // Restart once the tasks for the other changes have run, as the new process does not see those changes. if ( reload ) { - restart(); - return; + restartPending = true; + reload = false; } - targetNames.forEach( ( name ) => { - if ( queuedTargets.has( name ) ) { - tasks.push( ...getTarget( name ).tasks ); - } - } ); - + runningTargets = targetNames.filter( ( name ) => queuedTargets.has( name ) ); changes.clear(); queuedTargets.clear(); resume = null; startedAt = Date.now(); // Run the watch task again after the tasks, to wait for the next changes. - grunt.task.run( tasks.concat( nameArgs ) ); + grunt.task.run( getTasks( runningTargets ).concat( nameArgs ) ); done(); } @@ -237,9 +267,15 @@ module.exports = ( grunt ) => { } ); } ); + /* + * Skip the rest of the failed target's tasks, but still run those of the other targets in the run. A task + * can report more than one failure, after which the same targets are queued again. + */ function rerun() { + runningTargets = runningTargets.filter( ( running ) => running !== currentTarget ); + currentTarget = null; grunt.task.clearQueue(); - grunt.task.run( nameArgs ); + grunt.task.run( getTasks( runningTargets ).concat( nameArgs ) ); } grunt.warn = grunt.fail.warn = ( error ) => { @@ -260,19 +296,21 @@ module.exports = ( grunt ) => { * @param {Function} onReady Called once the initial scan is complete. */ function startWatching( onReady ) { - const ignored = ( grunt.config( [ taskName, 'options', 'ignored' ] ) || [] ).flatMap( - // Match each ignored directory itself, so that it is not searched. - ( pattern ) => pattern.endsWith( '/**' ) ? [ pattern, pattern.slice( 0, -3 ) ] : [ pattern ] - ); + // Compiled once, as every path found is checked against them. + const ignored = ( grunt.config( [ taskName, 'options', 'ignored' ] ) || [] ) + .flatMap( + // Match each ignored directory itself, so that it is not searched. + ( pattern ) => pattern.endsWith( '/**' ) ? [ pattern, pattern.slice( 0, -3 ) ] : [ pattern ] + ) + .map( ( pattern ) => new Minimatch( pattern, { dot: true } ) ); const patterns = targetNames.flatMap( ( name ) => getTarget( name ).files ); watcher = chokidar.watch( getWatchPaths( patterns ), { ignoreInitial: true, - ignored: ( filepath ) => grunt.file.isMatch( - { dot: true }, - ignored, - path.relative( process.cwd(), filepath ).replace( /\\/g, '/' ) - ), + ignored: ( filepath ) => { + const relative = path.relative( process.cwd(), filepath ).replace( /\\/g, '/' ); + return ignored.some( ( matcher ) => matcher.match( relative ) ); + }, } ); watcher.on( 'all', recordChange ); @@ -288,6 +326,11 @@ module.exports = ( grunt ) => { resume = this.async(); if ( watcher ) { + if ( restartPending ) { + restart(); + return; + } + if ( startedAt ) { grunt.log.writeln( ( 'Completed in ' + ( ( Date.now() - startedAt ) / 1000 ).toFixed( 3 ) + 's at ' + new Date().toString() ).cyan + ' - Waiting...' ); startedAt = null; From 7cfd6d24c3231996c2f763974c41d7a2e6d05066 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 9 Nov 2026 23:10:56 -0800 Subject: [PATCH 5/7] Build/Test Tools: Fix watch issues found in Copilot review - The `all` target now watches `wp-config-sample.php` in the root, which the build copies too, and copies it to the build. - When `_index.php` or `wp-admin/_index.php` is deleted, the `index.php` copied from it is deleted from the build, instead of being left behind. - When the Gruntfile changes, the watch task now runs the changes made while its tasks ran before restarting, as the new process would not see them. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1 --- Gruntfile.js | 27 ++++++++++++++++++++------- tools/watch/task.js | 20 +++++++++++--------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index f257c252b5c0a..7bc9ace071b8d 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1757,8 +1757,8 @@ module.exports = function(grunt) { delete grunt.config.data._watch.all; } else { /* - * Watch the files that the copy:files task copies from /src, so that a change is copied as by the build, - * including the sources it copies to other paths, such as _index.php. + * Watch the files that the copy:files task copies, so that a change is copied as by the build, including + * the sources it copies to other paths, such as _index.php, and wp-config-sample.php from the root. */ grunt.config( [ '_watch', 'all', 'files' ], grunt.config( 'copy.files.files' )[0].src.map( function( pattern ) { return pattern.charAt( 0 ) === '!' ? '!' + SOURCE_DIR + pattern.slice( 1 ) : SOURCE_DIR + pattern; @@ -2727,7 +2727,8 @@ module.exports = function(grunt) { } /** - * Gets the files in /src that the copy:files task copies to another path, such as _index.php to index.php. + * Gets the files that the copy:files task copies other than by its glob patterns in /src: those it copies to + * another path, such as _index.php to index.php, and wp-config-sample.php from the root. * * @return {Object} Source paths, keyed by their destinations. */ @@ -2735,8 +2736,15 @@ module.exports = function(grunt) { var mapped = {}; grunt.config( 'copy.files.files' ).forEach( function( files ) { - // Skip the entries of glob patterns, which have a src or cwd. - if ( files.src || files.cwd ) { + // Skip the entries of glob patterns, which have a cwd. + if ( files.cwd ) { + return; + } + // A single file copied into a directory, such as wp-config-sample.php. + if ( files.src ) { + [].concat( files.src ).forEach( function( src ) { + mapped[ files.dest + path.basename( src ) ] = src; + } ); return; } Object.keys( files ).forEach( function( dest ) { @@ -2836,9 +2844,14 @@ module.exports = function(grunt) { } else if ( target === 'all' && Object.keys( mappedBuildFiles ).some( function( mappedDest ) { return mappedBuildFiles[ mappedDest ] === filepath; } ) ) { - // Copy a file that the build copies to another path, such as _index.php to index.php. + // Copy a file that the build copies to another path, such as _index.php to index.php, or delete the copy. Object.keys( mappedBuildFiles ).forEach( function( mappedDest ) { - if ( action !== 'deleted' && mappedBuildFiles[ mappedDest ] === filepath ) { + if ( mappedBuildFiles[ mappedDest ] !== filepath ) { + return; + } + if ( action === 'deleted' ) { + deleted.push( path.relative( WORKING_DIR, mappedDest ).replace( /\\/g, '/' ) ); + } else { copyFiles[ mappedDest ] = [ filepath ]; } } ); diff --git a/tools/watch/task.js b/tools/watch/task.js index 13b62dfd680e8..4b5a0edbd7167 100644 --- a/tools/watch/task.js +++ b/tools/watch/task.js @@ -23,7 +23,7 @@ * the changed files. Changes made while tasks run are run once they finish. When * a task fails, the rest of its target's tasks are skipped, and those of the * other targets still run. A reload happens after the tasks of the other - * changes made with it have run. + * changes made with it, or while its tasks ran, have run. * * A target's tasks must not write files that match its own patterns, which * would run them again without end. @@ -326,19 +326,21 @@ module.exports = ( grunt ) => { resume = this.async(); if ( watcher ) { - if ( restartPending ) { - restart(); - return; - } - if ( startedAt ) { grunt.log.writeln( ( 'Completed in ' + ( ( Date.now() - startedAt ) / 1000 ).toFixed( 3 ) + 's at ' + new Date().toString() ).cyan + ' - Waiting...' ); startedAt = null; } - // Run the changes made while the tasks ran. - if ( changes.size && ! timer ) { - runQueued(); + // Run the changes made while the tasks ran, now or once the debounce timer fires, before any restart. + if ( changes.size ) { + if ( ! timer ) { + runQueued(); + } + return; + } + + if ( restartPending ) { + restart(); } return; } From f6c733d1dec16dc37a4ba4c4ca528fe0159eaea1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 9 Nov 2026 23:29:23 -0800 Subject: [PATCH 6/7] Build/Test Tools: Delete minified scripts and use grunt's minimatch - When a script is deleted, its minified copy is deleted from the build too, instead of being left behind to be served. - The watch task uses the minimatch that grunt provides as `grunt.file.minimatch`, rather than requiring `minimatch`, which is not a direct dependency and is found only because grunt's copy is hoisted. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3JW6baV4GNVgJvkzqN Claude-Code-Session-Id: 2364c706-aa94-4be3-a98c-6a26321655a1 --- Gruntfile.js | 4 ++++ tools/watch/task.js | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 7bc9ace071b8d..a66dc719afc49 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -2816,6 +2816,10 @@ module.exports = function(grunt) { } else { deleted.push( dest ); } + // Every uglify target writes the minified script next to the script, with a .min.js extension. + if ( getUglifyTarget( dest ) ) { + deleted.push( dest.replace( /\.js$/, '.min.js' ) ); + } return; } diff --git a/tools/watch/task.js b/tools/watch/task.js index 4b5a0edbd7167..4120e14932f44 100644 --- a/tools/watch/task.js +++ b/tools/watch/task.js @@ -34,7 +34,6 @@ const path = require( 'path' ); const { spawn } = require( 'child_process' ); const chokidar = require( 'chokidar' ); -const { Minimatch } = require( 'minimatch' ); /** * Time to wait for more changes before running the tasks, in milliseconds. @@ -302,7 +301,8 @@ module.exports = ( grunt ) => { // Match each ignored directory itself, so that it is not searched. ( pattern ) => pattern.endsWith( '/**' ) ? [ pattern, pattern.slice( 0, -3 ) ] : [ pattern ] ) - .map( ( pattern ) => new Minimatch( pattern, { dot: true } ) ); + // Grunt's own minimatch, which it provides for its file matching. + .map( ( pattern ) => new grunt.file.minimatch.Minimatch( pattern, { dot: true } ) ); const patterns = targetNames.flatMap( ( name ) => getTarget( name ).files ); watcher = chokidar.watch( getWatchPaths( patterns ), { From 46c7a23a54fd9d742ea475a4f508dc127857d202 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 10 Nov 2026 09:29:53 -0800 Subject: [PATCH 7/7] Build/Test Tools: Keep edits made during a watch reload, and report one failure per task - When the Gruntfile changes, the watch process kept closing its watcher before the new process was ready, so an edit made during the new process's initial scan, which can take several seconds, was never built. The previous process now keeps watching until the new one reports over IPC that it is ready, and then hands it the changes seen in the meantime. - The watch task's replacement for `grunt.fail.warn` returns to the failed task instead of exiting, as grunt's own does, so a task can fail again on the result it did not get. grunt-contrib-uglify, for example, followed "Uglification failed." with "Cannot read properties of undefined (reading 'min')". Only the first failure of a task is now reported, and any further ones are logged with `--verbose`. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: d249ded5-c78c-44ee-aadc-986582dfafb8 --- tools/watch/task.js | 94 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 11 deletions(-) diff --git a/tools/watch/task.js b/tools/watch/task.js index 4120e14932f44..525036b6fe164 100644 --- a/tools/watch/task.js +++ b/tools/watch/task.js @@ -23,7 +23,8 @@ * the changed files. Changes made while tasks run are run once they finish. When * a task fails, the rest of its target's tasks are skipped, and those of the * other targets still run. A reload happens after the tasks of the other - * changes made with it, or while its tasks ran, have run. + * changes made with it, or while its tasks ran, have run. The new process is + * handed the changes made while it starts, which the previous one still watches. * * A target's tasks must not write files that match its own patterns, which * would run them again without end. @@ -42,6 +43,12 @@ const chokidar = require( 'chokidar' ); */ const DEBOUNCE_DELAY = 250; +/** + * Environment variable set for a watch process started by a reload, which takes over the changes that the + * previous process saw while it started. + */ +const HAND_OFF_ENV = 'WP_WATCH_HAND_OFF'; + /** * Change actions, keyed by the chokidar event names. */ @@ -107,6 +114,10 @@ module.exports = ( grunt ) => { let restartPending = false; let startedAt = null; let currentTarget = null; + let failureReported = false; + + // The changes seen while restarting, as chokidar event names and paths, to hand to the new process. + let handOffChanges = null; // Changed files since the tasks last ran, keyed by path, and the targets to run for them. const changes = new Map(); @@ -116,6 +127,7 @@ module.exports = ( grunt ) => { let runningTargets = []; grunt.registerTask( '_watch-target', 'Marks the tasks of a watch target as started or finished.', ( step, name ) => { + failureReported = false; if ( 'start' === step ) { currentTarget = name; } else { @@ -169,6 +181,12 @@ module.exports = ( grunt ) => { return; } + // Once restarting, the changes are for the new process, which is handed them when it is ready. + if ( handOffChanges ) { + handOffChanges.push( [ event, filepath ] ); + return; + } + filepath = path.relative( process.cwd(), filepath ).replace( /\\/g, '/' ); targetNames.forEach( ( name ) => { @@ -233,13 +251,29 @@ module.exports = ( grunt ) => { */ function restart() { grunt.log.writeln( 'Reloading watch config...'.cyan ); - watcher.close(); + + /* + * Keep watching until the new process is ready, which can take several seconds for its initial scan, and + * hand it the changes seen meanwhile, which it would otherwise miss. + */ + handOffChanges = []; const child = spawn( process.execPath, process.execArgv.concat( process.argv[ 1 ], nameArgs, grunt.option.flags() ), - { stdio: 'inherit' } + { + stdio: [ 'inherit', 'inherit', 'inherit', 'ipc' ], + env: { ...process.env, [ HAND_OFF_ENV ]: '1' }, + } ); + child.on( 'message', ( message ) => { + if ( ! message || 'watch-ready' !== message.type ) { + return; + } + watcher.close(); + child.send( { type: 'watch-changes', changes: handOffChanges }, () => child.disconnect() ); + handOffChanges = []; + } ); child.on( 'exit', ( code ) => process.exit( code === null ? 1 : code ) ); // Stop the new process with this one, which only waits for it. @@ -248,6 +282,24 @@ module.exports = ( grunt ) => { } ); } + /** + * Takes over the changes that the previous watch process saw while this one started, when started by a reload. + * + * The previous process keeps watching until this one reports that it is ready, then hands over its changes. + */ + function takeOverChanges() { + if ( ! process.send || ! process.env[ HAND_OFF_ENV ] ) { + return; + } + + process.on( 'message', ( message ) => { + if ( message && 'watch-changes' === message.type ) { + message.changes.forEach( ( [ event, filepath ] ) => recordChange( event, filepath ) ); + } + } ); + process.send( { type: 'watch-ready' } ); + } + /** * Keeps the watch task running when a task fails, instead of exiting. * @@ -267,8 +319,7 @@ module.exports = ( grunt ) => { } ); /* - * Skip the rest of the failed target's tasks, but still run those of the other targets in the run. A task - * can report more than one failure, after which the same targets are queued again. + * Skip the rest of the failed target's tasks, but still run those of the other targets in the run. */ function rerun() { runningTargets = runningTargets.filter( ( running ) => running !== currentTarget ); @@ -277,15 +328,32 @@ module.exports = ( grunt ) => { grunt.task.run( getTasks( runningTargets ).concat( nameArgs ) ); } + /* + * Unlike grunt's own handlers, which exit, these return to the failed task, which can then fail again on + * the result it did not get, as grunt-contrib-uglify does after "Uglification failed.". Only the first + * failure is reported, until the next task starts. + */ + function reportFailure( prefix, error ) { + const message = prefix + ( typeof error === 'string' ? error : error.message ); + + if ( failureReported ) { + grunt.verbose.writeln( message ); + return; + } + failureReported = true; + grunt.log.writeln( 'Fatal error: ' === prefix ? message.red : message.yellow ); + rerun(); + } + grunt.warn = grunt.fail.warn = ( error ) => { - grunt.log.writeln( ( 'Warning: ' + ( typeof error === 'string' ? error : error.message ) ).yellow ); - if ( ! grunt.option( 'force' ) ) { - rerun(); + if ( grunt.option( 'force' ) ) { + grunt.log.writeln( ( 'Warning: ' + ( typeof error === 'string' ? error : error.message ) ).yellow ); + return; } + reportFailure( 'Warning: ', error ); }; grunt.fatal = grunt.fail.fatal = ( error ) => { - grunt.log.writeln( ( 'Fatal error: ' + ( typeof error === 'string' ? error : error.message ) ).red ); - rerun(); + reportFailure( 'Fatal error: ', error ); }; } @@ -324,6 +392,7 @@ module.exports = ( grunt ) => { taskName = this.name; nameArgs = this.nameArgs; resume = this.async(); + failureReported = false; if ( watcher ) { if ( startedAt ) { @@ -353,6 +422,9 @@ module.exports = ( grunt ) => { } keepWatchingOnFailure(); - startWatching( () => grunt.log.writeln( 'Waiting...' ) ); + startWatching( () => { + grunt.log.writeln( 'Waiting...' ); + takeOverChanges(); + } ); }; };