From 81c97a435a4f7c865240e3796fc05f6e596b0d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 29 May 2026 12:27:49 +0100 Subject: [PATCH 1/2] Build Tools: Use package.json name as the script-module ID Decouples script-module identity from wpPlugin.packageNamespace by reading each package's own name field as the script-module ID and externalizing internal-package imports against an exact-name registry. The PHP registry, the asset manifest, and wp_register_script_module already treat IDs as opaque strings, so the npm name now survives end-to-end (npm name === import specifier === script-module ID). No-op for Core (every package's name already matches the legacy derivation). Unblocks consumers whose owned npm scope differs from packageNamespace from keeping a single identifier across pnpm, IDE, and the WordPress runtime. Replaces #77226 with a removal-of-coupling framing. --- packages/wp-build/CHANGELOG.md | 4 + packages/wp-build/lib/build.mjs | 33 +++++++- .../lib/wordpress-externals-plugin.mjs | 76 +++++++++++++++++-- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index 2bb8dcd7cf7752..9f9d0480a85b23 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; enables consumers whose owned npm scope differs from `packageNamespace` to keep a single identifier across pnpm, IDE, and the WordPress runtime. + ### 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..c2e840751c646a 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -109,6 +109,23 @@ function getAllPackages() { } const PACKAGES = getAllPackages(); + +// Collect every discovered package's `name` field. This registry is the +// source of truth for script-module identity: the externals plugin uses it +// to externalize internal-package imports by exact name, and the bundler +// uses it as the script-module ID directly. Decoupling identity from +// `packageNamespace` lets the package's own `name` survive end-to-end +// (npm name === import specifier === script-module ID), instead of being +// rewritten to `@/` at build time. +const INTERNAL_PACKAGE_NAMES = new Set( + PACKAGES.map( + ( pkg ) => + getPackageInfoFromFile( + path.join( PACKAGES_DIR, pkg, 'package.json' ) + ).name + ).filter( Boolean ) +); + const ROOT_PACKAGE_JSON = getPackageInfoFromFile( path.join( ROOT_DIR, 'package.json' ) ); @@ -159,7 +176,8 @@ const wordpressExternalsPlugin = createWordpressExternalsPlugin( PACKAGE_NAMESPACE, SCRIPT_GLOBAL, EXTERNAL_NAMESPACES, - HANDLE_PREFIX + HANDLE_PREFIX, + INTERNAL_PACKAGE_NAMES ); const styleRuntimeRequire = createNodeRequire( import.meta.url ); @@ -724,10 +742,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, 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( From 0e821a2385bc62691e57565c401f5682b924c113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 29 May 2026 13:22:44 +0100 Subject: [PATCH 2/2] Build Tools: Discover script-module packages outside ./packages/ via convention Extends discovery so a plugin can pull in shared script-module packages from outside ./packages/ without any wp-build config. Any entry in package.json#dependencies whose own package.json declares wpScriptModuleExports is registered as a script module, bundled, and externalized under its actual npm name. Refactors the PACKAGES registry from string[] to Map so a single bundler pass handles local-source packages (transpiled + bundled) and convention-discovered packages (bundled + externalized, no transpilation). The packageJson name field already drives the script-module ID after the previous commit, so convention-discovered packages keep their npm identity end-to-end. Together with the identity refactor, closes the path from "shared package living anywhere the package manager can resolve it" to "separately-registered script module with module_dependencies tracked in .asset.php", with no new config surface. Local packages still take first-match precedence on name collision. Verified: Gutenberg's build is byte-identical (no dependencies entries declare wpScriptModuleExports, so the convention path is a no-op for Core). --- packages/wp-build/CHANGELOG.md | 3 +- packages/wp-build/lib/build.mjs | 238 ++++++++++++++++++++++---------- 2 files changed, 168 insertions(+), 73 deletions(-) diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index 9f9d0480a85b23..857be2e0b73857 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -4,7 +4,8 @@ ### 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 pnpm, IDE, and the WordPress runtime. +- 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 diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index c2e840751c646a..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,46 +96,121 @@ const TEST_FILE_PATTERNS = [ /\.(native|ios|android)\.(js|ts|tsx)$/, ]; +const ROOT_PACKAGE_JSON = getPackageInfoFromFile( + path.join( ROOT_DIR, 'package.json' ) +); +const WP_PLUGIN_CONFIG = ROOT_PACKAGE_JSON.wpPlugin || {}; +const SCRIPT_GLOBAL = WP_PLUGIN_CONFIG.scriptGlobal; +const PACKAGE_NAMESPACE = WP_PLUGIN_CONFIG.packageNamespace; +const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE; +const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {}; +const PAGES = WP_PLUGIN_CONFIG.pages || []; + /** - * Get all package names from the packages directory. + * A discovered package in the registry. * - * @return {string[]} Array of package names. + * @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() { - return glob - .sync( normalizePath( path.join( PACKAGES_DIR, '*', 'package.json' ) ) ) - .map( ( packageJsonPath ) => - path.basename( path.dirname( packageJsonPath ) ) - ); + 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(); -// Collect every discovered package's `name` field. This registry is the -// source of truth for script-module identity: the externals plugin uses it -// to externalize internal-package imports by exact name, and the bundler -// uses it as the script-module ID directly. Decoupling identity from -// `packageNamespace` lets the package's own `name` survive end-to-end -// (npm name === import specifier === script-module ID), instead of being -// rewritten to `@/` at build time. +// 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( - PACKAGES.map( - ( pkg ) => - getPackageInfoFromFile( - path.join( PACKAGES_DIR, pkg, 'package.json' ) - ).name - ).filter( Boolean ) -); - -const ROOT_PACKAGE_JSON = getPackageInfoFromFile( - path.join( ROOT_DIR, 'package.json' ) + Array.from( PACKAGES.values() ) + .map( ( entry ) => entry.packageJson.name ) + .filter( Boolean ) ); -const WP_PLUGIN_CONFIG = ROOT_PACKAGE_JSON.wpPlugin || {}; -const SCRIPT_GLOBAL = WP_PLUGIN_CONFIG.scriptGlobal; -const PACKAGE_NAMESPACE = WP_PLUGIN_CONFIG.packageNamespace; -const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE; -const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {}; -const PAGES = WP_PLUGIN_CONFIG.pages || []; /** * Interprets a configuration value as a boolean, where `"true"` and `"1"` @@ -540,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 = []; @@ -960,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.) @@ -1321,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, @@ -1540,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 || [ @@ -1651,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; } /** @@ -1670,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; @@ -2080,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 ); @@ -2101,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)` @@ -2114,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; @@ -2259,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 @@ -2302,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; @@ -2402,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: [