diff --git a/package-lock.json b/package-lock.json index f9f25eaf..6277801a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,14 @@ "browserslist": "^4.12.0", "invariant": "^2.2.4", "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "@babel/core": { @@ -78,6 +86,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true } } }, @@ -122,6 +136,14 @@ "invariant": "^2.2.4", "levenary": "^1.1.1", "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "@babel/helper-create-class-features-plugin": { @@ -999,6 +1021,14 @@ "invariant": "^2.2.2", "levenary": "^1.1.1", "semver": "^5.5.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "@babel/preset-modules": { @@ -4823,6 +4853,14 @@ "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "crypto-browserify": { @@ -5524,6 +5562,12 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true } } }, @@ -10551,6 +10595,14 @@ "requires": { "pify": "^4.0.1", "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "makeerror": { @@ -11052,6 +11104,12 @@ "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", "dev": true }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -11218,6 +11276,14 @@ "resolve": "^1.10.0", "semver": "2 || 3 || 4 || 5", "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "normalize-path": { @@ -13293,10 +13359,9 @@ } }, "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" }, "semver-compare": { "version": "1.0.0", diff --git a/package.json b/package.json index d5c331c4..111b8f3b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "dependencies": { "cosmiconfig": "^7.0.0", "loader-utils": "^2.0.0", - "schema-utils": "^2.7.1" + "schema-utils": "^2.7.1", + "semver": "^7.3.2" }, "devDependencies": { "@babel/cli": "^7.11.6", diff --git a/src/index.js b/src/index.js index 1b575642..40e7d9f1 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,8 @@ import { getOptions } from 'loader-utils'; import validateOptions from 'schema-utils'; import postcss from 'postcss'; +import { satisfies } from 'semver'; +import postcssPackage from 'postcss/package.json'; import Warning from './Warning'; import SyntaxError from './Error'; @@ -23,11 +25,12 @@ import { * * @param {String} content Source * @param {Object} sourceMap Source Map + * @param {Object} meta Meta * * @return {callback} callback Result */ -export default async function loader(content, sourceMap) { +export default async function loader(content, sourceMap, meta) { const options = getOptions(this); validateOptions(schema, options, { @@ -65,11 +68,6 @@ export default async function loader(content, sourceMap) { options.postcssOptions ); - if (options.execute) { - // eslint-disable-next-line no-param-reassign - content = exec(content, this); - } - if (useSourceMap) { processOptions.map = { inline: false, annotation: false }; @@ -84,10 +82,27 @@ export default async function loader(content, sourceMap) { processOptions.map.prev = sourceMap; } + let root; + + // Reuse PostCSS AST from other loaders + if ( + meta && + meta.ast && + meta.ast.type === 'postcss' && + satisfies(meta.ast.version, `^${postcssPackage.version}`) + ) { + ({ root } = meta.ast); + } + + if (!root && options.execute) { + // eslint-disable-next-line no-param-reassign + content = exec(content, this); + } + let result; try { - result = await postcss(plugins).process(content, processOptions); + result = await postcss(plugins).process(root || content, processOptions); } catch (error) { if (error.file) { this.addDependency(error.file); diff --git a/test/__snapshots__/execute.test.js.snap b/test/__snapshots__/execute.test.js.snap index e0a368bb..141c2a9e 100644 --- a/test/__snapshots__/execute.test.js.snap +++ b/test/__snapshots__/execute.test.js.snap @@ -1,5 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`"execute" option should reuse PostCSS AST with JS styles: css 1`] = ` +"a { + color: green +}" +`; + +exports[`"execute" option should reuse PostCSS AST with JS styles: errors 1`] = `Array []`; + +exports[`"execute" option should reuse PostCSS AST with JS styles: warnings 1`] = `Array []`; + exports[`"execute" option should work with "Boolean" value: css 1`] = ` "a { color: green diff --git a/test/__snapshots__/loader.test.js.snap b/test/__snapshots__/loader.test.js.snap index 96d1e461..8690491b 100644 --- a/test/__snapshots__/loader.test.js.snap +++ b/test/__snapshots__/loader.test.js.snap @@ -106,6 +106,57 @@ Warning ] `; +exports[`loader should reuse PostCSS AST: css 1`] = ` +"a { + color: black; +} + +a { + color: red; +} + +a { + color: green; +} + +a { + color: blue; +} + +.class { + -x-border-color: blue blue *; + -x-color: * #fafafa; +} + +.class-foo { + -z-border-color: blue blue *; + -z-color: * #fafafa; +} + +.phone { + &_title { + width: 500px; + + @media (max-width: 500px) { + width: auto; + } + + body.is_dark & { + color: white; + } + } + + img { + display: block; + } +} +" +`; + +exports[`loader should reuse PostCSS AST: errors 1`] = `Array []`; + +exports[`loader should reuse PostCSS AST: warnings 1`] = `Array []`; + exports[`loader should throw an error on invalid syntax: errors 1`] = ` Array [ "ModuleBuildError: Module build failed (from \`replaced original path\`): diff --git a/test/execute.test.js b/test/execute.test.js index cd59079e..43a82199 100644 --- a/test/execute.test.js +++ b/test/execute.test.js @@ -36,7 +36,6 @@ describe('"execute" option', () => { } ); const stats = await compile(compiler); - const codeFromBundle = getCodeFromBundle('style.exec.js', stats); expect(codeFromBundle.css).toMatchSnapshot('css'); @@ -73,13 +72,51 @@ describe('"execute" option', () => { }, } ); - const stats = await compile(compiler); - const codeFromBundle = getCodeFromBundle('style.js', stats); expect(codeFromBundle.css).toMatchSnapshot('css'); expect(getWarnings(stats)).toMatchSnapshot('warnings'); expect(getErrors(stats)).toMatchSnapshot('errors'); }); + + it('should reuse PostCSS AST with JS styles', async () => { + const spy = jest.fn(); + const compiler = getCompiler( + './jss/exec/index.js', + {}, + { + module: { + rules: [ + { + test: /style\.(exec\.js|js)$/i, + use: [ + { + loader: require.resolve('./helpers/testLoader'), + options: {}, + }, + { + loader: path.resolve(__dirname, '../src'), + options: { + execute: true, + }, + }, + { + loader: require.resolve('./helpers/astLoader'), + options: { spy, execute: true }, + }, + ], + }, + ], + }, + } + ); + const stats = await compile(compiler); + const codeFromBundle = getCodeFromBundle('style.exec.js', stats); + + expect(spy).toHaveBeenCalledTimes(1); + expect(codeFromBundle.css).toMatchSnapshot('css'); + expect(getWarnings(stats)).toMatchSnapshot('warnings'); + expect(getErrors(stats)).toMatchSnapshot('errors'); + }); }); diff --git a/test/helpers/astLoader.js b/test/helpers/astLoader.js new file mode 100644 index 00000000..36b0054b --- /dev/null +++ b/test/helpers/astLoader.js @@ -0,0 +1,46 @@ +import Module from 'module'; + +const postcss = require('postcss'); + +const parentModule = module; + +function exec(code, loaderContext) { + const { resource, context } = loaderContext; + + const module = new Module(resource, parentModule); + + // eslint-disable-next-line no-underscore-dangle + module.paths = Module._nodeModulePaths(context); + module.filename = resource; + + // eslint-disable-next-line no-underscore-dangle + module._compile(code, resource); + + return module.exports; +} + +module.exports = function astLoader(content) { + const callback = this.async(); + const { spy = jest.fn(), execute } = this.query; + + if (execute) { + // eslint-disable-next-line no-param-reassign + content = exec(content, this); + } + + postcss() + .process(content) + .then((result) => { + const ast = { + type: 'postcss', + version: result.processor.version, + root: result.root, + }; + + Object.defineProperty(ast, 'root', { + get: spy.mockReturnValue(result.root), + }); + + callback(null, result.css, result.map, { ast }); + }); +}; diff --git a/test/loader.test.js b/test/loader.test.js index 2ef1bd13..ffbd7a27 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -1,3 +1,5 @@ +import path from 'path'; + import postcss from 'postcss'; import { @@ -12,7 +14,6 @@ describe('loader', () => { it('should work', async () => { const compiler = getCompiler('./css/index.js'); const stats = await compile(compiler); - const codeFromBundle = getCodeFromBundle('style.css', stats); expect(codeFromBundle.css).toMatchSnapshot('css'); @@ -66,7 +67,6 @@ describe('loader', () => { }; const postcssPlugin = postcss.plugin('postcss-assets', plugin); - const compiler = getCompiler('./css/index.js', { postcssOptions: { plugins: [postcssPlugin()], @@ -79,4 +79,41 @@ describe('loader', () => { expect(getWarnings(stats)).toMatchSnapshot('warnings'); expect(getErrors(stats)).toMatchSnapshot('errors'); }); + + it('should reuse PostCSS AST', async () => { + const spy = jest.fn(); + const compiler = getCompiler( + './css/index.js', + {}, + { + module: { + rules: [ + { + test: /\.(css|sss)$/i, + use: [ + { + loader: require.resolve('./helpers/testLoader'), + options: {}, + }, + { + loader: path.resolve(__dirname, '../src'), + }, + { + loader: require.resolve('./helpers/astLoader'), + options: { spy }, + }, + ], + }, + ], + }, + } + ); + const stats = await compile(compiler); + const codeFromBundle = getCodeFromBundle('style.css', stats); + + expect(spy).toHaveBeenCalledTimes(1); + expect(codeFromBundle.css).toMatchSnapshot('css'); + expect(getWarnings(stats)).toMatchSnapshot('warnings'); + expect(getErrors(stats)).toMatchSnapshot('errors'); + }); });