diff --git a/packages/wp-build/CHANGELOG.md b/packages/wp-build/CHANGELOG.md index c736c703f48bc9..f7671455262faf 100644 --- a/packages/wp-build/CHANGELOG.md +++ b/packages/wp-build/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- 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) ## 0.11.0 (2026-04-01) diff --git a/packages/wp-build/README.md b/packages/wp-build/README.md index cf05da43cbdeb2..7388886c0a5824 100644 --- a/packages/wp-build/README.md +++ b/packages/wp-build/README.md @@ -255,6 +255,22 @@ 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.packageSources` + +Additional directories to scan for packages. By default the tool only discovers packages under `./packages/`. Paths are resolved relative to the project root: + +```json +{ + "wpPlugin": { + "packageSources": [ "../js-packages" ] + } +} +``` + +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`. + +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) 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..e4fbd5d03d1c6a 100755 --- a/packages/wp-build/lib/build.mjs +++ b/packages/wp-build/lib/build.mjs @@ -90,23 +90,64 @@ 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. */ -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 || {}; + +/** + * Directories to scan for packages. Always starts with `./packages/`. + * Additional directories from `wpPlugin.packageSources` are appended + * and resolved relative to the project root. + * + * @type {string[]} + */ +const PACKAGE_DIRS = [ + PACKAGES_DIR, + ...( WP_PLUGIN_CONFIG.packageSources || [] ).map( ( s ) => + path.resolve( ROOT_DIR, s ) + ), +]; + +/** + * 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(); + + 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 ), + } ); + } + } + } + + 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; @@ -480,7 +521,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 +529,9 @@ 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 builds = []; @@ -862,7 +901,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 +1254,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 +1464,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 +1574,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 +1596,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 +1761,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 +1792,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 +1925,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 +2043,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..9e49bc5383bf82 100644 --- a/packages/wp-build/lib/package-utils.mjs +++ b/packages/wp-build/lib/package-utils.mjs @@ -84,10 +84,13 @@ 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 ); + + const resolved = localRequire.resolve( + `${ fullPackageName }/package.json` + ); + const result = getPackageInfoFromFile( resolved ); packageJsonCache.set( cacheKey, result );