├── .editorconfig
├── .github
└── workflows
│ └── nodejs.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── package.json
├── src
├── index.js
├── lib
│ ├── rollup-plugin-strip-comments.js
│ ├── rollup-plugin-terser-simple.js
│ ├── transform-change-webpack-urls.js
│ ├── transform-extract-polyfills.js
│ ├── util.js
│ └── worker-pool.js
└── worker.js
└── test
├── _sucrase-loader.js
├── _util.js
├── _webpacks.js
├── fixtures
├── basic
│ └── entry.js
├── code-splitting
│ ├── about.js
│ ├── entry.js
│ ├── home.js
│ └── profile.js
└── typescript
│ ├── entry.js
│ ├── pinch-zoom.ts
│ ├── pointer-tracker.ts
│ ├── tsconfig.json
│ └── util.ts
├── index.test.js
└── webpacks
└── 4
└── package.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Node CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Use Node.js ${{ matrix.node-version }}
13 | uses: actions/setup-node@v1
14 | with:
15 | node-version: '12.x'
16 | - run: npm install
17 | - run: npm run build --if-present
18 | - run: npm test
19 | env:
20 | CI: true
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | package-lock.json
5 | yarn.lock
6 | worker-plugin-*.tgz
7 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches and contributions to this project. There are
4 | just a few small guidelines you need to follow.
5 |
6 | ## Contributor License Agreement
7 |
8 | Contributions to this project must be accompanied by a Contributor License
9 | Agreement. You (or your employer) retain the copyright to your contribution,
10 | this simply gives us permission to use and redistribute your contributions as
11 | part of the project. Head over to to see
12 | your current agreements on file or to sign a new one.
13 |
14 | You generally only need to submit a CLA once, so if you've already submitted one
15 | (even if it was for a different project), you probably don't need to do it
16 | again.
17 |
18 | ## Code reviews
19 |
20 | All submissions, including submissions by project members, require review. We
21 | use GitHub pull requests for this purpose. Consult
22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
23 | information on using pull requests.
24 |
25 | ## Community Guidelines
26 |
27 | This project follows [Google's Open Source Community
28 | Guidelines](https://opensource.google.com/conduct/).
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Optimize Plugin for Webpack
2 |
3 | Optimize your code for modern browsers while still supporting the other 10%,
4 | increasing your build performance, reducing bundle size and improving output quality.
5 |
6 | Put simply: it compiles code faster, better and smaller.
7 |
8 | ## Features
9 |
10 | - Much faster than your current Webpack setup
11 | - Transparently optimizes all of your code
12 | - Automatically optimizes all of your dependencies
13 | - Compiles bundles for modern browsers (90%) and legacy browsers (10%)
14 | - Removes unnecessary polyfills, even when inlined into dependencies
15 | - Builds a highly-optimized automated polyfills bundle
16 |
17 | ## Install
18 |
19 | ```sh
20 | npm install --save-dev optimize-plugin
21 | ```
22 |
23 | ## Usage
24 |
25 | First, disable any existing configuration you have to Babel, minification, and module/nomodule.
26 |
27 | Then, add `OptimizePlugin` to your Webpack plugins Array:
28 |
29 | ```js
30 | plugins: [
31 | new OptimizePlugin({
32 | // any options here
33 | })
34 | ]
35 | ```
36 |
37 | ### Options
38 |
39 | | Option | Type | Description
40 | |---|---|---
41 | | `concurrency` | `number\|false` | Maximum number of threads to use. Default: the number of available CPUs.
_Pass `false` for single-threaded, sometimes faster for small projects._
42 | | `sourceMap` | `boolean\|false` | Whether or not to produce source maps for the given input. |
43 | | `minify` | `boolean\|false` | Minify using Terser, if turned off only comments will be stripped. |
44 | | `downlevel` | `boolean\|true` | Produces a bundle for `nomodule` browsers. (IE11, ...) |
45 | | `modernize` | `boolean\|true` | Attempt to upgrade ES5 syntax to equivalent modern syntax. |
46 | | `verbose` | `boolean\|false` | Will log performance information and information about polyfills. |
47 | | `polyfillsFilename` | `string\|polyfills.legacy.js` | The name for the chunk containing polyfills for the legacy bundle. |
48 | | `exclude` | `RegExp[]\|[]` | Asset patterns that should be excluded |
49 |
50 |
51 | ## How does this work?
52 |
53 | Instead of running Babel on each individual source code file in your project, `optimize-plugin`
54 | transforms your entire application's bundled code. This means it can apply optimizations and
55 | transformations not only to your source, but to your dependencies - making polyfill extraction
56 | and reverse transpilation steps far more effective.
57 |
58 | This setup also allows `optimize-plugin` to achieve better performance. All work is done in
59 | a background thread pool, and the same AST is re-used for modern and legacy transformations.
60 | Previous solutions for module/nomodule have generally relied running two complete compilation
61 | passes, which incurs enormous overhead since the entire graph is built and traversed multiple
62 | times. With `optimize-plugin`, bundling and transpilation are now a separate concerns: Webpack
63 | handles graph creation and reduction, then passes its bundles to Babel for transpilation.
64 |
65 |
66 |
67 | ## FAQ
68 |
69 | ### What do I do with my current Babel configuration?
70 |
71 | In order to migrate to optimize-plugin, you'll need to move your babel configuration into a `.babelrc` or `babel.config.js` file and remove `babel-loader` from your Webpack configuration. Remember, optimize-plugin only uses your babel configuration when generating _modern_ bundles. Legacy bundles are automatically compiled to ES5 without looking at your Babel configuration, though you can customize their compilation by defining a [browserslist](https://github.com/browserslist/browserslist) field in your package.json.
72 |
73 | ### Do I need to include any polyfills manually?
74 |
75 | In general, adopting optimize-plugin means removing all of your current polyfills, since the plugin automatically detects and polyfills JavaScript features for legacy bundles. The plugin does _not_ polyfill DOM features though, so be sure to keep including any DOM polyfills your application relies (`ParentNode.append()`, Module Workers, etc).
76 |
77 | Remember: the premise of this plugin is that you don't need to configure JS transpilation or polyfills - it's all done automatically based on usage.
78 |
79 | ### License
80 |
81 | Apache-2.0
82 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "optimize-plugin",
3 | "version": "1.3.1",
4 | "description": "Webpack plugin to optimize bundles.",
5 | "main": "dist/optimize-plugin.js",
6 | "repository": "developit/optimize-plugin",
7 | "scripts": {
8 | "build": "microbundle --target node --format cjs --no-compress src/*.js",
9 | "prepack": "npm run build",
10 | "dev": "jest --verbose --watchAll",
11 | "test": "jest --verbose",
12 | "release": "npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish"
13 | },
14 | "babel": {
15 | "env": {
16 | "test": {
17 | "plugins": [
18 | "transform-es2015-modules-commonjs"
19 | ]
20 | }
21 | }
22 | },
23 | "eslintConfig": {
24 | "extends": [
25 | "standard",
26 | "plugin:jest/recommended"
27 | ],
28 | "parserOptions": {
29 | "sourceType": "module",
30 | "ecmaVersion": 2020
31 | },
32 | "env": {
33 | "browser": true,
34 | "jest": true
35 | },
36 | "rules": {
37 | "indent": [
38 | 2,
39 | 2
40 | ],
41 | "semi": [
42 | 2,
43 | "always"
44 | ]
45 | }
46 | },
47 | "jest": {
48 | "testEnvironment": "node",
49 | "watchPathIgnorePatterns": [
50 | "/node_modules/",
51 | "/test/fixtures/"
52 | ]
53 | },
54 | "files": [
55 | "src",
56 | "dist"
57 | ],
58 | "keywords": [
59 | "webpack",
60 | "plugin",
61 | "minify",
62 | "optimize"
63 | ],
64 | "author": "Jason Miller (https://github.com/developit)",
65 | "license": "Apache-2.0",
66 | "devDependencies": {
67 | "@types/jest": "^26.0.15",
68 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
69 | "clean-webpack-plugin": "^1.0.0",
70 | "eslint": "^7.14.0",
71 | "eslint-config-standard": "^16.0.2",
72 | "eslint-plugin-import": "^2.22.1",
73 | "eslint-plugin-jest": "^24.1.3",
74 | "eslint-plugin-node": "^11.1.0",
75 | "eslint-plugin-promise": "^4.0.1",
76 | "eslint-plugin-standard": "^4.1.0",
77 | "jest": "^25.5.4",
78 | "memory-fs": "^0.4.1",
79 | "microbundle": "^0.12.4",
80 | "preact": "^10.5.7",
81 | "sucrase": "^3.16.0",
82 | "ts-loader": "^9.2.6",
83 | "webpack": "^5.65.0",
84 | "webpack-4": "./test/webpacks/4"
85 | },
86 | "dependencies": {
87 | "@babel/core": "^7.12.7",
88 | "@rollup/plugin-commonjs": "^11.1.0",
89 | "@rollup/plugin-node-resolve": "^7.1.3",
90 | "babel-preset-modernize": "0.0.5",
91 | "core-js": "^3.7.0",
92 | "gzip-size": "^5.1.1",
93 | "jest-worker": "^25.5.0",
94 | "magic-string": "^0.25.7",
95 | "regenerator-runtime": "^0.13.7",
96 | "rollup": "^1.32.1",
97 | "terser": "^5.10.0",
98 | "webpack-sources": "^3.2.2"
99 | },
100 | "peerDependencies": {
101 | "@babel/preset-env": ">= 7.10",
102 | "webpack": ">= 4"
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 |
17 | import util from 'util';
18 | import { gzip } from 'zlib';
19 | import { promises as fs } from 'fs';
20 | import * as defaultWebpack from 'webpack';
21 | import { SourceMapSource, RawSource } from 'webpack-sources';
22 | import { rollup } from 'rollup';
23 | import commonjsPlugin from '@rollup/plugin-commonjs';
24 | import nodeResolvePlugin from '@rollup/plugin-node-resolve';
25 | import rollupPluginTerserSimple from './lib/rollup-plugin-terser-simple';
26 | import rollupPluginStripComments from './lib/rollup-plugin-strip-comments';
27 | import { getCorejsVersion, createPerformanceTimings } from './lib/util';
28 | import { WorkerPool } from './lib/worker-pool';
29 |
30 | const NAME = 'OptimizePlugin';
31 |
32 | const DEFAULT_OPTIONS = {
33 | /**
34 | * Number of Worker Threads to use for running Babel and Terser.
35 | * @default os.cpus().length // number of available CPUs
36 | */
37 | concurrency: undefined,
38 |
39 | /**
40 | * Produce Source Maps?
41 | * @default false
42 | */
43 | sourceMap: false,
44 |
45 | /**
46 | * Minify bundles using Terser?
47 | * @default true
48 | */
49 | minify: true,
50 |
51 | /**
52 | * Produce a set of bundles catering to older browsers alongside the default modern bundles.
53 | * @default true
54 | */
55 | downlevel: true,
56 |
57 | /**
58 | * Attempt to upgrade ES5 syntax to equivalent modern syntax.
59 | * @default true
60 | */
61 | modernize: true,
62 |
63 | /**
64 | * Show logs containing performance information and inlined polyfill.
65 | */
66 | verbose: true,
67 |
68 | /**
69 | * @default "polyfills.legacy.js"
70 | */
71 | polyfillsFilename: 'polyfills.legacy.js',
72 |
73 | /**
74 | * RegExp patterns of assets to exclude
75 | * @default []
76 | */
77 | exclude: []
78 | };
79 |
80 | export default class OptimizePlugin {
81 | /**
82 | * @param {Partial?} [options]
83 | */
84 | constructor (options, webpack = defaultWebpack) {
85 | this.webpack = webpack;
86 | this.options = Object.assign({}, options || {});
87 | for (const i in DEFAULT_OPTIONS) {
88 | if (this.options[i] == null) this.options[i] = DEFAULT_OPTIONS[i];
89 | }
90 |
91 | // const { concurrency } = options;
92 | // const workerPath = require.resolve('./worker');
93 | // if (concurrency === 0 || concurrency === false) {
94 | // this.workerPool = new MockWorkerPool({ workerPath });
95 | // }
96 | // else {
97 | // this.workerPool = new WorkerPool({ workerPath, concurrency });
98 | // }
99 |
100 | this.rollupCache = {
101 | modules: []
102 | };
103 |
104 | /** @type {Map>} */
105 | this.polyfillsCache = new Map();
106 | }
107 |
108 | isWebpack4 () {
109 | return this.webpack.version[0] === '4';
110 | }
111 |
112 | isWebpack5 () {
113 | return this.webpack.version[0] === '5';
114 | }
115 |
116 | serializeOptions () {
117 | return this._serialized || (this._serialized = JSON.stringify(this.options));
118 | }
119 |
120 | async optimize (compiler, compilation, chunkFiles) {
121 | const cwd = compiler.context;
122 | const { timings, start, end } = createPerformanceTimings();
123 |
124 | const options = {
125 | corejsVersion: getCorejsVersion(),
126 | minify: this.options.minify,
127 | downlevel: this.options.downlevel,
128 | modernize: this.options.modernize,
129 | timings: this.options.verbose
130 | };
131 |
132 | const processing = new WeakMap();
133 | const chunkAssets = Array.from(compilation.additionalChunkAssets || []);
134 | const files = [...chunkFiles, ...chunkAssets]
135 | .filter((asset) => {
136 | for (const pattern of this.options.exclude) {
137 | if (pattern.test(asset)) {
138 | return false;
139 | }
140 | }
141 | return true;
142 | });
143 |
144 | start('Optimize Assets');
145 | let transformed;
146 | try {
147 | transformed = await Promise.all(files.map(file => {
148 | // ignore non-JS files
149 | if (!file.match(/\.m?[jt]sx?$/i)) return undefined;
150 | const asset = compilation.assets[file];
151 | let pending = processing.get(asset);
152 | if (pending) return pending;
153 |
154 | let source, map;
155 | if (this.options.sourceMap && asset.sourceAndMap) {
156 | ({ source, map } = asset.sourceAndMap());
157 | } else {
158 | source = asset.source();
159 | }
160 |
161 | const original = { file, source, map, options };
162 | // @ts-ignore-next
163 | const result = this.workerPool.enqueue(original);
164 | pending = result.then(this.buildResultSources.bind(this, original)).catch(console.error);
165 | processing.set(asset, pending);
166 |
167 | const t = ` └ ${file}`;
168 | start(t);
169 | result.then(r => {
170 | for (const entry of r.timings) {
171 | // entry.name = ' ' + entry.name;
172 | entry.depth = 2;
173 | timings.push(entry);
174 | }
175 | end(t);
176 | });
177 |
178 | return pending;
179 | }));
180 | } catch (e) {
181 | console.log('errored out during transformation ', e);
182 | throw e;
183 | }
184 |
185 | end('Optimize Assets');
186 |
187 | const allPolyfills = new Set();
188 | const polyfillReasons = new Map();
189 | transformed.filter(Boolean).forEach(({ file, modern, legacyFile, legacy, polyfills }, index) => {
190 | for (const p of polyfills) {
191 | allPolyfills.add(p);
192 | let reasons = polyfillReasons.get(p);
193 | if (!reasons) polyfillReasons.set(p, reasons = []);
194 | reasons.push(legacyFile);
195 | }
196 |
197 | compilation.assets[file] = modern;
198 | if (legacy) {
199 | compilation.assets[legacyFile] = legacy;
200 | } else {
201 | // @todo is this actually necessary or desirable?
202 | // should it be ReplaceSource/RawSource with an empty value?
203 | delete compilation.assets[legacyFile];
204 | }
205 | });
206 |
207 | const polyfillsFilename = this.options.polyfillsFilename || 'polyfills.legacy.js';
208 | const polyfills = Array.from(allPolyfills);
209 | let polyfillsAsset;
210 |
211 | if (polyfills.length) {
212 | start('Bundle Polyfills');
213 | polyfillsAsset = await this.generatePolyfillsChunkCached(polyfills, cwd, polyfillsFilename, timings);
214 | compilation.assets[polyfillsFilename] = polyfillsAsset;
215 | end('Bundle Polyfills');
216 | } else {
217 | delete compilation.assets[polyfillsFilename];
218 | }
219 |
220 | timings.sort((t1, t2) => t1.start - t2.start);
221 | if (this.options.verbose) {
222 | await this.showOutputSummary(timings, polyfills, polyfillReasons, polyfillsAsset);
223 | }
224 | }
225 |
226 | async generatePolyfillsChunkCached (polyfills, cwd, polyfillsFilename, timings) {
227 | const polyfillsKey = polyfills.join('\n');
228 |
229 | let generatePolyfills = this.polyfillsCache.get(polyfillsKey);
230 | if (!generatePolyfills) {
231 | generatePolyfills = this.generatePolyfillsChunk(polyfills, cwd, timings);
232 | this.polyfillsCache.set(polyfillsKey, generatePolyfills);
233 | }
234 |
235 | const output = await generatePolyfills;
236 |
237 | return new SourceMapSource(
238 | output.code,
239 | polyfillsFilename,
240 | // @ts-ignore
241 | output.map
242 | );
243 | }
244 |
245 | /**
246 | * @todo Write cached polyfills chunk to disk
247 | */
248 | async generatePolyfillsChunk (polyfills, cwd, timings) {
249 | const ENTRY = '\0entry';
250 |
251 | const entryContent = polyfills.reduce((str, p) => `${str}\nimport "${p.replace(/\.js$/, '')}";`, '');
252 |
253 | const COREJS = require.resolve('core-js/package.json').replace('package.json', '');
254 | const isCoreJsPath = /(?:^|\/)core-js\/(.+)$/;
255 | const nonCoreJsPolyfills = polyfills.filter(p => !/(core-js|regenerator-runtime)/.test(p));
256 |
257 | if (timings && nonCoreJsPolyfills.length) {
258 | console.log(` Bundling ${nonCoreJsPolyfills.length} unrecognized polyfills.`);
259 | }
260 |
261 | const polyfillsBundle = await rollup({
262 | cache: this.rollupCache,
263 | context: 'window',
264 | perf: !!timings,
265 | input: ENTRY,
266 | treeshake: {
267 | propertyReadSideEffects: false,
268 | tryCatchDeoptimization: false,
269 | unknownGlobalSideEffects: false
270 | },
271 | plugins: [
272 | {
273 | name: 'entry',
274 | resolveId: id => id === ENTRY ? id : null,
275 | load: id => id === ENTRY ? entryContent : null
276 | },
277 | {
278 | name: 'core-js',
279 | resolveId (id) {
280 | if (/^regenerator-runtime(\/|$)/.test(id)) {
281 | return require.resolve('regenerator-runtime/runtime');
282 | }
283 | const m = id.match(isCoreJsPath);
284 | if (m && !/\.js$/.test(id)) {
285 | return COREJS + m[1] + '.js';
286 | }
287 | return null;
288 | },
289 | load (id) {
290 | const m = id.match(isCoreJsPath);
291 | if (m && id.indexOf('?') === -1) {
292 | return fs.readFile(COREJS + m[1], 'utf-8');
293 | }
294 | return null;
295 | }
296 | },
297 | // coreJsPlugin(),
298 | commonjsPlugin({
299 | // ignoreGlobal: true,
300 | sourceMap: false
301 | }),
302 | nonCoreJsPolyfills.length && nodeResolvePlugin({
303 | dedupe: nonCoreJsPolyfills,
304 | only: nonCoreJsPolyfills,
305 | preferBuiltins: false
306 | }),
307 | // {
308 | // name: 'babel',
309 | // renderChunk (source) {
310 | // return require('@babel/core').transformAsync(source, {
311 | // sourceMaps: false,
312 | // minified: true,
313 | // shouldPrintComment: () => false,
314 | // presets: [
315 | // require('babel-preset-modernize')
316 | // ]
317 | // });
318 | // }
319 | // },
320 | this.options.minify
321 | ? (
322 | rollupPluginTerserSimple()
323 | )
324 | : (
325 | rollupPluginStripComments()
326 | )
327 | ].filter(Boolean)
328 | });
329 | this.setRollupCache(polyfillsBundle.cache);
330 |
331 | const result = await polyfillsBundle.generate({
332 | exports: 'none',
333 | externalLiveBindings: false,
334 | freeze: false,
335 | compact: true,
336 | format: 'iife',
337 | sourcemap: false,
338 | strict: false
339 | });
340 | const output = result.output[0];
341 |
342 | // If verbose logging is enabled, bubble up some useful Rollup time information
343 | if (timings) {
344 | const times = polyfillsBundle.getTimings();
345 | const add = (name, timing) => {
346 | const t = times[timing];
347 | if (t) timings.push({ depth: 2, name, duration: t[0] });
348 | };
349 | add('parse', '## parse modules');
350 | add('node-resolve', '- plugin 2 (node-resolve) - resolveId (async)');
351 | add('generate', '# GENERATE');
352 | }
353 |
354 | return output;
355 | }
356 |
357 | // yes I did this to fix TS inference
358 | setRollupCache (cache) {
359 | this.rollupCache = cache;
360 | }
361 |
362 | /** @todo move to helper file */
363 | async showOutputSummary (timings, polyfills, polyfillReasons, polyfillsAsset) {
364 | let totalTime = 0;
365 | let timingsStr = '';
366 | for (const entry of timings) {
367 | totalTime += entry.duration;
368 | // timingsStr += `\n ${(' ' + (entry.duration || '- ')).substr(-6)}ms: ${entry.name}`;
369 | timingsStr += `\n ${new Array(entry.depth || 1).join(' ')}${String(entry.duration | 0 || '- ').padStart(6, ' ')}ms: ${entry.name}`;
370 | }
371 |
372 | polyfills = polyfills.map(polyfill => {
373 | const reasons = polyfillReasons.get(polyfill);
374 | return { polyfill, reasons, reasonsKey: reasons.join('\n') };
375 | });
376 | polyfills.sort((p1, p2) => p1.reasonsKey.localeCompare(p2.reasonsKey));
377 |
378 | const serializeReasons = (reasons) => {
379 | if (reasons.length === 1) return reasons[0];
380 | if (reasons.length > 3) {
381 | return `${reasons[0]}, ${reasons[1]} and ${reasons.length - 2} others`;
382 | }
383 | reasons = reasons.slice();
384 | const last = reasons.pop();
385 | return reasons.join(', ') + ' and ' + last;
386 | };
387 |
388 | let lastReasonsKey;
389 | let polyfillsStr = polyfills.reduce((str, { polyfill, reasons, reasonsKey }) => {
390 | if (reasonsKey !== lastReasonsKey) {
391 | str = str.replace(/├.*?$/, '└');
392 | str += `\n└ Used by ${serializeReasons(reasons)}:`;
393 | lastReasonsKey = reasonsKey;
394 | }
395 | str += `\n ├ ${polyfill}`;
396 | return str;
397 | }, '');
398 | polyfillsStr = polyfillsStr.replace(/├(.*?)$/, '└$1');
399 |
400 | const preamble = `[${NAME}] Completed in ${totalTime | 0}ms.${timingsStr}\n`;
401 |
402 | if (!polyfillsAsset) {
403 | console.log(preamble + 'No polyfills bundle was created.');
404 | return;
405 | }
406 |
407 | const polyfillsSize = polyfillsAsset ? (await util.promisify(gzip)(polyfillsAsset.source())).byteLength : 0;
408 | const polyfillsSizeStr = (polyfillsSize / 1000).toPrecision(3) + 'kB';
409 |
410 | console.log(
411 | preamble +
412 | `${polyfillsAsset._name} is ${polyfillsSizeStr} and bundles ${polyfills.length} polyfills:${polyfillsStr}`
413 | );
414 | }
415 |
416 | /** @todo Support other file extensions */
417 | toLegacyFilename (file) {
418 | let out = file.replace(/(\.m?[jt]sx?)$/g, '.legacy$1');
419 | if (out === file) {
420 | // this will create `foo.js.legacy.js`, but it's the best we can hope for.
421 | out += '.legacy.js';
422 | }
423 | return out;
424 | }
425 |
426 | buildResultSources (original, result) {
427 | const file = original.file;
428 | const modern = this.buildFile(original, result.modern);
429 | let legacy, legacyFile;
430 | if (result.legacy) {
431 | legacyFile = this.toLegacyFilename(file);
432 | legacy = this.buildFile(original, result.legacy, legacyFile);
433 | }
434 | return { file, legacyFile, modern, legacy, polyfills: result.polyfills };
435 | }
436 |
437 | buildFile (original, result, name) {
438 | if (result.map) {
439 | return new SourceMapSource(
440 | result.source,
441 | name || original.file,
442 | result.map,
443 | original.source,
444 | original.map
445 | );
446 | }
447 | // @todo use LineToLineMappedSource as the fallback?
448 | return new RawSource(result.source);
449 | }
450 |
451 | // modify chunkHash (webpack 4 & 5)
452 | updateChunkHash (compilation) {
453 | const updateWithHash = (chunk, hash) => {
454 | hash.update(NAME);
455 | hash.update(this.serializeOptions());
456 | };
457 |
458 | if (this.isWebpack4()) {
459 | compilation.mainTemplate.hooks.hashForChunk.tap(NAME, updateWithHash.bind(null, null));
460 | compilation.chunkTemplate.hooks.hashForChunk.tap(NAME, updateWithHash.bind(null, null));
461 | } else {
462 | // @ts-ignore
463 | this.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation).chunkHash.tap(NAME, updateWithHash);
464 | }
465 | }
466 |
467 | apply (compiler) {
468 | this.workerPool = new WorkerPool({
469 | workerPath: require.resolve('./worker'),
470 | concurrency: this.options.concurrency
471 | });
472 |
473 | compiler.hooks.compilation.tap(NAME, compilation => {
474 | this.updateChunkHash(compilation);
475 |
476 | if (this.isWebpack5()) {
477 | compilation.hooks.processAssets.tapPromise({
478 | name: NAME,
479 | stage: this.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE
480 | }, (assets) => {
481 | const chunkFiles = Object.keys(assets);
482 |
483 | return this.optimize(compiler, compilation, chunkFiles);
484 | });
485 | } else {
486 | compilation.hooks.optimizeChunkAssets.tapPromise(NAME, (chunks) => {
487 | const chunkFiles = Array.from(chunks).reduce(
488 | (acc, chunk) => acc.concat(Array.from(chunk.files || [])),
489 | []
490 | );
491 |
492 | return this.optimize(compiler, compilation, chunkFiles);
493 | });
494 | }
495 | });
496 | }
497 | }
498 |
--------------------------------------------------------------------------------
/src/lib/rollup-plugin-strip-comments.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import MagicString from 'magic-string';
18 |
19 | export default function rollupPluginStripComments () {
20 | return {
21 | name: 'simple-minify',
22 | renderChunk (source) {
23 | const comments = [];
24 | this.parse(source, { onComment: comments });
25 | if (comments.length) {
26 | const s = new MagicString(source);
27 | for (const comment of comments) {
28 | s.remove(comment.start, comment.end).trim();
29 | }
30 | return { code: s.toString(), map: null };
31 | }
32 | return null;
33 | }
34 | };
35 | }
36 |
--------------------------------------------------------------------------------
/src/lib/rollup-plugin-terser-simple.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import * as terser from 'terser';
18 | import { toBabelMap } from './util';
19 |
20 | export default function rollupPluginTerserSimple () {
21 | return {
22 | name: 'rollup-plugin-terser-simple',
23 | async renderChunk (source, chunk, options) {
24 | const { code, map } = await terser.minify(source, {
25 | compress: {
26 | global_defs: {
27 | 'typeof self': '"object"',
28 | 'globalThis': 'undefined'
29 | },
30 | pure_getters: true,
31 | unsafe: true
32 | },
33 | mangle: true,
34 | toplevel: true,
35 | sourceMap: options.sourcemap && {
36 | filename: chunk.fileName
37 | }
38 | });
39 | return {
40 | code,
41 | map: toBabelMap(map)
42 | };
43 | }
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/src/lib/transform-change-webpack-urls.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * Detects and analyzes a Webpack "entry" bundle, allowing String transformations on its internal chunk URL map.
19 | *
20 | * @example
21 | * {
22 | * plugins: [
23 | * ['./transform-change-webpack-urls', {
24 | * pattern: /\.js$/,
25 | * replacement: '.legacy.js'
26 | * }]
27 | * ]
28 | * }
29 | *
30 | * @see https://astexplorer.net/#/gist/0995f8452cfa62d797a2a778a3442b65/2e588cc89829971495ca8e38905bb5581a23cf5d
31 | */
32 |
33 | /** @typedef NodePath @type {import('@babel/core').NodePath} */
34 |
35 | export default function ({ types: t }) {
36 | function unwrap (path) {
37 | if (t.isExpressionStatement(path)) {
38 | return unwrap(path.get('expression'));
39 | }
40 | if (t.isUnaryExpression(path) && path.node.operator === '!') {
41 | return unwrap(path.get('argument'));
42 | }
43 | return path;
44 | }
45 |
46 | /**
47 | * Attempt to parse a path and find a webpack Bootstrap function, if so returning a description.
48 | * @param {NodePath} path
49 | */
50 | function getWebpackBootstrap (path) {
51 | path = unwrap(path);
52 | if (!t.isCallExpression(path)) return false;
53 | const factory = path.get('callee');
54 | if (!t.isFunctionExpression(factory)) return false;
55 | const bootstrap = parseWebpackBootstrap(factory);
56 | if (!bootstrap || !bootstrap.confident) return false;
57 | const args = path.get('arguments');
58 | bootstrap.modules = getWebpackModules(args);
59 | return bootstrap;
60 | }
61 |
62 | /**
63 | * Verify that a Path is a "bootstrap" function, which is Webpack's module registry and loader implementation.
64 | * @param {NodePath} path
65 | */
66 | function parseWebpackBootstrap (path) {
67 | const bootstrap = {
68 | confident: false,
69 | /** @type {NodePath} */
70 | factory: null,
71 | /** @type {NodePath} */
72 | urlMap: null
73 | };
74 |
75 | // // TODO: this is silly and should use binding lookup + parent checks. Something like:
76 | // const bindings = path.get('body').scope.bindings;
77 | // console.log(path.get('body').scope.hasGlobal('window'));
78 | // for (let name in bindings) {
79 | // const binding = bindings[name];
80 | // if (1){}
81 | // }
82 | // bindings.some(b => {
83 | // b.referencePaths.some(p => t.isMemberExpression(p.parent) && t.isAssignmentExpression(p.parentPath.parent) && p.parent.property.name === 'oe')
84 | // });
85 |
86 | const identifiers = {};
87 | path.get('body').traverse({
88 | MemberExpression (p) {
89 | // Find the script loader function (indicated by the presence of document.createElement("script").
90 | // Note that this check *does* traverse into nested functions.
91 | if (t.matchesPattern(p.node, 'document.createElement') && t.isCallExpression(p.parent) && t.isStringLiteral(p.parent.arguments[0], { value: 'script' })) {
92 | const id = p.parentPath.parent.id.name;
93 | p.scope.getBinding(id).referencePaths.forEach(p => {
94 | // Find script.src= assignment mapping:
95 | const parent = p.parentPath;
96 | if (t.isMemberExpression(parent) && t.isIdentifier(parent.node.property, { name: 'src' }) && t.isAssignmentExpression(parent.parent)) {
97 | // Find the assigned value: s.src = X
98 | let expr = parent.parentPath.get('right').resolve();
99 | // It might be a function call:
100 | if (t.isCallExpression(expr)) {
101 | expr = expr.get('callee').resolve();
102 | }
103 | // That function call might be an IIFE (it generally is):
104 | if (t.isFunction(expr)) {
105 | expr = expr.get('body.body').filter(t.isReturnStatement)[0];
106 | if (expr) expr = expr.get('argument');
107 | }
108 | // Store it for later manipulation
109 | bootstrap.urlMap = expr;
110 | }
111 | });
112 | return;
113 | }
114 |
115 | // Detect extensions to Webpack's main namespace.
116 | // They're assignments to properties on a local binding, but we don't yet know which one.
117 | // We ignore nested functions, and only look at assignments.
118 | if (p.scope !== path.scope || !t.isAssignmentExpression(p.parent)) {
119 | return;
120 | }
121 | if (t.isIdentifier(p.node.object) && t.isIdentifier(p.node.property)) {
122 | let a = identifiers[p.node.object.name];
123 | if (!a) a = identifiers[p.node.object.name] = {};
124 | a[p.node.property.name] = p;
125 | }
126 | }
127 | });
128 | // shallowWalk(path.get('body'), p => {
129 | // if (!t.isMemberExpression(p)) return;
130 | // if (t.isIdentifier(p.node.object) && t.isIdentifier(p.node.property)) {
131 | // let a = identifiers[p.node.object.name];
132 | // if (!a) a = identifiers[p.node.object.name] = {};
133 | // a[p.node.property.name] = true;
134 | // }
135 | // });
136 |
137 | // Check if we found a binding with properties that signify a Webpack namespace object:
138 | for (const a in identifiers) {
139 | const v = identifiers[a];
140 | // Look for `__webpack_public_path__`, `__webpack_modules__` and an onerror handler:
141 | if (v.p && v.c && v.oe) {
142 | bootstrap.confident = true;
143 | bootstrap.factory = path;
144 | // might be useful later
145 | bootstrap.api = v;
146 | break;
147 | }
148 | }
149 |
150 | // Regardless of structure, a `window.webpackJsonp` reference means this is a webpack bootstrap function:
151 | if (identifiers.window && identifiers.window.webpackJsonp) {
152 | bootstrap.confident = true;
153 | bootstrap.factory = path;
154 | bootstrap.webpackJsonp = identifiers.window.webpackJsonp;
155 | }
156 |
157 | return bootstrap;
158 | }
159 |
160 | function getWebpackModules (list) {
161 | const modules = [];
162 | const mod = m => {
163 | if (t.isFunctionExpression(m)) {
164 | modules.push(m);
165 | } else {
166 | throw Error('Not a webpack bundle');
167 | }
168 | };
169 | list.forEach(m => {
170 | if (t.isArrayExpression(m)) {
171 | m.get('elements').forEach(mod);
172 | } else {
173 | mod(m);
174 | }
175 | });
176 | return modules;
177 | }
178 |
179 | // function shallowWalk(path, callback) {
180 | // if (Array.isArray(path)) {
181 | // for (let i=0; i {
220 | const bootstrap = getWebpackBootstrap(expr);
221 | if (!bootstrap) return;
222 |
223 | // Replace the template part containing ".js" with ".module.js"
224 | if (bootstrap.urlMap) {
225 | bootstrap.urlMap.traverse({
226 | StringLiteral (s) {
227 | if (/\.js$/.test(s.node.value)) {
228 | s.replaceWith(t.stringLiteral(s.node.value.replace(pattern, replacement)));
229 | s.stop();
230 | }
231 | }
232 | });
233 | }
234 | });
235 | }
236 | }
237 | };
238 | }
239 |
--------------------------------------------------------------------------------
/src/lib/transform-extract-polyfills.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | export default function () {
18 | return {
19 | name: 'transform-extract-polyfills',
20 | visitor: {
21 | ImportDeclaration (path, state) {
22 | state.opts.onPolyfill(path.node.source.value);
23 | path.remove();
24 | }
25 | }
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/src/lib/util.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * Convert a Babel-style SourceMap to Terser-style, parsing if necessary.
19 | * @param {(import('@babel/core').BabelFileResult)['map']|string|null} map
20 | * @return {import('source-map').RawSourceMap|null} map
21 | * @todo This should be deleted, as it's exclusively to make TypeScript happy.
22 | */
23 | export function toTerserMap (map) {
24 | if (typeof map === 'string') map = JSON.parse(map);
25 | return typeof map === 'object' && map ? {
26 | ...map,
27 | version: String(map.version)
28 | } : null;
29 | }
30 |
31 | /**
32 | * Convert a Terser-style SourceMap to Babel-style, parsing if necessary.
33 | * @param {import('source-map').RawSourceMap|string|null} map
34 | * @returns {(import('@babel/core').BabelFileResult)['map']|null}
35 | * @todo This should be deleted, as it's exclusively to make TypeScript happy.
36 | */
37 | export function toBabelMap (map) {
38 | if (typeof map === 'string') map = JSON.parse(map);
39 | return typeof map === 'object' && map ? {
40 | file: '',
41 | ...map,
42 | version: parseInt(map.version, 10)
43 | } : null;
44 | }
45 |
46 | const DEFAULT_COREJS_VERSION = 2;
47 |
48 | let corejsVersion;
49 |
50 | /**
51 | * Get the user's installed version of core-js
52 | * @returns {number}
53 | */
54 | export function getCorejsVersion () {
55 | if (!corejsVersion) {
56 | try {
57 | // @ts-ignore
58 | corejsVersion = parseInt(require('core-js/package.json').version, 10);
59 | console.log(`[OptimizePlugin] Detected core-js version ${corejsVersion}`);
60 | } catch (e) {
61 | console.warn(
62 | `[OptimizePlugin] Unable to detect installed version of core-js. Assuming core-js@${DEFAULT_COREJS_VERSION}.`
63 | );
64 | corejsVersion = DEFAULT_COREJS_VERSION;
65 | }
66 | }
67 | return corejsVersion;
68 | }
69 |
70 | export function createPerformanceTimings () {
71 | const timings = [];
72 |
73 | const start = name => {
74 | timings.push({ name, start: Date.now() });
75 | };
76 |
77 | const end = name => {
78 | for (const entry of timings) {
79 | if (entry.name === name) {
80 | entry.end = Date.now();
81 | entry.duration = entry.end - entry.start;
82 | return;
83 | }
84 | }
85 | };
86 |
87 | return {
88 | timings,
89 | start,
90 | end
91 | };
92 | }
93 |
--------------------------------------------------------------------------------
/src/lib/worker-pool.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import Worker from 'jest-worker';
18 |
19 | export class WorkerPool {
20 | constructor ({ workerPath, concurrency }) {
21 | // concurrency = 0;
22 | if (concurrency === false || concurrency === 0) {
23 | return { enqueue: t => require(workerPath).process(t) };
24 | }
25 |
26 | const worker = new Worker(workerPath, {
27 | enableWorkerThreads: false,
28 | numWorkers: concurrency
29 | });
30 | let pending = 0;
31 | let timer;
32 | function check () {
33 | clearTimeout(timer);
34 | if (--pending === 0) {
35 | timer = setTimeout(() => {
36 | worker.end();
37 | }, 10);
38 | }
39 | }
40 | worker.enqueue = task => {
41 | clearTimeout(timer);
42 | const p = worker.process(task);
43 | pending++;
44 | p.then(check);
45 | return p;
46 | };
47 | return worker;
48 | }
49 | }
50 |
51 | /*
52 | import os from 'os';
53 | import Worker from 'jest-worker';
54 |
55 | export class WorkerPool {
56 | constructor ({ workerPath, concurrency }) {
57 | this.concurrency = Math.max(1, Math.round(concurrency || os.cpus().length || 1));
58 | this.runInBand = concurrency === 0 || concurrency === false;
59 | this.workerPath = workerPath;
60 | this.queue = [];
61 | this.workers = [];
62 | this.freeWorkers = [];
63 | }
64 |
65 | // terminateAll () {
66 | // let worker;
67 | // while ((worker = this.workers.pop())) {
68 | // worker.terminate();
69 | // }
70 | // }
71 |
72 | cleanup () {
73 | clearTimeout(this.cleanupTimer);
74 | const worker = this.getFreeWorker();
75 | if (worker) {
76 | worker.end();
77 | this.cleanupTimer = setTimeout(this.cleanup.bind(this), 100);
78 | }
79 | }
80 |
81 | getFreeWorker () {
82 | return this.freeWorkers.pop();
83 | }
84 |
85 | addWorker () {
86 | if (this.workers.length >= this.concurrency) return;
87 | const worker = this.runInBand ? require(this.workerPath) : new Worker(this.workerPath, {
88 | enableWorkerThreads: true,
89 | numWorkers: 1
90 | // maxRetries: 0
91 | });
92 | this.workers.push(worker);
93 | return worker;
94 | }
95 |
96 | enqueue (item) {
97 | return new Promise((resolve, reject) => {
98 | if (this.queue.push({ item, resolve, reject }) === 1) {
99 | this.process();
100 | }
101 | });
102 | }
103 |
104 | async process () {
105 | clearTimeout(this.cleanupTimer);
106 | if (!this.queue.length) {
107 | this.cleanupTimer = setTimeout(this.cleanup.bind(this), 100);
108 | return;
109 | }
110 | const worker = this.getFreeWorker() || this.addWorker();
111 | if (!worker) {
112 | console.log('queue full');
113 | return;
114 | }
115 | const { item, resolve, reject } = this.queue.pop();
116 | try {
117 | const result = await worker.process(item);
118 | resolve(result);
119 | } catch (e) {
120 | reject(e);
121 | }
122 | this.freeWorkers.unshift(worker);
123 | this.process();
124 | }
125 | }
126 |
127 | // class MockWorkerPool extends WorkerPool {
128 | // constructor(options) {
129 | // super({
130 | // ...options,
131 | // concurrency: 1
132 | // });
133 | // }
134 | // getFreeWorker() {
135 | // return require(this.workerPath);
136 | // }
137 | // }
138 | */
139 |
--------------------------------------------------------------------------------
/src/worker.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import * as terser from 'terser';
18 | import babel from '@babel/core';
19 | import transformWebpackUrls from './lib/transform-change-webpack-urls';
20 | import extractPolyfills from './lib/transform-extract-polyfills';
21 | import { toBabelMap, toTerserMap, createPerformanceTimings } from './lib/util';
22 |
23 | const NAME = 'OptimizePlugin';
24 |
25 | const TERSER_CACHE = {};
26 |
27 | const noopTimings = { timings: [], start: n => {}, end: n => {} };
28 |
29 | /**
30 | * @param {object} $0
31 | * @param {string} $0.file
32 | * @param {string} $0.source
33 | * @param {string|object} $0.map
34 | * @param {object} [$0.options]
35 | * @param {boolean} [$0.options.timings = false]
36 | * @param {boolean} [$0.options.minify = false]
37 | * @param {boolean} [$0.options.downlevel = false]
38 | * @param {boolean} [$0.options.modernize = false]
39 | * @param {number} [$0.options.corejsVersion]
40 | */
41 | export async function process ({ file, source, map, options = {} }) {
42 | const { timings, start, end } = options.timings ? createPerformanceTimings() : noopTimings;
43 | const { minify, downlevel, modernize } = options;
44 |
45 | const polyfills = new Set();
46 | let legacy;
47 |
48 | const outputOptions = {
49 | compact: minify,
50 | minified: minify,
51 | // envName: minify ? 'production' : 'development',
52 | comments: minify ? false : undefined,
53 | generatorOpts: {
54 | concise: true
55 | }
56 | };
57 |
58 | start('modern');
59 | const modern = await babel.transformAsync(source, {
60 | configFile: false,
61 | babelrc: false,
62 | filename: file,
63 | inputSourceMap: map,
64 | sourceMaps: true,
65 | sourceFileName: file,
66 | sourceType: 'module',
67 | envName: 'modern',
68 | // ast: true,
69 | presets: [
70 | ['@babel/preset-env', {
71 | loose: true,
72 | modules: false,
73 | bugfixes: true,
74 | targets: {
75 | esmodules: true
76 | },
77 | // corejs: options.corejsVersion,
78 | useBuiltIns: false
79 | }],
80 | modernize && ['babel-preset-modernize', {
81 | loose: true,
82 | webpack: true
83 | }]
84 | ].filter(Boolean),
85 | ...outputOptions,
86 | caller: {
87 | supportsStaticESM: true,
88 | name: NAME + '-modern'
89 | }
90 | });
91 | end('modern');
92 |
93 | if (minify) {
94 | start('modern-minify');
95 | const minified = await terser.minify(modern.code, {
96 | // Enables shorthand properties in objects and object patterns:
97 | ecma: 2017,
98 | module: false,
99 | nameCache: TERSER_CACHE,
100 | // sourceMap: true,
101 | sourceMap: {
102 | content: toTerserMap(modern.map)
103 | },
104 | compress: {
105 | global_defs: {
106 | MODERN_MODE: true,
107 | 'process.env.NODE_ENV': global.process.env.NODE_ENV || 'production'
108 | }
109 | },
110 | // Fix Safari 10 issues
111 | // ({a}) --> ({a:a})
112 | // !await a --> !(await a)
113 | safari10: true,
114 | mangle: {
115 | toplevel: true
116 | // safari10: true
117 | // properties: {
118 | // regex: /./
119 | // }
120 | }
121 | });
122 |
123 | modern.code = minified.code;
124 | modern.map = toBabelMap(minified.map);
125 |
126 | // @todo this means modern.ast is now out-of-sync with modern.code
127 | // can this work? or do we need to run Terser separately for modern/legacy?
128 | end('modern-minify');
129 | }
130 |
131 | if (downlevel) {
132 | start('legacy');
133 | // legacy = await babel.transformFromAstAsync(modern.ast, modern.code, {
134 | legacy = await babel.transformAsync(modern.code, {
135 | configFile: false,
136 | babelrc: false,
137 | filename: file,
138 | inputSourceMap: modern.map,
139 | sourceMaps: true,
140 | sourceFileName: file,
141 | sourceType: 'module',
142 | envName: 'legacy',
143 | presets: [
144 | ['@babel/preset-env', {
145 | loose: true,
146 | modules: false,
147 | // corejs: 3,
148 | corejs: options.corejsVersion,
149 | useBuiltIns: 'usage'
150 | }]
151 | ],
152 | plugins: [
153 | [transformWebpackUrls, {
154 | pattern: /\.js$/,
155 | replacement: '.legacy.js'
156 | }],
157 | [extractPolyfills, {
158 | onPolyfill (specifier) {
159 | polyfills.add(specifier);
160 | }
161 | }]
162 | ],
163 | ...outputOptions,
164 | caller: {
165 | supportsStaticESM: false,
166 | name: NAME + '-legacy'
167 | }
168 | });
169 | end('legacy');
170 | }
171 |
172 | return {
173 | modern: sanitizeResult(modern),
174 | legacy: legacy && sanitizeResult(legacy),
175 | polyfills: Array.from(polyfills),
176 | timings
177 | };
178 | }
179 |
180 | function sanitizeResult (result) {
181 | return { source: result.code, map: result.map };
182 | }
183 |
--------------------------------------------------------------------------------
/test/_sucrase-loader.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | const path = require('path');
18 | const { transform } = require('sucrase');
19 |
20 | module.exports = function (source) {
21 | const name = path.relative(process.cwd(), this.resource);
22 | const isTs = /\.tsx?$/.test(name);
23 | const { code } = transform(source, {
24 | transforms: isTs ? ['typescript', 'jsx'] : ['jsx'],
25 | production: true
26 | });
27 | return code;
28 | };
29 |
--------------------------------------------------------------------------------
/test/_util.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 |
17 | import { promises as fs } from 'fs';
18 | import path from 'path';
19 | import { gzip as gzipSync } from 'zlib';
20 | import util from 'util';
21 | import CleanPlugin from 'clean-webpack-plugin';
22 |
23 | export function sleep (ms) {
24 | return new Promise(resolve => setTimeout(resolve, ms));
25 | }
26 |
27 | const gzip = util.promisify(gzipSync);
28 | const gzipSize = async x => (await gzip(x)).byteLength;
29 |
30 | export async function printSizes (assets, name, console) {
31 | let modernSize = 0;
32 | let legacySize = 0;
33 | const prettyBytes = (size) => {
34 | if (size > 1500) return (size / 1000).toFixed(2) + 'kB';
35 | return size + 'b';
36 | };
37 | const showSize = async (file) => {
38 | const size = await gzipSize(assets[file]);
39 | let str = `\n ${file}: ${prettyBytes(size)}`;
40 | if (file.match(/.legacy/)) {
41 | legacySize += size;
42 | } else {
43 | modernSize += size;
44 | const legacyName = file.replace(/\.js$/, '.legacy.js');
45 | if (assets[legacyName]) str += await showSize(legacyName);
46 | }
47 | return str;
48 | };
49 |
50 | let str = `SIZES${name ? ` for ${name}` : ''}:`;
51 | for (const i in assets) {
52 | if (i.match(/.legacy/)) continue;
53 | str += await showSize(i);
54 | }
55 | str += await showSize('polyfills.legacy.js');
56 | str += `\n> Total: ${prettyBytes(modernSize)} - ${prettyBytes(legacySize - modernSize)} (${Math.round((legacySize - modernSize) / legacySize * 100)}%) smaller in modern browsers.`;
57 | console.log(str);
58 | }
59 |
60 | export function runWebpack (webpack, fixture, { output = {}, plugins = [], module = {}, resolve = {}, ...config } = {}, console) {
61 | return run(callback => webpack({
62 | mode: 'production',
63 | devtool: false,
64 | context: path.resolve(__dirname, 'fixtures', fixture),
65 | entry: './entry.js',
66 | output: {
67 | publicPath: 'dist/',
68 | path: path.resolve(__dirname, 'fixtures', fixture, 'dist'),
69 | ...(output || {})
70 | },
71 | module: {
72 | ...module,
73 | rules: [].concat(module.rules || [])
74 | },
75 | resolve,
76 | plugins: [
77 | new CleanPlugin([
78 | path.resolve(__dirname, 'fixtures', fixture, 'dist', '**')
79 | ])
80 | ].concat(plugins || []),
81 | ...config
82 | }, callback), console);
83 | }
84 |
85 | export function watchWebpack (webpack, fixture, { output, plugins, context, ...config } = {}, console) {
86 | context = context || path.resolve(__dirname, 'fixtures', fixture);
87 | const compiler = webpack({
88 | mode: 'production',
89 | context,
90 | entry: './entry.js',
91 | output: {
92 | publicPath: 'dist/',
93 | path: path.resolve(context, 'dist'),
94 | ...(output || {})
95 | },
96 | plugins: plugins || []
97 | });
98 | compiler.doRun = () => run(compiler.run.bind(compiler), console);
99 | return compiler;
100 | }
101 |
102 | export async function statsWithAssets (stats) {
103 | const assets = Object.keys(stats.compilation.assets);
104 | const basePath = stats.compilation.outputOptions.path;
105 | const contents = {};
106 |
107 | await Promise.all(assets.map(async (asset) => {
108 | const assetPath = path.join(basePath, asset);
109 | const content = await fs.readFile(assetPath, 'utf8');
110 |
111 | contents[asset] = content;
112 | }));
113 |
114 | stats.assets = contents;
115 | }
116 |
117 | function run (runner, console) {
118 | return new Promise((resolve, reject) => {
119 | runner(async (err, stats) => {
120 | if (err) return reject(err);
121 |
122 | await statsWithAssets(stats);
123 |
124 | stats.info = stats.toJson({ assets: true, chunks: true });
125 |
126 | if (stats.hasWarnings()) {
127 | stats.info.warnings.forEach(warning => {
128 | console.warn('Webpack warning: ', warning);
129 | });
130 | console.warn('\nWebpack build generated ' + stats.info.warnings.length + ' warnings(s), shown above.\n\n');
131 | }
132 | if (stats.hasErrors()) {
133 | return reject(stats.info.errors.join('\n'));
134 | }
135 | resolve(stats);
136 | });
137 | });
138 | }
139 |
--------------------------------------------------------------------------------
/test/_webpacks.js:
--------------------------------------------------------------------------------
1 | export const webpacks = [
2 | [5, require('webpack'), require.resolve('ts-loader')],
3 | [4, require('webpack-4/node_modules/webpack'), require.resolve('webpack-4/node_modules/ts-loader')]
4 | ];
5 |
--------------------------------------------------------------------------------
/test/fixtures/basic/entry.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 |
17 | console.log('hello world');
18 |
19 | Object.defineProperty(window, 'test', {
20 | value: 'hello world'
21 | });
22 |
--------------------------------------------------------------------------------
/test/fixtures/code-splitting/about.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 | import { h } from 'preact';
17 |
18 | const HTML = `
19 | This is a lovely website.
20 | More specifically, you're looking at the About page.
21 | `;
22 |
23 | export default function Home () {
24 | return h('pre', { id: 'home', dangerouslySetInnerHTML: { __html: HTML } });
25 | }
26 |
--------------------------------------------------------------------------------
/test/fixtures/code-splitting/entry.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 |
17 | import { h, render } from 'preact';
18 | import { useState } from 'preact/hooks';
19 |
20 | function lazy (load) {
21 | let pending, component;
22 | return function Lazy (props) {
23 | const [, render] = useState({});
24 | if (!pending) {
25 | pending = pending || load() || (() => {});
26 | if (pending && pending.then) pending.then(c => render(component = c));
27 | else component = pending;
28 | }
29 | return component ? h(component, props) : null;
30 | };
31 | }
32 |
33 | const Home = lazy(() => import(/* webpackChunkName:"home" */ './home'));
34 | const Profile = lazy(() => import(/* webpackChunkName:"profile" */ './profile'));
35 | const About = lazy(() => import(/* webpackChunkName:"about" */ './about'));
36 |
37 | function App ({ url }) {
38 | return h('div', { id: 'app' },
39 | h('h1', { class: 'header' }, 'hello world'),
40 | h(Home, null),
41 | h(About, null),
42 | h(Profile, null)
43 | );
44 | }
45 |
46 | render(h(App, null), document.body);
47 |
--------------------------------------------------------------------------------
/test/fixtures/code-splitting/home.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 | import { h } from 'preact';
17 |
18 | const HTML = `
19 | Hello and welcome to, the internet.
20 | `;
21 |
22 | export default function Home () {
23 | return h('pre', { id: 'home', dangerouslySetInnerHTML: { __html: HTML } });
24 | }
25 |
--------------------------------------------------------------------------------
/test/fixtures/code-splitting/profile.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 | import { h } from 'preact';
17 |
18 | const HTML = `
19 | Imagine an engaging and graphically impressive profile page here.
20 | Perhaps the profile portrays a specific user, or a koala.
21 | `;
22 |
23 | export default function Home () {
24 | return h('pre', { id: 'profile', dangerouslySetInnerHTML: { __html: HTML } });
25 | }
26 |
--------------------------------------------------------------------------------
/test/fixtures/typescript/entry.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 |
17 | import * as util from './util';
18 | import PinchZoom from './pinch-zoom';
19 |
20 | console.log(util, PinchZoom);
21 | document.body.appendChild(document.createElement('pinch-zoom'));
22 |
--------------------------------------------------------------------------------
/test/fixtures/typescript/pinch-zoom.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import PointerTracker, { Pointer } from './pointer-tracker';
18 | // import './styles.css';
19 |
20 | interface Point {
21 | clientX: number;
22 | clientY: number;
23 | }
24 |
25 | interface ChangeOptions {
26 | /**
27 | * Fire a 'change' event if values are different to current values
28 | */
29 | allowChangeEvent?: boolean;
30 | }
31 |
32 | interface ApplyChangeOpts extends ChangeOptions {
33 | panX?: number;
34 | panY?: number;
35 | scaleDiff?: number;
36 | originX?: number;
37 | originY?: number;
38 | }
39 |
40 | interface SetTransformOpts extends ChangeOptions {
41 | scale?: number;
42 | x?: number;
43 | y?: number;
44 | }
45 |
46 | type ScaleRelativeToValues = 'container' | 'content';
47 |
48 | export interface ScaleToOpts extends ChangeOptions {
49 | /** Transform origin. Can be a number, or string percent, eg "50%" */
50 | originX?: number | string;
51 | /** Transform origin. Can be a number, or string percent, eg "50%" */
52 | originY?: number | string;
53 | /** Should the transform origin be relative to the container, or content? */
54 | relativeTo?: ScaleRelativeToValues;
55 | }
56 |
57 | function getDistance(a: Point, b?: Point): number {
58 | if (!b) return 0;
59 | return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2);
60 | }
61 |
62 | function getMidpoint(a: Point, b?: Point): Point {
63 | if (!b) return a;
64 |
65 | return {
66 | clientX: (a.clientX + b.clientX) / 2,
67 | clientY: (a.clientY + b.clientY) / 2,
68 | };
69 | }
70 |
71 | function getAbsoluteValue(value: string | number, max: number): number {
72 | if (typeof value === 'number') return value;
73 |
74 | if (value.trimRight().endsWith('%')) {
75 | return max * parseFloat(value) / 100;
76 | }
77 | return parseFloat(value);
78 | }
79 |
80 | // I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough.
81 | // Given that, better to use something everything supports.
82 | let cachedSvg: SVGSVGElement;
83 |
84 | function getSVG(): SVGSVGElement {
85 | return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'));
86 | }
87 |
88 | function createMatrix(): SVGMatrix {
89 | return getSVG().createSVGMatrix();
90 | }
91 |
92 | function createPoint(): SVGPoint {
93 | return getSVG().createSVGPoint();
94 | }
95 |
96 | const MIN_SCALE = 0.01;
97 |
98 | export default class PinchZoom extends HTMLElement {
99 | // The element that we'll transform.
100 | // Ideally this would be shadow DOM, but we don't have the browser
101 | // support yet.
102 | private _positioningEl?: Element;
103 | // Current transform.
104 | private _transform: SVGMatrix = createMatrix();
105 |
106 | constructor() {
107 | super();
108 |
109 | // Watch for children changes.
110 | // Note this won't fire for initial contents,
111 | // so _stageElChange is also called in connectedCallback.
112 | new MutationObserver(() => this._stageElChange())
113 | .observe(this, { childList: true });
114 |
115 | // Watch for pointers
116 | const pointerTracker: PointerTracker = new PointerTracker(this, {
117 | start: (pointer, event) => {
118 | // We only want to track 2 pointers at most
119 | if (pointerTracker.currentPointers.length === 2 || !this._positioningEl) return false;
120 | event.preventDefault();
121 | return true;
122 | },
123 | move: (previousPointers) => {
124 | this._onPointerMove(previousPointers, pointerTracker.currentPointers);
125 | },
126 | });
127 |
128 | this.addEventListener('wheel', event => this._onWheel(event));
129 | }
130 |
131 | connectedCallback() {
132 | this._stageElChange();
133 | }
134 |
135 | get x() {
136 | return this._transform.e;
137 | }
138 |
139 | get y() {
140 | return this._transform.f;
141 | }
142 |
143 | get scale() {
144 | return this._transform.a;
145 | }
146 |
147 | /**
148 | * Change the scale, adjusting x/y by a given transform origin.
149 | */
150 | scaleTo(scale: number, opts: ScaleToOpts = {}) {
151 | let {
152 | originX = 0,
153 | originY = 0,
154 | } = opts;
155 |
156 | const {
157 | relativeTo = 'content',
158 | allowChangeEvent = false,
159 | } = opts;
160 |
161 | const relativeToEl = (relativeTo === 'content' ? this._positioningEl : this);
162 |
163 | // No content element? Fall back to just setting scale
164 | if (!relativeToEl || !this._positioningEl) {
165 | this.setTransform({ scale, allowChangeEvent });
166 | return;
167 | }
168 |
169 | const rect = relativeToEl.getBoundingClientRect();
170 | originX = getAbsoluteValue(originX, rect.width);
171 | originY = getAbsoluteValue(originY, rect.height);
172 |
173 | if (relativeTo === 'content') {
174 | originX += this.x;
175 | originY += this.y;
176 | } else {
177 | const currentRect = this._positioningEl.getBoundingClientRect();
178 | originX -= currentRect.left;
179 | originY -= currentRect.top;
180 | }
181 |
182 | this._applyChange({
183 | allowChangeEvent,
184 | originX,
185 | originY,
186 | scaleDiff: scale / this.scale,
187 | });
188 | }
189 |
190 | /**
191 | * Update the stage with a given scale/x/y.
192 | */
193 | setTransform(opts: SetTransformOpts = {}) {
194 | const {
195 | scale = this.scale,
196 | allowChangeEvent = false,
197 | } = opts;
198 |
199 | let {
200 | x = this.x,
201 | y = this.y,
202 | } = opts;
203 |
204 | // If we don't have an element to position, just set the value as given.
205 | // We'll check bounds later.
206 | if (!this._positioningEl) {
207 | this._updateTransform(scale, x, y, allowChangeEvent);
208 | return;
209 | }
210 |
211 | // Get current layout
212 | const thisBounds = this.getBoundingClientRect();
213 | const positioningElBounds = this._positioningEl.getBoundingClientRect();
214 |
215 | // Not displayed. May be disconnected or display:none.
216 | // Just take the values, and we'll check bounds later.
217 | if (!thisBounds.width || !thisBounds.height) {
218 | this._updateTransform(scale, x, y, allowChangeEvent);
219 | return;
220 | }
221 |
222 | // Create points for _positioningEl.
223 | let topLeft = createPoint();
224 | topLeft.x = positioningElBounds.left - thisBounds.left;
225 | topLeft.y = positioningElBounds.top - thisBounds.top;
226 | let bottomRight = createPoint();
227 | bottomRight.x = positioningElBounds.width + topLeft.x;
228 | bottomRight.y = positioningElBounds.height + topLeft.y;
229 |
230 | // Calculate the intended position of _positioningEl.
231 | const matrix = createMatrix()
232 | .translate(x, y)
233 | .scale(scale)
234 | // Undo current transform
235 | .multiply(this._transform.inverse());
236 |
237 | topLeft = topLeft.matrixTransform(matrix);
238 | bottomRight = bottomRight.matrixTransform(matrix);
239 |
240 | // Ensure _positioningEl can't move beyond out-of-bounds.
241 | // Correct for x
242 | if (topLeft.x > thisBounds.width) {
243 | x += thisBounds.width - topLeft.x;
244 | } else if (bottomRight.x < 0) {
245 | x += -bottomRight.x;
246 | }
247 |
248 | // Correct for y
249 | if (topLeft.y > thisBounds.height) {
250 | y += thisBounds.height - topLeft.y;
251 | } else if (bottomRight.y < 0) {
252 | y += -bottomRight.y;
253 | }
254 |
255 | this._updateTransform(scale, x, y, allowChangeEvent);
256 | }
257 |
258 | /**
259 | * Update transform values without checking bounds. This is only called in setTransform.
260 | */
261 | private _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) {
262 | // Avoid scaling to zero
263 | if (scale < MIN_SCALE) return;
264 |
265 | // Return if there's no change
266 | if (
267 | scale === this.scale &&
268 | x === this.x &&
269 | y === this.y
270 | ) return;
271 |
272 | this._transform.e = x;
273 | this._transform.f = y;
274 | this._transform.d = this._transform.a = scale;
275 |
276 | this.style.setProperty('--x', this.x + 'px');
277 | this.style.setProperty('--y', this.y + 'px');
278 | this.style.setProperty('--scale', this.scale + '');
279 |
280 | if (allowChangeEvent) {
281 | const event = new Event('change', { bubbles: true });
282 | this.dispatchEvent(event);
283 | }
284 | }
285 |
286 | /**
287 | * Called when the direct children of this element change.
288 | * Until we have have shadow dom support across the board, we
289 | * require a single element to be the child of , and
290 | * that's the element we pan/scale.
291 | */
292 | private _stageElChange() {
293 | this._positioningEl = undefined;
294 |
295 | if (this.children.length === 0) return;
296 |
297 | this._positioningEl = this.children[0];
298 |
299 | if (this.children.length > 1) {
300 | console.warn(' must not have more than one child.');
301 | }
302 |
303 | // Do a bounds check
304 | this.setTransform({ allowChangeEvent: true });
305 | }
306 |
307 | private _onWheel(event: WheelEvent) {
308 | if (!this._positioningEl) return;
309 | event.preventDefault();
310 |
311 | const currentRect = this._positioningEl.getBoundingClientRect();
312 | let { deltaY } = event;
313 | const { ctrlKey, deltaMode } = event;
314 |
315 | if (deltaMode === 1) { // 1 is "lines", 0 is "pixels"
316 | // Firefox uses "lines" for some types of mouse
317 | deltaY *= 15;
318 | }
319 |
320 | // ctrlKey is true when pinch-zooming on a trackpad.
321 | const divisor = ctrlKey ? 100 : 300;
322 | const scaleDiff = 1 - deltaY / divisor;
323 |
324 | this._applyChange({
325 | scaleDiff,
326 | originX: event.clientX - currentRect.left,
327 | originY: event.clientY - currentRect.top,
328 | allowChangeEvent: true,
329 | });
330 | }
331 |
332 | private _onPointerMove(previousPointers: Pointer[], currentPointers: Pointer[]) {
333 | if (!this._positioningEl) return;
334 |
335 | // Combine next points with previous points
336 | const currentRect = this._positioningEl.getBoundingClientRect();
337 |
338 | // For calculating panning movement
339 | const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
340 | const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
341 |
342 | // Midpoint within the element
343 | const originX = prevMidpoint.clientX - currentRect.left;
344 | const originY = prevMidpoint.clientY - currentRect.top;
345 |
346 | // Calculate the desired change in scale
347 | const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
348 | const newDistance = getDistance(currentPointers[0], currentPointers[1]);
349 | const scaleDiff = prevDistance ? newDistance / prevDistance : 1;
350 |
351 | this._applyChange({
352 | originX, originY, scaleDiff,
353 | panX: newMidpoint.clientX - prevMidpoint.clientX,
354 | panY: newMidpoint.clientY - prevMidpoint.clientY,
355 | allowChangeEvent: true,
356 | });
357 | }
358 |
359 | /** Transform the view & fire a change event */
360 | private _applyChange(opts: ApplyChangeOpts = {}) {
361 | const {
362 | panX = 0, panY = 0,
363 | originX = 0, originY = 0,
364 | scaleDiff = 1,
365 | allowChangeEvent = false,
366 | } = opts;
367 |
368 | const matrix = createMatrix()
369 | // Translate according to panning.
370 | .translate(panX, panY)
371 | // Scale about the origin.
372 | .translate(originX, originY)
373 | // Apply current translate
374 | .translate(this.x, this.y)
375 | .scale(scaleDiff)
376 | .translate(-originX, -originY)
377 | // Apply current scale.
378 | .scale(this.scale);
379 |
380 | // Convert the transform into basic translate & scale.
381 | this.setTransform({
382 | allowChangeEvent,
383 | scale: matrix.a,
384 | x: matrix.e,
385 | y: matrix.f,
386 | });
387 | }
388 | }
389 |
390 | customElements.define('pinch-zoom', PinchZoom);
391 |
--------------------------------------------------------------------------------
/test/fixtures/typescript/pointer-tracker.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | const enum Button { Left }
18 |
19 | class Pointer {
20 | /** x offset from the top of the document */
21 | pageX: number;
22 | /** y offset from the top of the document */
23 | pageY: number;
24 | /** x offset from the top of the viewport */
25 | clientX: number;
26 | /** y offset from the top of the viewport */
27 | clientY: number;
28 | /** Unique ID for this pointer */
29 | id: number = -1;
30 | /** The platform object used to create this Pointer */
31 | nativePointer: Touch | PointerEvent | MouseEvent;
32 |
33 | constructor (nativePointer: Touch | PointerEvent | MouseEvent) {
34 | this.nativePointer = nativePointer;
35 | this.pageX = nativePointer.pageX;
36 | this.pageY = nativePointer.pageY;
37 | this.clientX = nativePointer.clientX;
38 | this.clientY = nativePointer.clientY;
39 |
40 | if (self.Touch && nativePointer instanceof Touch) {
41 | this.id = nativePointer.identifier;
42 | } else if (isPointerEvent(nativePointer)) { // is PointerEvent
43 | this.id = nativePointer.pointerId;
44 | }
45 | }
46 |
47 | /**
48 | * Returns an expanded set of Pointers for high-resolution inputs.
49 | */
50 | getCoalesced(): Pointer[] {
51 | if ('getCoalescedEvents' in this.nativePointer) {
52 | return this.nativePointer.getCoalescedEvents().map(p => new Pointer(p));
53 | }
54 | return [this];
55 | }
56 | }
57 |
58 | // Export the typing, but keep the class private.
59 | type PointerType = Pointer;
60 | export { PointerType as Pointer };
61 |
62 | const isPointerEvent = (event: any): event is PointerEvent =>
63 | self.PointerEvent && event instanceof PointerEvent;
64 |
65 | const noop = () => {};
66 |
67 | export type InputEvent = TouchEvent | PointerEvent | MouseEvent;
68 | type StartCallback = (pointer: Pointer, event: InputEvent) => boolean;
69 | type MoveCallback = (
70 | previousPointers: Pointer[],
71 | changedPointers: Pointer[],
72 | event: InputEvent,
73 | ) => void;
74 | type EndCallback = (pointer: Pointer, event: InputEvent) => void;
75 |
76 | interface PointerTrackerCallbacks {
77 | /**
78 | * Called when a pointer is pressed/touched within the element.
79 | *
80 | * @param pointer The new pointer.
81 | * This pointer isn't included in this.currentPointers or this.startPointers yet.
82 | * @param event The event related to this pointer.
83 | *
84 | * @returns Whether you want to track this pointer as it moves.
85 | */
86 | start?: StartCallback;
87 | /**
88 | * Called when pointers have moved.
89 | *
90 | * @param previousPointers The state of the pointers before this event.
91 | * This contains the same number of pointers, in the same order, as
92 | * this.currentPointers and this.startPointers.
93 | * @param changedPointers The pointers that have changed since the last move callback.
94 | * @param event The event related to the pointer changes.
95 | */
96 | move?: MoveCallback;
97 | /**
98 | * Called when a pointer is released.
99 | *
100 | * @param pointer The final state of the pointer that ended. This
101 | * pointer is now absent from this.currentPointers and
102 | * this.startPointers.
103 | * @param event The event related to this pointer.
104 | */
105 | end?: EndCallback;
106 | }
107 |
108 | /**
109 | * Track pointers across a particular element
110 | */
111 | export default class PointerTracker {
112 | /**
113 | * State of the tracked pointers when they were pressed/touched.
114 | */
115 | readonly startPointers: Pointer[] = [];
116 | /**
117 | * Latest state of the tracked pointers. Contains the same number
118 | * of pointers, and in the same order as this.startPointers.
119 | */
120 | readonly currentPointers: Pointer[] = [];
121 |
122 | private _startCallback: StartCallback;
123 | private _moveCallback: MoveCallback;
124 | private _endCallback: EndCallback;
125 |
126 | /**
127 | * Track pointers across a particular element
128 | *
129 | * @param element Element to monitor.
130 | * @param callbacks
131 | */
132 | constructor (private _element: HTMLElement, callbacks: PointerTrackerCallbacks) {
133 | const {
134 | start = () => true,
135 | move = noop,
136 | end = noop,
137 | } = callbacks;
138 |
139 | this._startCallback = start;
140 | this._moveCallback = move;
141 | this._endCallback = end;
142 |
143 | // Bind methods
144 | this._pointerStart = this._pointerStart.bind(this);
145 | this._touchStart = this._touchStart.bind(this);
146 | this._move = this._move.bind(this);
147 | this._triggerPointerEnd = this._triggerPointerEnd.bind(this);
148 | this._pointerEnd = this._pointerEnd.bind(this);
149 | this._touchEnd = this._touchEnd.bind(this);
150 |
151 | // Add listeners
152 | if (self.PointerEvent) {
153 | this._element.addEventListener('pointerdown', this._pointerStart);
154 | } else {
155 | this._element.addEventListener('mousedown', this._pointerStart);
156 | this._element.addEventListener('touchstart', this._touchStart);
157 | this._element.addEventListener('touchmove', this._move);
158 | this._element.addEventListener('touchend', this._touchEnd);
159 | }
160 | }
161 |
162 | /**
163 | * Call the start callback for this pointer, and track it if the user wants.
164 | *
165 | * @param pointer Pointer
166 | * @param event Related event
167 | * @returns Whether the pointer is being tracked.
168 | */
169 | private _triggerPointerStart(pointer: Pointer, event: InputEvent): boolean {
170 | if (!this._startCallback(pointer, event)) return false;
171 | this.currentPointers.push(pointer);
172 | this.startPointers.push(pointer);
173 | return true;
174 | }
175 |
176 | /**
177 | * Listener for mouse/pointer starts. Bound to the class in the constructor.
178 | *
179 | * @param event This will only be a MouseEvent if the browser doesn't support
180 | * pointer events.
181 | */
182 | private _pointerStart(event: PointerEvent | MouseEvent) {
183 | if (event.button !== Button.Left) return;
184 | if (!this._triggerPointerStart(new Pointer(event), event)) return;
185 |
186 | // Add listeners for additional events.
187 | // The listeners may already exist, but no harm in adding them again.
188 | if (isPointerEvent(event)) {
189 | this._element.setPointerCapture(event.pointerId);
190 | this._element.addEventListener('pointermove', this._move);
191 | this._element.addEventListener('pointerup', this._pointerEnd);
192 | } else { // MouseEvent
193 | window.addEventListener('mousemove', this._move);
194 | window.addEventListener('mouseup', this._pointerEnd);
195 | }
196 | }
197 |
198 | /**
199 | * Listener for touchstart. Bound to the class in the constructor.
200 | * Only used if the browser doesn't support pointer events.
201 | */
202 | private _touchStart(event: TouchEvent) {
203 | for (const touch of Array.from(event.changedTouches)) {
204 | this._triggerPointerStart(new Pointer(touch), event);
205 | }
206 | }
207 |
208 | /**
209 | * Listener for pointer/mouse/touch move events.
210 | * Bound to the class in the constructor.
211 | */
212 | private _move(event: PointerEvent | MouseEvent | TouchEvent) {
213 | const previousPointers = this.currentPointers.slice();
214 | const changedPointers = ('changedTouches' in event) ? // Shortcut for 'is touch event'.
215 | Array.from(event.changedTouches).map(t => new Pointer(t)) :
216 | [new Pointer(event)];
217 | const trackedChangedPointers = [];
218 |
219 | for (const pointer of changedPointers) {
220 | const index = this.currentPointers.findIndex(p => p.id === pointer.id);
221 | if (index === -1) continue; // Not a pointer we're tracking
222 | trackedChangedPointers.push(pointer);
223 | this.currentPointers[index] = pointer;
224 | }
225 |
226 | if (trackedChangedPointers.length === 0) return;
227 |
228 | this._moveCallback(previousPointers, trackedChangedPointers, event);
229 | }
230 |
231 | /**
232 | * Call the end callback for this pointer.
233 | *
234 | * @param pointer Pointer
235 | * @param event Related event
236 | */
237 | private _triggerPointerEnd(pointer: Pointer, event: InputEvent): boolean {
238 | const index = this.currentPointers.findIndex(p => p.id === pointer.id);
239 | // Not a pointer we're interested in?
240 | if (index === -1) return false;
241 |
242 | this.currentPointers.splice(index, 1);
243 | this.startPointers.splice(index, 1);
244 |
245 | this._endCallback(pointer, event);
246 | return true;
247 | }
248 |
249 | /**
250 | * Listener for mouse/pointer ends. Bound to the class in the constructor.
251 | * @param event This will only be a MouseEvent if the browser doesn't support
252 | * pointer events.
253 | */
254 | private _pointerEnd(event: PointerEvent | MouseEvent) {
255 | if (!this._triggerPointerEnd(new Pointer(event), event)) return;
256 |
257 | if (isPointerEvent(event)) {
258 | if (this.currentPointers.length) return;
259 | this._element.removeEventListener('pointermove', this._move);
260 | this._element.removeEventListener('pointerup', this._pointerEnd);
261 | } else { // MouseEvent
262 | window.removeEventListener('mousemove', this._move);
263 | window.removeEventListener('mouseup', this._pointerEnd);
264 | }
265 | }
266 |
267 | /**
268 | * Listener for touchend. Bound to the class in the constructor.
269 | * Only used if the browser doesn't support pointer events.
270 | */
271 | private _touchEnd(event: TouchEvent) {
272 | for (const touch of Array.from(event.changedTouches)) {
273 | this._triggerPointerEnd(new Pointer(touch), event);
274 | }
275 | }
276 | }
277 |
--------------------------------------------------------------------------------
/test/fixtures/typescript/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "sourceMap": false,
4 | "target": "ES2018"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/test/fixtures/typescript/util.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /** Compare two objects, returning a boolean indicating if
18 | * they have the same properties and strictly equal values.
19 | */
20 | export function shallowEqual(one: any, two: any) {
21 | for (const i in one) if (one[i] !== two[i]) return false;
22 | for (const i in two) if (!(i in one)) return false;
23 | return true;
24 | }
25 |
26 | /** Replace the contents of a canvas with the given data */
27 | export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) {
28 | const ctx = canvas.getContext('2d');
29 | if (!ctx) throw Error('Canvas not initialized');
30 | ctx.clearRect(0, 0, canvas.width, canvas.height);
31 | ctx.putImageData(data, 0, 0);
32 | }
33 |
34 | /**
35 | * Encode some image data in a given format using the browser's encoders
36 | *
37 | * @param {ImageData} data
38 | * @param {string} type A mime type, eg image/jpeg.
39 | * @param {number} [quality] Between 0-1.
40 | */
41 | export async function canvasEncode(data: ImageData, type: string, quality?: number): Promise {
42 | const canvas = document.createElement('canvas');
43 | canvas.width = data.width;
44 | canvas.height = data.height;
45 | const ctx = canvas.getContext('2d');
46 | if (!ctx) throw Error('Canvas not initialized');
47 | ctx.putImageData(data, 0, 0);
48 |
49 | let blob: Blob | null;
50 |
51 | if ('toBlob' in canvas) {
52 | blob = await new Promise(r => canvas.toBlob(r, type, quality));
53 | } else {
54 | // Welcome to Edge.
55 | // TypeScript thinks `canvas` is 'never', so it needs casting.
56 | const dataUrl = (canvas as HTMLCanvasElement).toDataURL(type, quality);
57 | const result = /data:([^;]+);base64,(.*)$/.exec(dataUrl);
58 |
59 | if (!result) throw Error('Data URL reading failed');
60 |
61 | const outputType = result[1];
62 | const binaryStr = atob(result[2]);
63 | const data = new Uint8Array(binaryStr.length);
64 |
65 | for (let i = 0; i < data.length; i += 1) {
66 | data[i] = binaryStr.charCodeAt(i);
67 | }
68 |
69 | blob = new Blob([data], { type: outputType });
70 | }
71 |
72 | if (!blob) throw Error('Encoding failed');
73 | return blob;
74 | }
75 |
76 | async function decodeImage(url: string): Promise {
77 | const img = new Image();
78 | img.decoding = 'async';
79 | img.src = url;
80 | const loaded = new Promise((resolve, reject) => {
81 | img.onload = () => resolve();
82 | img.onerror = () => reject(Error('Image loading error'));
83 | });
84 |
85 | if (img.decode) {
86 | // Nice off-thread way supported in Safari/Chrome.
87 | // Safari throws on decode if the source is SVG.
88 | // https://bugs.webkit.org/show_bug.cgi?id=188347
89 | await img.decode().catch(() => null);
90 | }
91 |
92 | // Always await loaded, as we may have bailed due to the Safari bug above.
93 | await loaded;
94 | return img;
95 | }
96 |
97 | /**
98 | * Attempts to load the given URL as an image.
99 | */
100 | export function canDecodeImage(url: string): Promise {
101 | return decodeImage(url).then(() => true, () => false);
102 | }
103 |
104 | export function blobToArrayBuffer(blob: Blob): Promise {
105 | return new Response(blob).arrayBuffer();
106 | }
107 |
108 | export function blobToText(blob: Blob): Promise {
109 | return new Response(blob).text();
110 | }
111 |
112 | const magicNumberToMimeType = new Map([
113 | [/^%PDF-/, 'application/pdf'],
114 | [/^GIF87a/, 'image/gif'],
115 | [/^GIF89a/, 'image/gif'],
116 | [/^\x89PNG\x0D\x0A\x1A\x0A/, 'image/png'],
117 | [/^\xFF\xD8\xFF/, 'image/jpeg'],
118 | [/^BM/, 'image/bmp'],
119 | [/^I I/, 'image/tiff'],
120 | [/^II*/, 'image/tiff'],
121 | [/^MM\x00*/, 'image/tiff'],
122 | [/^RIFF....WEBPVP8[LX ]/, 'image/webp'],
123 | ]);
124 |
125 | export async function sniffMimeType(blob: Blob): Promise {
126 | const firstChunk = await blobToArrayBuffer(blob.slice(0, 16));
127 | const firstChunkString =
128 | Array.from(new Uint8Array(firstChunk))
129 | .map(v => String.fromCodePoint(v))
130 | .join('');
131 | for (const [detector, mimeType] of magicNumberToMimeType) {
132 | if (detector.test(firstChunkString)) {
133 | return mimeType;
134 | }
135 | }
136 | return '';
137 | }
138 |
139 | export async function blobToImg(blob: Blob): Promise {
140 | const url = URL.createObjectURL(blob);
141 |
142 | try {
143 | return await decodeImage(url);
144 | } finally {
145 | URL.revokeObjectURL(url);
146 | }
147 | }
148 |
149 | interface DrawableToImageDataOptions {
150 | width?: number;
151 | height?: number;
152 | sx?: number;
153 | sy?: number;
154 | sw?: number;
155 | sh?: number;
156 | }
157 |
158 | export function drawableToImageData(
159 | drawable: ImageBitmap | HTMLImageElement,
160 | opts: DrawableToImageDataOptions = {},
161 | ): ImageData {
162 | const {
163 | width = drawable.width,
164 | height = drawable.height,
165 | sx = 0,
166 | sy = 0,
167 | sw = drawable.width,
168 | sh = drawable.height,
169 | } = opts;
170 |
171 | // Make canvas same size as image
172 | const canvas = document.createElement('canvas');
173 | canvas.width = width;
174 | canvas.height = height;
175 | // Draw image onto canvas
176 | const ctx = canvas.getContext('2d');
177 | if (!ctx) throw new Error('Could not create canvas context');
178 | ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
179 | return ctx.getImageData(0, 0, width, height);
180 | }
181 |
182 | export async function nativeDecode(blob: Blob): Promise {
183 | // Prefer createImageBitmap as it's the off-thread option for Firefox.
184 | const drawable = 'createImageBitmap' in self ?
185 | await createImageBitmap(blob) : await blobToImg(blob);
186 |
187 | return drawableToImageData(drawable);
188 | }
189 |
190 | export type NativeResizeMethod = 'pixelated' | 'low' | 'medium' | 'high';
191 |
192 | export function nativeResize(
193 | data: ImageData,
194 | sx: number, sy: number, sw: number, sh: number,
195 | dw: number, dh: number,
196 | method: NativeResizeMethod,
197 | ): ImageData {
198 | const canvasSource = document.createElement('canvas');
199 | canvasSource.width = data.width;
200 | canvasSource.height = data.height;
201 | drawDataToCanvas(canvasSource, data);
202 |
203 | const canvasDest = document.createElement('canvas');
204 | canvasDest.width = dw;
205 | canvasDest.height = dh;
206 | const ctx = canvasDest.getContext('2d');
207 | if (!ctx) throw new Error('Could not create canvas context');
208 |
209 | if (method === 'pixelated') {
210 | ctx.imageSmoothingEnabled = false;
211 | } else {
212 | ctx.imageSmoothingQuality = method;
213 | }
214 |
215 | ctx.drawImage(canvasSource, sx, sy, sw, sh, 0, 0, dw, dh);
216 | return ctx.getImageData(0, 0, dw, dh);
217 | }
218 |
219 | /**
220 | * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
221 | * @param defaultVal Value to return if 'field' doesn't exist.
222 | */
223 | export function inputFieldValueAsNumber(field: any, defaultVal: number = 0): number {
224 | if (!field) return defaultVal;
225 | return Number(inputFieldValue(field));
226 | }
227 |
228 | /**
229 | * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
230 | * @param defaultVal Value to return if 'field' doesn't exist.
231 | */
232 | export function inputFieldCheckedAsNumber(field: any, defaultVal: number = 0): number {
233 | if (!field) return defaultVal;
234 | return Number(inputFieldChecked(field));
235 | }
236 |
237 | /**
238 | * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
239 | * @param defaultVal Value to return if 'field' doesn't exist.
240 | */
241 | export function inputFieldChecked(field: any, defaultVal: boolean = false): boolean {
242 | if (!field) return defaultVal;
243 | return (field as HTMLInputElement).checked;
244 | }
245 |
246 | /**
247 | * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
248 | * @param defaultVal Value to return if 'field' doesn't exist.
249 | */
250 | export function inputFieldValue(field: any, defaultVal: string = ''): string {
251 | if (!field) return defaultVal;
252 | return (field as HTMLInputElement).value;
253 | }
254 |
255 | /**
256 | * Creates a promise that resolves when the user types the konami code.
257 | */
258 | export function konami(): Promise {
259 | return new Promise((resolve) => {
260 | // Keycodes for: ↑ ↑ ↓ ↓ ← → ← → B A
261 | const expectedPattern = '38384040373937396665';
262 | let rollingPattern = '';
263 |
264 | const listener = (event: KeyboardEvent) => {
265 | rollingPattern += event.keyCode;
266 | rollingPattern = rollingPattern.slice(-expectedPattern.length);
267 | if (rollingPattern === expectedPattern) {
268 | window.removeEventListener('keydown', listener);
269 | resolve();
270 | }
271 | };
272 |
273 | window.addEventListener('keydown', listener);
274 | });
275 | }
276 |
277 | interface TransitionOptions {
278 | from?: number;
279 | to?: number;
280 | duration?: number;
281 | easing?: string;
282 | }
283 |
284 | export async function transitionHeight(el: HTMLElement, opts: TransitionOptions): Promise {
285 | const {
286 | from = el.getBoundingClientRect().height,
287 | to = el.getBoundingClientRect().height,
288 | duration = 1000,
289 | easing = 'ease-in-out',
290 | } = opts;
291 |
292 | if (from === to || duration === 0) {
293 | el.style.height = to + 'px';
294 | return;
295 | }
296 |
297 | el.style.height = from + 'px';
298 | // Force a style calc so the browser picks up the start value.
299 | getComputedStyle(el).transform;
300 | el.style.transition = `height ${duration}ms ${easing}`;
301 | el.style.height = to + 'px';
302 |
303 | return new Promise((resolve) => {
304 | const listener = (event: Event) => {
305 | if (event.target !== el) return;
306 | el.style.transition = '';
307 | el.removeEventListener('transitionend', listener);
308 | el.removeEventListener('transitioncancel', listener);
309 | resolve();
310 | };
311 |
312 | el.addEventListener('transitionend', listener);
313 | el.addEventListener('transitioncancel', listener);
314 | });
315 | }
316 |
317 | /**
318 | * Simple event listener that prevents the default.
319 | */
320 | export function preventDefault(event: Event) {
321 | event.preventDefault();
322 | }
323 |
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2020 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 | * use this file except in compliance with the License. You may obtain a copy of
6 | * the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 | * License for the specific language governing permissions and limitations under
14 | * the License.
15 | */
16 |
17 | import { resolve } from 'path';
18 | import OptimizePlugin from '..';
19 | import { webpacks } from './_webpacks';
20 | import { runWebpack, printSizes } from './_util';
21 |
22 | jest.setTimeout(30000);
23 |
24 | // easier for debugging
25 | const concurrency = false;
26 |
27 | describe('optimize-plugin', () => {
28 | const $console = {
29 | log: console.log,
30 | warn: console.warn,
31 | info: console.info
32 | };
33 |
34 | beforeAll(() => {
35 | console.warn = () => 0;
36 | console.log = () => 0;
37 | console.info = () => 0;
38 | });
39 |
40 | afterAll(() => {
41 | console.warn = $console.warn;
42 | console.log = $console.log;
43 | console.info = $console.info;
44 | });
45 |
46 | describe.each(webpacks)('webpack %i', (_, webpack, tsLoader) => {
47 | test('exports a class', () => {
48 | expect(OptimizePlugin).toBeInstanceOf(Function);
49 | expect(OptimizePlugin.prototype).toHaveProperty('apply', expect.any(Function));
50 | });
51 |
52 | test('it works', async () => {
53 | const stats = await runWebpack(webpack, 'basic', {
54 | module: {
55 | rules: [
56 | {
57 | test: /\.jsx?$/,
58 | loader: resolve(__dirname, '_sucrase-loader.js')
59 | }
60 | ]
61 | },
62 | plugins: [
63 | new OptimizePlugin({ concurrency }, webpack)
64 | ]
65 | }, $console);
66 |
67 | const assetNames = Object.keys(stats.assets);
68 | expect(assetNames).toHaveLength(3);
69 |
70 | expect(assetNames).toContain('main.js');
71 | expect(assetNames).toContain('main.legacy.js');
72 | expect(assetNames).toContain('polyfills.legacy.js');
73 |
74 | const main = stats.assets['main.js'];
75 | expect(main).toMatch(/hello world/g);
76 |
77 | const legacy = stats.assets['main.legacy.js'];
78 | expect(legacy).toMatch(/hello world/g);
79 |
80 | const polyfills = stats.assets['polyfills.legacy.js'];
81 | expect(polyfills).toMatch(/Object\.defineProperty/g);
82 | expect(polyfills).not.toMatch(/require\(/g);
83 |
84 | await printSizes(stats.assets, '"it works"', $console);
85 | });
86 |
87 | test('code splitting', async () => {
88 | const stats = await runWebpack(webpack, 'code-splitting', {
89 | output: {
90 | chunkFilename: '[name].js'
91 | },
92 | plugins: [
93 | new OptimizePlugin({ concurrency }, webpack)
94 | ]
95 | }, $console);
96 |
97 | const assetNames = Object.keys(stats.assets);
98 | expect(assetNames).toHaveLength(9);
99 |
100 | expect(assetNames).toContain('main.js');
101 | expect(assetNames).toContain('main.legacy.js');
102 | expect(assetNames).toContain('home.js');
103 | expect(assetNames).toContain('home.legacy.js');
104 | expect(assetNames).toContain('about.js');
105 | expect(assetNames).toContain('about.legacy.js');
106 | expect(assetNames).toContain('profile.js');
107 | expect(assetNames).toContain('profile.legacy.js');
108 | expect(assetNames).toContain('polyfills.legacy.js');
109 |
110 | const main = stats.assets['main.js'];
111 | expect(main).toMatch(/hello world/g);
112 |
113 | const legacy = stats.assets['main.legacy.js'];
114 | expect(legacy).toMatch(/hello world/g);
115 |
116 | const polyfills = stats.assets['polyfills.legacy.js'];
117 | expect(polyfills).toMatch(/Object\.defineProperty/g);
118 | expect(polyfills).not.toMatch(/require\(/g);
119 |
120 | await printSizes(stats.assets, 'code splitting', $console);
121 | });
122 |
123 | describe('TypeScript Support', () => {
124 | test('using ts-loader', async () => {
125 | const stats = await runWebpack(webpack, 'typescript', {
126 | resolve: {
127 | extensions: ['.ts', '.js']
128 | },
129 | module: {
130 | rules: [
131 | {
132 | test: /\.tsx?$/,
133 | loader: tsLoader,
134 | options: {
135 | transpileOnly: true
136 | }
137 | }
138 | ]
139 | },
140 | plugins: [
141 | new OptimizePlugin({ concurrency }, webpack)
142 | ]
143 | }, $console);
144 |
145 | const assetNames = Object.keys(stats.assets);
146 | expect(assetNames).toHaveLength(3);
147 |
148 | expect(assetNames).toContain('main.js');
149 | expect(assetNames).toContain('main.legacy.js');
150 | expect(assetNames).toContain('polyfills.legacy.js');
151 |
152 | const main = stats.assets['main.js'];
153 | expect(main).toMatch(/pinch-zoom/g);
154 |
155 | const legacy = stats.assets['main.legacy.js'];
156 | expect(legacy).toMatch(/pinch-zoom/g);
157 |
158 | const polyfills = stats.assets['polyfills.legacy.js'];
159 | expect(polyfills).toMatch(/Object\.defineProperty/g);
160 | expect(polyfills).not.toMatch(/require\(/g);
161 |
162 | await printSizes(stats.assets, 'typescript support', $console);
163 | });
164 |
165 | test('using Sucrase', async () => {
166 | const stats = await runWebpack(webpack, 'typescript', {
167 | resolve: {
168 | extensions: ['.ts', '.js']
169 | },
170 | module: {
171 | rules: [
172 | {
173 | test: /\.tsx?$/,
174 | loader: resolve(__dirname, '_sucrase-loader.js')
175 | }
176 | ]
177 | },
178 | plugins: [
179 | new OptimizePlugin({ concurrency }, webpack)
180 | ]
181 | }, $console);
182 |
183 | const assetNames = Object.keys(stats.assets);
184 | expect(assetNames).toHaveLength(3);
185 |
186 | expect(assetNames).toContain('main.js');
187 | expect(assetNames).toContain('main.legacy.js');
188 | expect(assetNames).toContain('polyfills.legacy.js');
189 |
190 | const main = stats.assets['main.js'];
191 | expect(main).toMatch(/pinch-zoom/g);
192 |
193 | const legacy = stats.assets['main.legacy.js'];
194 | expect(legacy).toMatch(/pinch-zoom/g);
195 |
196 | const polyfills = stats.assets['polyfills.legacy.js'];
197 | expect(polyfills).toMatch(/Object\.defineProperty/g);
198 | expect(polyfills).not.toMatch(/require\(/g);
199 |
200 | await printSizes(stats.assets, 'sucrase', $console);
201 | });
202 | });
203 | });
204 | });
205 |
--------------------------------------------------------------------------------
/test/webpacks/4/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "webpack-4",
3 | "version": "1.0.0",
4 | "dependencies": {
5 | "ts-loader": "^8.3.0",
6 | "webpack": "^4.46.0"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------