diff --git a/docs/contributors/scripts.md b/docs/contributors/scripts.md index 4928ec91bde64b..28a768e41599ca 100644 --- a/docs/contributors/scripts.md +++ b/docs/contributors/scripts.md @@ -65,3 +65,15 @@ It is recommended to use the main `wp-polyfill` script handle which takes care o | [Formdata Polyfill](https://www.npmjs.com/package/formdata-polyfill) | wp-polyfill-formdata| Polyfill conditionally replaces the native implementation | | [Node Contains Polyfill](https://polyfill.io) | wp-polyfill-node-contains |Polyfill for Node.contains | | [Element Closest Polyfill](https://www.npmjs.com/package/element-closest) | wp-polyfill-element-closest| Return the closest element matching a selector up the DOM tree | + +## Bundling and code sharing + +When using a JavaScript bundler like [webpack](https://webpack.js.org/), the scripts mentioned here +can be excluded from the bundle and provided by WordPress in the form of script dependencies [(see +`wp_enqueue_script`)][https://developer.wordpress.org/reference/functions/wp_enqueue_script/#default-scripts-included-and-registered-by-wordpress]. + +The +[`@wordpress/dependency-extraction-webpack-plugin`](https://github.com/WordPress/gutenberg/tree/master/packages/dependency-extraction-webpack-plugin) +provides a webpack plugin to help extract WordPress dependencies from bundles. `@wordpress/scripts` +[`build`](https://github.com/WordPress/gutenberg/tree/master/packages/scripts#build) script includes +the plugin by default. diff --git a/docs/manifest.json b/docs/manifest.json index 4a7815af08489f..56e1f6134609b4 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -629,6 +629,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/date/README.md", "parent": "packages" }, + { + "title": "@wordpress/dependency-extraction-webpack-plugin", + "slug": "packages-dependency-extraction-webpack-plugin", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/dependency-extraction-webpack-plugin/README.md", + "parent": "packages" + }, { "title": "@wordpress/deprecated", "slug": "packages-deprecated", diff --git a/package-lock.json b/package-lock.json index efa0fa578d8e97..d629bd46288c5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3140,6 +3140,32 @@ "moment-timezone": "^0.5.16" } }, + "@wordpress/dependency-extraction-webpack-plugin": { + "version": "file:packages/dependency-extraction-webpack-plugin", + "dev": true, + "requires": { + "webpack": "^4.8.3", + "webpack-sources": "^1.3.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "webpack-sources": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz", + "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + } + } + }, "@wordpress/deprecated": { "version": "file:packages/deprecated", "requires": { @@ -3468,6 +3494,7 @@ "dev": true, "requires": { "@wordpress/babel-preset-default": "file:packages/babel-preset-default", + "@wordpress/dependency-extraction-webpack-plugin": "file:packages/dependency-extraction-webpack-plugin", "@wordpress/eslint-plugin": "file:packages/eslint-plugin", "@wordpress/jest-preset-default": "file:packages/jest-preset-default", "@wordpress/npm-package-json-lint-config": "file:packages/npm-package-json-lint-config", diff --git a/package.json b/package.json index 202c88ccf84388..0676ee933f2af1 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@wordpress/babel-preset-default": "file:packages/babel-preset-default", "@wordpress/browserslist-config": "file:packages/browserslist-config", "@wordpress/custom-templated-path-webpack-plugin": "file:packages/custom-templated-path-webpack-plugin", + "@wordpress/dependency-extraction-webpack-plugin": "file:packages/dependency-extraction-webpack-plugin", "@wordpress/docgen": "file:packages/docgen", "@wordpress/e2e-test-utils": "file:packages/e2e-test-utils", "@wordpress/e2e-tests": "file:packages/e2e-tests", diff --git a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md new file mode 100644 index 00000000000000..46c5eb68883748 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md @@ -0,0 +1,5 @@ +## Unreleased + +### New Feature + +- Introduce the `@wordpress/dependency-extraction-webpack-plugin` package. diff --git a/packages/dependency-extraction-webpack-plugin/README.md b/packages/dependency-extraction-webpack-plugin/README.md new file mode 100644 index 00000000000000..78cf2abcb7486e --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/README.md @@ -0,0 +1,196 @@ +# Dependency Extraction Webpack Plugin + +This webpack plugin serves two purposes: + +- Externalize dependencies that are available as script dependencies on modern WordPress sites. +- Add a JSON file for each entrypoint that declares the WordPress script dependencies for the + entrypoint. + +This allows JavaScript bundles produced by webpack to leverage WordPress style dependency sharing +without an error-prone process of manually maintaining a dependency list. + +Consult the [webpack website](https://webpack.js.org) for additional information on webpack concepts. + +## Installation + +Install the module + +```bash +npm install @wordpress/dependency-extraction-webpack-plugin --save-dev +``` + +## Usage + +### Webpack + +Use this plugin as you would other webpack plugins: + +```js +// webpack.config.js +const WordPressExternalDependenciesPlugin = require( '@wordpress/dependency-extraction-webpack-plugin' ); + +module.exports = { + // …snip + plugins: [ + new WordPressExternalDependenciesPlugin(), + ] +} +``` + +Each entrypoint in the webpack bundle will include JSON file that declares the WordPress script dependencies that should be enqueued. + +For example: + +``` +// Source file entrypoint.js +import { Component } from '@wordpress/element'; + +// Webpack will produce the output output/entrypoint.js +/* bundled JavaScript output */ + +// Webpack will also produce output/entrypoint.deps.json declaring script dependencies +['wp-element'] +``` + +By default, the following module requests are handled: + +| Request | Global | Script handle | +| --- | --- | --- | +| `@babel/runtime/regenerator` | `regeneratorRuntime` | `wp-polyfill` | +| `@wordpress/*` | `wp['*']` | `wp-*` | +| `jquery` | `jQuery` | `jquery` | +| `jquery` | `jQuery` | `jquery` | +| `lodash-es` | `lodash` | `lodash` | +| `lodash` | `lodash` | `lodash` | +| `moment` | `moment` | `moment` | +| `react-dom` | `ReactDOM` | `react-dom` | +| `react` | `React` | `react` | + +**Note:** This plugin overlaps with the functionality provided by [webpack +`externals`](https://webpack.js.org/configuration/externals). This plugin is intended to extract +script handles from bundle compilation so that a list of script dependencies does not need to be +manually maintained. If you don't need to extract a list of script dependencies, use the `externals` +option directly. + +This plugin is compatible with `externals`, but they may conflict. For example, adding +`{ externals: { '@wordpress/blob': 'wp.blob' } }` to webpack configuration will effectively hide the +`@wordpress/blob` module from the plugin and it will not be included in dependency lists. + +#### Options + +An object can be passed to the constructor to customize the behavior, for example: + +```js +module.exports = { + plugins: [ + new WordPressExternalDependenciesPlugin( { injectPolyfill: true } ), + ] +} +``` + +##### `useDefaults` + +- Type: boolean +- Default: `true` + +Pass `useDefaults: false` to disable the default request handling. + +##### `injectPolyfill` + +- Type: boolean +- Default: `false` + +Force `wp-polyfill` to be included in each entrypoint's dependency list. This would be the same as +adding `import '@wordpress/polyfill';` to each entrypoint. + +##### `requestToExternal` + +- Type: function + +`requestToExternal` allows the module handling to be customized. The function should accept a +module request string and may return a string representing the global variable to use. An array of +strings may be used to access globals via an object path, e.g. `wp.i18n` may be represented as `[ +'wp', 'i18n' ]`. + +`requestToExternal` provided via configuration has precedence over default external handling. +Unhandled requests will be handled by the default unless `useDefaults` is set to `false`. + +```js +/** + * Externalize 'my-module' + * + * @param {string} request Requested module + * + * @return {(string|undefined)} Script global + */ +function requestToExternal( request ) { + + // Handle imports like `import myModule from 'my-module'` + if ( request === 'my-module' ) { + // Expect to find `my-module` as myModule in the global scope: + return 'myModule'; + } +} + +module.exports = { + plugins: [ + new WordPressExternalDependenciesPlugin( { requestToExternal } ), + ] +} +``` + +##### `requestToHandle` + +- Type: function + +All of the external modules handled by the plugin are expected to be WordPress script dependencies +and will be added to the dependency list. `requestToHandle` allows the script handle included in the dependency list to be customized. + +If no string is returned, the script handle is assumed to be the same as the request. + +`requestToHandle` provided via configuration has precedence over the defaults. Unhandled requests will be handled by the default unless `useDefaults` is set to `false`. + +```js +/** + * Map 'my-module' request to 'my-module-script-handle' + * + * @param {string} request Requested module + * + * @return {(string|undefined)} Script global + */ +function requestToHandle( request ) { + + // Handle imports like `import myModule from 'my-module'` + if ( request === 'my-module' ) { + // Expect to find `my-module` as myModule in the global scope: + return 'my-module-script-handle'; + } +} + +module.exports = { + plugins: [ + new WordPressExternalDependenciesPlugin( { requestToExternal } ), + ] +} +``` + +##### `requestToExternal` and `requestToHandle` + +The functions `requestToExternal` and `requestToHandle` allow this module to handle arbitrary +modules. `requestToExternal` is necessary to handle any module and maps a module request to a global +name. `requestToHandle` maps the same module request to a script handle, the strings that will be +included in the `entrypoint.deps.json` files. + +### WordPress + +Enqueue your script as usual and read the script dependencies dynamically: + +```php +$script_path = 'path/to/script.js'; +$script_deps_path = 'path/to/script.deps.json'; +$script_dependencies = file_exists( $script_deps_path ) + ? json_decode( file_get_contents( $script_deps_path ) ) + : array(); +$script_url = plugins_url( $script_path, __FILE__ ); +wp_enqueue_script( 'script', $script_url, $script_dependencies ); +``` diff --git a/packages/dependency-extraction-webpack-plugin/index.js b/packages/dependency-extraction-webpack-plugin/index.js new file mode 100644 index 00000000000000..5e067856671cd4 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/index.js @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +const { createHash } = require( 'crypto' ); +const { ExternalsPlugin } = require( 'webpack' ); +const { RawSource } = require( 'webpack-sources' ); + +/** + * Internal dependencies + */ +const { defaultRequestToExternal, defaultRequestToHandle } = require( './util' ); + +class DependencyExtractionWebpackPlugin { + constructor( options ) { + this.options = Object.assign( + { + injectPolyfill: false, + useDefaults: true, + }, + options + ); + + // Track requests that are externalized. + // + // Because we don't have a closed set of dependencies, we need to track what has + // been externalized so we can recognize them in a later phase when the dependency + // lists are generated. + this.externalizedDeps = new Set(); + + // Offload externalization work to the ExternalsPlugin. + this.externalsPlugin = new ExternalsPlugin( 'this', this.externalizeWpDeps.bind( this ) ); + } + + externalizeWpDeps( context, request, callback ) { + let externalRequest; + + // Handle via options.requestToExternal first + if ( typeof this.options.requestToExternal === 'function' ) { + externalRequest = this.options.requestToExternal( request ); + } + + // Cascade to default if unhandled and enabled + if ( typeof externalRequest === 'undefined' && this.options.useDefaults ) { + externalRequest = defaultRequestToExternal( request ); + } + + if ( externalRequest ) { + this.externalizedDeps.add( request ); + + return callback( null, { this: externalRequest } ); + } + + return callback(); + } + + mapRequestToDependency( request ) { + // Handle via options.requestToDependency first + if ( typeof this.options.requestToDependency === 'function' ) { + const scriptDependency = this.options.requestToDependency( request ); + if ( scriptDependency ) { + return scriptDependency; + } + } + + // Cascade to default if enabled + if ( this.options.useDefaults ) { + const scriptDependency = defaultRequestToHandle( request ); + if ( scriptDependency ) { + return scriptDependency; + } + } + + // Fall back to the request name + return request; + } + + apply( compiler ) { + this.externalsPlugin.apply( compiler ); + + const { output } = compiler.options; + const { filename: outputFilename } = output; + + compiler.hooks.emit.tap( this.constructor.name, ( compilation ) => { + // Process each entrypoint independently. + for ( const [ entrypointName, entrypoint ] of compilation.entrypoints.entries() ) { + const entrypointExternalizedWpDeps = new Set(); + if ( this.options.injectPolyfill ) { + entrypointExternalizedWpDeps.add( 'wp-polyfill' ); + } + + // Search for externalized modules in all chunks. + for ( const chunk of entrypoint.chunks ) { + for ( const { userRequest } of chunk.modulesIterable ) { + if ( this.externalizedDeps.has( userRequest ) ) { + const scriptDependency = this.mapRequestToDependency( userRequest ); + entrypointExternalizedWpDeps.add( scriptDependency ); + } + } + } + + // Build a stable JSON string that declares the WordPress script dependencies. + const sortedDepsArray = Array.from( entrypointExternalizedWpDeps ).sort(); + const depsString = JSON.stringify( sortedDepsArray ); + + // Determine a filename for the `[entrypoint].deps.json` file. + const [ filename, query ] = entrypointName.split( '?', 2 ); + const depsFilename = compilation.getPath( + outputFilename.replace( /\.js$/i, '.deps.json' ), + { + chunk: entrypoint.getRuntimeChunk(), + filename, + query, + basename: basename( filename ), + contentHash: createHash( 'md4' ) + .update( depsString ) + .digest( 'hex' ), + } + ); + + // Add source and file into compilation for webpack to output. + compilation.assets[ depsFilename ] = new RawSource( depsString ); + entrypoint.getRuntimeChunk().files.push( depsFilename ); + } + } ); + } +} + +function basename( name ) { + if ( ! name.includes( '/' ) ) { + return name; + } + return name.substr( name.lastIndexOf( '/' ) + 1 ); +} + +module.exports = DependencyExtractionWebpackPlugin; diff --git a/packages/dependency-extraction-webpack-plugin/package.json b/packages/dependency-extraction-webpack-plugin/package.json new file mode 100644 index 00000000000000..9d3e816fcbb1a2 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/package.json @@ -0,0 +1,32 @@ +{ + "name": "@wordpress/dependency-extraction-webpack-plugin", + "version": "1.0.0-alpha.0", + "description": "Extract WordPress script dependencies from webpack bundles.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "webpack", + "dependency" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/dependency-extraction-webpack-plugin/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/dependency-extraction-webpack-plugin" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "files": [ + "index.js" + ], + "main": "index.js", + "dependencies": { + "webpack": "^4.8.3", + "webpack-sources": "^1.3.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap new file mode 100644 index 00000000000000..37d144bb4a6397 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap @@ -0,0 +1,182 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Webpack \`dynamic-import\` should produce expected output: Dependencies JSON should match snapshot 1`] = ` +Array [ + "lodash", + "wp-blob", +] +`; + +exports[`Webpack \`dynamic-import\` should produce expected output: External modules should match snapshot 1`] = ` +Array [ + Object { + "externalType": "this", + "request": Object { + "this": Array [ + "wp", + "blob", + ], + }, + "userRequest": "@wordpress/blob", + }, + Object { + "externalType": "this", + "request": Object { + "this": "lodash", + }, + "userRequest": "lodash", + }, +] +`; + +exports[`Webpack \`no-default\` should produce expected output: Dependencies JSON should match snapshot 1`] = `Array []`; + +exports[`Webpack \`no-default\` should produce expected output: External modules should match snapshot 1`] = `Array []`; + +exports[`Webpack \`no-deps\` should produce expected output: Dependencies JSON should match snapshot 1`] = `Array []`; + +exports[`Webpack \`no-deps\` should produce expected output: External modules should match snapshot 1`] = `Array []`; + +exports[`Webpack \`overrides\` should produce expected output: Dependencies JSON should match snapshot 1`] = ` +Array [ + "wp-blob", + "wp-script-handle-for-rxjs", + "wp-url", +] +`; + +exports[`Webpack \`overrides\` should produce expected output: External modules should match snapshot 1`] = ` +Array [ + Object { + "externalType": "this", + "request": Object { + "this": Array [ + "wp", + "blob", + ], + }, + "userRequest": "@wordpress/blob", + }, + Object { + "externalType": "this", + "request": Object { + "this": Array [ + "wp", + "url", + ], + }, + "userRequest": "@wordpress/url", + }, + Object { + "externalType": "this", + "request": Object { + "this": Array [ + "rxjs", + "operators", + ], + }, + "userRequest": "rxjs/operators", + }, + Object { + "externalType": "this", + "request": Object { + "this": "rxjs", + }, + "userRequest": "rxjs", + }, +] +`; + +exports[`Webpack \`with-externs\` should produce expected output: Dependencies JSON should match snapshot 1`] = ` +Array [ + "wp-url", +] +`; + +exports[`Webpack \`with-externs\` should produce expected output: External modules should match snapshot 1`] = ` +Array [ + Object { + "externalType": "var", + "request": "wp.blob", + "userRequest": "@wordpress/blob", + }, + Object { + "externalType": "this", + "request": Object { + "this": Array [ + "wp", + "url", + ], + }, + "userRequest": "@wordpress/url", + }, + Object { + "externalType": "var", + "request": "rxjs.operators", + "userRequest": "rxjs/operators", + }, + Object { + "externalType": "var", + "request": "rxjs", + "userRequest": "rxjs", + }, +] +`; + +exports[`Webpack \`wordpress\` should produce expected output: Dependencies JSON should match snapshot 1`] = ` +Array [ + "lodash", + "wp-blob", +] +`; + +exports[`Webpack \`wordpress\` should produce expected output: External modules should match snapshot 1`] = ` +Array [ + Object { + "externalType": "this", + "request": Object { + "this": Array [ + "wp", + "blob", + ], + }, + "userRequest": "@wordpress/blob", + }, + Object { + "externalType": "this", + "request": Object { + "this": "lodash", + }, + "userRequest": "lodash", + }, +] +`; + +exports[`Webpack \`wordpress-require\` should produce expected output: Dependencies JSON should match snapshot 1`] = ` +Array [ + "lodash", + "wp-blob", +] +`; + +exports[`Webpack \`wordpress-require\` should produce expected output: External modules should match snapshot 1`] = ` +Array [ + Object { + "externalType": "this", + "request": Object { + "this": Array [ + "wp", + "blob", + ], + }, + "userRequest": "@wordpress/blob", + }, + Object { + "externalType": "this", + "request": Object { + "this": "lodash", + }, + "userRequest": "lodash", + }, +] +`; diff --git a/packages/dependency-extraction-webpack-plugin/test/build.js b/packages/dependency-extraction-webpack-plugin/test/build.js new file mode 100644 index 00000000000000..af24b3f6dbf364 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/build.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const glob = require( 'glob' ).sync; +const mkdirp = require( 'mkdirp' ).sync; +const path = require( 'path' ); +const rimraf = require( 'rimraf' ).sync; +const webpack = require( 'webpack' ); + +const fixturesPath = path.join( __dirname, 'fixtures' ); +const configFixtures = fs.readdirSync( fixturesPath ).sort(); + +afterAll( () => rimraf( path.join( __dirname, 'build' ) ) ); + +describe.each( configFixtures )( 'Webpack `%s`', ( configCase ) => { + const testDirectory = path.join( fixturesPath, configCase ); + const outputDirectory = path.join( __dirname, 'build', configCase ); + + beforeEach( () => { + rimraf( outputDirectory ); + mkdirp( outputDirectory ); + } ); + + // This afterEach is necessary to prevent watched tests from retriggering on every run. + afterEach( () => rimraf( outputDirectory ) ); + + test( 'should produce expected output', () => + new Promise( ( resolve ) => { + const options = Object.assign( + { + context: testDirectory, + entry: './index.js', + mode: 'production', + optimization: { + minimize: false, + namedChunks: true, + namedModules: true, + }, + output: {}, + }, + require( path.join( testDirectory, 'webpack.config.js' ) ) + ); + options.output.path = outputDirectory; + + webpack( options, ( err, stats ) => { + expect( err ).toBeNull(); + + const depsFiles = glob( `${ outputDirectory }/*.deps.json` ); + const expectedLength = + typeof options.entry === 'object' ? Object.keys( options.entry ).length : 1; + expect( depsFiles ).toHaveLength( expectedLength ); + + // Deps files should match + depsFiles.forEach( ( depsFile ) => { + expect( require( depsFile ) ).toMatchSnapshot( + 'Dependencies JSON should match snapshot' + ); + } ); + + // Webpack stats external modules should match + const externalModules = stats.compilation.modules + .filter( ( { external } ) => external ) + .sort() + .map( ( module ) => ( { + externalType: module.externalType, + request: module.request, + userRequest: module.userRequest, + } ) ); + expect( externalModules ).toMatchSnapshot( 'External modules should match snapshot' ); + + resolve(); + } ); + } ) ); +} ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/index.js new file mode 100644 index 00000000000000..419272a48cffb8 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/index.js @@ -0,0 +1,6 @@ +/** + * External dependencies + */ +import _ from 'lodash'; + +import( './util' ).then( _.noop ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/util.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/util.js new file mode 100644 index 00000000000000..57dd4db43468dd --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/util.js @@ -0,0 +1,6 @@ +/** + * WordPress dependencies + */ +import { isBlobURL } from '@wordpress/blob'; + +isBlobURL(); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/webpack.config.js new file mode 100644 index 00000000000000..ca6a3e9e85a75d --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/dynamic-import/webpack.config.js @@ -0,0 +1,5 @@ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/index.js new file mode 100644 index 00000000000000..612e420c2a6c4d --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/index.js @@ -0,0 +1,6 @@ +/** + * External dependencies + */ +import _ from 'lodash'; + +_.map( [], _.identity ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/webpack.config.js new file mode 100644 index 00000000000000..2b009fba2cc4fe --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-default/webpack.config.js @@ -0,0 +1,5 @@ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ new DependencyExtractionWebpackPlugin( { useDefaults: false } ) ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/no-deps/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-deps/index.js new file mode 100644 index 00000000000000..4bfad0baa7885d --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-deps/index.js @@ -0,0 +1 @@ +/* Silence is golden */ diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/no-deps/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-deps/webpack.config.js new file mode 100644 index 00000000000000..ca6a3e9e85a75d --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/no-deps/webpack.config.js @@ -0,0 +1,5 @@ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/index.js new file mode 100644 index 00000000000000..21fda20b8d8fad --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/index.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { isBlobURL } from '@wordpress/blob'; +import { isURL } from '@wordpress/url'; + +/** + * External dependencies + */ +import { range } from 'rxjs'; +import { map, filter } from 'rxjs/operators'; + +range( 1, 200 ) + .pipe( + filter( isBlobURL ), + map( ( x ) => x + x ) + ) + .subscribe( ( x ) => isURL( x ) ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/webpack.config.js new file mode 100644 index 00000000000000..e72c63ad010a25 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/overrides/webpack.config.js @@ -0,0 +1,22 @@ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ + new DependencyExtractionWebpackPlugin( { + requestToExternal( request ) { + if ( request === 'rxjs' ) { + return 'rxjs'; + } + + if ( request === 'rxjs/operators' ) { + return [ 'rxjs', 'operators' ]; + } + }, + requestToDependency( request ) { + if ( request === 'rxjs' || request === 'rxjs/operators' ) { + return 'wp-script-handle-for-rxjs'; + } + }, + } ), + ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/with-externs/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/with-externs/index.js new file mode 100644 index 00000000000000..21fda20b8d8fad --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/with-externs/index.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { isBlobURL } from '@wordpress/blob'; +import { isURL } from '@wordpress/url'; + +/** + * External dependencies + */ +import { range } from 'rxjs'; +import { map, filter } from 'rxjs/operators'; + +range( 1, 200 ) + .pipe( + filter( isBlobURL ), + map( ( x ) => x + x ) + ) + .subscribe( ( x ) => isURL( x ) ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/with-externs/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/with-externs/webpack.config.js new file mode 100644 index 00000000000000..fe7038fe04a1ec --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/with-externs/webpack.config.js @@ -0,0 +1,10 @@ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + externals: { + '@wordpress/blob': 'wp.blob', + 'rxjs/operators': 'rxjs.operators', + rxjs: true, + }, + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/index.js new file mode 100644 index 00000000000000..30a2be6ebcada1 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/index.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +const { isBlobURL } = require( '@wordpress/blob' ); + +/** + * External dependencies + */ +const _ = require( 'lodash' ); + +_.isEmpty( isBlobURL( '' ) ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/webpack.config.js new file mode 100644 index 00000000000000..ca6a3e9e85a75d --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress-require/webpack.config.js @@ -0,0 +1,5 @@ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/index.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/index.js new file mode 100644 index 00000000000000..917b4cd7e204bf --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/index.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import { isBlobURL } from '@wordpress/blob'; + +/** + * External dependencies + */ +import _ from 'lodash'; + +_.isEmpty( isBlobURL( '' ) ); diff --git a/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/webpack.config.js b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/webpack.config.js new file mode 100644 index 00000000000000..ca6a3e9e85a75d --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/fixtures/wordpress/webpack.config.js @@ -0,0 +1,5 @@ +const DependencyExtractionWebpackPlugin = require( '../../..' ); + +module.exports = { + plugins: [ new DependencyExtractionWebpackPlugin() ], +}; diff --git a/packages/dependency-extraction-webpack-plugin/test/util.js b/packages/dependency-extraction-webpack-plugin/test/util.js new file mode 100644 index 00000000000000..f7d5f166b7d56a --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/test/util.js @@ -0,0 +1,41 @@ +/** + * Internal dependencies + */ +const { defaultRequestToExternal, defaultRequestToHandle } = require( '../util' ); + +describe( 'defaultRequestToExternal', () => { + test( 'Returns undefined on unrecognized request', () => { + expect( defaultRequestToExternal( 'unknown-request' ) ).toBeUndefined(); + } ); + + test( 'Handles known lodash-es request', () => { + expect( defaultRequestToExternal( 'lodash-es' ) ).toBe( 'lodash' ); + } ); + + test( 'Handles known @wordpress request', () => { + expect( defaultRequestToExternal( '@wordpress/i18n' ) ).toEqual( [ 'wp', 'i18n' ] ); + } ); + + test( 'Handles future @wordpress namespace packages', () => { + expect( defaultRequestToExternal( '@wordpress/some-future-package' ) ).toEqual( [ + 'wp', + 'someFuturePackage', + ] ); + } ); +} ); + +describe( 'defaultRequestToHandle', () => { + test( 'Handles known lodash-es request', () => { + expect( defaultRequestToHandle( 'lodash-es' ) ).toBe( 'lodash' ); + } ); + + test( 'Handles known @wordpress request', () => { + expect( defaultRequestToHandle( '@wordpress/i18n' ) ).toBe( 'wp-i18n' ); + } ); + + test( 'Handles @wordpress request', () => { + expect( defaultRequestToHandle( '@wordpress/some-future-package' ) ).toBe( + 'wp-some-future-package' + ); + } ); +} ); diff --git a/packages/dependency-extraction-webpack-plugin/util.js b/packages/dependency-extraction-webpack-plugin/util.js new file mode 100644 index 00000000000000..b8b7238fe2a9a1 --- /dev/null +++ b/packages/dependency-extraction-webpack-plugin/util.js @@ -0,0 +1,85 @@ +const WORDPRESS_NAMESPACE = '@wordpress/'; + +/** + * Default request to global transformation + * + * Transform @wordpress dependencies: + * @wordpress/api-fetch -> wp.apiFetch + * @wordpress/i18n -> wp.i18n + * + * @param {string} request Requested module + * + * @return {(string|string[]|undefined)} Script global + */ +function defaultRequestToExternal( request ) { + switch ( request ) { + case 'moment': + return request; + + case '@babel/runtime/regenerator': + return 'regeneratorRuntime'; + + case 'lodash': + case 'lodash-es': + return 'lodash'; + + case 'jquery': + return 'jQuery'; + + case 'react': + return 'React'; + + case 'react-dom': + return 'ReactDOM'; + } + + if ( request.startsWith( WORDPRESS_NAMESPACE ) ) { + return [ 'wp', camelCaseDash( request.substring( WORDPRESS_NAMESPACE.length ) ) ]; + } +} + +/** + * Default request to WordPress script handle transformation + * + * Transform @wordpress dependencies: + * @wordpress/i18n -> wp-i18n + * @wordpress/escape-html -> wp-escape-html + * + * @param {string} request Requested module + * + * @return {(string|undefined)} Script handle + */ +function defaultRequestToHandle( request ) { + switch ( request ) { + case '@babel/runtime/regenerator': + return 'wp-polyfill'; + + case 'lodash-es': + return 'lodash'; + } + + if ( request.startsWith( WORDPRESS_NAMESPACE ) ) { + return 'wp-' + request.substring( WORDPRESS_NAMESPACE.length ); + } +} + +/** + * Given a string, returns a new string with dash separators converted to + * camelCase equivalent. This is not as aggressive as `_.camelCase` in + * converting to uppercase, where Lodash will also capitalize letters + * following numbers. + * + * Temporarily duplicated from @wordpress/scripts/utils. + * + * @param {string} string Input dash-delimited string. + * + * @return {string} Camel-cased string. + */ +function camelCaseDash( string ) { + return string.replace( /-([a-z])/g, ( match, letter ) => letter.toUpperCase() ); +} + +module.exports = { + defaultRequestToExternal, + defaultRequestToHandle, +}; diff --git a/packages/scripts/CHANGELOG.md b/packages/scripts/CHANGELOG.md index 962e9eb4b471f0..a030be54312745 100644 --- a/packages/scripts/CHANGELOG.md +++ b/packages/scripts/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +### New Feature + +- Leverage `@wordpress/dependency-extraction-webpack-plugin` plugin to extract WordPress + dependencies. + ## 3.1.0 (2019-03-20) ## New features diff --git a/packages/scripts/README.md b/packages/scripts/README.md index 80b130073a1661..b21c7078a61d56 100644 --- a/packages/scripts/README.md +++ b/packages/scripts/README.md @@ -271,17 +271,10 @@ The `build` and `start` commands use [webpack](https://webpack.js.org/) behind t * [Entry](https://webpack.js.org/configuration/entry-context/#entry): `src/index.js` * [Output](https://webpack.js.org/configuration/output): `build/index.js` -* [Externals](https://webpack.js.org/configuration/externals). These are libraries that are to be found in the global scope: - -Package | Input syntax | Output ---- | --- | --- -React | `import x from 'react';` | `var x = window.React.x;` -ReactDOM | `import x from 'react-dom';` | `var x = window.ReactDOM.x;` -moment | `import x from 'moment';` | `var x = window.moment.x;` -jQuery | `import x from 'jquery';` | `var x = window.jQuery.x;` -lodash | `import x from 'lodash';` | `var x = window.lodash.x;` -lodash-es | `import x from 'lodash-es';` | `var x = window.lodash.x;` -WordPress packages | `import x from '@wordpress/package-name'` | `var x = window.wp.packageName.x` +* [Plugins](https://webpack.js.org/configuration/plugins): The webpack plugin provided by +[`@wordpress/dependency-extraction-webpack-plugin`](/packages/dependency-extraction-webpack-plugin/README.md) is used +with the default configuration to ensure that WordPress provided scripts are not included in the +built bundle. #### Provide your own webpack config diff --git a/packages/scripts/config/webpack.config.js b/packages/scripts/config/webpack.config.js index 65ec3878e7808c..44f173d958e0f9 100644 --- a/packages/scripts/config/webpack.config.js +++ b/packages/scripts/config/webpack.config.js @@ -6,62 +6,14 @@ const LiveReloadPlugin = require( 'webpack-livereload-plugin' ); const path = require( 'path' ); /** - * Internal dependencies + * WordPress dependencies */ -const { camelCaseDash, hasBabelConfig } = require( '../utils' ); +const DependencyExtractionWebpackPlugin = require( '@wordpress/dependency-extraction-webpack-plugin' ); /** - * Converts @wordpress/* string request into request object. - * - * Note this isn't the same as camel case because of the - * way that numbers don't trigger the capitalized next letter. - * - * @example - * formatRequest( '@wordpress/api-fetch' ); - * // { this: [ 'wp', 'apiFetch' ] } - * formatRequest( '@wordpress/i18n' ); - * // { this: [ 'wp', 'i18n' ] } - * - * @param {string} request Request name from import statement. - * @return {Object} Request object formatted for further processing. + * Internal dependencies */ -const formatRequest = ( request ) => { - // '@wordpress/api-fetch' -> [ '@wordpress', 'api-fetch' ] - const [ , name ] = request.split( '/' ); - - // { this: [ 'wp', 'apiFetch' ] } - return { - this: [ 'wp', camelCaseDash( name ) ], - }; -}; - -const wordpressExternals = ( context, request, callback ) => { - if ( /^@wordpress\//.test( request ) ) { - callback( null, formatRequest( request ), 'this' ); - } else { - callback(); - } -}; - -const externals = [ - { - react: 'React', - 'react-dom': 'ReactDOM', - moment: 'moment', - jquery: 'jQuery', - lodash: 'lodash', - 'lodash-es': 'lodash', - - // Distributed NPM packages may depend on Babel's runtime regenerator. - // In a WordPress context, the regenerator is assigned to the global - // scope via the `wp-polyfill` script. It is reassigned here as an - // externals to reduce the size of generated bundles. - // - // See: https://github.com/WordPress/gutenberg/issues/13890 - '@babel/runtime/regenerator': 'regeneratorRuntime', - }, - wordpressExternals, -]; +const { hasBabelConfig } = require( '../utils' ); const isProduction = process.env.NODE_ENV === 'production'; const mode = isProduction ? 'production' : 'development'; @@ -75,7 +27,6 @@ const config = { filename: '[name].js', path: path.resolve( process.cwd(), 'build' ), }, - externals, resolve: { alias: { 'lodash-es': 'lodash', @@ -116,6 +67,7 @@ const config = { // WP_LIVE_RELOAD_PORT global variable changes port on which live reload works // when running watch mode. ! isProduction && new LiveReloadPlugin( { port: process.env.WP_LIVE_RELOAD_PORT || 35729 } ), + new DependencyExtractionWebpackPlugin(), ].filter( Boolean ), stats: { children: false, diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 0edf052d93add1..e61e6958b8ce40 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@wordpress/babel-preset-default": "file:../babel-preset-default", + "@wordpress/dependency-extraction-webpack-plugin": "file:../dependency-extraction-webpack-plugin", "@wordpress/eslint-plugin": "file:../eslint-plugin", "@wordpress/jest-preset-default": "file:../jest-preset-default", "@wordpress/npm-package-json-lint-config": "file:../npm-package-json-lint-config",