fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1 @@
extends @wordpress/browserslist-config

3
node_modules/@wordpress/scripts/config/.eslintignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
build
node_modules
vendor

27
node_modules/@wordpress/scripts/config/.eslintrc.js generated vendored Normal file
View File

@@ -0,0 +1,27 @@
/**
* Internal dependencies
*/
const { hasBabelConfig } = require( '../utils' );
const eslintConfig = {
root: true,
extends: [ 'plugin:@wordpress/eslint-plugin/recommended' ],
overrides: [
{
// Unit test files and their helpers only.
files: [ '**/@(test|__tests__)/**/*.js', '**/?(*.)test.js' ],
extends: [ 'plugin:@wordpress/eslint-plugin/test-unit' ],
},
],
};
if ( ! hasBabelConfig() ) {
eslintConfig.parserOptions = {
requireConfigFile: false,
babelOptions: {
presets: [ require.resolve( '@wordpress/babel-preset-default' ) ],
},
};
}
module.exports = eslintConfig;

View File

@@ -0,0 +1,8 @@
{
"default": true,
"MD003": { "style": "atx" },
"MD007": { "indent": 4 },
"MD013": { "line_length": 9999 },
"no-hard-tabs": false,
"whitespace": false
}

View File

@@ -0,0 +1,3 @@
**/build/**
**/node_modules/**
**/vendor/**

View File

@@ -0,0 +1,5 @@
# By default, all `node_modules` are ignored.
build
vendor
wordpress

View File

@@ -0,0 +1,2 @@
build
vendor

View File

@@ -0,0 +1 @@
module.exports = require( '@wordpress/prettier-config' );

View File

@@ -0,0 +1,4 @@
# By default, all `node_modules` are ignored.
build
vendor

View File

@@ -0,0 +1,6 @@
{
"extends": "@wordpress/stylelint-config/scss",
"rules": {
"selector-class-pattern": null
}
}

View File

@@ -0,0 +1,11 @@
/**
* External dependencies
*/
const babelJest = require( 'babel-jest' );
// Remove this workaround when https://github.com/facebook/jest/issues/11444 gets resolved in Jest.
const babelJestInterop = babelJest.__esModule ? babelJest.default : babelJest;
module.exports = babelJestInterop.createTransformer( {
presets: [ '@wordpress/babel-preset-default' ],
} );

View File

@@ -0,0 +1,13 @@
services:
wordpress-develop:
volumes:
- %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR%
php:
volumes:
- %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR%
cli:
volumes:
- %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR%
phpunit:
volumes:
- %PLUGIN_MOUNT_DIR%:/var/www/${LOCAL_DIR-src}/wp-content/plugins/%PLUGIN_INSTALL_DIR%

View File

@@ -0,0 +1,36 @@
/**
* External dependencies
*/
const path = require( 'path' );
/**
* Internal dependencies
*/
const { hasBabelConfig } = require( '../utils' );
const jestE2EConfig = {
globalSetup: path.join( __dirname, 'jest-environment-puppeteer', 'setup' ),
globalTeardown: path.join(
__dirname,
'jest-environment-puppeteer',
'teardown'
),
reporters: [
'default',
path.join( __dirname, 'jest-github-actions-reporter', 'index.js' ),
],
setupFilesAfterEnv: [ 'expect-puppeteer' ],
testEnvironment: path.join( __dirname, 'jest-environment-puppeteer' ),
testMatch: [ '**/specs/**/*.[jt]s?(x)', '**/?(*.)spec.[jt]s?(x)' ],
testPathIgnorePatterns: [ '/node_modules/' ],
testRunner: 'jest-circus/runner',
testTimeout: 30000,
};
if ( ! hasBabelConfig() ) {
jestE2EConfig.transform = {
'\\.[jt]sx?$': path.join( __dirname, 'babel-transform' ),
};
}
module.exports = jestE2EConfig;

View File

