-
Notifications
You must be signed in to change notification settings - Fork 22
Feature: Add @10up/build - esbuild-powered WordPress build tool
#466
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
Introduces a new build package that provides fast WordPress block and asset compilation using esbuild, designed as a drop-in replacement for 10up-toolkit's webpack-based build system.
Features:
- 10-100x faster builds than webpack with esbuild core
- Full WordPress dependency extraction (.asset.php generation)
- Support for ES Modules (viewScriptModule) and IIFE output formats
- PostCSS + lightningcss CSS pipeline with SCSS/SASS support
- Automatic block.json asset detection and transformation
- Custom external namespace support (@woocommerce/*, @my-plugin/*, etc.)
- React Fast Refresh for development with HMR
- Compatible with existing 10up-toolkit configuration schema
CLI commands:
- `10up-build build` - Production build
- `10up-build start` - Watch mode with Fast Refresh server
Configuration:
- Reads from package.json["10up-toolkit"] field
- Supports paths.blocksDir, paths.distDir, paths.globalStylesDir, paths.globalMixinsDir
- Supports entry, moduleEntry, externalNamespaces options
- Respects postcss.config.js for custom PostCSS configuration
Test coverage:
- 76 tests covering config loading, entry detection, externals handling,
block.json transformation, dependency extraction, and full build integration
- Test fixtures for basic projects, block projects, and PostCSS configuration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
| if (!filePath.startsWith('file:')) { | ||
| return filePath; | ||
| } | ||
| return filePath.replace(/\.tsx?$/, '.js').replace(/\.js$/, '.js'); |
Check warning
Code scanning / CodeQL
Replacement of a substring with itself Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 20 days ago
In general, the fix is to remove or adjust any String.prototype.replace (or similar) calls where the pattern and replacement yield the same string, unless there is a very explicit and documented reason to keep them. Here, the second .replace(/\.js$/, '.js') is redundant: after the first replace(/\.tsx?$/, '.js'), all .ts/.tsx suffixes are already .js, and existing .js files require no further modification. The best fix that preserves behavior is to delete the no-op replace call and keep only the TypeScript-to-JavaScript conversion.
Concretely, in packages/build/src/plugins/block-json.ts, within transformTSAsset, change the transform function so that the return statement on line 44 becomes a single replace(/\.tsx?$/, '.js') call. No additional imports or helper methods are needed; we are only simplifying the existing expression.
-
Copy modified line R44
| @@ -41,7 +41,7 @@ | ||
| if (!filePath.startsWith('file:')) { | ||
| return filePath; | ||
| } | ||
| return filePath.replace(/\.tsx?$/, '.js').replace(/\.js$/, '.js'); | ||
| return filePath.replace(/\.tsx?$/, '.js'); | ||
| }; | ||
|
|
||
| return Array.isArray(asset) ? asset.map(transform) : transform(asset); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is for .jsx imports
| // Install packages | ||
| console.log(`\nInstalling ${toInstall.length} packages...`); | ||
|
|
||
| const installCmd = `npm install --save-optional ${toInstall.map(p => `${p}@${tag}`).join(' ')}`; |
Check warning
Code scanning / CodeQL
Unsafe shell command constructed from library input Medium
library input
shell command
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 20 days ago
In general, to fix this class of issue, avoid constructing a single shell command string from untrusted data and passing it to exec/execSync. Instead, call the program directly with execFile/execFileSync or spawn/spawnSync, passing arguments as an array so they are not interpreted by a shell. If you must use a shell (e.g., to use pipes or redirection), then escape any untrusted values (e.g., with shell-quote) before embedding them.
Here, we can safely replace the string-based execSync(installCmd, ...) with an argument-array invocation of npm. The command being run is simple (npm install --save-optional ...packages...), with no pipes or redirections, so we do not need a shell at all. We will:
- Build an array of arguments for
npm:['install', '--save-optional', ...toInstall.map(p =>${p}@${tag})]. - Replace the
installCmdstring with this args array. - Replace
execSync(installCmd, { ... })withexecSync('npm', { cwd: installDir, stdio: 'inherit', args: installArgs })or, more idiomatically, switch fromexecSynctospawnSyncfromchild_process, which is designed for this pattern.
Because we are constrained to only add imports of well-known libraries and not alter unrelated code, the minimal and clear fix is to import spawnSync from child_process in wp-deps.ts, leave execSync for any other existing uses, and then switch this particular call to use spawnSync('npm', installArgs, { cwd: installDir, stdio: 'inherit' }). spawnSync takes the command and argument array directly, avoiding any shell interpretation of tag or package names, while preserving all existing functionality and behavior (same cwd, same stdio, same success/failure semantics).
Concretely in packages/build/src/wp-deps.ts:
- Update the import from
child_processto also importspawnSync. - Replace the
installCmdstring with aninstallArgsarray, preserving the same package/tag concatenation. - Replace the
tryblock to usespawnSync('npm', installArgs, { cwd: installDir, stdio: 'inherit' })and explicitly checkresult.status !== 0to throw the same error message if npm fails.
No changes are required in cli.ts.
-
Copy modified line R7 -
Copy modified line R388 -
Copy modified lines R391-R394
| @@ -4,7 +4,7 @@ | ||
| * Provides commands to sync and update @wordpress/* dependencies | ||
| */ | ||
|
|
||
| import { execSync } from 'child_process'; | ||
| import { execSync, spawnSync } from 'child_process'; | ||
| import { readFileSync, writeFileSync, existsSync } from 'fs'; | ||
| import { join, dirname } from 'path'; | ||
| import pc from 'picocolors'; | ||
| @@ -385,10 +385,13 @@ | ||
| // Install packages | ||
| console.log(`\nInstalling ${toInstall.length} packages...`); | ||
|
|
||
| const installCmd = `npm install --save-optional ${toInstall.map(p => `${p}@${tag}`).join(' ')}`; | ||
| const installArgs = ['install', '--save-optional', ...toInstall.map(p => `${p}@${tag}`)]; | ||
|
|
||
| try { | ||
| execSync(installCmd, { cwd: installDir, stdio: 'inherit' }); | ||
| const result = spawnSync('npm', installArgs, { cwd: installDir, stdio: 'inherit' }); | ||
| if (result.status !== 0) { | ||
| throw new Error('Failed to install packages. Check npm output above.'); | ||
| } | ||
| console.log(pc.green('\n✓ WordPress dependencies synced successfully!')); | ||
| } catch (error) { | ||
| throw new Error('Failed to install packages. Check npm output above.'); |
| console.log(`\nUpdating to ${pc.cyan(tag)}...`); | ||
|
|
||
| const packages = sortedDeps.map(([pkg]) => `${pkg}@${tag}`); | ||
| const installCmd = `npm install --save-optional ${packages.join(' ')}`; |
Check warning
Code scanning / CodeQL
Unsafe shell command constructed from library input Medium
library input
shell command
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 20 days ago
In general, to fix this issue we should avoid passing a single concatenated string to execSync and instead use an API that accepts the executable and its arguments as an array (for example child_process.execFileSync or child_process.spawnSync). This prevents the shell from interpreting special characters in user-controlled input, because the command is invoked directly without an intervening shell.
The best targeted fix here is to keep the behavior of running npm install --save-optional ... in the same working directory with inherited stdio, but refactor the command to use execFileSync('npm', args, options) instead of execSync(installCmd, options). We already have the list of package specifiers as an array (packages), so we can build the argument array as ['install', '--save-optional', ...packages]. Then invoke execFileSync with those arguments. This preserves all existing behavior (same tool, same flags, same packages, same cwd and stdio) while removing the shell injection vector.
Concretely, in packages/build/src/wp-deps.ts within updateWpDeps:
- Add
execFileSyncto the existing import fromchild_process. - Replace the string
installCmdand theexecSync(installCmd, ...)call with aninstallArgsarray andexecFileSync('npm', installArgs, { cwd, stdio: 'inherit' }).
No changes are needed in cli.ts for this issue, and no validation of tag is strictly required once we are no longer going through a shell.
-
Copy modified line R7 -
Copy modified line R438 -
Copy modified line R441
| @@ -4,7 +4,7 @@ | ||
| * Provides commands to sync and update @wordpress/* dependencies | ||
| */ | ||
|
|
||
| import { execSync } from 'child_process'; | ||
| import { execSync, execFileSync } from 'child_process'; | ||
| import { readFileSync, writeFileSync, existsSync } from 'fs'; | ||
| import { join, dirname } from 'path'; | ||
| import pc from 'picocolors'; | ||
| @@ -435,10 +435,10 @@ | ||
| console.log(`\nUpdating to ${pc.cyan(tag)}...`); | ||
|
|
||
| const packages = sortedDeps.map(([pkg]) => `${pkg}@${tag}`); | ||
| const installCmd = `npm install --save-optional ${packages.join(' ')}`; | ||
| const installArgs = ['install', '--save-optional', ...packages]; | ||
|
|
||
| try { | ||
| execSync(installCmd, { cwd, stdio: 'inherit' }); | ||
| execFileSync('npm', installArgs, { cwd, stdio: 'inherit' }); | ||
| console.log(pc.green('\n✓ WordPress dependencies updated successfully!')); | ||
| } catch (error) { | ||
| throw new Error('Failed to update packages. Check npm output above.'); |
| const installCmd = `npm install --save-optional ${toInstall.map(p => `${p}@${tag}`).join(' ')}`; | ||
|
|
||
| try { | ||
| execSync(installCmd, { cwd: installDir, stdio: 'inherit' }); |
Check failure
Code scanning / CodeQL
Uncontrolled command line Critical
user-provided value
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 20 days ago
To fix the problem, avoid running a shell with a single concatenated command line and instead invoke npm directly with execFileSync (or spawnSync) and an array of arguments. This removes shell parsing from the equation, so even if tag or a package name contains spaces or shell metacharacters, they are treated as plain arguments rather than part of a shell command.
Concretely, in packages/build/src/wp-deps.ts, we should:
- Import
execFileSyncfromchild_processalongside the existingexecSyncimport (keepingexecSyncif it is used elsewhere in unseen code). - Replace the construction of
installCmdas a single string with a construction of anargsarray:['install', '--save-optional', ...toInstall.map(p =>${p}@${tag})]. - Replace
execSync(installCmd, { ... })withexecFileSync('npm', args, { cwd: installDir, stdio: 'inherit' }).
This preserves existing behavior (same npm install --save-optional <pkgs> invocation in the same directory, same output handling) while eliminating the uncontrolled command line.
All required functionality is provided by Node’s standard library; no external packages are needed.
-
Copy modified line R7 -
Copy modified lines R388-R392 -
Copy modified line R395
| @@ -4,7 +4,7 @@ | ||
| * Provides commands to sync and update @wordpress/* dependencies | ||
| */ | ||
|
|
||
| import { execSync } from 'child_process'; | ||
| import { execSync, execFileSync } from 'child_process'; | ||
| import { readFileSync, writeFileSync, existsSync } from 'fs'; | ||
| import { join, dirname } from 'path'; | ||
| import pc from 'picocolors'; | ||
| @@ -385,10 +385,14 @@ | ||
| // Install packages | ||
| console.log(`\nInstalling ${toInstall.length} packages...`); | ||
|
|
||
| const installCmd = `npm install --save-optional ${toInstall.map(p => `${p}@${tag}`).join(' ')}`; | ||
| const npmArgs = [ | ||
| 'install', | ||
| '--save-optional', | ||
| ...toInstall.map(p => `${p}@${tag}`), | ||
| ]; | ||
|
|
||
| try { | ||
| execSync(installCmd, { cwd: installDir, stdio: 'inherit' }); | ||
| execFileSync('npm', npmArgs, { cwd: installDir, stdio: 'inherit' }); | ||
| console.log(pc.green('\n✓ WordPress dependencies synced successfully!')); | ||
| } catch (error) { | ||
| throw new Error('Failed to install packages. Check npm output above.'); |
Note
This is just an exploration I did to see how well this would actually work because I got fed up with the speed of CI builds. WordPress Core / Gutenberg recently switched over to an esbuild powered build system so this draws inspiration from that. Realistically RSPack is probably a safer easier option but I wanted to try this.
Introduces a new build package that provides fast WordPress block and asset compilation using esbuild, designed as a drop-in replacement for 10up-toolkit's webpack-based build system.
Features:
CLI commands:
10up-build build- Production build10up-build start- Watch mode with Fast Refresh serverConfiguration:
Test coverage: