diff --git a/__tests__/fixtures/purge-example.html b/__tests__/fixtures/purge-example.html new file mode 100644 index 000000000..0a4a8e96f --- /dev/null +++ b/__tests__/fixtures/purge-example.html @@ -0,0 +1,22 @@ + +
+ + + + + + + + +
+ + +span.inline-grid.grid-cols-3(class="px-1.5") + .col-span-2 + Hello + .col-span-1.text-center + World! \ No newline at end of file diff --git a/__tests__/purgeUnusedStyles.test.js b/__tests__/purgeUnusedStyles.test.js new file mode 100644 index 000000000..68d71d317 --- /dev/null +++ b/__tests__/purgeUnusedStyles.test.js @@ -0,0 +1,274 @@ +import fs from 'fs' +import path from 'path' +import postcss from 'postcss' +import tailwind from '../src/index' +import defaultConfig from '../stubs/defaultConfig.stub.js' + +const config = { + ...defaultConfig, + theme: { + extend: { + colors: { + 'black!': '#000', + }, + spacing: { + '1.5': '0.375rem', + '(1/2+8)': 'calc(50% + 2rem)', + }, + minHeight: { + '(screen-4)': 'calc(100vh - 1rem)', + }, + fontFamily: { + '%#$@': 'Comic Sans', + }, + }, + }, +} + +test('purges unused classes', () => { + const OLD_NODE_ENV = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], + }), + ]) + .process(input, { from: inputPath }) + .then(result => { + process.env.NODE_ENV = OLD_NODE_ENV + + expect(result.css).not.toContain('.bg-red-600') + expect(result.css).not.toContain('.w-1\\/3') + expect(result.css).not.toContain('.flex') + expect(result.css).not.toContain('.font-sans') + expect(result.css).not.toContain('.text-right') + expect(result.css).not.toContain('.px-4') + expect(result.css).not.toContain('.h-full') + + expect(result.css).toContain('.bg-red-500') + expect(result.css).toContain('.md\\:bg-blue-300') + expect(result.css).toContain('.w-1\\/2') + expect(result.css).toContain('.block') + expect(result.css).toContain('.md\\:flow-root') + expect(result.css).toContain('.h-screen') + expect(result.css).toContain('.min-h-\\(screen-4\\)') + expect(result.css).toContain('.bg-black\\!') + expect(result.css).toContain('.font-\\%\\#\\$\\@') + expect(result.css).toContain('.w-\\(1\\/2\\+8\\)') + expect(result.css).toContain('.inline-grid') + expect(result.css).toContain('.grid-cols-3') + expect(result.css).toContain('.px-1\\.5') + expect(result.css).toContain('.col-span-2') + expect(result.css).toContain('.col-span-1') + expect(result.css).toContain('.text-center') + }) +}) + +test('does not purge except in production', () => { + const OLD_NODE_ENV = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...defaultConfig, + purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], + }), + ]) + .process(input, { from: inputPath }) + .then(result => { + process.env.NODE_ENV = OLD_NODE_ENV + const expected = fs.readFileSync( + path.resolve(`${__dirname}/fixtures/tailwind-output.css`), + 'utf8' + ) + + expect(result.css).toBe(expected) + }) +}) + +test('purges outside of production if explicitly enabled', () => { + const OLD_NODE_ENV = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + purge: { enabled: true, content: [path.resolve(`${__dirname}/fixtures/**/*.html`)] }, + }), + ]) + .process(input, { from: inputPath }) + .then(result => { + process.env.NODE_ENV = OLD_NODE_ENV + + expect(result.css).not.toContain('.bg-red-600') + expect(result.css).not.toContain('.w-1\\/3') + expect(result.css).not.toContain('.flex') + expect(result.css).not.toContain('.font-sans') + expect(result.css).not.toContain('.text-right') + expect(result.css).not.toContain('.px-4') + expect(result.css).not.toContain('.h-full') + + expect(result.css).toContain('.bg-red-500') + expect(result.css).toContain('.md\\:bg-blue-300') + expect(result.css).toContain('.w-1\\/2') + expect(result.css).toContain('.block') + expect(result.css).toContain('.md\\:flow-root') + expect(result.css).toContain('.h-screen') + expect(result.css).toContain('.min-h-\\(screen-4\\)') + expect(result.css).toContain('.bg-black\\!') + expect(result.css).toContain('.font-\\%\\#\\$\\@') + expect(result.css).toContain('.w-\\(1\\/2\\+8\\)') + expect(result.css).toContain('.inline-grid') + expect(result.css).toContain('.grid-cols-3') + expect(result.css).toContain('.px-1\\.5') + expect(result.css).toContain('.col-span-2') + expect(result.css).toContain('.col-span-1') + expect(result.css).toContain('.text-center') + }) +}) + +test('purgecss options can be provided', () => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + purge: { + enabled: true, + options: { + content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], + whitelist: ['md:bg-green-500'], + }, + }, + }), + ]) + .process(input, { from: inputPath }) + .then(result => { + expect(result.css).not.toContain('.bg-red-600') + expect(result.css).not.toContain('.w-1\\/3') + expect(result.css).not.toContain('.flex') + expect(result.css).not.toContain('.font-sans') + expect(result.css).not.toContain('.text-right') + expect(result.css).not.toContain('.px-4') + expect(result.css).not.toContain('.h-full') + + expect(result.css).toContain('.md\\:bg-green-500') + expect(result.css).toContain('.bg-red-500') + expect(result.css).toContain('.md\\:bg-blue-300') + expect(result.css).toContain('.w-1\\/2') + expect(result.css).toContain('.block') + expect(result.css).toContain('.md\\:flow-root') + expect(result.css).toContain('.h-screen') + expect(result.css).toContain('.min-h-\\(screen-4\\)') + expect(result.css).toContain('.bg-black\\!') + expect(result.css).toContain('.font-\\%\\#\\$\\@') + expect(result.css).toContain('.w-\\(1\\/2\\+8\\)') + expect(result.css).toContain('.inline-grid') + expect(result.css).toContain('.grid-cols-3') + expect(result.css).toContain('.px-1\\.5') + expect(result.css).toContain('.col-span-2') + expect(result.css).toContain('.col-span-1') + expect(result.css).toContain('.text-center') + }) +}) + +test('can purge all CSS, not just Tailwind classes', () => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + purge: { + enabled: true, + mode: 'all', + content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], + }, + }), + function(css) { + // Remove any comments to avoid accidentally asserting against them + // instead of against real CSS rules. + css.walkComments(c => c.remove()) + }, + ]) + .process(input, { from: inputPath }) + .then(result => { + expect(result.css).not.toContain('html') + expect(result.css).not.toContain('body') + expect(result.css).not.toContain('button') + expect(result.css).not.toContain('legend') + expect(result.css).not.toContain('progress') + + expect(result.css).toContain('.bg-red-500') + expect(result.css).toContain('.md\\:bg-blue-300') + expect(result.css).toContain('.w-1\\/2') + expect(result.css).toContain('.block') + expect(result.css).toContain('.md\\:flow-root') + expect(result.css).toContain('.h-screen') + expect(result.css).toContain('.min-h-\\(screen-4\\)') + expect(result.css).toContain('.bg-black\\!') + expect(result.css).toContain('.font-\\%\\#\\$\\@') + expect(result.css).toContain('.w-\\(1\\/2\\+8\\)') + expect(result.css).toContain('.inline-grid') + expect(result.css).toContain('.grid-cols-3') + expect(result.css).toContain('.px-1\\.5') + expect(result.css).toContain('.col-span-2') + expect(result.css).toContain('.col-span-1') + expect(result.css).toContain('.text-center') + }) +}) + +test('the `conservative` mode can be set explicitly', () => { + const OLD_NODE_ENV = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + purge: { + mode: 'conservative', + content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], + }, + }), + ]) + .process(input, { from: inputPath }) + .then(result => { + process.env.NODE_ENV = OLD_NODE_ENV + + expect(result.css).not.toContain('.bg-red-600') + expect(result.css).not.toContain('.w-1\\/3') + expect(result.css).not.toContain('.flex') + expect(result.css).not.toContain('.font-sans') + expect(result.css).not.toContain('.text-right') + expect(result.css).not.toContain('.px-4') + expect(result.css).not.toContain('.h-full') + + expect(result.css).toContain('.bg-red-500') + expect(result.css).toContain('.md\\:bg-blue-300') + expect(result.css).toContain('.w-1\\/2') + expect(result.css).toContain('.block') + expect(result.css).toContain('.md\\:flow-root') + expect(result.css).toContain('.h-screen') + expect(result.css).toContain('.min-h-\\(screen-4\\)') + expect(result.css).toContain('.bg-black\\!') + expect(result.css).toContain('.font-\\%\\#\\$\\@') + expect(result.css).toContain('.w-\\(1\\/2\\+8\\)') + expect(result.css).toContain('.inline-grid') + expect(result.css).toContain('.grid-cols-3') + expect(result.css).toContain('.px-1\\.5') + expect(result.css).toContain('.col-span-2') + expect(result.css).toContain('.col-span-1') + expect(result.css).toContain('.text-center') + }) +}) diff --git a/__tests__/responsiveAtRule.test.js b/__tests__/responsiveAtRule.test.js index 63c6319a4..a59dff31f 100644 --- a/__tests__/responsiveAtRule.test.js +++ b/__tests__/responsiveAtRule.test.js @@ -12,6 +12,8 @@ test('it can generate responsive variants', () => { .banana { color: yellow; } .chocolate { color: brown; } } + + @tailwind screens; ` const output = ` @@ -52,6 +54,8 @@ test('it can generate responsive variants with a custom separator', () => { .banana { color: yellow; } .chocolate { color: brown; } } + + @tailwind screens; ` const output = ` @@ -92,6 +96,8 @@ test('it can generate responsive variants when classes have non-standard charact .hover\\:banana { color: yellow; } .chocolate-2\\.5 { color: brown; } } + + @tailwind screens; ` const output = ` @@ -137,6 +143,8 @@ test('responsive variants are grouped', () => { @responsive { .chocolate { color: brown; } } + + @tailwind screens; ` const output = ` @@ -181,6 +189,8 @@ test('it can generate responsive variants for nested at-rules', () => { .grid\\:banana { color: blue; } } } + + @tailwind screens; ` const output = ` @@ -244,6 +254,8 @@ test('it can generate responsive variants for deeply nested at-rules', () => { } } } + + @tailwind screens; ` const output = ` @@ -307,6 +319,8 @@ test('screen prefix is only applied to the last class in a selector', () => { @responsive { .banana li * .sandwich #foo > div { color: yellow; } } + + @tailwind screens; ` const output = ` @@ -342,6 +356,8 @@ test('responsive variants are generated for all selectors in a rule', () => { @responsive { .foo, .bar { color: yellow; } } + + @tailwind screens; ` const output = ` @@ -377,6 +393,8 @@ test('selectors with no classes cannot be made responsive', () => { @responsive { div { color: yellow; } } + + @tailwind screens; ` expect.assertions(1) return run(input, { @@ -398,6 +416,8 @@ test('all selectors in a rule must contain classes', () => { @responsive { .foo, div { color: yellow; } } + + @tailwind screens; ` expect.assertions(1) return run(input, { diff --git a/__tests__/sanity.test.js b/__tests__/sanity.test.js index b27f1aae3..bde1251f3 100644 --- a/__tests__/sanity.test.js +++ b/__tests__/sanity.test.js @@ -77,3 +77,19 @@ it('generates the right CSS with implicit screen utilities', () => { expect(result.css).toBe(expected) }) }) + +it('generates the right CSS when "important" is enabled', () => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([tailwind({ ...config, important: true })]) + .process(input, { from: inputPath }) + .then(result => { + const expected = fs.readFileSync( + path.resolve(`${__dirname}/fixtures/tailwind-output-important.css`), + 'utf8' + ) + + expect(result.css).toBe(expected) + }) +}) diff --git a/package.json b/package.json index f89bd7319..b5bc5f105 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "rimraf": "^3.0.0" }, "dependencies": { + "@fullhuman/postcss-purgecss": "^2.1.2", "autoprefixer": "^9.4.5", "bytes": "^3.0.0", "chalk": "^4.0.0", diff --git a/src/lib/purgeUnusedStyles.js b/src/lib/purgeUnusedStyles.js new file mode 100644 index 000000000..feab57129 --- /dev/null +++ b/src/lib/purgeUnusedStyles.js @@ -0,0 +1,73 @@ +import _ from 'lodash' +import postcss from 'postcss' +import purgecss from '@fullhuman/postcss-purgecss' + +function removeTailwindComments(css) { + css.walkComments(comment => { + switch (comment.text.trim()) { + case 'tailwind start components': + case 'tailwind start utilities': + case 'tailwind start screens': + case 'tailwind end components': + case 'tailwind end utilities': + case 'tailwind end screens': + comment.remove() + break + default: + break + } + }) +} + +export default function purgeUnusedUtilities(config) { + const purgeEnabled = + _.get(config, 'purge.enabled', false) === true || + (config.purge !== undefined && process.env.NODE_ENV === 'production') + + if (!purgeEnabled) { + return removeTailwindComments + } + + return postcss([ + function(css) { + const mode = _.get(config, 'purge.mode', 'conservative') + + if (mode === 'conservative') { + css.prepend(postcss.comment({ text: 'purgecss start ignore' })) + css.append(postcss.comment({ text: 'purgecss end ignore' })) + + css.walkComments(comment => { + switch (comment.text.trim()) { + case 'tailwind start components': + case 'tailwind start utilities': + case 'tailwind start screens': + comment.text = 'purgecss end ignore' + break + case 'tailwind end components': + case 'tailwind end utilities': + case 'tailwind end screens': + comment.text = 'purgecss start ignore' + break + default: + break + } + }) + } else if (mode === 'all') { + removeTailwindComments(css) + } + }, + purgecss({ + content: Array.isArray(config.purge) ? config.purge : config.purge.content, + defaultExtractor: content => { + // Capture as liberally as possible, including things like `h-(screen-1.5)` + const broadMatches = content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || [] + + // Capture classes within other delimiters like .block(class="w-1/2") in Pug + const innerMatches = content.match(/[^<>"'`\s.()]*[^<>"'`\s.():]/g) || [] + + return broadMatches.concat(innerMatches) + }, + ...config.purge.options, + }), + ]) +} diff --git a/src/lib/substituteResponsiveAtRules.js b/src/lib/substituteResponsiveAtRules.js index 04e9b687f..7436d323e 100644 --- a/src/lib/substituteResponsiveAtRules.js +++ b/src/lib/substituteResponsiveAtRules.js @@ -43,22 +43,16 @@ export default function(config) { const hasScreenRules = finalRules.some(i => i.nodes.length !== 0) - if (!hasScreenRules) { - return - } - - let includesScreensExplicitly = false - css.walkAtRules('tailwind', atRule => { - if (atRule.params === 'screens') { - atRule.replaceWith(finalRules) - includesScreensExplicitly = true + if (atRule.params !== 'screens') { + return } - }) - if (!includesScreensExplicitly) { - css.append(finalRules) - return - } + if (hasScreenRules) { + atRule.before(finalRules) + } + + atRule.remove() + }) } } diff --git a/src/lib/substituteTailwindAtRules.js b/src/lib/substituteTailwindAtRules.js index d82a28692..1607f388b 100644 --- a/src/lib/substituteTailwindAtRules.js +++ b/src/lib/substituteTailwindAtRules.js @@ -40,6 +40,8 @@ export default function( } }) + let includesScreensExplicitly = false + css.walkAtRules('tailwind', atRule => { if (atRule.params === 'preflight') { // prettier-ignore @@ -52,14 +54,32 @@ export default function( } if (atRule.params === 'components') { + atRule.before(postcss.comment({ text: 'tailwind start components' })) atRule.before(updateSource(pluginComponents, atRule.source)) + atRule.after(postcss.comment({ text: 'tailwind end components' })) atRule.remove() } if (atRule.params === 'utilities') { + atRule.before(postcss.comment({ text: 'tailwind start utilities' })) atRule.before(updateSource(pluginUtilities, atRule.source)) + atRule.after(postcss.comment({ text: 'tailwind end utilities' })) atRule.remove() } + + if (atRule.params === 'screens') { + includesScreensExplicitly = true + atRule.before(postcss.comment({ text: 'tailwind start screens' })) + atRule.after(postcss.comment({ text: 'tailwind end screens' })) + } }) + + if (!includesScreensExplicitly) { + css.append([ + postcss.comment({ text: 'tailwind start screens' }), + postcss.atRule({ name: 'tailwind', params: 'screens' }), + postcss.comment({ text: 'tailwind end screens' }), + ]) + } } } diff --git a/src/processTailwindFeatures.js b/src/processTailwindFeatures.js index 61fecaf12..294a8c5d7 100644 --- a/src/processTailwindFeatures.js +++ b/src/processTailwindFeatures.js @@ -7,6 +7,7 @@ import substituteVariantsAtRules from './lib/substituteVariantsAtRules' import substituteResponsiveAtRules from './lib/substituteResponsiveAtRules' import substituteScreenAtRules from './lib/substituteScreenAtRules' import substituteClassApplyAtRules from './lib/substituteClassApplyAtRules' +import purgeUnusedStyles from './lib/purgeUnusedStyles' import corePlugins from './corePlugins' import processPlugins from './util/processPlugins' @@ -23,6 +24,7 @@ export default function(getConfig) { substituteResponsiveAtRules(config), substituteScreenAtRules(config), substituteClassApplyAtRules(config, processedPlugins.utilities), + purgeUnusedStyles(config), ]).process(css, { from: _.get(css, 'source.input.file') }) } } diff --git a/yarn.lock b/yarn.lock index 238bd50b8..e2d50ac93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -845,6 +845,14 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@fullhuman/postcss-purgecss@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@fullhuman/postcss-purgecss/-/postcss-purgecss-2.1.2.tgz#8fe4d4ae2b58214b5452cb490a31c7146517442f" + integrity sha512-Jf34YVBK9GtXTblpu0svNUJdA7rTQoRMz+yEJe6mwTnXDIGipWLzaX/VgU/x6IPC6WvU5SY/XlawwqhxoyFPTg== + dependencies: + postcss "7.0.27" + purgecss "^2.1.2" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" @@ -1715,6 +1723,11 @@ commander@^4.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.0.1.tgz#b67622721785993182e807f4883633e6401ba53c" integrity sha512-IPF4ouhCP+qdlcmCedhxX4xiGBPyigb8v5NeUp+0LyhwLgxMqyp3S0vl7TAPfS/hiP7FC3caI/PB9lTmP8r1NA== +commander@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" + integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -4266,6 +4279,15 @@ postcss-value-parser@^4.0.3: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz#651ff4593aa9eda8d5d0d66593a2417aeaeb325d" integrity sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg== +postcss@7.0.27, postcss@^7.0.11, postcss@^7.0.18, postcss@^7.0.21, postcss@^7.0.27: + version "7.0.27" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.27.tgz#cc67cdc6b0daa375105b7c424a85567345fc54d9" + integrity sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + postcss@^6.0.9: version "6.0.23" resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" @@ -4275,15 +4297,6 @@ postcss@^6.0.9: source-map "^0.6.1" supports-color "^5.4.0" -postcss@^7.0.11, postcss@^7.0.18, postcss@^7.0.21, postcss@^7.0.27: - version "7.0.27" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.27.tgz#cc67cdc6b0daa375105b7c424a85567345fc54d9" - integrity sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ== - dependencies: - chalk "^2.4.2" - source-map "^0.6.1" - supports-color "^6.1.0" - prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -4362,6 +4375,16 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +purgecss@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/purgecss/-/purgecss-2.1.2.tgz#96f666d04c56705208aaa1a544b5f22e13828955" + integrity sha512-5oDBxiT9VonwKmEMohPFRFZrj8fdSVKxHPwq7G5Rx/2pXicZFJu+D4m5bb3NuV0sSK3ooNxq5jFIwwHzifP5FA== + dependencies: + commander "^5.0.0" + glob "^7.0.0" + postcss "7.0.27" + postcss-selector-parser "^6.0.2" + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"