@@ -0,0 +1,85 @@
/**
* Parts of this source were derived and modified from the package
* jest-environment-puppeteer, released under the MIT license.
*
* https://github.com/smooth-code/jest-puppeteer/tree/master/packages/jest-environment-puppeteer
*
* Copyright 2018 Smooth Code
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* External dependencies
*/
const fs = require( 'fs' );
const path = require( 'path' );
const { promisify } = require( 'util' );
const cwd = require( 'cwd' );
const merge = require( 'merge-deep' );
const exists = promisify( fs.exists );
const DEFAULT_CONFIG = {
launch: {},
browser: 'chromium',
browserContext: 'default',
exitOnPageError: true,
};
const DEFAULT_CONFIG_CI = merge( DEFAULT_CONFIG, {
launch: {
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
],
},
} );
async function readConfig() {
const defaultConfig =
process.env.CI === 'true' ? DEFAULT_CONFIG_CI : DEFAULT_CONFIG;
const hasCustomConfigPath = !! process.env.JEST_PUPPETEER_CONFIG;
const configPath =
process.env.JEST_PUPPETEER_CONFIG || 'jest-puppeteer.config.js';
const absConfigPath = path.resolve( cwd(), configPath );
const configExists = await exists( absConfigPath );
if ( hasCustomConfigPath && ! configExists ) {
throw new Error(
`Error: Can't find a root directory while resolving a config file path.\nProvided path to resolve: ${ configPath }`
);
}
if ( ! hasCustomConfigPath && ! configExists ) {
return defaultConfig;
}
const localConfig = await require( absConfigPath );
return merge( {}, defaultConfig, localConfig );
}
function getPuppeteer( { browser } ) {
switch ( browser.toLowerCase() ) {
case 'chromium':
return require( 'puppeteer-core' );
case 'firefox':
return require( 'puppeteer-firefox' );
default:
throw new Error(
`Error: "browser" config option is given an unsupported value: ${ browser }`
);
}
}
module.exports = {
readConfig,
getPuppeteer,
};

View File

@@ -0,0 +1,101 @@
/**
* Parts of this source were derived and modified from the package
* jest-environment-puppeteer, released under the MIT license.
*
* https://github.com/smooth-code/jest-puppeteer/tree/master/packages/jest-environment-puppeteer
*
* Copyright 2018 Smooth Code
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* External dependencies
*/
const {
setup: setupServer,
teardown: teardownServer,
ERROR_TIMEOUT,
ERROR_NO_COMMAND,
} = require( 'jest-dev-server' );
const chalk = require( 'chalk' );
/**
* Internal dependencies
*/
const { readConfig, getPuppeteer } = require( './config' );
let browser;
let didAlreadyRunInWatchMode = false;
let servers = [];
async function setup( jestConfig = {} ) {
const config = await readConfig();
const puppeteer = getPuppeteer( config );
if ( config.connect ) {
browser = await puppeteer.connect( config.connect );
} else {
browser = await puppeteer.launch( config.launch );
}
process.env.PUPPETEER_WS_ENDPOINT = browser.wsEndpoint();
// If we are in watch mode, - only setupServer() once.
if ( jestConfig.watch || jestConfig.watchAll ) {
if ( didAlreadyRunInWatchMode ) {
return;
}
didAlreadyRunInWatchMode = true;
}
if ( config.server ) {
try {
servers = await setupServer( config.server );
} catch ( error ) {
const { error: printError } = console;
if ( error.code === ERROR_TIMEOUT ) {
printError( '' );
printError( chalk.red( error.message ) );
printError(
chalk.blue(
`\n☝️ You can set "server.launchTimeout" in jest-puppeteer.config.js`
)
);
process.exit( 1 );
}
if ( error.code === ERROR_NO_COMMAND ) {
printError( '' );
printError( chalk.red( error.message ) );
printError(
chalk.blue(
`\n☝️ You must set "server.command" in jest-puppeteer.config.js`
)
);
process.exit( 1 );
}
throw error;
}
}
}
async function teardown( jestConfig = {} ) {
const config = await readConfig();
if ( config.connect ) {
await browser.disconnect();
} else {
await browser.close();
}
if ( ! jestConfig.watch && ! jestConfig.watchAll ) {
await teardownServer( servers );
}
}
module.exports = {
setup,
teardown,
};

View File

@@ -0,0 +1,221 @@
/**
* Parts of this source were derived and modified from the package
* jest-environment-puppeteer, released under the MIT license.
*
* https://github.com/smooth-code/jest-puppeteer/tree/master/packages/jest-environment-puppeteer
*
* Copyright 2018 Smooth Code
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* External dependencies
*/
const path = require( 'path' );
const { writeFile, mkdir } = require( 'fs' ).promises;
const filenamify = require( 'filenamify' );
const NodeEnvironment = require( 'jest-environment-node' ).default;
const chalk = require( 'chalk' );
/**
* Internal dependencies
*/
const { readConfig, getPuppeteer } = require( './config' );
const handleError = ( error ) => {
// To match the same behavior in jest-jasmine2:
// https://github.com/facebook/jest/blob/1be8d737abd0e2f30e3314184a0efc372ad6d88f/packages/jest-jasmine2/src/jasmine/Env.ts#L250-L251
// Emitting an uncaughtException event to the process will throw an
// empty error which is very hard to debug in puppeteer context.
// eslint-disable-next-line no-console
console.error( error );
};
const KEYS = {
CONTROL_C: '\u0003',
CONTROL_D: '\u0004',
ENTER: '\r',
};
const { WP_ARTIFACTS_PATH } = process.env;
class PuppeteerEnvironment extends NodeEnvironment {
// Jest is not available here, so we have to reverse engineer
// the setTimeout function, see https://github.com/facebook/jest/blob/v23.1.0/packages/jest-runtime/src/index.js#L823
setTimeout( timeout ) {
this.global[ Symbol.for( 'TEST_TIMEOUT_SYMBOL' ) ] = timeout;
}
async setup() {
const config = await readConfig();
const puppeteer = getPuppeteer( config );
this.global.puppeteerConfig = config;
const wsEndpoint = process.env.PUPPETEER_WS_ENDPOINT;
if ( ! wsEndpoint ) {
throw new Error( 'wsEndpoint not found' );
}
this.global.jestPuppeteer = {
debug: async () => {
// Set timeout to 4 days.
this.setTimeout( 345600000 );
// Run a debugger (in case Puppeteer has been launched with `{ devtools: true }`)
await this.global.page.evaluate( () => {
// eslint-disable-next-line no-debugger
debugger;
} );
// eslint-disable-next-line no-console
console.log(
chalk.blue(
'\n\n🕵 Code is paused, press enter to resume'
)
);
// Run an infinite promise.
return new Promise( ( resolve ) => {
const { stdin } = process;
const onKeyPress = ( key ) => {
if (
key === KEYS.CONTROL_C ||
key === KEYS.CONTROL_D ||
key === KEYS.ENTER
) {
stdin.removeListener( 'data', onKeyPress );
if ( ! listening ) {
if ( stdin.isTTY ) {
stdin.setRawMode( false );
}
stdin.pause();
}
resolve();
}
};
const listening = stdin.listenerCount( 'data' ) > 0;
if ( ! listening ) {
if ( stdin.isTTY ) {
stdin.setRawMode( true );
}
stdin.resume();
stdin.setEncoding( 'utf8' );
}
stdin.on( 'data', onKeyPress );
} );
},
resetPage: async () => {
if ( this.global.page ) {
this.global.page.removeListener( 'pageerror', handleError );
await this.global.page.close();
}
this.global.page = await this.global.context.newPage();
if ( config && config.exitOnPageError ) {
this.global.page.addListener( 'pageerror', handleError );
}
},
resetBrowser: async () => {
if ( this.global.page ) {
this.global.page.removeListener( 'pageerror', handleError );
}
if (
config.browserContext === 'incognito' &&
this.global.context
) {
await this.global.context.close();
} else if ( this.global.page ) {
await this.global.page.close();
}
this.global.page = null;
if ( this.global.browser ) {
await this.global.browser.disconnect();
}
this.global.browser = await puppeteer.connect( {
...config.connect,
...config.launch,
browserURL: undefined,
browserWSEndpoint: wsEndpoint,
} );
if ( config.browserContext === 'incognito' ) {
// Using this, pages will be created in a pristine context.
this.global.context =
await this.global.browser.createIncognitoBrowserContext();
} else if (
config.browserContext === 'default' ||
! config.browserContext
) {
/**
* Since this is a new browser, browserContexts() will return only one instance
* https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#browserbrowsercontexts
*/
this.global.context =
await this.global.browser.browserContexts()[ 0 ];
} else {
throw new Error(
`browserContext should be either 'incognito' or 'default'. Received '${ config.browserContext }'`
);
}
await this.global.jestPuppeteer.resetPage();
},
};
await this.global.jestPuppeteer.resetBrowser();
try {
await mkdir( WP_ARTIFACTS_PATH, { recursive: true } );
} catch ( err ) {
if ( err.code !== 'EEXIST' ) {
throw err;
}
}
}
async teardown() {
const { page, context, browser, puppeteerConfig } = this.global;
if ( page ) {
page.removeListener( 'pageerror', handleError );
}
if ( puppeteerConfig.browserContext === 'incognito' ) {
if ( context ) {
await context.close();
}
} else if ( page ) {
await page.close();
}
if ( browser ) {
await browser.disconnect();
}
}
async storeArtifacts( testName ) {
const datetime = new Date().toISOString().split( '.' )[ 0 ];
const fileName = filenamify( `${ testName } ${ datetime }`, {
replacement: '-',
} );
await writeFile(
path.join( WP_ARTIFACTS_PATH, `${ fileName }-snapshot.html` ),
await this.global.page.content()
);
await this.global.page.screenshot( {
path: path.join( WP_ARTIFACTS_PATH, `${ fileName }.jpg` ),
} );
}
async handleTestEvent( event, state ) {
if ( event.name === 'test_fn_failure' ) {
const testName = state.currentlyRunningTest.name;
await this.storeArtifacts( testName );
}
}
}
module.exports = PuppeteerEnvironment;

