Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
139 changes: 139 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/wp-build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"wp-build": "./src/build.mjs"
},
"dependencies": {
"@babel/parser": "7.28.5",
"@babel/traverse": "7.28.5",
"@emotion/babel-plugin": "11.11.0",
"@types/toposort": "2.0.7",
"autoprefixer": "10.4.21",
Expand Down
154 changes: 154 additions & 0 deletions packages/wp-build/src/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import rtlcss from 'rtlcss';
import cssnano from 'cssnano';
import babel from 'esbuild-plugin-babel';
import { camelCase } from 'change-case';
import fs from 'node:fs';
import * as babelParser from '@babel/parser';
import traverse from '@babel/traverse';

/**
* Internal dependencies
Expand Down Expand Up @@ -246,6 +249,124 @@ function momentTimezoneAliasPlugin() {
};
}

/**
* Post-process ESM output files to ensure fully specified relative import paths.
* Uses Babel AST parser to deterministically identify and update import statements.
* Adds .js extensions or /index.js for directory imports as required by ESM spec.
*
* @param {string} outputPath Path to the output file to process.
* @return {Promise<void>}
*/
async function ensureFullySpecifiedImports( outputPath ) {
try {
// Only process .js files
if ( ! outputPath.endsWith( '.js' ) ) {
return;
}

const source = await readFile( outputPath, 'utf8' );
const outputDir = path.dirname( outputPath );

// Parse source code into an AST
let ast;
try {
ast = babelParser.parse( source, {
sourceType: 'module',
allowImportExportEverywhere: true,
plugins: [ 'jsx', 'typescript' ],
} );
} catch ( parseError ) {
// If parsing fails, silently skip (file might be non-ES modules or invalid)
return;
}

let modified = false;
const imports = [];

// Walk the AST to find all import/export declarations
traverse.default( ast, {
ImportDeclaration( nodePath ) {
const node = nodePath.node;
const sourceNode = node.source;

// Only process relative imports
if ( sourceNode.value && /^\.\.?\//.test( sourceNode.value ) ) {
// Get string content position (excludes quotes)
// sourceNode.start includes the quote, so add 1 to get the content start
const contentStart = sourceNode.start + 1;
const contentEnd = sourceNode.end - 1;
imports.push( {
original: sourceNode.value,
start: contentStart,
end: contentEnd,
} );
}
},
} );

// Process imports in reverse order to maintain correct positions
let updated = source;
for ( let i = imports.length - 1; i >= 0; i-- ) {
const imp = imports[ i ];

// Skip if already has file extension
if ( /\.[a-z]+$/i.test( imp.original ) ) {
continue;
}

// Resolve the full path
const fullPath = path.resolve( outputDir, imp.original );

let newPath = null;

// Check if it's a directory with index.js
try {
const stat = fs.statSync( fullPath );
if ( stat.isDirectory() ) {
const indexPath = path.join( fullPath, 'index.js' );
try {
fs.statSync( indexPath );
// Directory with index.js - add /index.js
newPath = `${ imp.original }/index.js`;
} catch {
// index.js doesn't exist, skip this import
}
}
} catch {
// Path doesn't exist as directory, try as .js file
}

// Check if it's a .js file (if not already set as directory)
if ( ! newPath ) {
try {
fs.statSync( `${ fullPath }.js` );
// File exists, add .js extension
newPath = `${ imp.original }.js`;
} catch {
// File doesn't exist with .js extension, skip this import
}
}

// Update source if we found a valid path
if ( newPath ) {
updated =
updated.slice( 0, imp.start ) +
newPath +
updated.slice( imp.end );
modified = true;
}
}

if ( modified ) {
await writeFile( outputPath, updated, 'utf8' );
}
} catch ( error ) {
console.warn(
`⚠️ Failed to process ESM imports in ${ outputPath }: ${ error.message }`
);
}
}

/**
* Resolve the entry point for bundling from package.json exports field.
* Falls back to build-module/index.js if no exports field is found.
Expand Down Expand Up @@ -577,6 +698,21 @@ async function bundlePackage( packageName, options = {} ) {

await Promise.all( builds );

// Post-process bundled ESM modules to ensure fully specified imports
if ( packageJson.wpScriptModuleExports ) {
const rootBuildModuleDir = path.join(
BUILD_DIR,
'modules',
packageName
);
const jsFiles = await glob(
normalizePath( path.join( rootBuildModuleDir, '**/*.js' ) )
);
await Promise.all(
jsFiles.map( ( file ) => ensureFullySpecifiedImports( file ) )
);
}

// Collect style metadata after builds complete (so asset files exist)
// Only register the main style.css file - complex cases handled manually in lib/client-assets.php
if ( hasMainStyle ) {
Expand Down Expand Up @@ -1089,6 +1225,16 @@ async function transpilePackage( packageName ) {

await Promise.all( builds );

// Post-process ESM build-module files to ensure fully specified imports
if ( packageJson.module ) {
const jsFiles = await glob(
normalizePath( path.join( buildModuleDir, '**/*.js' ) )
);
await Promise.all(
jsFiles.map( ( file ) => ensureFullySpecifiedImports( file ) )
);
}

await compileStyles( packageName );

return Date.now() - startTime;
Expand Down Expand Up @@ -1349,6 +1495,14 @@ async function buildRoute( routeName ) {
await unlink( tempEntryPath );
}

// Post-process route ESM files to ensure fully specified imports
const routeJsFiles = await glob(
normalizePath( path.join( outputDir, '*.js' ) )
);
await Promise.all(
routeJsFiles.map( ( file ) => ensureFullySpecifiedImports( file ) )
);

return Date.now() - startTime;
}

Expand Down
Loading