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
5 changes: 5 additions & 0 deletions packages/wp-build/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## 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 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

- Remove the incorrect `#wpwrap` background from wp-admin critical CSS to prevent a black flash before hydration; rely on the existing `body` background instead.
Expand Down
239 changes: 180 additions & 59 deletions packages/wp-build/lib/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -95,20 +96,6 @@
/\.(native|ios|android)\.(js|ts|tsx)$/,
];

/**
* Get all package names from the packages directory.
*
* @return {string[]} Array of package names.
*/
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' )
);
Expand All @@ -119,6 +106,112 @@
const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {};
const PAGES = WP_PLUGIN_CONFIG.pages || [];

/**
* 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} 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/<dir>/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<string, PackageEntry>} Registry keyed by short identifier:
* directory name for local packages, full npm name for convention deps.
*/
function getAllPackages() {
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(

Check failure on line 171 in packages/wp-build/lib/build.mjs

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Replace `⏎↹↹↹↹`${·depName·}/package.json`⏎↹↹↹` with `·`${·depName·}/package.json`·`
`${ 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();

// 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(
Array.from( PACKAGES.values() )
.map( ( entry ) => entry.packageJson.name )
.filter( Boolean )
);

/**
* Interprets a configuration value as a boolean, where `"true"` and `"1"`
* are considered true while all other values are false.
Expand Down Expand Up @@ -159,7 +252,8 @@
PACKAGE_NAMESPACE,
SCRIPT_GLOBAL,
EXTERNAL_NAMESPACES,
HANDLE_PREFIX
HANDLE_PREFIX,
INTERNAL_PACKAGE_NAMES
);

const styleRuntimeRequire = createNodeRequire( import.meta.url );
Expand Down Expand Up @@ -522,19 +616,18 @@
*/
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 = [];

Expand Down Expand Up @@ -724,10 +817,19 @@
);
}

// 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 `@<packageNamespace>/<dirName>` shape
// only when `name` is missing (e.g. an unnamed local package).
const packageId =
packageJson.name ||

Check failure on line 827 in packages/wp-build/lib/build.mjs

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Replace `⏎↹↹↹↹` with `·`
`@${ packageNamespace }/${ packageName }`;
const scriptModuleId =
exportName === '.'

Check failure on line 830 in packages/wp-build/lib/build.mjs

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Replace `⏎↹↹↹↹↹?·packageId⏎↹↹↹↹↹` with `·?·packageId·`
? `@${ packageNamespace }/${ packageName }`
: `@${ packageNamespace }/${ packageName }/${ fileName }`;
? packageId
: `${ packageId }/${ fileName }`;

builtModules.push( {
id: scriptModuleId,
Expand Down Expand Up @@ -933,7 +1035,7 @@

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.)
Expand Down Expand Up @@ -1294,17 +1396,17 @@
*/
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,
Expand Down Expand Up @@ -1513,10 +1615,9 @@
* @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 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 || [
Expand Down Expand Up @@ -1624,12 +1725,20 @@
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;
}

/**
Expand All @@ -1643,9 +1752,12 @@
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;
Expand Down Expand Up @@ -2053,17 +2165,14 @@

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 );
Expand All @@ -2074,6 +2183,15 @@
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)`
Expand All @@ -2087,7 +2205,7 @@
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 @@ -2232,17 +2350,14 @@
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
Expand Down Expand Up @@ -2275,8 +2390,14 @@
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;
Expand Down Expand Up @@ -2375,9 +2496,9 @@
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: [
Expand Down
Loading
Loading