View File

@@ -0,0 +1 @@
module.exports = require( './global' ).setup;

View File

@@ -0,0 +1 @@
module.exports = require( './global' ).teardown;

View File

@@ -0,0 +1,66 @@
/**
* Based on https://github.com/facebook/jest/pull/11320.
*
* We might be able to remove this file once the Jest PR is merged, and a
* version of Jest that includes the GithubActionsReporter is released.
*/
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const newLine = /\n/g;
const encodedNewLine = '%0A';
const lineAndColumnInStackTrace = /^.*?:([0-9]+):([0-9]+).*$/;
class GithubActionsReporter {
async onRunComplete( _contexts, _aggregatedResults ) {
if ( ! process.env.GITHUB_ACTIONS ) {
return;
}
if ( ! _aggregatedResults ) {
return;
}
const messages = getMessages( _aggregatedResults.testResults );
for ( const message of messages ) {
// eslint-disable-next-line no-console
console.log( message );
}
}
}
function getMessages( results ) {
if ( ! results ) {
return [];
}
return results.reduce(
flatMap( ( { testFilePath, testResults } ) =>
testResults
.filter( ( r ) => r.status === 'failed' )
.reduce(
flatMap( ( r ) => r.failureMessages ),
[]
)
.map( ( m ) => m.replace( newLine, encodedNewLine ) )
.map( ( m ) => lineAndColumnInStackTrace.exec( m ) )
.filter( ( m ) => m !== null )
.map(
( [ message, line, col ] ) =>
`::error file=${ testFilePath },line=${ line },col=${ col }::${ message }`
)
),
[]
);
}
function flatMap( fn ) {
return ( out, entry ) => out.concat( ...fn( entry ) );
}
module.exports = GithubActionsReporter;

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
const path = require( 'path' );
/**
* Internal dependencies
*/
const { hasBabelConfig } = require( '../utils' );
const jestUnitConfig = {
preset: '@wordpress/jest-preset-default',
reporters: [
'default',
path.join( __dirname, 'jest-github-actions-reporter', 'index.js' ),
],
};
if ( ! hasBabelConfig() ) {
jestUnitConfig.transform = {
'\\.[jt]sx?$': path.join( __dirname, 'babel-transform' ),
};
}
module.exports = jestUnitConfig;

