From 8e59c9a757ae32af82ca54f84955346ecc890a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 10 Apr 2026 11:09:10 +0100 Subject: [PATCH 1/7] refactor PACKAGES to a registry Map in wp-build convert getAllPackages() from string[] to Map and update all call sites to use registry lookups --- packages/wp-build/CHANGELOG.md | 4 + packages/wp-build/README.md | 40 ++++ packages/wp-build/lib/build.mjs | 263 ++++++++++++++++++------ packages/wp-build/lib/package-utils.mjs | 37 +++- 4 files changed, 281 insertions(+), 63 deletions(-) diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index c736c703f48bc9..b1c732082f6d72 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- Add `wpPlugin.sources` configuration for discovering packages from additional roots or by npm name. Directory paths scan for packages; named sources (e.g. `@automattic/charts`) are resolved via `require.resolve()` and preserve their npm identity as the script-module ID. + ## 0.12.0 (2026-04-15) ## 0.11.0 (2026-04-01) diff --git a/packages/wp-build/README.md b/packages/wp-build/README.md index cf05da43cbdeb2..b0921a5028bda0 100644 --- a/packages/wp-build/README.md +++ b/packages/wp-build/README.md @@ -255,6 +255,46 @@ This allows your packages to consume third-party dependencies as externals: If `handlePrefix` is omitted, it defaults to the namespace key (e.g., `"woo"` → `woo-cart`). +### `wpPlugin.sources` + +Additional package sources to discover and compile. By default the tool only discovers packages under `./packages/`. The `sources` field accepts an array that can contain two types of entries: + +**Directory paths** — relative paths pointing to a directory containing packages: + +```json +{ + "wpPlugin": { + "sources": [ "../js-packages" ] + } +} +``` + +With this configuration the tool scans both `./packages/*` and `../js-packages/*`. Local packages always take priority: if a sources-discovered package has the same directory name as a local one, the local one wins. + +**Package names** — npm package names resolved via Node's module resolution: + +```json +{ + "wpPlugin": { + "sources": [ "@automattic/charts", "@automattic/number-formatters" ] + } +} +``` + +Named sources are resolved using `require.resolve()`, so they work with any package manager's workspace protocol (pnpm `workspace:*`, yarn `workspace:*`, npm workspaces). The package must be declared as a dependency and installed. + +Named sources **preserve their npm identity** as the script-module ID. For example, `@automattic/charts` is registered as `@automattic/charts` — not rewritten under the consumer's `packageNamespace`. This enables proper deduplication when multiple plugins consume the same shared package. The source's scoped namespace (e.g., `@automattic`) is automatically added to the externals configuration so that imports resolve correctly. + +Both types can be mixed in a single array: + +```json +{ + "wpPlugin": { + "sources": [ "../shared-packages", "@automattic/charts" ] + } +} +``` + ### `wpPlugin.pages` (Experimental) Define admin pages that support routes. Each page gets generated PHP functions for route registration and can be extended by other plugins. diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 628cf0234b43ae..1c3369e8afc1dd 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -4,8 +4,10 @@ * 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 } from 'node:module'; import { parseArgs } from 'node:util'; import esbuild from 'esbuild'; import glob from 'fast-glob'; @@ -90,29 +92,176 @@ const TEST_FILE_PATTERNS = [ ]; /** - * 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} [externalSource] True when the entry comes from a named + * package source (e.g. `@scope/name`). + * These packages preserve their own npm + * identity for script-module IDs instead + * of being scoped under `packageNamespace`. */ -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' ) ); const WP_PLUGIN_CONFIG = ROOT_PACKAGE_JSON.wpPlugin || {}; + +/** + * Check whether a sources entry is a npm package name rather than a + * relative/absolute directory path. + * + * Package names follow the npm naming rules: + * - Scoped: `@scope/name` + * - Bare: `my-package` (starts with a letter or digit, no path separators) + * + * Directory paths start with `.`, `/`, or a drive letter on Windows (`C:\`). + * + * @param {string} source A single entry from `wpPlugin.sources`. + * @return {boolean} True when the entry looks like a package name. + */ +function isPackageName( source ) { + if ( source.startsWith( '@' ) ) { + return true; + } + // Relative or absolute paths. + if ( source.startsWith( '.' ) || path.isAbsolute( source ) ) { + return false; + } + // Bare package name (no slashes → not a path). + return ! source.includes( '/' ); +} + +/** + * Resolve a npm package name to its directory and parsed package.json. + * Uses Node's module resolution from the project root context so that + * workspace symlinks (pnpm, yarn, npm) are followed automatically. + * + * @param {string} npmName Full package name (e.g. `@automattic/charts`). + * @return {{ dir: string, packageJson: import('./package-utils.mjs').PackageJson }|null} + * Resolved entry or null when the package is not resolvable. + */ +function resolveNamedSource( npmName ) { + // Read directly from node_modules instead of require.resolve() to + // avoid ERR_PACKAGE_PATH_NOT_EXPORTED when the package's `exports` + // field does not include `./package.json`. + const pkgJsonPath = path.join( + ROOT_DIR, + 'node_modules', + npmName, + 'package.json' + ); + + if ( ! existsSync( pkgJsonPath ) ) { + console.warn( + `⚠️ Source "${ npmName }" could not be resolved. ` + + 'Make sure it is listed in dependencies and installed.' + ); + return null; + } + + return { + dir: path.dirname( pkgJsonPath ), + packageJson: getPackageInfoFromFile( pkgJsonPath ), + }; +} + +/** + * Directories to scan for packages. Always starts with `./packages/`. + * Additional directory-type entries from `wpPlugin.sources` are appended. + * Package-name entries are handled separately in `getAllPackages()`. + * + * @type {string[]} + */ +const SOURCES = WP_PLUGIN_CONFIG.sources || []; +const PACKAGE_DIRS = [ + PACKAGES_DIR, + ...SOURCES.filter( ( s ) => ! isPackageName( s ) ).map( ( s ) => + path.resolve( ROOT_DIR, s ) + ), +]; +const NAMED_SOURCES = SOURCES.filter( isPackageName ); + +/** + * Get all packages by scanning every directory in PACKAGE_DIRS and + * resolving every named source in NAMED_SOURCES. + * + * Local packages (`./packages/`) are scanned first, so they take priority. + * Named sources are resolved last — they preserve their npm identity + * (e.g. `@automattic/charts` stays `@automattic/charts` in script-module + * IDs instead of being rewritten to `@/charts`). + * + * @return {Map} Map of package names to their entry data. + */ +function getAllPackages() { + const registry = new Map(); + + // 1. Directory-based discovery (local packages first, then source dirs). + for ( const dir of PACKAGE_DIRS ) { + const pkgJsonPaths = glob.sync( + normalizePath( + path.join( dir, '*', 'package.json' ) + ) + ); + + for ( const pkgJsonPath of pkgJsonPaths ) { + const name = path.basename( path.dirname( pkgJsonPath ) ); + // First match wins — local packages take priority over + // sources-discovered packages. + if ( ! registry.has( name ) ) { + registry.set( name, { + dir: path.dirname( pkgJsonPath ), + packageJson: getPackageInfoFromFile( pkgJsonPath ), + } ); + } + } + } + + // 2. Named sources — resolve via require.resolve() and preserve + // the full npm name as the registry key. + for ( const npmName of NAMED_SOURCES ) { + if ( registry.has( npmName ) ) { + continue; + } + + const entry = resolveNamedSource( npmName ); + if ( entry ) { + registry.set( npmName, { + ...entry, + externalSource: true, + } ); + } + } + + return registry; +} + +const PACKAGES = getAllPackages(); 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 || []; +// Merge user-defined external namespaces with namespaces inferred from +// named sources. For example, a source `@automattic/charts` implies that +// `@automattic/*` imports should be treated as externals so that the +// externals plugin can detect their `wpScriptModuleExports` field. +const EXTERNAL_NAMESPACES = { + ...( WP_PLUGIN_CONFIG.externalNamespaces || {} ), +}; +for ( const npmName of NAMED_SOURCES ) { + if ( npmName.startsWith( '@' ) ) { + const ns = npmName.split( '/' )[ 0 ].slice( 1 ); // '@scope/name' → 'scope' + if ( ! EXTERNAL_NAMESPACES[ ns ] ) { + EXTERNAL_NAMESPACES[ ns ] = { + handlePrefix: ns, + }; + } + } +} + /** * Interprets a configuration value as a boolean, where `"true"` and `"1"` * are considered true while all other values are false. @@ -480,7 +629,6 @@ function resolveEntryPoint( packageDir, packageJson ) { */ async function bundlePackage( packageName, options = {} ) { const { - sourceDir = PACKAGES_DIR, handlePrefix = HANDLE_PREFIX, scriptGlobal = SCRIPT_GLOBAL, packageNamespace = PACKAGE_NAMESPACE, @@ -489,10 +637,10 @@ async function bundlePackage( packageName, options = {} ) { const builtModules = []; const builtScripts = []; const builtStyles = []; - const packageDir = path.join( sourceDir, packageName ); - const packageJson = getPackageInfoFromFile( - path.join( sourceDir, packageName, 'package.json' ) - ); + const packageEntry = PACKAGES.get( packageName ); + const packageDir = packageEntry.dir; + const packageJson = packageEntry.packageJson; + const isExternalSource = !! packageEntry.externalSource; const builds = []; @@ -653,10 +801,16 @@ async function bundlePackage( packageName, options = {} ) { ); } - const scriptModuleId = - exportName === '.' - ? `@${ packageNamespace }/${ packageName }` - : `@${ packageNamespace }/${ packageName }/${ fileName }`; + // External sources preserve their npm identity as the + // script-module ID (e.g. `@automattic/charts`). Local + // packages are scoped under the plugin's namespace. + const scriptModuleId = isExternalSource + ? ( exportName === '.' + ? packageName + : `${ packageName }/${ fileName }` ) + : ( exportName === '.' + ? `@${ packageNamespace }/${ packageName }` + : `@${ packageNamespace }/${ packageName }/${ fileName }` ); builtModules.push( { id: scriptModuleId, @@ -862,7 +1016,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; for ( const scriptHandle of scriptDependencies ) { // Skip non-package dependencies (like 'react', 'lodash', etc.) @@ -1215,16 +1369,9 @@ 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' ) - ); - - if ( ! packageJson ) { - throw new Error( - `Could not find package.json for package: ${ packageName }` - ); - } + const packageEntry = PACKAGES.get( packageName ); + const packageDir = packageEntry.dir; + const packageJson = packageEntry.packageJson; const srcFiles = await glob( `src/**/*.${ SOURCE_EXTENSIONS }`, { cwd: packageDir, @@ -1432,10 +1579,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 packageEntry = PACKAGES.get( packageName ); + const packageDir = packageEntry.dir; + const packageJson = packageEntry.packageJson; // Get SCSS entry point patterns from package.json, default to root-level only const scssEntryPointPatterns = packageJson.wpStyleEntryPoints || [ @@ -1543,12 +1689,15 @@ function isPackageSourceFile( filename ) { return false; } - return PACKAGES.some( ( packageName ) => { + for ( const entry of PACKAGES.values() ) { 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; } /** @@ -1562,9 +1711,9 @@ function getPackageName( filename ) { path.relative( process.cwd(), filename ) ); - for ( const packageName of PACKAGES ) { + for ( const [ packageName, entry ] of PACKAGES ) { const packagePath = normalizePath( - path.join( 'packages', packageName ) + path.relative( ROOT_DIR, entry.dir ) ); if ( relativePath.startsWith( packagePath + '/' ) ) { return packageName; @@ -1727,17 +1876,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 ); @@ -1761,7 +1907,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; @@ -1894,17 +2040,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 for dependency tracking @@ -2015,8 +2158,8 @@ async function watchMode() { await processNextRebuild(); } - const watchPaths = PACKAGES.map( ( packageName ) => - path.join( PACKAGES_DIR, packageName, 'src' ) + const watchPaths = Array.from( PACKAGES.values() ).map( ( entry ) => + path.join( entry.dir, 'src' ) ); const watcher = chokidar.watch( watchPaths, { diff --git a/packages/wp-build/lib/package-utils.mjs b/packages/wp-build/lib/package-utils.mjs index 15b7dd04c5781a..79437f2b3b48f9 100644 --- a/packages/wp-build/lib/package-utils.mjs +++ b/packages/wp-build/lib/package-utils.mjs @@ -84,10 +84,41 @@ export function getPackageInfo( fullPackageName, resolveDir = null ) { return packageJsonCache.get( cacheKey ); } - // Resolve from the package root context to get correct versions const contextPath = path.join( packageRoot, 'package.json' ); - const require = createRequire( contextPath ); - const resolved = require.resolve( `${ fullPackageName }/package.json` ); + const localRequire = createRequire( contextPath ); + + let resolved; + try { + // Preferred: resolve the package.json subpath directly. + resolved = localRequire.resolve( + `${ fullPackageName }/package.json` + ); + } catch { + // Fallback for packages whose `exports` field does not expose + // `./package.json`. Walk up the directory tree checking each + // `node_modules/` — mirrors Node's resolution algorithm without + // the exports restriction. + let searchDir = packageRoot; + const fsRoot = path.parse( searchDir ).root; + while ( searchDir !== fsRoot ) { + const directPath = path.join( + searchDir, + 'node_modules', + fullPackageName, + 'package.json' + ); + if ( existsSync( directPath ) ) { + resolved = directPath; + break; + } + searchDir = path.dirname( searchDir ); + } + + if ( ! resolved ) { + return null; + } + } + const result = getPackageInfoFromFile( resolved ); packageJsonCache.set( cacheKey, result ); From 9c681692b53f353f50af4e2d4b8a01843c807d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 13 Apr 2026 18:25:53 +0100 Subject: [PATCH 2/7] add wpPlugin.packageSources config support directory paths and npm package names for cross-directory package discovery. Named sources preserve their npm identity as script-module IDs. Fix package.json resolution for strict exports. --- packages/wp-build/CHANGELOG.md | 2 +- packages/wp-build/README.md | 14 +++++++------- packages/wp-build/lib/build.mjs | 25 +++++++++++++------------ 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index b1c732082f6d72..93069c5d651fd3 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -4,7 +4,7 @@ ### Enhancements -- Add `wpPlugin.sources` configuration for discovering packages from additional roots or by npm name. Directory paths scan for packages; named sources (e.g. `@automattic/charts`) are resolved via `require.resolve()` and preserve their npm identity as the script-module ID. +- Add `wpPlugin.packageSources` configuration for discovering packages from additional directories or by npm name. Named sources (e.g. `@acme/shared-ui`) preserve their npm identity as the script-module ID. ## 0.12.0 (2026-04-15) diff --git a/packages/wp-build/README.md b/packages/wp-build/README.md index b0921a5028bda0..59b08c85f54b6f 100644 --- a/packages/wp-build/README.md +++ b/packages/wp-build/README.md @@ -255,16 +255,16 @@ This allows your packages to consume third-party dependencies as externals: If `handlePrefix` is omitted, it defaults to the namespace key (e.g., `"woo"` → `woo-cart`). -### `wpPlugin.sources` +### `wpPlugin.packageSources` -Additional package sources to discover and compile. By default the tool only discovers packages under `./packages/`. The `sources` field accepts an array that can contain two types of entries: +Additional package sources to discover and compile. By default the tool only discovers packages under `./packages/`. The `packageSources` field accepts an array that can contain two types of entries: **Directory paths** — relative paths pointing to a directory containing packages: ```json { "wpPlugin": { - "sources": [ "../js-packages" ] + "packageSources": [ "../js-packages" ] } } ``` @@ -276,21 +276,21 @@ With this configuration the tool scans both `./packages/*` and `../js-packages/* ```json { "wpPlugin": { - "sources": [ "@automattic/charts", "@automattic/number-formatters" ] + "packageSources": [ "@acme/shared-ui", "@acme/formatters" ] } } ``` -Named sources are resolved using `require.resolve()`, so they work with any package manager's workspace protocol (pnpm `workspace:*`, yarn `workspace:*`, npm workspaces). The package must be declared as a dependency and installed. +Named package sources are resolved from `node_modules`, so they work with any package manager's workspace protocol (pnpm `workspace:*`, yarn `workspace:*`, npm workspaces). The package must be declared as a dependency and installed. -Named sources **preserve their npm identity** as the script-module ID. For example, `@automattic/charts` is registered as `@automattic/charts` — not rewritten under the consumer's `packageNamespace`. This enables proper deduplication when multiple plugins consume the same shared package. The source's scoped namespace (e.g., `@automattic`) is automatically added to the externals configuration so that imports resolve correctly. +Named sources **preserve their npm identity** as the script-module ID. For example, `@acme/shared-ui` is registered as `@acme/shared-ui` — not rewritten under the consumer's `packageNamespace`. This enables proper deduplication when multiple plugins consume the same shared package. The source's scoped namespace (e.g., `@acme`) is automatically added to the externals configuration so that imports resolve correctly. Both types can be mixed in a single array: ```json { "wpPlugin": { - "sources": [ "../shared-packages", "@automattic/charts" ] + "packageSources": [ "../shared-packages", "@acme/shared-ui" ] } } ``` diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 1c3369e8afc1dd..94a3f380546094 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -119,7 +119,7 @@ const WP_PLUGIN_CONFIG = ROOT_PACKAGE_JSON.wpPlugin || {}; * * Directory paths start with `.`, `/`, or a drive letter on Windows (`C:\`). * - * @param {string} source A single entry from `wpPlugin.sources`. + * @param {string} source A single entry from `wpPlugin.packageSources`. * @return {boolean} True when the entry looks like a package name. */ function isPackageName( source ) { @@ -139,7 +139,7 @@ function isPackageName( source ) { * Uses Node's module resolution from the project root context so that * workspace symlinks (pnpm, yarn, npm) are followed automatically. * - * @param {string} npmName Full package name (e.g. `@automattic/charts`). + * @param {string} npmName Full package name (e.g. `@acme/shared-ui`). * @return {{ dir: string, packageJson: import('./package-utils.mjs').PackageJson }|null} * Resolved entry or null when the package is not resolvable. */ @@ -170,19 +170,20 @@ function resolveNamedSource( npmName ) { /** * Directories to scan for packages. Always starts with `./packages/`. - * Additional directory-type entries from `wpPlugin.sources` are appended. - * Package-name entries are handled separately in `getAllPackages()`. + * Additional directory-type entries from `wpPlugin.packageSources` are + * appended. Package-name entries are handled separately in + * `getAllPackages()`. * * @type {string[]} */ -const SOURCES = WP_PLUGIN_CONFIG.sources || []; +const PACKAGE_SOURCES = WP_PLUGIN_CONFIG.packageSources || []; const PACKAGE_DIRS = [ PACKAGES_DIR, - ...SOURCES.filter( ( s ) => ! isPackageName( s ) ).map( ( s ) => + ...PACKAGE_SOURCES.filter( ( s ) => ! isPackageName( s ) ).map( ( s ) => path.resolve( ROOT_DIR, s ) ), ]; -const NAMED_SOURCES = SOURCES.filter( isPackageName ); +const NAMED_SOURCES = PACKAGE_SOURCES.filter( isPackageName ); /** * Get all packages by scanning every directory in PACKAGE_DIRS and @@ -190,8 +191,8 @@ const NAMED_SOURCES = SOURCES.filter( isPackageName ); * * Local packages (`./packages/`) are scanned first, so they take priority. * Named sources are resolved last — they preserve their npm identity - * (e.g. `@automattic/charts` stays `@automattic/charts` in script-module - * IDs instead of being rewritten to `@/charts`). + * (e.g. `@acme/shared-ui` stays `@acme/shared-ui` in script-module + * IDs instead of being rewritten to `@/shared-ui`). * * @return {Map} Map of package names to their entry data. */ @@ -245,8 +246,8 @@ const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE; const PAGES = WP_PLUGIN_CONFIG.pages || []; // Merge user-defined external namespaces with namespaces inferred from -// named sources. For example, a source `@automattic/charts` implies that -// `@automattic/*` imports should be treated as externals so that the +// named sources. For example, a source `@acme/shared-ui` implies that +// `@acme/*` imports should be treated as externals so that the // externals plugin can detect their `wpScriptModuleExports` field. const EXTERNAL_NAMESPACES = { ...( WP_PLUGIN_CONFIG.externalNamespaces || {} ), @@ -802,7 +803,7 @@ async function bundlePackage( packageName, options = {} ) { } // External sources preserve their npm identity as the - // script-module ID (e.g. `@automattic/charts`). Local + // script-module ID (e.g. `@acme/shared-ui`). Local // packages are scoped under the plugin's namespace. const scriptModuleId = isExternalSource ? ( exportName === '.' From 263275e74858db1b8479f7df92e47a516b27055b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 14 Apr 2026 13:25:44 +0100 Subject: [PATCH 3/7] use per-package externals instead of namespace inference replace scope-wide externalization (@scope/*) with exact package matching for named sources. Skip transpilation for external packages. --- packages/wp-build/README.md | 2 +- packages/wp-build/lib/build.mjs | 40 ++++++----- .../lib/wordpress-externals-plugin.mjs | 71 ++++++++++++++++++- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/wp-build/README.md b/packages/wp-build/README.md index 59b08c85f54b6f..ad2280871ed484 100644 --- a/packages/wp-build/README.md +++ b/packages/wp-build/README.md @@ -283,7 +283,7 @@ With this configuration the tool scans both `./packages/*` and `../js-packages/* Named package sources are resolved from `node_modules`, so they work with any package manager's workspace protocol (pnpm `workspace:*`, yarn `workspace:*`, npm workspaces). The package must be declared as a dependency and installed. -Named sources **preserve their npm identity** as the script-module ID. For example, `@acme/shared-ui` is registered as `@acme/shared-ui` — not rewritten under the consumer's `packageNamespace`. This enables proper deduplication when multiple plugins consume the same shared package. The source's scoped namespace (e.g., `@acme`) is automatically added to the externals configuration so that imports resolve correctly. +Named sources **preserve their npm identity** as the script-module ID. For example, `@acme/shared-ui` is registered as `@acme/shared-ui` — not rewritten under the consumer's `packageNamespace`. This enables proper deduplication when multiple plugins consume the same shared package. Each named source is externalized individually (by exact package name, not the entire `@scope/*`), so only the packages explicitly listed in `packageSources` are treated as externals. The source package must have a `wpScriptModuleExports` field in its `package.json` for the externals plugin to detect it as a script module. Both types can be mixed in a single array: diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 94a3f380546094..e36ade4cc12b34 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -245,23 +245,12 @@ const PACKAGE_NAMESPACE = WP_PLUGIN_CONFIG.packageNamespace; const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE; const PAGES = WP_PLUGIN_CONFIG.pages || []; -// Merge user-defined external namespaces with namespaces inferred from -// named sources. For example, a source `@acme/shared-ui` implies that -// `@acme/*` imports should be treated as externals so that the -// externals plugin can detect their `wpScriptModuleExports` field. -const EXTERNAL_NAMESPACES = { - ...( WP_PLUGIN_CONFIG.externalNamespaces || {} ), -}; -for ( const npmName of NAMED_SOURCES ) { - if ( npmName.startsWith( '@' ) ) { - const ns = npmName.split( '/' )[ 0 ].slice( 1 ); // '@scope/name' → 'scope' - if ( ! EXTERNAL_NAMESPACES[ ns ] ) { - EXTERNAL_NAMESPACES[ ns ] = { - handlePrefix: ns, - }; - } - } -} +const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {}; + +// Individual packages from named sources to externalize. Unlike +// EXTERNAL_NAMESPACES (which externalizes an entire `@scope/*`), this +// targets only the exact packages listed in `packageSources`. +const EXTERNAL_PACKAGES = new Set( NAMED_SOURCES ); /** * Interprets a configuration value as a boolean, where `"true"` and `"1"` @@ -303,7 +292,8 @@ const wordpressExternalsPlugin = createWordpressExternalsPlugin( PACKAGE_NAMESPACE, SCRIPT_GLOBAL, EXTERNAL_NAMESPACES, - HANDLE_PREFIX + HANDLE_PREFIX, + EXTERNAL_PACKAGES ); /** @@ -1895,6 +1885,13 @@ async function buildAll( baseUrlExpression ) { await Promise.all( level.map( async ( fullName ) => { const packageName = fullToShort.get( fullName ); + const entry = PACKAGES.get( packageName ); + + // External sources are pre-built — only bundle, skip transpilation. + if ( entry.externalSource ) { + return; + } + const buildTime = await transpilePackage( packageName ); console.log( ` ✔ Transpiled ${ packageName } (${ buildTime }ms)` @@ -2062,8 +2059,13 @@ async function watchMode() { async function rebuildPackage( packageName ) { try { const startTime = Date.now(); + const entry = PACKAGES.get( packageName ); + + // External sources are pre-built — only rebundle. + if ( ! entry.externalSource ) { + await transpilePackage( packageName ); + } - await transpilePackage( packageName ); await bundlePackage( packageName ); const buildTime = Date.now() - startTime; diff --git a/packages/wp-build/lib/wordpress-externals-plugin.mjs b/packages/wp-build/lib/wordpress-externals-plugin.mjs index 2b7e01a66f60b0..b14df3dc4a99ea 100644 --- a/packages/wp-build/lib/wordpress-externals-plugin.mjs +++ b/packages/wp-build/lib/wordpress-externals-plugin.mjs @@ -56,7 +56,8 @@ export function createWordpressExternalsPlugin( packageNamespace, scriptGlobal, externalNamespaces = {}, - handlePrefix + handlePrefix, + externalPackages = new Set() ) { /** * WordPress externals plugin for esbuild. @@ -281,6 +282,74 @@ export function createWordpressExternalsPlugin( ); } + // Handle individual package externals from packageSources. + // These match exact package names rather than whole scopes, + // avoiding over-broad externalization. + for ( const extPkg of externalPackages ) { + const escaped = extPkg.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&' + ); + build.onResolve( + { filter: new RegExp( `^${ escaped }(/|$)` ) }, + /** @param {import('esbuild').OnResolveArgs} args */ + ( args ) => { + const subpath = + args.path.length > extPkg.length + ? args.path.slice( + extPkg.length + 1 + ) + : null; + + const packageJson = getPackageInfo( + extPkg, + args.resolveDir + ); + if ( ! packageJson ) { + return undefined; + } + + const isScriptModule = + isScriptModuleImport( + packageJson, + subpath + ); + if ( isScriptModule ) { + 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, + }; + } + + // Not a script module — let esbuild + // bundle it inline. + return undefined; + } + ); + } + build.onLoad( { filter: /.*/, namespace: 'vendor-external' }, /** @param {import('esbuild').OnLoadArgs} args */ From a914703ebdc1ee87ba2194fba4f1923647d08afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 14 Apr 2026 13:30:02 +0100 Subject: [PATCH 4/7] warn when named source lacks wpScriptModuleExports --- packages/wp-build/lib/build.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index e36ade4cc12b34..9a02b8a2ecee43 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -229,6 +229,13 @@ function getAllPackages() { const entry = resolveNamedSource( npmName ); if ( entry ) { + if ( ! entry.packageJson.wpScriptModuleExports ) { + console.warn( + `⚠️ Source "${ npmName }" does not declare wpScriptModuleExports. ` + + 'Imports will be bundled inline instead of externalized.' + ); + } + registry.set( npmName, { ...entry, externalSource: true, From dc47e9667e37a1881edd52474085879ac407c56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Fri, 17 Apr 2026 11:15:18 +0100 Subject: [PATCH 5/7] fix lint errors in wp-build --- packages/wp-build/lib/build.mjs | 20 ++++++++++-------- packages/wp-build/lib/package-utils.mjs | 4 +--- .../lib/wordpress-externals-plugin.mjs | 21 +++++++------------ 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index 9a02b8a2ecee43..d0bf9a897c1afe 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -7,7 +7,6 @@ import { readFile, writeFile, copyFile, mkdir, unlink } from 'fs/promises'; import { existsSync } from 'fs'; import path from 'path'; import { createHash } from 'node:crypto'; -import { createRequire } from 'node:module'; import { parseArgs } from 'node:util'; import esbuild from 'esbuild'; import glob from 'fast-glob'; @@ -202,9 +201,7 @@ function getAllPackages() { // 1. Directory-based discovery (local packages first, then source dirs). for ( const dir of PACKAGE_DIRS ) { const pkgJsonPaths = glob.sync( - normalizePath( - path.join( dir, '*', 'package.json' ) - ) + normalizePath( path.join( dir, '*', 'package.json' ) ) ); for ( const pkgJsonPath of pkgJsonPaths ) { @@ -802,13 +799,18 @@ async function bundlePackage( packageName, options = {} ) { // External sources preserve their npm identity as the // script-module ID (e.g. `@acme/shared-ui`). Local // packages are scoped under the plugin's namespace. - const scriptModuleId = isExternalSource - ? ( exportName === '.' + let scriptModuleId; + if ( isExternalSource ) { + scriptModuleId = + exportName === '.' ? packageName - : `${ packageName }/${ fileName }` ) - : ( exportName === '.' + : `${ packageName }/${ fileName }`; + } else { + scriptModuleId = + exportName === '.' ? `@${ packageNamespace }/${ packageName }` - : `@${ packageNamespace }/${ packageName }/${ fileName }` ); + : `@${ packageNamespace }/${ packageName }/${ fileName }`; + } builtModules.push( { id: scriptModuleId, diff --git a/packages/wp-build/lib/package-utils.mjs b/packages/wp-build/lib/package-utils.mjs index 79437f2b3b48f9..1a66afe14f6a79 100644 --- a/packages/wp-build/lib/package-utils.mjs +++ b/packages/wp-build/lib/package-utils.mjs @@ -90,9 +90,7 @@ export function getPackageInfo( fullPackageName, resolveDir = null ) { let resolved; try { // Preferred: resolve the package.json subpath directly. - resolved = localRequire.resolve( - `${ fullPackageName }/package.json` - ); + resolved = localRequire.resolve( `${ fullPackageName }/package.json` ); } catch { // Fallback for packages whose `exports` field does not expose // `./package.json`. Walk up the directory tree checking each diff --git a/packages/wp-build/lib/wordpress-externals-plugin.mjs b/packages/wp-build/lib/wordpress-externals-plugin.mjs index b14df3dc4a99ea..7722b565b42a2e 100644 --- a/packages/wp-build/lib/wordpress-externals-plugin.mjs +++ b/packages/wp-build/lib/wordpress-externals-plugin.mjs @@ -50,6 +50,7 @@ async function generateContentHash( * @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} [externalPackages] Individual package names to externalize by exact match (e.g., `@acme/shared-ui`). Used for named `packageSources` entries. * @return {Function} Function that creates the esbuild plugin instance. */ export function createWordpressExternalsPlugin( @@ -296,9 +297,7 @@ export function createWordpressExternalsPlugin( ( args ) => { const subpath = args.path.length > extPkg.length - ? args.path.slice( - extPkg.length + 1 - ) + ? args.path.slice( extPkg.length + 1 ) : null; const packageJson = getPackageInfo( @@ -309,11 +308,10 @@ export function createWordpressExternalsPlugin( return undefined; } - const isScriptModule = - isScriptModuleImport( - packageJson, - subpath - ); + const isScriptModule = isScriptModuleImport( + packageJson, + subpath + ); if ( isScriptModule ) { const kind = args.kind === 'dynamic-import' @@ -325,9 +323,7 @@ export function createWordpressExternalsPlugin( 'static' ); } else if ( - ! moduleDependencies.has( - args.path - ) + ! moduleDependencies.has( args.path ) ) { moduleDependencies.set( args.path, @@ -338,8 +334,7 @@ export function createWordpressExternalsPlugin( return { path: args.path, external: true, - sideEffects: - !! packageJson.sideEffects, + sideEffects: !! packageJson.sideEffects, }; } From ef1fc8098b54912d7d8d51979a95c1b2c394e361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 20 Apr 2026 11:45:41 +0100 Subject: [PATCH 6/7] reduce wp-build packageSources scope to directory paths --- packages/wp-build/lib/build.mjs | 157 ++---------------- packages/wp-build/lib/package-utils.mjs | 32 +--- .../lib/wordpress-externals-plugin.mjs | 66 +------- 3 files changed, 19 insertions(+), 236 deletions(-) diff --git a/packages/wp-build/lib/build.mjs b/packages/wp-build/lib/build.mjs index d0bf9a897c1afe..e4fbd5d03d1c6a 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -4,7 +4,6 @@ * 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 { parseArgs } from 'node:util'; @@ -94,13 +93,8 @@ const TEST_FILE_PATTERNS = [ * 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} [externalSource] True when the entry comes from a named - * package source (e.g. `@scope/name`). - * These packages preserve their own npm - * identity for script-module IDs instead - * of being scoped under `packageNamespace`. + * @property {string} dir Absolute path to the package directory. + * @property {import('./package-utils.mjs').PackageJson} packageJson Parsed package.json contents. */ const ROOT_PACKAGE_JSON = getPackageInfoFromFile( @@ -108,97 +102,30 @@ const ROOT_PACKAGE_JSON = getPackageInfoFromFile( ); const WP_PLUGIN_CONFIG = ROOT_PACKAGE_JSON.wpPlugin || {}; -/** - * Check whether a sources entry is a npm package name rather than a - * relative/absolute directory path. - * - * Package names follow the npm naming rules: - * - Scoped: `@scope/name` - * - Bare: `my-package` (starts with a letter or digit, no path separators) - * - * Directory paths start with `.`, `/`, or a drive letter on Windows (`C:\`). - * - * @param {string} source A single entry from `wpPlugin.packageSources`. - * @return {boolean} True when the entry looks like a package name. - */ -function isPackageName( source ) { - if ( source.startsWith( '@' ) ) { - return true; - } - // Relative or absolute paths. - if ( source.startsWith( '.' ) || path.isAbsolute( source ) ) { - return false; - } - // Bare package name (no slashes → not a path). - return ! source.includes( '/' ); -} - -/** - * Resolve a npm package name to its directory and parsed package.json. - * Uses Node's module resolution from the project root context so that - * workspace symlinks (pnpm, yarn, npm) are followed automatically. - * - * @param {string} npmName Full package name (e.g. `@acme/shared-ui`). - * @return {{ dir: string, packageJson: import('./package-utils.mjs').PackageJson }|null} - * Resolved entry or null when the package is not resolvable. - */ -function resolveNamedSource( npmName ) { - // Read directly from node_modules instead of require.resolve() to - // avoid ERR_PACKAGE_PATH_NOT_EXPORTED when the package's `exports` - // field does not include `./package.json`. - const pkgJsonPath = path.join( - ROOT_DIR, - 'node_modules', - npmName, - 'package.json' - ); - - if ( ! existsSync( pkgJsonPath ) ) { - console.warn( - `⚠️ Source "${ npmName }" could not be resolved. ` + - 'Make sure it is listed in dependencies and installed.' - ); - return null; - } - - return { - dir: path.dirname( pkgJsonPath ), - packageJson: getPackageInfoFromFile( pkgJsonPath ), - }; -} - /** * Directories to scan for packages. Always starts with `./packages/`. - * Additional directory-type entries from `wpPlugin.packageSources` are - * appended. Package-name entries are handled separately in - * `getAllPackages()`. + * Additional directories from `wpPlugin.packageSources` are appended + * and resolved relative to the project root. * * @type {string[]} */ -const PACKAGE_SOURCES = WP_PLUGIN_CONFIG.packageSources || []; const PACKAGE_DIRS = [ PACKAGES_DIR, - ...PACKAGE_SOURCES.filter( ( s ) => ! isPackageName( s ) ).map( ( s ) => + ...( WP_PLUGIN_CONFIG.packageSources || [] ).map( ( s ) => path.resolve( ROOT_DIR, s ) ), ]; -const NAMED_SOURCES = PACKAGE_SOURCES.filter( isPackageName ); /** - * Get all packages by scanning every directory in PACKAGE_DIRS and - * resolving every named source in NAMED_SOURCES. - * - * Local packages (`./packages/`) are scanned first, so they take priority. - * Named sources are resolved last — they preserve their npm identity - * (e.g. `@acme/shared-ui` stays `@acme/shared-ui` in script-module - * IDs instead of being rewritten to `@/shared-ui`). + * Discover every package by scanning each directory in PACKAGE_DIRS. + * Local packages (`./packages/`) are scanned first, so they take + * priority when two directories contain a package with the same name. * * @return {Map} Map of package names to their entry data. */ function getAllPackages() { const registry = new Map(); - // 1. Directory-based discovery (local packages first, then source dirs). for ( const dir of PACKAGE_DIRS ) { const pkgJsonPaths = glob.sync( normalizePath( path.join( dir, '*', 'package.json' ) ) @@ -217,29 +144,6 @@ function getAllPackages() { } } - // 2. Named sources — resolve via require.resolve() and preserve - // the full npm name as the registry key. - for ( const npmName of NAMED_SOURCES ) { - if ( registry.has( npmName ) ) { - continue; - } - - const entry = resolveNamedSource( npmName ); - if ( entry ) { - if ( ! entry.packageJson.wpScriptModuleExports ) { - console.warn( - `⚠️ Source "${ npmName }" does not declare wpScriptModuleExports. ` + - 'Imports will be bundled inline instead of externalized.' - ); - } - - registry.set( npmName, { - ...entry, - externalSource: true, - } ); - } - } - return registry; } @@ -247,14 +151,8 @@ const PACKAGES = getAllPackages(); const SCRIPT_GLOBAL = WP_PLUGIN_CONFIG.scriptGlobal; const PACKAGE_NAMESPACE = WP_PLUGIN_CONFIG.packageNamespace; const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE; -const PAGES = WP_PLUGIN_CONFIG.pages || []; - const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {}; - -// Individual packages from named sources to externalize. Unlike -// EXTERNAL_NAMESPACES (which externalizes an entire `@scope/*`), this -// targets only the exact packages listed in `packageSources`. -const EXTERNAL_PACKAGES = new Set( NAMED_SOURCES ); +const PAGES = WP_PLUGIN_CONFIG.pages || []; /** * Interprets a configuration value as a boolean, where `"true"` and `"1"` @@ -296,8 +194,7 @@ const wordpressExternalsPlugin = createWordpressExternalsPlugin( PACKAGE_NAMESPACE, SCRIPT_GLOBAL, EXTERNAL_NAMESPACES, - HANDLE_PREFIX, - EXTERNAL_PACKAGES + HANDLE_PREFIX ); /** @@ -635,7 +532,6 @@ async function bundlePackage( packageName, options = {} ) { const packageEntry = PACKAGES.get( packageName ); const packageDir = packageEntry.dir; const packageJson = packageEntry.packageJson; - const isExternalSource = !! packageEntry.externalSource; const builds = []; @@ -796,21 +692,10 @@ async function bundlePackage( packageName, options = {} ) { ); } - // External sources preserve their npm identity as the - // script-module ID (e.g. `@acme/shared-ui`). Local - // packages are scoped under the plugin's namespace. - let scriptModuleId; - if ( isExternalSource ) { - scriptModuleId = - exportName === '.' - ? packageName - : `${ packageName }/${ fileName }`; - } else { - scriptModuleId = - exportName === '.' - ? `@${ packageNamespace }/${ packageName }` - : `@${ packageNamespace }/${ packageName }/${ fileName }`; - } + const scriptModuleId = + exportName === '.' + ? `@${ packageNamespace }/${ packageName }` + : `@${ packageNamespace }/${ packageName }/${ fileName }`; builtModules.push( { id: scriptModuleId, @@ -1894,13 +1779,6 @@ async function buildAll( baseUrlExpression ) { await Promise.all( level.map( async ( fullName ) => { const packageName = fullToShort.get( fullName ); - const entry = PACKAGES.get( packageName ); - - // External sources are pre-built — only bundle, skip transpilation. - if ( entry.externalSource ) { - return; - } - const buildTime = await transpilePackage( packageName ); console.log( ` ✔ Transpiled ${ packageName } (${ buildTime }ms)` @@ -2068,13 +1946,8 @@ async function watchMode() { async function rebuildPackage( packageName ) { try { const startTime = Date.now(); - const entry = PACKAGES.get( packageName ); - - // External sources are pre-built — only rebundle. - if ( ! entry.externalSource ) { - await transpilePackage( packageName ); - } + await transpilePackage( packageName ); await bundlePackage( packageName ); const buildTime = Date.now() - startTime; diff --git a/packages/wp-build/lib/package-utils.mjs b/packages/wp-build/lib/package-utils.mjs index 1a66afe14f6a79..9e49bc5383bf82 100644 --- a/packages/wp-build/lib/package-utils.mjs +++ b/packages/wp-build/lib/package-utils.mjs @@ -87,35 +87,9 @@ export function getPackageInfo( fullPackageName, resolveDir = null ) { const contextPath = path.join( packageRoot, 'package.json' ); const localRequire = createRequire( contextPath ); - let resolved; - try { - // Preferred: resolve the package.json subpath directly. - resolved = localRequire.resolve( `${ fullPackageName }/package.json` ); - } catch { - // Fallback for packages whose `exports` field does not expose - // `./package.json`. Walk up the directory tree checking each - // `node_modules/` — mirrors Node's resolution algorithm without - // the exports restriction. - let searchDir = packageRoot; - const fsRoot = path.parse( searchDir ).root; - while ( searchDir !== fsRoot ) { - const directPath = path.join( - searchDir, - 'node_modules', - fullPackageName, - 'package.json' - ); - if ( existsSync( directPath ) ) { - resolved = directPath; - break; - } - searchDir = path.dirname( searchDir ); - } - - if ( ! resolved ) { - return null; - } - } + const resolved = localRequire.resolve( + `${ fullPackageName }/package.json` + ); const result = getPackageInfoFromFile( resolved ); packageJsonCache.set( cacheKey, result ); diff --git a/packages/wp-build/lib/wordpress-externals-plugin.mjs b/packages/wp-build/lib/wordpress-externals-plugin.mjs index 7722b565b42a2e..2b7e01a66f60b0 100644 --- a/packages/wp-build/lib/wordpress-externals-plugin.mjs +++ b/packages/wp-build/lib/wordpress-externals-plugin.mjs @@ -50,15 +50,13 @@ async function generateContentHash( * @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} [externalPackages] Individual package names to externalize by exact match (e.g., `@acme/shared-ui`). Used for named `packageSources` entries. * @return {Function} Function that creates the esbuild plugin instance. */ export function createWordpressExternalsPlugin( packageNamespace, scriptGlobal, externalNamespaces = {}, - handlePrefix, - externalPackages = new Set() + handlePrefix ) { /** * WordPress externals plugin for esbuild. @@ -283,68 +281,6 @@ export function createWordpressExternalsPlugin( ); } - // Handle individual package externals from packageSources. - // These match exact package names rather than whole scopes, - // avoiding over-broad externalization. - for ( const extPkg of externalPackages ) { - const escaped = extPkg.replace( - /[.*+?^${}()|[\]\\]/g, - '\\$&' - ); - build.onResolve( - { filter: new RegExp( `^${ escaped }(/|$)` ) }, - /** @param {import('esbuild').OnResolveArgs} args */ - ( args ) => { - const subpath = - args.path.length > extPkg.length - ? args.path.slice( extPkg.length + 1 ) - : null; - - const packageJson = getPackageInfo( - extPkg, - args.resolveDir - ); - if ( ! packageJson ) { - return undefined; - } - - const isScriptModule = isScriptModuleImport( - packageJson, - subpath - ); - if ( isScriptModule ) { - 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, - }; - } - - // Not a script module — let esbuild - // bundle it inline. - return undefined; - } - ); - } - build.onLoad( { filter: /.*/, namespace: 'vendor-external' }, /** @param {import('esbuild').OnLoadArgs} args */ From 491abf1c892fd3c69734b4895e3f41d93a649949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 20 Apr 2026 11:51:31 +0100 Subject: [PATCH 7/7] docs: update packageSources to directory paths only --- packages/wp-build/CHANGELOG.md | 2 +- packages/wp-build/README.md | 30 +++--------------------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index 93069c5d651fd3..f7671455262faf 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -4,7 +4,7 @@ ### Enhancements -- Add `wpPlugin.packageSources` configuration for discovering packages from additional directories or by npm name. Named sources (e.g. `@acme/shared-ui`) preserve their npm identity as the script-module ID. +- Add `wpPlugin.packageSources` configuration for discovering packages from additional directories. Discovered packages are treated identically to local `./packages/` entries. ## 0.12.0 (2026-04-15) diff --git a/packages/wp-build/README.md b/packages/wp-build/README.md index ad2280871ed484..7388886c0a5824 100644 --- a/packages/wp-build/README.md +++ b/packages/wp-build/README.md @@ -257,9 +257,7 @@ If `handlePrefix` is omitted, it defaults to the namespace key (e.g., `"woo"` ### `wpPlugin.packageSources` -Additional package sources to discover and compile. By default the tool only discovers packages under `./packages/`. The `packageSources` field accepts an array that can contain two types of entries: - -**Directory paths** — relative paths pointing to a directory containing packages: +Additional directories to scan for packages. By default the tool only discovers packages under `./packages/`. Paths are resolved relative to the project root: ```json { @@ -269,31 +267,9 @@ Additional package sources to discover and compile. By default the tool only dis } ``` -With this configuration the tool scans both `./packages/*` and `../js-packages/*`. Local packages always take priority: if a sources-discovered package has the same directory name as a local one, the local one wins. - -**Package names** — npm package names resolved via Node's module resolution: - -```json -{ - "wpPlugin": { - "packageSources": [ "@acme/shared-ui", "@acme/formatters" ] - } -} -``` - -Named package sources are resolved from `node_modules`, so they work with any package manager's workspace protocol (pnpm `workspace:*`, yarn `workspace:*`, npm workspaces). The package must be declared as a dependency and installed. - -Named sources **preserve their npm identity** as the script-module ID. For example, `@acme/shared-ui` is registered as `@acme/shared-ui` — not rewritten under the consumer's `packageNamespace`. This enables proper deduplication when multiple plugins consume the same shared package. Each named source is externalized individually (by exact package name, not the entire `@scope/*`), so only the packages explicitly listed in `packageSources` are treated as externals. The source package must have a `wpScriptModuleExports` field in its `package.json` for the externals plugin to detect it as a script module. - -Both types can be mixed in a single array: +With this configuration the tool scans both `./packages/*` and `../js-packages/*`. Discovered packages are treated identically to local ones: they are transpiled, bundled, and registered under the plugin's `packageNamespace`. -```json -{ - "wpPlugin": { - "packageSources": [ "../shared-packages", "@acme/shared-ui" ] - } -} -``` +Local packages always take priority. If a package discovered through `packageSources` has the same directory name as a local one, the local entry wins. ### `wpPlugin.pages` (Experimental)