diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index cabdeb1974e7f2..37f65137036029 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -2,6 +2,10 @@ ## 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; lets consumers whose owned npm scope differs from `packageNamespace` keep a single identifier across npm, IDE, and the WordPress runtime. + ## 0.16.0 (2026-06-10) ### Bug Fixes diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index e126056293fb94..b270030380240b 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -105,6 +105,19 @@ function getAllPackages() { } const PACKAGES = getAllPackages(); + +// The `name` field of every internal package, used by the externals +// plugin to externalize internal-package imports by exact name regardless +// of `packageNamespace`. Keeps script-module identity tied to a package's +// own `name` (npm name === import specifier === script-module ID). +const INTERNAL_PACKAGE_NAMES = new Set( + PACKAGES.map( + ( packageName ) => + getPackageInfoFromFile( + path.join( PACKAGES_DIR, packageName, 'package.json' ) + ).name + ).filter( Boolean ) +); const ROOT_PACKAGE_JSON = getPackageInfoFromFile( path.join( ROOT_DIR, 'package.json' ) ); @@ -155,7 +168,8 @@ const wordpressExternalsPlugin = createWordpressExternalsPlugin( PACKAGE_NAMESPACE, SCRIPT_GLOBAL, EXTERNAL_NAMESPACES, - HANDLE_PREFIX + HANDLE_PREFIX, + INTERNAL_PACKAGE_NAMES ); const styleRuntimeRequire = createNodeRequire( import.meta.url ); @@ -696,10 +710,16 @@ async function bundlePackage( packageName, options = {} ) { ); } + // The script-module ID is the package's own `name` field, so the + // npm name survives end-to-end (npm name === import specifier === + // script-module ID). The PHP registry, the asset manifest, and + // `wp_register_script_module` all treat the ID as an opaque string. + // 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 }`; + exportName === '.' ? packageId : `${ packageId }/${ fileName }`; builtModules.push( { id: scriptModuleId, diff --git a/packages/wp-build/lib/wordpress-externals-plugin.mjs b/packages/wp-build/lib/wordpress-externals-plugin.mjs index 2b7e01a66f60b0..db9f1c4edb2de0 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] `name` fields of every internal package. Imports matching any of these are externalized by exact name, regardless of `packageNamespace`. * @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,84 @@ export function createWordpressExternalsPlugin( ); } + // Externalize imports of internal packages by their exact `name`. They are + // matched by name rather than by scope, since a package's scope may also hold + // unrelated third-party packages that scope matching would externalize too. + if ( internalPackageNames.size > 0 ) { + // Longest-first: avoids `@org/block` shadowing `@org/block-editor`. + const namesSorted = Array.from( internalPackageNames ).sort( + ( a, b ) => b.length - a.length + ); + const escapedNames = namesSorted.map( ( n ) => + n.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) + ); + const internalNamesFilter = new RegExp( + `^(?:${ escapedNames.join( '|' ) })(?:/|$)` + ); + + build.onResolve( + { filter: internalNamesFilter }, + /** @param {import('esbuild').OnResolveArgs} args */ + ( args ) => { + const head = args.path.startsWith( '@' ) + ? args.path.split( '/', 2 ).join( '/' ) + : args.path.split( '/', 1 )[ 0 ]; + + if ( ! internalPackageNames.has( head ) ) { + return undefined; + } + + const subpath = + args.path.length > head.length + ? args.path.slice( head.length + 1 ) + : null; + + const packageJson = getPackageInfo( + head, + args.resolveDir + ); + + if ( ! packageJson ) { + return undefined; + } + + const isScriptModule = isScriptModuleImport( + packageJson, + subpath + ); + const isScript = !! packageJson.wpScript; + + // Dual packages: IIFE yields to the namespace handler. + let externalize = isScriptModule; + if ( isScriptModule && isScript ) { + externalize = buildFormat === 'esm'; + } + if ( ! externalize ) { + 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(