View File

@@ -0,0 +1,3 @@
{
"extends": "@wordpress/npm-package-json-lint-config"
}

View File

@@ -0,0 +1,60 @@
/**
* External dependencies
*/
const path = require( 'path' );
const { defineConfig, devices } = require( '@playwright/test' );
process.env.WP_ARTIFACTS_PATH ??= path.join( process.cwd(), 'artifacts' );
process.env.STORAGE_STATE_PATH ??= path.join(
process.env.WP_ARTIFACTS_PATH,
'storage-states/admin.json'
);
const config = defineConfig( {
reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ],
forbidOnly: !! process.env.CI,
// fullyParallel: false,
workers: 1,
retries: process.env.CI ? 2 : 0,
timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds.
// Don't report slow test "files", as we will be running our tests in serial.
reportSlowTests: null,
testDir: './specs',
outputDir: path.join( process.env.WP_ARTIFACTS_PATH, 'test-results' ),
snapshotPathTemplate:
'{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}',
globalSetup: require.resolve( './playwright/global-setup.js' ),
use: {
baseURL: process.env.WP_BASE_URL || 'http://localhost:8889',
headless: true,
viewport: {
width: 960,
height: 700,
},
ignoreHTTPSErrors: true,
locale: 'en-US',
contextOptions: {
reducedMotion: 'reduce',
strictSelectors: true,
},
storageState: process.env.STORAGE_STATE_PATH,
actionTimeout: 10_000, // 10 seconds.
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
webServer: {
command: 'npm run wp-env start',
port: 8889,
timeout: 120_000, // 120 seconds.
reuseExistingServer: true,
},
projects: [
{
name: 'chromium',
use: { ...devices[ 'Desktop Chrome' ] },
},
],
} );
module.exports = config;

View File

@@ -0,0 +1,35 @@
/**
* External dependencies
*/
const { request } = require( '@playwright/test' );
/**
* WordPress dependencies
*/
const { RequestUtils } = require( '@wordpress/e2e-test-utils-playwright' );
/**
*
* @param {import('@playwright/test').FullConfig} config
* @return {Promise<void>}
*/
async function globalSetup( config ) {
const { storageState, baseURL } = config.projects[ 0 ].use;
const storageStatePath =
typeof storageState === 'string' ? storageState : undefined;
const requestContext = await request.newContext( {
baseURL,
} );
const requestUtils = new RequestUtils( requestContext, {
storageStatePath,
} );
// Authenticate and save the storageState to disk.
await requestUtils.setupRest();
await requestContext.dispose();
}
module.exports = globalSetup;

View File

@@ -0,0 +1,11 @@
module.exports = {
launch: {
devtools: process.env.PUPPETEER_DEVTOOLS === 'true',
headless: process.env.PUPPETEER_HEADLESS !== 'false',
slowMo: parseInt( process.env.PUPPETEER_SLOWMO, 10 ) || 0,
args: [
'--enable-blink-features=ComputedAccessibilityInfo',
'--disable-web-security',
],
},
};

View File

