Skip to content

Conversation

@fabiankaegy
Copy link
Member

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:

  • 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

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>
@changeset-bot
Copy link

changeset-bot bot commented Jan 7, 2026

⚠️ No Changeset found

Latest commit: 481ab35

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

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

This replaces '.js' with itself.

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.

Suggested changeset 1
packages/build/src/plugins/block-json.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/build/src/plugins/block-json.ts b/packages/build/src/plugins/block-json.ts
--- a/packages/build/src/plugins/block-json.ts
+++ b/packages/build/src/plugins/block-json.ts
@@ -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);
EOF
@@ -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);
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Member Author

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

This string concatenation which depends on
library input
is later used in a
shell command
.

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 installCmd string with this args array.
  • Replace execSync(installCmd, { ... }) with execSync('npm', { cwd: installDir, stdio: 'inherit', args: installArgs }) or, more idiomatically, switch from execSync to spawnSync from child_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_process to also import spawnSync.
  • Replace the installCmd string with an installArgs array, preserving the same package/tag concatenation.
  • Replace the try block to use spawnSync('npm', installArgs, { cwd: installDir, stdio: 'inherit' }) and explicitly check result.status !== 0 to throw the same error message if npm fails.

No changes are required in cli.ts.

Suggested changeset 1
packages/build/src/wp-deps.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/build/src/wp-deps.ts b/packages/build/src/wp-deps.ts
--- a/packages/build/src/wp-deps.ts
+++ b/packages/build/src/wp-deps.ts
@@ -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.');
EOF
@@ -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.');
Copilot is powered by AI and may make mistakes. Always verify output.
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

This string concatenation which depends on
library input
is later used in a
shell command
.

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 execFileSync to the existing import from child_process.
  • Replace the string installCmd and the execSync(installCmd, ...) call with an installArgs array and execFileSync('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.

Suggested changeset 1
packages/build/src/wp-deps.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/build/src/wp-deps.ts b/packages/build/src/wp-deps.ts
--- a/packages/build/src/wp-deps.ts
+++ b/packages/build/src/wp-deps.ts
@@ -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.');
EOF
@@ -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.');
Copilot is powered by AI and may make mistakes. Always verify output.
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

This command line depends on a
user-provided value
.

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:

  1. Import execFileSync from child_process alongside the existing execSync import (keeping execSync if it is used elsewhere in unseen code).
  2. Replace the construction of installCmd as a single string with a construction of an args array: ['install', '--save-optional', ...toInstall.map(p => ${p}@${tag})].
  3. Replace execSync(installCmd, { ... }) with execFileSync('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.

Suggested changeset 1
packages/build/src/wp-deps.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/build/src/wp-deps.ts b/packages/build/src/wp-deps.ts
--- a/packages/build/src/wp-deps.ts
+++ b/packages/build/src/wp-deps.ts
@@ -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.');
EOF
@@ -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.');
Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant