diff --git a/README.md b/README.md index b5900db..036bfd6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Add `sass` and `sass-loader` when using `getReactifiedConfig` with `sassLoadPath Generates webpack configurations for vanilla JS and CSS files. Creates both minified and unminified versions. +By default (`strategy: 'per-file'`), each source file gets its own webpack config per variant. With many files, webpack-cli runs them all in parallel via MultiCompiler, which increases peak memory and build time. Use `strategy: 'bundled'` to emit up to four configs total (JS min/unmin + CSS min/unmin) with multi-entry builds — recommended for projects with more than a handful of vanilla assets. Invalid `strategy` values throw at config generation time. Glob matches are deduplicated (stable order) before entries are built. + **Required options:** - `workingDirectory` - Working directory for glob resolution (typically `__dirname`) - `jsBuildDirectory` - Output directory for JS files @@ -27,18 +29,51 @@ Generates webpack configurations for vanilla JS and CSS files. Creates both mini - `isProduction` - Enable production mode optimizations **Optional options:** +- `strategy` - `'per-file'` (default, backward compatible) or `'bundled'` (one config per asset type and variant) +- `entryKey` - When `strategy` is `'bundled'`: `'basename'` (default, flat output names matching the legacy per-file layout, e.g. `js/src/settings.js` → `js/build/settings.min.js`) or `'relative'` (nested paths under the build directory) - `jsPatterns` - Glob patterns for JS files (default: `['**/*.js']`) - `jsIgnorePatterns` - Patterns to ignore for JS files (default: `[]`) - `cssPatterns` - Glob patterns for CSS files (default: `['**/*.css']`) - `cssIgnorePatterns` - Patterns to ignore for CSS files (default: `[]`) -**Example:** +**Entry naming** + +| Mode | Option | Entry key example | Output example | +|------|--------|-------------------|----------------| +| Per-file (default) | — | `settings` from any path | `js/build/settings.min.js` (basename only; collisions possible) | +| Bundled (default) | `entryKey: 'basename'` | `settings` | `js/build/settings.min.js` (same flat layout as per-file) | +| Bundled | `entryKey: 'relative'` | `modules/admin/settings` | `js/build/modules/admin/settings.min.js` | + +With bundled builds, two entry sources that share a basename (e.g. `admin/settings.js` and `frontend/settings.js`) throw a clear error when configs are generated — entries are not silently overwritten. + +Bundled configs disable code splitting (`splitChunks` / `runtimeChunk`) so each entry stays a single output file even when entries share local imports. + +**Polylang Pro (flat public URLs, bundled builds):** + +```javascript +const vanillaConfigs = getVanillaConfig( { + workingDirectory: __dirname, + strategy: 'bundled', + jsPatterns: [ 'js/src/**/*.js' ], + jsIgnorePatterns: [ '**/*.min.js' ], + cssPatterns: [ 'css/src/**/*.css' ], + jsBuildDirectory: path.resolve( __dirname, 'js/build' ), + cssBuildDirectory: path.resolve( __dirname, 'css/build' ), + isProduction: mode === 'production', +} ); +``` + +**Glob scoping:** Keep `jsPatterns`, `cssPatterns`, and ignore lists tight so build scripts only pick up intended sources (for example, exclude `vendor/**/tmp/**`). + +**Nested outputs (bundled + relative keys):** ```javascript const { getVanillaConfig } = require( '@wpsyntex/polylang-build-scripts' ); const vanillaConfigs = getVanillaConfig( { workingDirectory: __dirname, + strategy: 'bundled', + entryKey: 'relative', jsPatterns: [ '**/*.js' ], jsIgnorePatterns: [ 'node_modules/**', '**/build/**', '**/*.min.js' ], cssPatterns: [ '**/*.css' ], @@ -91,6 +126,28 @@ const reactConfigs = getReactifiedConfig( { module.exports = reactConfigs; ``` +## runWebpackConfigs + +Runs an array of webpack configs with optional concurrency limiting. Useful when combining many configs (for example, legacy per-file vanilla plus reactified builds) without letting webpack-cli spawn unbounded parallel compilations. Rejects with `TypeError` when `configs` is not an array or when `concurrency` is invalid. Fails fast on the first compilation error. + +Vanilla CSS builds use distinct intermediate filenames for minified (`[name].min.work`) and unminified (`[name].unmin.work`) variants, with cleanup scoped to the CSS output directory. Min and unmin configs are therefore safe to run in parallel (for example `runWebpackConfigs( vanillaConfigs, { concurrency: 4 } )`). This applies to both `per-file` and `bundled` strategies (per-file previously cleaned `*.work` from `process.cwd()`, which is now aligned with the output directory). + +```javascript +const { runWebpackConfigs } = require( '@wpsyntex/polylang-build-scripts' ); + +const configs = [ + ...getVanillaConfig( { /* options */ } ), + ...getReactifiedConfig( { /* options */ } ), +]; + +runWebpackConfigs( configs, { concurrency: 2 } ) + .then( () => console.log( 'Build complete' ) ) + .catch( ( error ) => { + console.error( error ); + process.exit( 1 ); + } ); +``` + ## Combined Usage ```javascript diff --git a/configs/__fixtures__/vanilla/admin/lib/partial.css b/configs/__fixtures__/vanilla/admin/lib/partial.css new file mode 100644 index 0000000..0d8f15f --- /dev/null +++ b/configs/__fixtures__/vanilla/admin/lib/partial.css @@ -0,0 +1,3 @@ +.partial { + color: red; +} diff --git a/configs/__fixtures__/vanilla/admin/settings.js b/configs/__fixtures__/vanilla/admin/settings.js new file mode 100644 index 0000000..8ab9dab --- /dev/null +++ b/configs/__fixtures__/vanilla/admin/settings.js @@ -0,0 +1 @@ +window.pllAdminSettings = true; diff --git a/configs/__fixtures__/vanilla/admin/style.css b/configs/__fixtures__/vanilla/admin/style.css new file mode 100644 index 0000000..f82a01c --- /dev/null +++ b/configs/__fixtures__/vanilla/admin/style.css @@ -0,0 +1,5 @@ +@import './lib/partial.css'; + +.entry { + color: blue; +} diff --git a/configs/__fixtures__/vanilla/js/src/entry-a.js b/configs/__fixtures__/vanilla/js/src/entry-a.js new file mode 100644 index 0000000..6775015 --- /dev/null +++ b/configs/__fixtures__/vanilla/js/src/entry-a.js @@ -0,0 +1,2 @@ +require( './shared-util.js' ); +window.pllEntryA = true; diff --git a/configs/__fixtures__/vanilla/js/src/entry-b.js b/configs/__fixtures__/vanilla/js/src/entry-b.js new file mode 100644 index 0000000..2c24bcf --- /dev/null +++ b/configs/__fixtures__/vanilla/js/src/entry-b.js @@ -0,0 +1,2 @@ +require( './shared-util.js' ); +window.pllEntryB = true; diff --git a/configs/__fixtures__/vanilla/js/src/shared-util.js b/configs/__fixtures__/vanilla/js/src/shared-util.js new file mode 100644 index 0000000..6679b44 --- /dev/null +++ b/configs/__fixtures__/vanilla/js/src/shared-util.js @@ -0,0 +1 @@ +window.pllSharedUtil = true; diff --git a/configs/__fixtures__/vanilla/modules/admin/widget.js b/configs/__fixtures__/vanilla/modules/admin/widget.js new file mode 100644 index 0000000..2291641 --- /dev/null +++ b/configs/__fixtures__/vanilla/modules/admin/widget.js @@ -0,0 +1 @@ +window.pllWidget = true; diff --git a/configs/vanilla.integration.test.js b/configs/vanilla.integration.test.js new file mode 100644 index 0000000..007cced --- /dev/null +++ b/configs/vanilla.integration.test.js @@ -0,0 +1,226 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const webpack = require( 'webpack' ); + +/** + * Internal dependencies + */ +const { runWebpackConfigs } = require( '../runner' ); +const { getVanillaConfig } = require( './vanilla' ); + +describe( 'getVanillaConfig bundled integration', () => { + const fixturesDirectory = path.join( __dirname, '__fixtures__/vanilla' ); + + const runWebpack = ( config ) => + new Promise( ( resolve, reject ) => { + webpack( config, ( error, stats ) => { + if ( error ) { + reject( error ); + return; + } + + if ( stats.hasErrors() ) { + reject( new Error( stats.toString( { colors: false } ) ) ); + return; + } + + resolve( stats ); + } ); + } ); + + it( 'should build nested JS/CSS outputs and resolve @import', async () => { + const jsOutputDirectory = path.join( fixturesDirectory, 'build-js' ); + const cssOutputDirectory = path.join( fixturesDirectory, 'build-css' ); + + fs.rmSync( jsOutputDirectory, { recursive: true, force: true } ); + fs.rmSync( cssOutputDirectory, { recursive: true, force: true } ); + fs.mkdirSync( jsOutputDirectory, { recursive: true } ); + fs.mkdirSync( cssOutputDirectory, { recursive: true } ); + + const configs = getVanillaConfig( { + workingDirectory: fixturesDirectory, + jsPatterns: [ '**/*.js' ], + cssPatterns: [ 'admin/style.css' ], + jsBuildDirectory: jsOutputDirectory, + cssBuildDirectory: cssOutputDirectory, + isProduction: true, + strategy: 'bundled', + entryKey: 'relative', + } ); + + expect( configs ).toHaveLength( 4 ); + + try { + for ( const config of configs ) { + await runWebpack( config ); + } + + expect( + fs.existsSync( + path.join( jsOutputDirectory, 'admin/settings.js' ) + ) + ).toBe( true ); + expect( + fs.existsSync( + path.join( jsOutputDirectory, 'admin/settings.min.js' ) + ) + ).toBe( true ); + expect( + fs.existsSync( + path.join( jsOutputDirectory, 'modules/admin/widget.js' ) + ) + ).toBe( true ); + expect( + fs.existsSync( + path.join( + jsOutputDirectory, + 'modules/admin/widget.min.js' + ) + ) + ).toBe( true ); + + const unminifiedCss = fs.readFileSync( + path.join( cssOutputDirectory, 'admin/style.css' ), + 'utf8' + ); + const minifiedCss = fs.readFileSync( + path.join( cssOutputDirectory, 'admin/style.min.css' ), + 'utf8' + ); + + expect( unminifiedCss ).not.toContain( '@import' ); + expect( minifiedCss ).not.toContain( '@import' ); + expect( unminifiedCss ).toContain( '.partial {' ); + expect( unminifiedCss ).toContain( '.entry {' ); + expect( minifiedCss ).toContain( '.partial{' ); + expect( minifiedCss ).toContain( '.entry{' ); + } finally { + fs.rmSync( jsOutputDirectory, { recursive: true, force: true } ); + fs.rmSync( cssOutputDirectory, { recursive: true, force: true } ); + } + } ); + + it( 'should build flat JS outputs with default basename entry keys', async () => { + const jsOutputDirectory = path.join( + fixturesDirectory, + 'build-js-basename' + ); + + fs.rmSync( jsOutputDirectory, { recursive: true, force: true } ); + fs.mkdirSync( jsOutputDirectory, { recursive: true } ); + + const configs = getVanillaConfig( { + workingDirectory: fixturesDirectory, + jsPatterns: [ 'admin/settings.js' ], + cssPatterns: [], + jsBuildDirectory: jsOutputDirectory, + cssBuildDirectory: jsOutputDirectory, + isProduction: true, + strategy: 'bundled', + } ); + + expect( configs ).toHaveLength( 2 ); + + try { + for ( const config of configs ) { + await runWebpack( config ); + } + + expect( + fs.existsSync( path.join( jsOutputDirectory, 'settings.js' ) ) + ).toBe( true ); + expect( + fs.existsSync( + path.join( jsOutputDirectory, 'settings.min.js' ) + ) + ).toBe( true ); + expect( + fs.existsSync( + path.join( jsOutputDirectory, 'admin/settings.js' ) + ) + ).toBe( false ); + } finally { + fs.rmSync( jsOutputDirectory, { recursive: true, force: true } ); + } + } ); + + it( 'should run bundled CSS min and unmin configs in parallel', async () => { + const cssOutputDirectory = path.join( + fixturesDirectory, + 'build-css-parallel' + ); + + fs.rmSync( cssOutputDirectory, { recursive: true, force: true } ); + fs.mkdirSync( cssOutputDirectory, { recursive: true } ); + + const configs = getVanillaConfig( { + workingDirectory: fixturesDirectory, + jsPatterns: [], + cssPatterns: [ 'admin/style.css' ], + jsBuildDirectory: cssOutputDirectory, + cssBuildDirectory: cssOutputDirectory, + isProduction: true, + strategy: 'bundled', + } ); + + expect( configs ).toHaveLength( 2 ); + + try { + await runWebpackConfigs( configs, { concurrency: 2 } ); + + expect( + fs.existsSync( path.join( cssOutputDirectory, 'style.css' ) ) + ).toBe( true ); + expect( + fs.existsSync( + path.join( cssOutputDirectory, 'style.min.css' ) + ) + ).toBe( true ); + } finally { + fs.rmSync( cssOutputDirectory, { recursive: true, force: true } ); + } + } ); + + it( 'should not emit extra chunks when entries share a local import', async () => { + const jsOutputDirectory = path.join( + fixturesDirectory, + 'build-js-shared' + ); + + fs.rmSync( jsOutputDirectory, { recursive: true, force: true } ); + fs.mkdirSync( jsOutputDirectory, { recursive: true } ); + + const configs = getVanillaConfig( { + workingDirectory: fixturesDirectory, + jsPatterns: [ 'js/src/entry-a.js', 'js/src/entry-b.js' ], + cssPatterns: [], + jsBuildDirectory: jsOutputDirectory, + cssBuildDirectory: jsOutputDirectory, + isProduction: true, + strategy: 'bundled', + } ); + + try { + await runWebpackConfigs( configs, { concurrency: 2 } ); + + const outputFiles = fs.readdirSync( jsOutputDirectory ); + const jsOutputs = outputFiles.filter( ( file ) => + file.endsWith( '.js' ) + ); + + expect( jsOutputs.sort() ).toEqual( + [ + 'entry-a.js', + 'entry-a.min.js', + 'entry-b.js', + 'entry-b.min.js', + ].sort() + ); + } finally { + fs.rmSync( jsOutputDirectory, { recursive: true, force: true } ); + } + } ); +} ); diff --git a/configs/vanilla.js b/configs/vanilla.js index a2ed616..79438e1 100644 --- a/configs/vanilla.js +++ b/configs/vanilla.js @@ -6,8 +6,27 @@ const glob = require( 'glob' ).sync; /** * Internal dependencies. */ -const { transformJsEntry } = require( '../transformers/js.js' ); -const { transformCssEntry } = require( '../transformers/css.js' ); +const { + transformJsEntry, + createBundledJsConfig, +} = require( '../transformers/js.js' ); +const { + transformCssEntry, + createBundledCssConfig, +} = require( '../transformers/css.js' ); + +const dedupeStable = ( fileNames ) => { + const seen = new Set(); + + return fileNames.filter( ( filename ) => { + if ( seen.has( filename ) ) { + return false; + } + + seen.add( filename ); + return true; + } ); +}; /** * Generate webpack configuration for vanilla JS and CSS files. @@ -22,6 +41,8 @@ const { transformCssEntry } = require( '../transformers/css.js' ); * @param {string} options.jsBuildDirectory Output directory for JS files. * @param {string} options.cssBuildDirectory Output directory for CSS files. * @param {boolean} options.isProduction Whether to enable production mode optimizations. + * @param {string} [options.strategy] Build strategy: `'per-file'` (one config per file per variant, default) or `'bundled'` (four configs total: JS/CSS × min/unmin). + * @param {string} [options.entryKey] Bundled entry key mode: `'basename'` (flat names like per-file, default) or `'relative'` (nested output paths). * @return {Object[]} Array of webpack configurations. */ const getVanillaConfig = ( { @@ -33,21 +54,79 @@ const getVanillaConfig = ( { jsBuildDirectory, cssBuildDirectory, isProduction, + strategy = 'per-file', + entryKey = 'basename', } ) => { - const jsFileNames = jsPatterns.flatMap( ( pattern ) => - glob( pattern, { - cwd: workingDirectory, - ignore: jsIgnorePatterns, - } ).map( ( filename ) => `./${ filename }` ) + if ( strategy !== 'per-file' && strategy !== 'bundled' ) { + throw new Error( + `Invalid vanilla build strategy "${ strategy }". Expected "per-file" or "bundled".` + ); + } + + const jsFileNames = dedupeStable( + jsPatterns.flatMap( ( pattern ) => + glob( pattern, { + cwd: workingDirectory, + ignore: jsIgnorePatterns, + } ).map( ( filename ) => `./${ filename }` ) + ) ); - const cssFileNames = cssPatterns.flatMap( ( pattern ) => - glob( pattern, { - cwd: workingDirectory, - ignore: cssIgnorePatterns, - } ).map( ( filename ) => `./${ filename }` ) + const cssFileNames = dedupeStable( + cssPatterns.flatMap( ( pattern ) => + glob( pattern, { + cwd: workingDirectory, + ignore: cssIgnorePatterns, + } ).map( ( filename ) => `./${ filename }` ) + ) ); + if ( strategy === 'bundled' ) { + const configs = []; + + if ( jsFileNames.length > 0 ) { + configs.push( + createBundledJsConfig( + jsFileNames, + jsBuildDirectory, + false, + workingDirectory, + entryKey + ), + createBundledJsConfig( + jsFileNames, + jsBuildDirectory, + true, + workingDirectory, + entryKey + ) + ); + } + + if ( cssFileNames.length > 0 ) { + configs.push( + createBundledCssConfig( + cssFileNames, + cssBuildDirectory, + false, + isProduction, + workingDirectory, + entryKey + ), + createBundledCssConfig( + cssFileNames, + cssBuildDirectory, + true, + isProduction, + workingDirectory, + entryKey + ) + ); + } + + return configs; + } + return [ ...jsFileNames.map( transformJsEntry( jsBuildDirectory, false ) ), ...jsFileNames.map( transformJsEntry( jsBuildDirectory, true ) ), diff --git a/configs/vanilla.test.js b/configs/vanilla.test.js index 406e639..200ccad 100644 --- a/configs/vanilla.test.js +++ b/configs/vanilla.test.js @@ -27,7 +27,9 @@ jest.mock( '../main', () => { return ( filename ) => ( { entry: { [ mockPath.parse( filename ).name ]: filename }, output: { - filename: '[name].work', + filename: minimize + ? '[name].min.work' + : '[name].unmin.work', path: destination, }, optimization: { @@ -46,6 +48,10 @@ const glob = require( 'glob' ); */ const { getVanillaConfig } = require( './vanilla' ); +const isCssWebpackConfig = ( config ) => + typeof config.output?.filename === 'string' && + config.output.filename.endsWith( '.work' ); + describe( 'getVanillaConfig', () => { const testWorkingDirectory = '/test/project'; const jsBuildDirectory = '/test/project/js/build'; @@ -228,9 +234,7 @@ describe( 'getVanillaConfig', () => { isProduction: true, } ); - const cssConfigs = configs.filter( - ( config ) => config.output.filename === '[name].work' - ); + const cssConfigs = configs.filter( isCssWebpackConfig ); expect( cssConfigs ).toHaveLength( 4 ); const unminifiedCssConfigs = cssConfigs.filter( @@ -316,9 +320,7 @@ describe( 'getVanillaConfig', () => { isProduction: true, } ); - const cssConfigs = configs.filter( - ( config ) => config.output.filename === '[name].work' - ); + const cssConfigs = configs.filter( isCssWebpackConfig ); expect( cssConfigs ).toHaveLength( 4 ); } ); @@ -348,17 +350,17 @@ describe( 'getVanillaConfig', () => { // Check devtool setting (source maps enabled in dev mode for unminified CSS only) const prodUnminifiedCssConfig = prodConfig.find( ( config ) => - config.output.filename === '[name].work' && + isCssWebpackConfig( config ) && config.optimization.minimize === false ); const devUnminifiedCssConfig = devConfig.find( ( config ) => - config.output.filename === '[name].work' && + isCssWebpackConfig( config ) && config.optimization.minimize === false ); const devMinifiedCssConfig = devConfig.find( ( config ) => - config.output.filename === '[name].work' && + isCssWebpackConfig( config ) && config.optimization.minimize === true ); @@ -390,9 +392,7 @@ describe( 'getVanillaConfig', () => { } ); const jsConfig = configs.find( ( config ) => config.entry.admin ); - const cssConfig = configs.find( - ( config ) => config.output.filename === '[name].work' - ); + const cssConfig = configs.find( isCssWebpackConfig ); expect( jsConfig.output.path ).toBe( customJsDir ); expect( cssConfig.output.path ).toBe( customCssDir ); @@ -445,6 +445,229 @@ describe( 'getVanillaConfig', () => { expect( unminifiedConfig.optimization.minimize ).toBe( false ); } ); + describe( 'bundled strategy', () => { + it( 'should return four configs for many JS and CSS files', () => { + glob.sync.mockImplementation( ( pattern ) => { + if ( pattern === '**/*.js' ) { + return Array.from( + { length: 20 }, + ( _, index ) => `js/file-${ index }.js` + ); + } + if ( pattern === '**/*.css' ) { + return Array.from( + { length: 10 }, + ( _, index ) => `css/file-${ index }.css` + ); + } + + return []; + } ); + + const configs = getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + strategy: 'bundled', + } ); + + expect( configs ).toHaveLength( 4 ); + + const jsConfigs = configs.filter( + ( config ) => + config.output.filename.includes( '[name]' ) && + config.output.filename.endsWith( '.js' ) + ); + const cssConfigs = configs.filter( isCssWebpackConfig ); + + expect( jsConfigs ).toHaveLength( 2 ); + expect( cssConfigs ).toHaveLength( 2 ); + expect( jsConfigs[ 0 ].entry ).toHaveProperty( 'file-0' ); + expect( cssConfigs[ 0 ].entry ).toHaveProperty( 'file-0' ); + } ); + + it( 'should disable code splitting for bundled JS configs', () => { + glob.sync.mockImplementation( ( pattern ) => { + if ( pattern === '**/*.js' ) { + return [ 'main.js' ]; + } + + return []; + } ); + + const configs = getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + strategy: 'bundled', + } ); + + configs.forEach( ( config ) => { + expect( config.optimization.splitChunks ).toBe( false ); + expect( config.optimization.runtimeChunk ).toBe( false ); + } ); + } ); + + it( 'should use basename entry keys by default for bundled JS', () => { + glob.sync.mockImplementation( ( pattern ) => { + if ( pattern === '**/*.js' ) { + return [ 'js/src/settings.js' ]; + } + + return []; + } ); + + const configs = getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + strategy: 'bundled', + } ); + + const jsConfig = configs.find( + ( config ) => config.output.filename === '[name].js' + ); + + expect( jsConfig.entry ).toEqual( { + settings: './js/src/settings.js', + } ); + expect( jsConfig.context ).toBe( testWorkingDirectory ); + } ); + + it( 'should use relative entry keys when entryKey is relative', () => { + glob.sync.mockImplementation( ( pattern ) => { + if ( pattern === '**/*.js' ) { + return [ 'modules/admin/settings.js' ]; + } + + return []; + } ); + + const configs = getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + strategy: 'bundled', + entryKey: 'relative', + } ); + + const jsConfig = configs.find( + ( config ) => config.output.filename === '[name].js' + ); + + expect( jsConfig.entry ).toEqual( { + 'modules/admin/settings': './modules/admin/settings.js', + } ); + } ); + + it( 'should scope CSS clean patterns to the output directory', () => { + glob.sync.mockImplementation( ( pattern ) => { + if ( pattern === '**/*.css' ) { + return [ 'admin/style.css' ]; + } + + return []; + } ); + + const configs = getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + strategy: 'bundled', + } ); + + const cssConfig = configs.find( + ( config ) => config.output.filename === '[name].unmin.work' + ); + const cleanPlugin = cssConfig.plugins.find( + ( plugin ) => plugin.constructor.name === 'CleanWebpackPlugin' + ); + + expect( cleanPlugin.cleanAfterEveryBuildPatterns ).toEqual( [ + `${ cssBuildDirectory }/**/*.unmin.work`, + ] ); + } ); + + it( 'should throw when bundled basename keys collide', () => { + glob.sync.mockImplementation( ( pattern ) => { + if ( pattern === '**/*.js' ) { + return [ 'admin/settings.js', 'frontend/settings.js' ]; + } + + return []; + } ); + + expect( () => + getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + strategy: 'bundled', + } ) + ).toThrow( /Duplicate bundled entry key "settings"/ ); + } ); + + it( 'should return an empty array when no files match', () => { + glob.sync.mockReturnValue( [] ); + + const configs = getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + strategy: 'bundled', + } ); + + expect( configs ).toEqual( [] ); + } ); + } ); + + it( 'should throw for an invalid strategy', () => { + glob.sync.mockReturnValue( [] ); + + expect( () => + getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + strategy: 'invalid', + } ) + ).toThrow( /Invalid vanilla build strategy/ ); + } ); + + it( 'should dedupe files matched by multiple glob patterns', () => { + glob.sync.mockImplementation( ( pattern ) => { + if ( pattern === 'admin/**/*.js' ) { + return [ 'admin/settings.js' ]; + } + if ( pattern === '**/*.js' ) { + return [ 'admin/settings.js' ]; + } + + return []; + } ); + + const configs = getVanillaConfig( { + workingDirectory: testWorkingDirectory, + jsPatterns: [ 'admin/**/*.js', '**/*.js' ], + jsBuildDirectory, + cssBuildDirectory, + isProduction: true, + } ); + + const jsConfigs = configs.filter( ( config ) => + config.output.filename.endsWith( '.js' ) + ); + expect( jsConfigs ).toHaveLength( 2 ); + } ); + it( 'should configure optimization for minified JS files', () => { glob.sync.mockImplementation( ( pattern ) => { if ( pattern === '**/*.js' ) { diff --git a/lib/entry-keys.js b/lib/entry-keys.js new file mode 100644 index 0000000..420cd5b --- /dev/null +++ b/lib/entry-keys.js @@ -0,0 +1,82 @@ +/** + * External dependencies. + */ +const path = require( 'path' ); + +/** + * Derive a stable webpack entry key from a relative source path. + * + * Uses the path relative to the working directory (without leading `./`) + * and without the file extension, so nested outputs preserve directory structure. + * Example: `./modules/admin/settings.js` → `modules/admin/settings`. + * + * Note: the legacy per-file strategy uses only the basename (`settings`), which + * can collide when two files share a name in different directories. + * + * @param {string} filename Relative entry path (e.g. `./admin/settings.js`). + * @return {string} Entry key for webpack multi-entry configs. + */ +const getBundledEntryKey = ( filename ) => { + const relativePath = filename.replace( /^\.\//, '' ); + const parsed = path.parse( relativePath ); + const directory = parsed.dir; + + return directory ? `${ directory }/${ parsed.name }` : parsed.name; +}; + +/** + * Derive a webpack entry key from the file basename (legacy per-file naming). + * + * @param {string} filename Relative entry path (e.g. `./js/src/settings.js`). + * @return {string} Basename without extension. + */ +const getBasenameEntryKey = ( filename ) => path.parse( filename ).name; + +/** + * @param {'relative'|'basename'} entryKeyMode Entry key mode. + * @return {(filename: string) => string} Key resolver for bundled multi-entry configs. + */ +const getEntryKeyResolver = ( entryKeyMode ) => { + if ( entryKeyMode === 'basename' ) { + return getBasenameEntryKey; + } + if ( entryKeyMode === 'relative' ) { + return getBundledEntryKey; + } + + throw new Error( + `Invalid entryKey "${ entryKeyMode }". Expected "relative" or "basename".` + ); +}; + +/** + * Build a webpack entry object from relative file paths, detecting key collisions. + * + * @param {string[]} fileNames Relative entry paths. + * @param {(filename: string) => string} keyResolver Resolves each path to an entry key. + * @return {Object} Webpack entry map. + */ +const buildBundledEntry = ( fileNames, keyResolver ) => { + const entry = {}; + + for ( const filename of fileNames ) { + const key = keyResolver( filename ); + + if ( Object.prototype.hasOwnProperty.call( entry, key ) ) { + throw new Error( + `Duplicate bundled entry key "${ key }" for "${ filename }" and "${ entry[ key ] }".` + ); + } + + entry[ key ] = filename; + } + + return entry; +}; + +module.exports = { + getBundledEntryKey, + getBasenameEntryKey, + getEntryKeyResolver, + buildBundledEntry, +}; diff --git a/lib/entry-keys.test.js b/lib/entry-keys.test.js new file mode 100644 index 0000000..b31b37a --- /dev/null +++ b/lib/entry-keys.test.js @@ -0,0 +1,49 @@ +/** + * Internal dependencies + */ +const { + getBundledEntryKey, + getBasenameEntryKey, + buildBundledEntry, + getEntryKeyResolver, +} = require( './entry-keys' ); + +describe( 'getBundledEntryKey', () => { + it( 'should strip leading ./ and file extension', () => { + expect( getBundledEntryKey( './admin/settings.js' ) ).toBe( + 'admin/settings' + ); + } ); + + it( 'should preserve nested directory structure', () => { + expect( getBundledEntryKey( './modules/admin/widget.js' ) ).toBe( + 'modules/admin/widget' + ); + } ); + + it( 'should use basename only for root-level files', () => { + expect( getBundledEntryKey( './main.js' ) ).toBe( 'main' ); + } ); +} ); + +describe( 'getBasenameEntryKey', () => { + it( 'should use basename regardless of directory', () => { + expect( getBasenameEntryKey( './js/src/settings.js' ) ).toBe( + 'settings' + ); + expect( getBasenameEntryKey( './vendor/pkg/js/src/settings.js' ) ).toBe( + 'settings' + ); + } ); +} ); + +describe( 'buildBundledEntry', () => { + it( 'should throw when two files resolve to the same key', () => { + expect( () => + buildBundledEntry( + [ './a/foo.js', './b/foo.js' ], + getEntryKeyResolver( 'basename' ) + ) + ).toThrow( /Duplicate bundled entry key "foo"/ ); + } ); +} ); diff --git a/main.js b/main.js index c0a48d1..4df256f 100644 --- a/main.js +++ b/main.js @@ -1,11 +1,21 @@ -const { transformCssEntry } = require( './transformers/css' ); -const { transformJsEntry } = require( './transformers/js' ); +const { + transformCssEntry, + createBundledCssConfig, +} = require( './transformers/css' ); +const { + transformJsEntry, + createBundledJsConfig, +} = require( './transformers/js' ); const { getVanillaConfig } = require( './configs/vanilla' ); const { getReactifiedConfig } = require( './configs/reactified' ); +const { runWebpackConfigs } = require( './runner' ); module.exports = { transformCssEntry, + createBundledCssConfig, transformJsEntry, + createBundledJsConfig, getVanillaConfig, getReactifiedConfig, + runWebpackConfigs, }; diff --git a/package.json b/package.json index 13e8703..5189ac4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wpsyntex/polylang-build-scripts", - "version": "2.4.0", + "version": "2.5.0", "description": "Contains the scripts needed to build Polylang", "repository": { "type": "git", diff --git a/runner.js b/runner.js new file mode 100644 index 0000000..609945d --- /dev/null +++ b/runner.js @@ -0,0 +1,95 @@ +/** + * Peer dependencies. + */ +const webpack = require( 'webpack' ); + +/** + * Run a single webpack compilation. + * + * @param {Object} config Webpack configuration. + * @return {Promise} Compilation stats. + */ +const runSingleWebpack = ( config ) => + new Promise( ( resolve, reject ) => { + webpack( config, ( error, stats ) => { + if ( error ) { + reject( error ); + return; + } + + if ( stats.hasErrors() ) { + reject( new Error( stats.toString( { colors: false } ) ) ); + return; + } + + resolve( stats ); + } ); + } ); + +/** + * Run webpack compilations with optional concurrency limiting. + * + * Use this instead of webpack-cli MultiCompiler when many configs would run in + * parallel and exhaust memory (e.g. legacy per-file vanilla builds). + * + * @param {Object[]} configs Webpack configurations to run. + * @param {Object} [options] Runner options. + * @param {number} [options.concurrency=Infinity] Max parallel compilations. + * Rejects on the first compilation failure (fail-fast); successful runs return + * stats in the same order as `configs`. + * + * @return {Promise} Stats for each config, in order. + */ +const runWebpackConfigs = async ( + configs, + { concurrency = Infinity } = {} +) => { + if ( ! Array.isArray( configs ) ) { + throw new TypeError( + 'runWebpackConfigs expects an array of webpack configurations.' + ); + } + + if ( configs.length === 0 ) { + return []; + } + + const isValidConcurrency = + typeof concurrency === 'number' && + ( concurrency === Infinity || + ( Number.isFinite( concurrency ) && concurrency >= 1 ) ); + + if ( ! isValidConcurrency ) { + throw new TypeError( + 'runWebpackConfigs concurrency must be Infinity or a finite number greater than or equal to 1.' + ); + } + + const results = new Array( configs.length ); + let nextIndex = 0; + let failed = false; + + const worker = async () => { + while ( ! failed && nextIndex < configs.length ) { + const currentIndex = nextIndex; + nextIndex += 1; + + try { + results[ currentIndex ] = await runSingleWebpack( + configs[ currentIndex ] + ); + } catch ( error ) { + failed = true; + throw error; + } + } + }; + + const workerCount = Math.min( concurrency, configs.length ); + + await Promise.all( Array.from( { length: workerCount }, () => worker() ) ); + + return results; +}; + +module.exports = { runWebpackConfigs }; diff --git a/runner.test.js b/runner.test.js new file mode 100644 index 0000000..0283f72 --- /dev/null +++ b/runner.test.js @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +const webpack = require( 'webpack' ); + +/** + * Internal dependencies + */ +const { runWebpackConfigs } = require( './runner' ); + +jest.mock( 'webpack', () => { + const mockWebpack = jest.fn(); + return mockWebpack; +} ); + +describe( 'runWebpackConfigs', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should return an empty array for empty input', async () => { + await expect( runWebpackConfigs( [] ) ).resolves.toEqual( [] ); + expect( webpack ).not.toHaveBeenCalled(); + } ); + + it( 'should throw TypeError when configs is not an array', async () => { + await expect( runWebpackConfigs( null ) ).rejects.toThrow( TypeError ); + await expect( runWebpackConfigs( {} ) ).rejects.toThrow( TypeError ); + } ); + + it( 'should throw TypeError for invalid concurrency', async () => { + await expect( + runWebpackConfigs( [ { entry: './a.js' } ], { concurrency: 0 } ) + ).rejects.toThrow( TypeError ); + await expect( + runWebpackConfigs( [ { entry: './a.js' } ], { + concurrency: Number.NaN, + } ) + ).rejects.toThrow( TypeError ); + } ); + + it( 'should run all configs and return stats in order', async () => { + const statsA = { hasErrors: () => false }; + const statsB = { hasErrors: () => false }; + let callIndex = 0; + + webpack.mockImplementation( ( config, callback ) => { + const stats = callIndex === 0 ? statsA : statsB; + callIndex += 1; + callback( null, stats ); + } ); + + const configs = [ { entry: './a.js' }, { entry: './b.js' } ]; + const results = await runWebpackConfigs( configs ); + + expect( webpack ).toHaveBeenCalledTimes( 2 ); + expect( results ).toEqual( [ statsA, statsB ] ); + } ); + + it( 'should reject when a compilation reports errors', async () => { + webpack.mockImplementation( ( config, callback ) => { + callback( null, { + hasErrors: () => true, + toString: () => 'compilation failed', + } ); + } ); + + await expect( + runWebpackConfigs( [ { entry: './a.js' } ] ) + ).rejects.toThrow( 'compilation failed' ); + } ); + + it( 'should limit parallel compilations when concurrency is set', async () => { + let inFlight = 0; + let maxInFlight = 0; + + webpack.mockImplementation( ( config, callback ) => { + inFlight += 1; + maxInFlight = Math.max( maxInFlight, inFlight ); + + setTimeout( () => { + inFlight -= 1; + callback( null, { hasErrors: () => false } ); + }, 10 ); + } ); + + const configs = Array.from( { length: 4 }, ( _, index ) => ( { + entry: `./file-${ index }.js`, + } ) ); + + await runWebpackConfigs( configs, { concurrency: 2 } ); + + expect( maxInFlight ).toBeLessThanOrEqual( 2 ); + expect( webpack ).toHaveBeenCalledTimes( 4 ); + } ); +} ); diff --git a/transformers/css.integration.test.js b/transformers/css.integration.test.js index bc16cc9..21a7cc7 100644 --- a/transformers/css.integration.test.js +++ b/transformers/css.integration.test.js @@ -2,7 +2,6 @@ * External dependencies */ const fs = require( 'fs' ); -const os = require( 'os' ); const path = require( 'path' ); const webpack = require( 'webpack' ); @@ -33,9 +32,9 @@ describe( 'transformCssEntry integration', () => { } ); it( 'should resolve @import in unminified and minified CSS output', async () => { - const outputDirectory = fs.mkdtempSync( - path.join( os.tmpdir(), 'pll-css-build-' ) - ); + const outputDirectory = path.join( fixturesDirectory, 'build-output' ); + fs.rmSync( outputDirectory, { recursive: true, force: true } ); + fs.mkdirSync( outputDirectory, { recursive: true } ); try { await runWebpack( diff --git a/transformers/css.js b/transformers/css.js index d01250b..759cdbd 100644 --- a/transformers/css.js +++ b/transformers/css.js @@ -3,6 +3,14 @@ */ const path = require( 'path' ); +/** + * Internal dependencies. + */ +const { + buildBundledEntry, + getEntryKeyResolver, +} = require( '../lib/entry-keys.js' ); + /** * Peer dependencies. */ @@ -10,6 +18,17 @@ const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' ); const CssMinimizerPlugin = require( 'css-minimizer-webpack-plugin' ); const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ); +function getCssIntermediateFilename( minimize ) { + return minimize ? '[name].min.work' : '[name].unmin.work'; +} + +function getCssIntermediateCleanPattern( destination, minimize ) { + return path.join( + destination, + minimize ? '**/*.min.work' : '**/*.unmin.work' + ); +} + /** * Prepare webpack configuration to build css files with resolved @import statements. * @@ -24,7 +43,7 @@ function transformCssEntry( destination, minimize, isProduction ) { const config = { entry, output: { - filename: `[name].work`, + filename: getCssIntermediateFilename( minimize ), path: destination, }, plugins: [ @@ -36,7 +55,7 @@ function transformCssEntry( destination, minimize, isProduction ) { verbose: false, cleanOnceBeforeBuildPatterns: [], cleanAfterEveryBuildPatterns: [ - path.join( process.cwd(), '**/*.work' ), + getCssIntermediateCleanPattern( destination, minimize ), ], } ), ], @@ -64,4 +83,72 @@ function transformCssEntry( destination, minimize, isProduction ) { }; } -module.exports = { transformCssEntry }; +/** + * Build a single webpack config for all vanilla CSS entries (min or unmin). + * + * @param {string[]} fileNames Relative entry paths. + * @param {string} destination Output directory. + * @param {boolean} minimize True to generate minified files. + * @param {boolean} isProduction True when building in production mode. + * @param {string} context Webpack context (working directory). + * @param {string} [entryKeyMode='basename'] `'basename'` or `'relative'` entry key mode. + * @return {Object} Webpack configuration. + */ +function createBundledCssConfig( + fileNames, + destination, + minimize, + isProduction, + context, + entryKeyMode = 'basename' +) { + const entry = buildBundledEntry( + fileNames, + getEntryKeyResolver( entryKeyMode ) + ); + + return { + context, + entry, + output: { + filename: getCssIntermediateFilename( minimize ), + path: destination, + }, + plugins: [ + new MiniCssExtractPlugin( { + filename: minimize ? '[name].min.css' : '[name].css', + } ), + new CleanWebpackPlugin( { + dry: false, + verbose: false, + cleanOnceBeforeBuildPatterns: [], + cleanAfterEveryBuildPatterns: [ + getCssIntermediateCleanPattern( destination, minimize ), + ], + } ), + ], + module: { + rules: [ + { + test: /\.css$/i, + use: [ MiniCssExtractPlugin.loader, 'css-loader' ], + }, + ], + }, + devtool: ! minimize && ! isProduction ? 'source-map' : false, + optimization: { + minimize, + minimizer: minimize + ? [ + new CssMinimizerPlugin( { + test: /\.min\.css$/i, + } ), + ] + : [], + splitChunks: false, + runtimeChunk: false, + }, + }; +} + +module.exports = { transformCssEntry, createBundledCssConfig }; diff --git a/transformers/css.test.js b/transformers/css.test.js index e93ad3e..4d8942b 100644 --- a/transformers/css.test.js +++ b/transformers/css.test.js @@ -3,6 +3,7 @@ */ const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' ); const CssMinimizerPlugin = require( 'css-minimizer-webpack-plugin' ); +const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ); const { transformCssEntry } = require( './css.js' ); describe( 'transformCssEntry', () => { @@ -69,4 +70,31 @@ describe( 'transformCssEntry', () => { expect( getConfig( true, false ).devtool ).toBe( false ); expect( getConfig( true, true ).devtool ).toBe( false ); } ); + + it( 'should use distinct intermediate filenames for min and unmin', () => { + expect( getConfig( false, true ).output.filename ).toBe( + '[name].unmin.work' + ); + expect( getConfig( true, true ).output.filename ).toBe( + '[name].min.work' + ); + } ); + + it( 'should scope clean patterns to the destination directory', () => { + const unminConfig = getConfig( false, true ); + const minConfig = getConfig( true, true ); + const unminClean = unminConfig.plugins.find( + ( plugin ) => plugin instanceof CleanWebpackPlugin + ); + const minClean = minConfig.plugins.find( + ( plugin ) => plugin instanceof CleanWebpackPlugin + ); + + expect( unminClean.cleanAfterEveryBuildPatterns ).toEqual( [ + `${ destination }/**/*.unmin.work`, + ] ); + expect( minClean.cleanAfterEveryBuildPatterns ).toEqual( [ + `${ destination }/**/*.min.work`, + ] ); + } ); } ); diff --git a/transformers/js.js b/transformers/js.js index d07519b..22d9bda 100644 --- a/transformers/js.js +++ b/transformers/js.js @@ -3,6 +3,14 @@ */ const path = require( 'path' ); +/** + * Internal dependencies. + */ +const { + buildBundledEntry, + getEntryKeyResolver, +} = require( '../lib/entry-keys.js' ); + /** * Peer dependencies */ @@ -37,4 +45,43 @@ function transformJsEntry( destination, minimize = false ) { }; } -module.exports = { transformJsEntry }; +/** + * Build a single webpack config for all vanilla JS entries (min or unmin). + * + * @param {string[]} fileNames Relative entry paths. + * @param {string} destination Output directory. + * @param {boolean} minimize True to generate minified files. + * @param {string} context Webpack context (working directory). + * @param {string} [entryKeyMode='basename'] `'basename'` or `'relative'` entry key mode. + * @return {Object} Webpack configuration. + */ +function createBundledJsConfig( + fileNames, + destination, + minimize, + context, + entryKeyMode = 'basename' +) { + const entry = buildBundledEntry( + fileNames, + getEntryKeyResolver( entryKeyMode ) + ); + + return { + context, + entry, + output: { + filename: minimize ? '[name].min.js' : '[name].js', + path: destination, + iife: false, + }, + optimization: { + minimize, + minimizer: [ new TerserPlugin( { extractComments: false } ) ], + splitChunks: false, + runtimeChunk: false, + }, + }; +} + +module.exports = { transformJsEntry, createBundledJsConfig };