diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md
index 2bb8dcd7cf7752..857be2e0b73857 100644
--- a/packages/wp-build/CHANGELOG.md
+++ b/packages/wp-build/CHANGELOG.md
@@ -2,6 +2,11 @@
## Unreleased
+### Enhancements
+
+- Use each package's own `name` field as its script-module ID and externalize internal-package imports by exact name. Decouples script-module identity from `wpPlugin.packageNamespace`, so the npm name survives end-to-end (npm name === import specifier === script-module ID). No-op for Core; enables consumers whose owned npm scope differs from `packageNamespace` to keep a single identifier across npm, IDE, and the WordPress runtime.
+- Discover script-module packages outside `./packages/` via convention. Any entry in the plugin's `dependencies` whose `package.json` declares `wpScriptModuleExports` is registered as a script module, bundled, and externalized under its own npm name. No new config; local packages still take precedence on name collision.
+
### Bug Fixes
- Remove the incorrect `#wpwrap` background from wp-admin critical CSS to prevent a black flash before hydration; rely on the existing `body` background instead.
diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs
index 0ae6c549245942..fd1ac1360aa718 100755
--- a/packages/wp-build/lib/build.mjs
+++ b/packages/wp-build/lib/build.mjs
@@ -4,6 +4,7 @@
* External dependencies
*/
import { readFile, writeFile, copyFile, mkdir, unlink } from 'fs/promises';
+import { existsSync } from 'fs';
import path from 'path';
import { createHash } from 'node:crypto';
import { createRequire as createNodeRequire } from 'node:module';
@@ -95,20 +96,6 @@ const TEST_FILE_PATTERNS = [
/\.(native|ios|android)\.(js|ts|tsx)$/,
];
-/**
- * Get all package names from the packages directory.
- *
- * @return {string[]} Array of package names.
- */
-function getAllPackages() {
- return glob
- .sync( normalizePath( path.join( PACKAGES_DIR, '*', 'package.json' ) ) )
- .map( ( packageJsonPath ) =>
- path.basename( path.dirname( packageJsonPath ) )
- );
-}
-
-const PACKAGES = getAllPackages();
const ROOT_PACKAGE_JSON = getPackageInfoFromFile(
path.join( ROOT_DIR, 'package.json' )
);
@@ -119,6 +106,112 @@ const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE;
const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {};
const PAGES = WP_PLUGIN_CONFIG.pages || [];
+/**
+ * A discovered package in the registry.
+ *
+ * @typedef {Object} PackageEntry
+ * @property {string} dir Absolute path to the package directory.
+ * @property {import('./package-utils.mjs').PackageJson} packageJson Parsed package.json contents.
+ * @property {boolean} external True when the package is pre-built outside this plugin (e.g. a workspace dep). External packages are bundled and externalized but not transpiled; local packages from `./packages/` are also transpiled from source.
+ */
+
+/**
+ * Build the registry of script-module packages this plugin builds.
+ *
+ * Two convention-driven discovery sources:
+ *
+ * 1. Local packages: every `./packages/
/package.json`.
+ * 2. Convention deps: every entry in the plugin's `dependencies` whose own
+ * `package.json` declares `wpScriptModuleExports`. Lets a plugin pull in
+ * shared script-module packages from outside `./packages/` (workspace
+ * siblings, npm-installed siblings) without any extra wp-build config.
+ * Local packages win first-match for any given name.
+ *
+ * @return {Map} Registry keyed by short identifier:
+ * directory name for local packages, full npm name for convention deps.
+ */
+function getAllPackages() {
+ const registry = new Map();
+
+ // 1. Local packages from ./packages/*
+ const localPaths = glob.sync(
+ normalizePath( path.join( PACKAGES_DIR, '*', 'package.json' ) )
+ );
+ for ( const pkgJsonPath of localPaths ) {
+ const dir = path.dirname( pkgJsonPath );
+ const key = path.basename( dir );
+ registry.set( key, {
+ dir,
+ packageJson: getPackageInfoFromFile( pkgJsonPath ),
+ external: false,
+ } );
+ }
+
+ // 2. Convention deps with wpScriptModuleExports
+ const deps = Object.keys( ROOT_PACKAGE_JSON.dependencies || {} );
+ const localNames = new Set(
+ Array.from( registry.values() ).map(
+ ( entry ) => entry.packageJson.name
+ )
+ );
+ const localRequire = createNodeRequire(
+ path.join( ROOT_DIR, 'package.json' )
+ );
+ for ( const depName of deps ) {
+ // First-match-wins: a local package with the same `name` already
+ // claimed this slot, skip the dep.
+ if ( localNames.has( depName ) || registry.has( depName ) ) {
+ continue;
+ }
+
+ // Resolve the dep's package.json. Some packages don't expose it in
+ // their `exports`, so fall back to a direct node_modules lookup.
+ let pkgJsonPath;
+ try {
+ pkgJsonPath = localRequire.resolve(
+ `${ depName }/package.json`
+ );
+ } catch {
+ const direct = path.join(
+ ROOT_DIR,
+ 'node_modules',
+ depName,
+ 'package.json'
+ );
+ if ( ! existsSync( direct ) ) {
+ continue;
+ }
+ pkgJsonPath = direct;
+ }
+
+ const depPackageJson = getPackageInfoFromFile( pkgJsonPath );
+ if ( ! depPackageJson.wpScriptModuleExports ) {
+ continue;
+ }
+
+ registry.set( depName, {
+ dir: path.dirname( pkgJsonPath ),
+ packageJson: depPackageJson,
+ external: true,
+ } );
+ }
+
+ return registry;
+}
+
+const PACKAGES = getAllPackages();
+
+// Set of every discovered package's `name` field. Used by the externals
+// plugin to externalize internal-package imports by exact name, regardless
+// of `packageNamespace`. Decouples script-module identity from a config
+// string so a package's own `name` survives end-to-end (npm name === import
+// specifier === script-module ID).
+const INTERNAL_PACKAGE_NAMES = new Set(
+ Array.from( PACKAGES.values() )
+ .map( ( entry ) => entry.packageJson.name )
+ .filter( Boolean )
+);
+
/**
* Interprets a configuration value as a boolean, where `"true"` and `"1"`
* are considered true while all other values are false.
@@ -159,7 +252,8 @@ const wordpressExternalsPlugin = createWordpressExternalsPlugin(
PACKAGE_NAMESPACE,
SCRIPT_GLOBAL,
EXTERNAL_NAMESPACES,
- HANDLE_PREFIX
+ HANDLE_PREFIX,
+ INTERNAL_PACKAGE_NAMES
);
const styleRuntimeRequire = createNodeRequire( import.meta.url );
@@ -522,19 +616,18 @@ function resolveEntryPoint( packageDir, packageJson ) {
*/
async function bundlePackage( packageName, options = {} ) {
const {
- sourceDir = PACKAGES_DIR,
handlePrefix = HANDLE_PREFIX,
scriptGlobal = SCRIPT_GLOBAL,
packageNamespace = PACKAGE_NAMESPACE,
} = options;
+ const entry = PACKAGES.get( packageName );
+ const packageDir = entry.dir;
+ const packageJson = entry.packageJson;
+
const builtModules = [];
const builtScripts = [];
const builtStyles = [];
- const packageDir = path.join( sourceDir, packageName );
- const packageJson = getPackageInfoFromFile(
- path.join( sourceDir, packageName, 'package.json' )
- );
const builds = [];
@@ -724,10 +817,19 @@ async function bundlePackage( packageName, options = {} ) {
);
}
+ // The script-module ID is the package's own `name` field. The
+ // PHP registry, the asset manifest, and `wp_register_script_module`
+ // all treat the ID as an opaque string, so this lets the npm name
+ // survive end-to-end without being rewritten by build configuration.
+ // Falls back to the legacy `@/` shape
+ // only when `name` is missing (e.g. an unnamed local package).
+ const packageId =
+ packageJson.name ||
+ `@${ packageNamespace }/${ packageName }`;
const scriptModuleId =
exportName === '.'
- ? `@${ packageNamespace }/${ packageName }`
- : `@${ packageNamespace }/${ packageName }/${ fileName }`;
+ ? packageId
+ : `${ packageId }/${ fileName }`;
builtModules.push( {
id: scriptModuleId,
@@ -933,7 +1035,7 @@ async function inferStyleDependencies( scriptDependencies, packageName ) {
const styleDeps = [];
// Get the resolve directory for context-aware package resolution
- const resolveDir = path.join( PACKAGES_DIR, packageName );
+ const resolveDir = PACKAGES.get( packageName )?.dir || PACKAGES_DIR;
for ( const scriptHandle of scriptDependencies ) {
// Skip non-package dependencies (like 'react', 'lodash', etc.)
@@ -1294,17 +1396,17 @@ async function generatePagesPhp( pageData, replacements ) {
*/
async function transpilePackage( packageName ) {
const startTime = Date.now();
- const packageDir = path.join( PACKAGES_DIR, packageName );
- const packageJson = getPackageInfoFromFile(
- path.join( PACKAGES_DIR, packageName, 'package.json' )
- );
+ const entry = PACKAGES.get( packageName );
- if ( ! packageJson ) {
+ if ( ! entry ) {
throw new Error(
`Could not find package.json for package: ${ packageName }`
);
}
+ const packageDir = entry.dir;
+ const packageJson = entry.packageJson;
+
const srcFiles = await glob( `src/**/*.${ SOURCE_EXTENSIONS }`, {
cwd: packageDir,
ignore: IGNORE_PATTERNS,
@@ -1513,10 +1615,9 @@ async function transpilePackage( packageName ) {
* @return {Promise} Build time in milliseconds, or null if no styles.
*/
async function compileStyles( packageName ) {
- const packageDir = path.join( PACKAGES_DIR, packageName );
- const packageJson = getPackageInfoFromFile(
- path.join( PACKAGES_DIR, packageName, 'package.json' )
- );
+ const entry = PACKAGES.get( packageName );
+ const packageDir = entry.dir;
+ const packageJson = entry.packageJson;
// Get SCSS entry point patterns from package.json, default to root-level only
const scssEntryPointPatterns = packageJson.wpStyleEntryPoints || [
@@ -1624,12 +1725,20 @@ function isPackageSourceFile( filename ) {
return false;
}
- return PACKAGES.some( ( packageName ) => {
+ for ( const entry of PACKAGES.values() ) {
+ // External packages are not transpiled from source, so their files
+ // do not trigger rebuilds via this path.
+ if ( entry.external ) {
+ continue;
+ }
const packagePath = normalizePath(
- path.join( 'packages', packageName )
+ path.relative( ROOT_DIR, entry.dir )
);
- return relativePath.startsWith( packagePath + '/' );
- } );
+ if ( relativePath.startsWith( packagePath + '/' ) ) {
+ return true;
+ }
+ }
+ return false;
}
/**
@@ -1643,9 +1752,12 @@ function getPackageName( filename ) {
path.relative( process.cwd(), filename )
);
- for ( const packageName of PACKAGES ) {
+ for ( const [ packageName, entry ] of PACKAGES ) {
+ if ( entry.external ) {
+ continue;
+ }
const packagePath = normalizePath(
- path.join( 'packages', packageName )
+ path.relative( ROOT_DIR, entry.dir )
);
if ( relativePath.startsWith( packagePath + '/' ) ) {
return packageName;
@@ -2053,17 +2165,14 @@ async function buildAll( baseUrlExpression ) {
const startTime = Date.now();
- // Build maps: short name ↔ full name ↔ package.json from package.json files
+ // Build maps: short name ↔ full name ↔ package.json from the registry.
const shortToFull = new Map();
const fullToShort = new Map();
const fullToPackageJson = new Map();
- for ( const pkg of PACKAGES ) {
- const packageJson = getPackageInfoFromFile(
- path.join( PACKAGES_DIR, pkg, 'package.json' )
- );
- shortToFull.set( pkg, packageJson.name );
- fullToShort.set( packageJson.name, pkg );
- fullToPackageJson.set( packageJson.name, packageJson );
+ for ( const [ pkg, entry ] of PACKAGES ) {
+ shortToFull.set( pkg, entry.packageJson.name );
+ fullToShort.set( entry.packageJson.name, pkg );
+ fullToPackageJson.set( entry.packageJson.name, entry.packageJson );
}
const levels = groupByDepth( fullToPackageJson );
@@ -2074,6 +2183,15 @@ async function buildAll( baseUrlExpression ) {
await Promise.all(
level.map( async ( fullName ) => {
const packageName = fullToShort.get( fullName );
+ const entry = PACKAGES.get( packageName );
+
+ // External packages are pre-built outside this plugin
+ // (e.g. a workspace dep). Skip transpilation; they are
+ // bundled and externalized in Phase 2.
+ if ( entry.external ) {
+ return;
+ }
+
const buildTime = await transpilePackage( packageName );
console.log(
` ✔ Transpiled ${ packageName } (${ buildTime }ms)`
@@ -2087,7 +2205,7 @@ async function buildAll( baseUrlExpression ) {
const scripts = [];
const styles = [];
await Promise.all(
- PACKAGES.map( async ( packageName ) => {
+ Array.from( PACKAGES.keys() ).map( async ( packageName ) => {
const startBundleTime = Date.now();
const ret = await bundlePackage( packageName );
const buildTime = Date.now() - startBundleTime;
@@ -2232,17 +2350,14 @@ async function watchMode() {
let isRebuilding = false;
const needsRebuild = new Set();
- // Build maps: short name ↔ full name ↔ package.json from package.json files (once)
+ // Build maps: short name ↔ full name ↔ package.json from the registry (once)
const shortToFull = new Map();
const fullToShort = new Map();
const fullToPackageJson = new Map();
- for ( const pkg of PACKAGES ) {
- const packageJson = getPackageInfoFromFile(
- path.join( PACKAGES_DIR, pkg, 'package.json' )
- );
- shortToFull.set( pkg, packageJson.name );
- fullToShort.set( packageJson.name, pkg );
- fullToPackageJson.set( packageJson.name, packageJson );
+ for ( const [ pkg, entry ] of PACKAGES ) {
+ shortToFull.set( pkg, entry.packageJson.name );
+ fullToShort.set( entry.packageJson.name, pkg );
+ fullToPackageJson.set( entry.packageJson.name, entry.packageJson );
}
// Get all routes and widgets for dependency tracking
@@ -2275,8 +2390,14 @@ async function watchMode() {
async function rebuildPackage( packageName ) {
try {
const startTime = Date.now();
+ const entry = PACKAGES.get( packageName );
- await transpilePackage( packageName );
+ // External packages are pre-built outside this plugin; only
+ // rebundle them when their declared script-module entry is
+ // regenerated. Skip transpilation.
+ if ( ! entry?.external ) {
+ await transpilePackage( packageName );
+ }
await bundlePackage( packageName );
const buildTime = Date.now() - startTime;
@@ -2375,9 +2496,9 @@ async function watchMode() {
await processNextRebuild();
}
- const watchPaths = PACKAGES.map( ( packageName ) =>
- path.join( PACKAGES_DIR, packageName, 'src' )
- );
+ const watchPaths = Array.from( PACKAGES.values() )
+ .filter( ( entry ) => ! entry.external )
+ .map( ( entry ) => path.join( entry.dir, 'src' ) );
const watcher = chokidar.watch( watchPaths, {
ignored: [
diff --git a/packages/wp-build/lib/wordpress-externals-plugin.mjs b/packages/wp-build/lib/wordpress-externals-plugin.mjs
index 2b7e01a66f60b0..014f013c370bfa 100644
--- a/packages/wp-build/lib/wordpress-externals-plugin.mjs
+++ b/packages/wp-build/lib/wordpress-externals-plugin.mjs
@@ -46,17 +46,19 @@ async function generateContentHash(
* This plugin handles WordPress package externals and vendor libraries,
* treating them as external dependencies available via global variables.
*
- * @param {string} packageNamespace Custom package namespace (e.g., 'wordpress', 'my-plugin').
- * @param {string|false} scriptGlobal Global variable name (e.g., 'wp', 'myPlugin') or false to disable globals.
- * @param {Object} externalNamespaces Additional namespaces to externalize (e.g., { 'woo': { global: 'woo', handlePrefix: 'woocommerce' } }).
- * @param {string} handlePrefix Handle prefix for main package (e.g., 'wp', 'mp'). Defaults to packageNamespace.
+ * @param {string} packageNamespace Custom package namespace (e.g., 'wordpress', 'my-plugin').
+ * @param {string|false} scriptGlobal Global variable name (e.g., 'wp', 'myPlugin') or false to disable globals.
+ * @param {Object} externalNamespaces Additional namespaces to externalize (e.g., { 'woo': { global: 'woo', handlePrefix: 'woocommerce' } }).
+ * @param {string} handlePrefix Handle prefix for main package (e.g., 'wp', 'mp'). Defaults to packageNamespace.
+ * @param {Set} [internalPackageNames] Exact package names discovered locally (the `name` field from each `packages//package.json`). Imports matching any of these are externalized as script modules by exact name, regardless of `packageNamespace`. Enables a package's `name` to drive its script-module identity directly.
* @return {Function} Function that creates the esbuild plugin instance.
*/
export function createWordpressExternalsPlugin(
packageNamespace,
scriptGlobal,
externalNamespaces = {},
- handlePrefix
+ handlePrefix,
+ internalPackageNames = new Set()
) {
/**
* WordPress externals plugin for esbuild.
@@ -199,6 +201,70 @@ export function createWordpressExternalsPlugin(
);
}
+ // Strict-name externalization for discovered internal packages.
+ // Matches the import specifier against the package's own `name`
+ // field (the registry passed in from build.mjs), letting an
+ // internal package keep its npm-scope identity end-to-end
+ // instead of being rewritten as `@/`.
+ // Registered before the namespace-pattern handlers below so it
+ // wins for any package whose name is in the local registry; the
+ // regex pattern below still covers external dependencies that
+ // happen to live under the same namespace (e.g. `@wordpress/*`
+ // packages bundled as Core prerequisites).
+ for ( const internalName of internalPackageNames ) {
+ const escapedName = internalName.replace(
+ /[.*+?^${}()|[\]\\]/g,
+ '\\$&'
+ );
+
+ build.onResolve(
+ { filter: new RegExp( `^${ escapedName }(/|$)` ) },
+ /** @param {import('esbuild').OnResolveArgs} args */
+ ( args ) => {
+ const subpath =
+ args.path.length > internalName.length
+ ? args.path.slice( internalName.length + 1 )
+ : null;
+
+ const packageJson = getPackageInfo(
+ internalName,
+ args.resolveDir
+ );
+
+ if ( ! packageJson ) {
+ return undefined;
+ }
+
+ const isScriptModule = isScriptModuleImport(
+ packageJson,
+ subpath
+ );
+ if ( ! isScriptModule ) {
+ return undefined;
+ }
+
+ const kind =
+ args.kind === 'dynamic-import'
+ ? 'dynamic'
+ : 'static';
+
+ if ( kind === 'static' ) {
+ moduleDependencies.set( args.path, 'static' );
+ } else if (
+ ! moduleDependencies.has( args.path )
+ ) {
+ moduleDependencies.set( args.path, 'dynamic' );
+ }
+
+ return {
+ path: args.path,
+ external: true,
+ sideEffects: !! packageJson.sideEffects,
+ };
+ }
+ );
+ }
+
// Handle package namespace externals (wordpress and custom)
for ( const externalConfig of packageExternals ) {
build.onResolve(