Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/wp-build/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions packages/wp-build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
138 changes: 83 additions & 55 deletions packages/wp-build/lib/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PackageEntry>} 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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 = [];

Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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 );

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transpilePackage now assumes PACKAGES.get( packageName ) always returns an entry. If packageName is ever missing/undefined (e.g., due to duplicate full package names or unexpected dependency graph output), this will throw a generic TypeError. Consider adding an explicit guard and throwing a more actionable error (similar to the prior “Could not find package.json…” check).

Suggested change
const packageEntry = PACKAGES.get( packageName );
const packageEntry = PACKAGES.get( packageName );
if ( ! packageEntry ) {
throw new Error(
`Could not find package entry for "${ packageName }" in the package map.`
);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All callers pass names from PACKAGES iterations, so get() always returns a defined entry. The removed check was also dead code — getPackageInfoFromFile throws on missing files, never returns null.

const packageDir = packageEntry.dir;
const packageJson = packageEntry.packageJson;

const srcFiles = await glob( `src/**/*.${ SOURCE_EXTENSIONS }`, {
cwd: packageDir,
Expand Down Expand Up @@ -1432,10 +1464,9 @@ async function transpilePackage( packageName ) {
* @return {Promise<number|null>} 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 || [
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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 );
Comment on lines +1769 to +1771

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When building fullToShort / fullToPackageJson, duplicate package.json.name values will silently overwrite earlier entries. With multi-root discovery this becomes more likely and can break build ordering and watch rebundles. Consider detecting duplicates (e.g., if fullToShort already has the full name) and throwing a clear error that includes both colliding package directories.

Suggested change
shortToFull.set( pkg, entry.packageJson.name );
fullToShort.set( entry.packageJson.name, pkg );
fullToPackageJson.set( entry.packageJson.name, entry.packageJson );
const fullName = entry.packageJson.name;
if ( fullToShort.has( fullName ) ) {
const existingPackage = fullToShort.get( fullName );
throw new Error(
`Duplicate package.json name "${ fullName }" detected for package directories "${ existingPackage }" and "${ pkg }". Package names must be unique across all discovered roots.`
);
}
shortToFull.set( pkg, fullName );
fullToShort.set( fullName, pkg );
fullToPackageJson.set( fullName, entry.packageJson );

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid edge case, but the existing code doesn't validate any similar collision. Keeping consistent — can address in a follow-up if needed.

}

const levels = groupByDepth( fullToPackageJson );
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down
9 changes: 6 additions & 3 deletions packages/wp-build/lib/package-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

Expand Down
Loading