@@ -0,0 +1,477 @@
/**
* External dependencies
*/
const { BundleAnalyzerPlugin } = require( 'webpack-bundle-analyzer' );
const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' );
const CopyWebpackPlugin = require( 'copy-webpack-plugin' );
const webpack = require( 'webpack' );
const browserslist = require( 'browserslist' );
const MiniCSSExtractPlugin = require( 'mini-css-extract-plugin' );
const { basename, dirname, resolve } = require( 'path' );
const ReactRefreshWebpackPlugin = require( '@pmmmwh/react-refresh-webpack-plugin' );
const RtlCssPlugin = require( 'rtlcss-webpack-plugin' );
const TerserPlugin = require( 'terser-webpack-plugin' );
const { realpathSync } = require( 'fs' );
const { sync: glob } = require( 'fast-glob' );
/**
* WordPress dependencies
*/
const DependencyExtractionWebpackPlugin = require( '@wordpress/dependency-extraction-webpack-plugin' );
const postcssPlugins = require( '@wordpress/postcss-plugins-preset' );
/**
* Internal dependencies
*/
const {
fromConfigRoot,
hasBabelConfig,
hasArgInCLI,
hasCssnanoConfig,
hasPostCSSConfig,
getWordPressSrcDirectory,
getWebpackEntryPoints,
getRenderPropPaths,
getAsBooleanFromENV,
getBlockJsonModuleFields,
getBlockJsonScriptFields,
fromProjectRoot,
} = require( '../utils' );
const isProduction = process.env.NODE_ENV === 'production';
const mode = isProduction ? 'production' : 'development';
let target = 'browserslist';
if ( ! browserslist.findConfig( '.' ) ) {
target += ':' + fromConfigRoot( '.browserslistrc' );
}
const hasReactFastRefresh = hasArgInCLI( '--hot' ) && ! isProduction;
const hasExperimentalModulesFlag = getAsBooleanFromENV(
'WP_EXPERIMENTAL_MODULES'
);
/**
* The plugin recomputes the render paths once on each compilation. It is necessary to avoid repeating processing
* when filtering every discovered PHP file in the source folder. This is the most performant way to ensure that
* changes in `block.json` files are picked up in watch mode.
*/
class RenderPathsPlugin {
/**
* Paths with the `render` props included in `block.json` files.
*
* @type {string[]}
*/
static renderPaths;
apply( compiler ) {
const pluginName = this.constructor.name;
compiler.hooks.thisCompilation.tap( pluginName, () => {
this.constructor.renderPaths = getRenderPropPaths();
} );
}
}
const cssLoaders = [
{
loader: MiniCSSExtractPlugin.loader,
},
{
loader: require.resolve( 'css-loader' ),
options: {
importLoaders: 1,
sourceMap: ! isProduction,
modules: {
auto: true,
},
},
},
{
loader: require.resolve( 'postcss-loader' ),
options: {
// Provide a fallback configuration if there's not
// one explicitly available in the project.
...( ! hasPostCSSConfig() && {
postcssOptions: {
ident: 'postcss',
sourceMap: ! isProduction,
plugins: isProduction
? [
...postcssPlugins,
require( 'cssnano' )( {
// Provide a fallback configuration if there's not
// one explicitly available in the project.
...( ! hasCssnanoConfig() && {
preset: [
'default',
{
discardComments: {
removeAll: true,
},
},
],
} ),
} ),
]
: postcssPlugins,
},
} ),
},
},
];
/** @type {webpack.Configuration} */
const baseConfig = {
mode,
target,
output: {
filename: '[name].js',
path: resolve( process.cwd(), 'build' ),
},
resolve: {
alias: {
'lodash-es': 'lodash',
},
extensions: [ '.jsx', '.ts', '.tsx', '...' ],
},
optimization: {
// Only concatenate modules in production, when not analyzing bundles.
concatenateModules: isProduction && ! process.env.WP_BUNDLE_ANALYZER,
splitChunks: {
cacheGroups: {
style: {
type: 'css/mini-extract',
test: /[\\/]style(\.module)?\.(pc|sc|sa|c)ss$/,
chunks: 'all',
enforce: true,
name( _, chunks, cacheGroupKey ) {
const chunkName = chunks[ 0 ].name;
return `${ dirname(
chunkName
) }/${ cacheGroupKey }-${ basename( chunkName ) }`;
},
},
default: false,
},
},
minimizer: [
new TerserPlugin( {
parallel: true,
terserOptions: {
output: {
comments: /translators:/i,
},
compress: {
passes: 2,
},
mangle: {
reserved: [ '__', '_n', '_nx', '_x' ],
},
},
extractComments: false,
} ),
],
},
module: {
rules: [
{
test: /\.m?(j|t)sx?$/,
exclude: /node_modules/,
use: [
{
loader: require.resolve( 'babel-loader' ),
options: {
// Babel uses a directory within local node_modules
// by default. Use the environment variable option
// to enable more persistent caching.
cacheDirectory:
process.env.BABEL_CACHE_DIRECTORY || true,
// Provide a fallback configuration if there's not
// one explicitly available in the project.
...( ! hasBabelConfig() && {
babelrc: false,
configFile: false,
presets: [
require.resolve(
'@wordpress/babel-preset-default'
),
],
plugins: [
hasReactFastRefresh &&
require.resolve(
'react-refresh/babel'
),
].filter( Boolean ),
} ),
},
},
],
},
{
test: /\.css$/,
use: cssLoaders,
},
{
test: /\.pcss$/,
use: cssLoaders,
},
{
test: /\.(sc|sa)ss$/,
use: [
...cssLoaders,
{
loader: require.resolve( 'sass-loader' ),
options: {
sourceMap: ! isProduction,
},
},
],
},
{
test: /\.svg$/,
issuer: /\.(j|t)sx?$/,
use: [ '@svgr/webpack', 'url-loader' ],
type: 'javascript/auto',
},
{
test: /\.svg$/,
issuer: /\.(pc|sc|sa|c)ss$/,
type: 'asset/inline',
},
{
test: /\.(bmp|png|jpe?g|gif|webp)$/i,
type: 'asset/resource',
generator: {
filename: 'images/[name].[hash:8][ext]',
},
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
generator: {
filename: 'fonts/[name].[hash:8][ext]',
},
},
],
},
stats: {
children: false,
},
};
// WP_DEVTOOL global variable controls how source maps are generated.
// See: https://webpack.js.org/configuration/devtool/#devtool.
if ( process.env.WP_DEVTOOL ) {
baseConfig.devtool = process.env.WP_DEVTOOL;
}
if ( ! isProduction ) {
// Set default sourcemap mode if it wasn't set by WP_DEVTOOL.
baseConfig.devtool = baseConfig.devtool || 'source-map';
}
// Add source-map-loader if devtool is set, whether in dev mode or not.
if ( baseConfig.devtool ) {
baseConfig.module.rules.unshift( {
test: /\.(j|t)sx?$/,
exclude: [ /node_modules/ ],
use: require.resolve( 'source-map-loader' ),
enforce: 'pre',
} );
}
/** @type {webpack.Configuration} */
const scriptConfig = {
...baseConfig,
entry: getWebpackEntryPoints( 'script' ),
devServer: isProduction
? undefined
: {
devMiddleware: {
writeToDisk: true,
},
allowedHosts: 'auto',
host: 'localhost',
port: 8887,
proxy: {
'/build': {
pathRewrite: {
'^/build': '',
},
},
},
},
plugins: [
new webpack.DefinePlugin( {
// Inject the `SCRIPT_DEBUG` global, used for development features flagging.
SCRIPT_DEBUG: ! isProduction,
} ),
// If we run a modules build, the 2 compilations can "clean" each other's output
// Prevent the cleaning from happening
! hasExperimentalModulesFlag &&
new CleanWebpackPlugin( {
cleanAfterEveryBuildPatterns: [ '!fonts/**', '!images/**' ],
// Prevent it from deleting webpack assets during builds that have
// multiple configurations returned in the webpack config.
cleanStaleWebpackAssets: false,
} ),
new RenderPathsPlugin(),
new CopyWebpackPlugin( {
patterns: [
{
from: '**/block.json',
context: getWordPressSrcDirectory(),
noErrorOnMissing: true,
transform( content, absoluteFrom ) {
const convertExtension = ( path ) => {
return path.replace( /\.m?(j|t)sx?$/, '.js' );
};
if ( basename( absoluteFrom ) === 'block.json' ) {
const blockJson = JSON.parse( content.toString() );
[
getBlockJsonScriptFields( blockJson ),
getBlockJsonModuleFields( blockJson ),
].forEach( ( fields ) => {
if ( fields ) {
for ( const [
key,
value,
] of Object.entries( fields ) ) {
if ( Array.isArray( value ) ) {
blockJson[ key ] =
value.map( convertExtension );
} else if (
typeof value === 'string'
) {
blockJson[ key ] =
convertExtension( value );
}
}
}
} );
return JSON.stringify( blockJson, null, 2 );
}
return content;
},
},
{
from: '**/*.php',
context: getWordPressSrcDirectory(),
noErrorOnMissing: true,
filter: ( filepath ) => {
return (
process.env.WP_COPY_PHP_FILES_TO_DIST ||
RenderPathsPlugin.renderPaths.includes(
realpathSync( filepath ).replace( /\\/g, '/' )
)
);
},
},
],
} ),
// The WP_BUNDLE_ANALYZER global variable enables a utility that represents
// bundle content as a convenient interactive zoomable treemap.
process.env.WP_BUNDLE_ANALYZER && new BundleAnalyzerPlugin(),
// MiniCSSExtractPlugin to extract the CSS thats gets imported into JavaScript.
new MiniCSSExtractPlugin( { filename: '[name].css' } ),
// RtlCssPlugin to generate RTL CSS files.
new RtlCssPlugin( {
filename: `[name]-rtl.css`,
} ),
// React Fast Refresh.
hasReactFastRefresh && new ReactRefreshWebpackPlugin(),
// WP_NO_EXTERNALS global variable controls whether scripts' assets get
// generated, and the default externals set.
! process.env.WP_NO_EXTERNALS &&
new DependencyExtractionWebpackPlugin(),
].filter( Boolean ),
};
if ( hasExperimentalModulesFlag ) {
/**
* Add block.json files to compilation to ensure changes trigger rebuilds when watching
*/
class BlockJsonDependenciesPlugin {
constructor() {
/** @type {ReadonlyArray<string>} */
this.blockJsonFiles = glob( '**/block.json', {
absolute: true,
cwd: fromProjectRoot( getWordPressSrcDirectory() ),
} );
}
/**
* Apply the plugin
* @param {webpack.Compiler} compiler the compiler instance
* @return {void}
*/
apply( compiler ) {
if ( this.blockJsonFiles.length ) {
compiler.hooks.compilation.tap(
'BlockJsonDependenciesPlugin',
( compilation ) => {
compilation.fileDependencies.addAll(
this.blockJsonFiles
);
}
);
}
}
}
/** @type {webpack.Configuration} */
const moduleConfig = {
...baseConfig,
entry: getWebpackEntryPoints( 'module' ),
experiments: {
...baseConfig.experiments,
outputModule: true,
},
output: {
...baseConfig.output,
module: true,
chunkFormat: 'module',
environment: {
...baseConfig.output.environment,
module: true,
},
library: {
...baseConfig.output.library,
type: 'module',
},
},
plugins: [
new webpack.DefinePlugin( {
// Inject the `SCRIPT_DEBUG` global, used for development features flagging.
SCRIPT_DEBUG: ! isProduction,
} ),
// The WP_BUNDLE_ANALYZER global variable enables a utility that represents
// bundle content as a convenient interactive zoomable treemap.
process.env.WP_BUNDLE_ANALYZER && new BundleAnalyzerPlugin(),
// MiniCSSExtractPlugin to extract the CSS thats gets imported into JavaScript.
new MiniCSSExtractPlugin( { filename: '[name].css' } ),
// WP_NO_EXTERNALS global variable controls whether scripts' assets get
// generated, and the default externals set.
! process.env.WP_NO_EXTERNALS &&
new DependencyExtractionWebpackPlugin(),
new BlockJsonDependenciesPlugin(),
].filter( Boolean ),
};
module.exports = [ scriptConfig, moduleConfig ];
} else {
module.exports = scriptConfig;
}