From bd03cb3114c5ea20cbb928aa5ffb89b7ab361dd1 Mon Sep 17 00:00:00 2001 From: Jakub Pawlowicz Date: Mon, 4 May 2015 15:54:45 +0100 Subject: [PATCH] Adds semantic merging (off by default). The main obstacle on reordering and merging declarations is a fact that arbitrary classes can be applied to an element and at CSS level we don't know if that's the case or not. However with semantic merging mode on we trust CSS author knows what she is doing. This commit is just a start (see #588) of a journey. It is and will always be turned off by default as it requires certain effort from stylesheets' author. So far plain class selectors and some BEM basics are supported. --- README.md | 2 ++ bin/cleancss | 2 ++ lib/clean.js | 1 + lib/selectors/optimizers/advanced.js | 26 ++++++++++++++++++- test/binary-test.js | 12 +++++++++ test/module-test.js | 16 ++++++++++++ test/selectors/optimizer-test.js | 37 ++++++++++++++++++++++++++++ 7 files changed, 95 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c454813..97b4fc0c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ cleancss [options] source-file, [source-file, ...] -c, --compatibility [ie7|ie8] Force compatibility mode (see Readme for advanced examples) --source-map Enables building input's source map --source-map-inline-sources Enables inlining sources inside source map's `sourcesContent` field +--semantic-merging Enables semantic merging mode by assuming BEM-like content (warning, this may break your styling!) -d, --debug Shows debug information (minification time & compression efficiency) ``` @@ -128,6 +129,7 @@ CleanCSS constructor accepts a hash as a parameter, i.e., * `restructuring` - set to false to disable restructuring in advanced optimizations * `root` - path to **resolve** absolute `@import` rules and **rebase** relative URLs * `roundingPrecision` - rounding precision; defaults to `2`; `-1` disables rounding +* `semanticMerging` - set to true to enable semantic merging mode which assumes BEM-like content (default is false as it's highly likely this will break your stylesheets - **use with caution**!) * `shorthandCompacting` - set to false to skip shorthand compacting (default is true unless sourceMap is set when it's false) * `sourceMap` - exposes source map under `sourceMap` property, e.g. `new CleanCSS().minify(source).sourceMap` (default is false) If input styles are a product of CSS preprocessor (LESS, SASS) an input source map can be passed as a string. diff --git a/bin/cleancss b/bin/cleancss index 58983193..bb224624 100755 --- a/bin/cleancss +++ b/bin/cleancss @@ -31,6 +31,7 @@ commands .option('-c, --compatibility [ie7|ie8]', 'Force compatibility mode (see Readme for advanced examples)') .option('--source-map', 'Enables building input\'s source map') .option('--source-map-inline-sources', 'Enables inlining sources inside source maps') + .option('--semantic-merging', 'Enables unsafe mode by assuming BEM-like semantic stylesheets (warning, this may break your styling!)') .option('-t, --timeout [seconds]', 'Per connection timeout when fetching remote @imports (defaults to 5 seconds)') .option('-d, --debug', 'Shows debug information (minification time & compression efficiency)'); @@ -72,6 +73,7 @@ var options = { restructuring: commands.skipRestructuring ? false : true, root: commands.root, roundingPrecision: commands.roundingPrecision, + semanticMerging: commands.semanticMerging ? true : false, shorthandCompacting: commands.skipShorthandCompacting ? false : true, sourceMap: commands.sourceMap, sourceMapInlineSources: commands.sourceMapInlineSources, diff --git a/lib/clean.js b/lib/clean.js index 53470994..9f1e766f 100644 --- a/lib/clean.js +++ b/lib/clean.js @@ -49,6 +49,7 @@ var CleanCSS = module.exports = function CleanCSS(options) { restructuring: undefined === options.restructuring ? true : !!options.restructuring, root: options.root || process.cwd(), roundingPrecision: options.roundingPrecision, + semanticMerging: undefined === options.semanticMerging ? false : !!options.semanticMerging, shorthandCompacting: undefined === options.shorthandCompacting ? true : !!options.shorthandCompacting, sourceMap: options.sourceMap, sourceMapInlineSources: !!options.sourceMapInlineSources, diff --git a/lib/selectors/optimizers/advanced.js b/lib/selectors/optimizers/advanced.js index 89ce48cf..f4f9f51e 100644 --- a/lib/selectors/optimizers/advanced.js +++ b/lib/selectors/optimizers/advanced.js @@ -312,6 +312,27 @@ AdvancedOptimizer.prototype.mergeNonAdjacentBySelector = function (tokens) { } }; +function isBemElement(token) { + var asString = stringifySelectors(token[1]); + return asString.indexOf('__') > -1 || asString.indexOf('--') > -1; +} + +function withoutModifier(selector) { + return selector.replace(/--[^ ,>\+~:]+/g, ''); +} + +function removeAnyUnsafeElements(left, candidates) { + var leftSelector = withoutModifier(stringifySelectors(left[1])); + + for (var body in candidates) { + var right = candidates[body]; + var rightSelector = withoutModifier(stringifySelectors(right[1])); + + if (rightSelector.indexOf(leftSelector) > -1 || leftSelector.indexOf(rightSelector) > -1) + delete candidates[body]; + } +} + AdvancedOptimizer.prototype.mergeNonAdjacentByBody = function (tokens) { var candidates = {}; var adjacentSpace = this.options.compatibility.selectors.adjacentSpace; @@ -321,9 +342,12 @@ AdvancedOptimizer.prototype.mergeNonAdjacentByBody = function (tokens) { if (token[0] != 'selector') continue; - if (token[2].length > 0 && unsafeSelector(stringifySelectors(token[1]))) + if (token[2].length > 0 && (!this.options.semanticMerging && unsafeSelector(stringifySelectors(token[1])))) candidates = {}; + if (token[2].length > 0 && this.options.semanticMerging && isBemElement(token)) + removeAnyUnsafeElements(token, candidates); + var oldToken = candidates[stringifyBody(token[2])]; if (oldToken && !this.isSpecial(stringifySelectors(token[1])) && !this.isSpecial(stringifySelectors(oldToken[1]))) { token[1] = CleanUp.selectors(oldToken[1].concat(token[1]), false, adjacentSpace); diff --git a/test/binary-test.js b/test/binary-test.js index 415f87b4..88ab37b5 100644 --- a/test/binary-test.js +++ b/test/binary-test.js @@ -498,5 +498,17 @@ exports.commandsSuite = vows.describe('binary commands').addBatch({ deleteFile('import-inline.min.css.map'); } }) + }, + 'semantic merging': { + 'disabled': pipedContext('.a{margin:0}.b{margin:10px;padding:0}.c{margin:0}', '', { + 'should output right data': function(error, stdout) { + assert.equal(stdout, '.a{margin:0}.b{margin:10px;padding:0}.c{margin:0}'); + } + }), + 'enabled': pipedContext('.a{margin:0}.b{margin:10px;padding:0}.c{margin:0}', '--semantic-merging', { + 'should output right data': function(error, stdout) { + assert.equal(stdout, '.a,.c{margin:0}.b{margin:10px;padding:0}'); + } + }) } }); diff --git a/test/module-test.js b/test/module-test.js index 2c1f64dc..5bdd7c4b 100644 --- a/test/module-test.js +++ b/test/module-test.js @@ -364,6 +364,22 @@ vows.describe('module tests').addBatch({ 'gets right output': function (minified) { assert.equal(minified.styles, 'div{margin-top:0}.one{margin:0}.two{display:block;margin-top:0}'); } + }, + 'semantic merging - off': { + 'topic': function () { + return new CleanCSS().minify('.a{margin:0}.b{margin:10px;padding:0}.c{margin:0}'); + }, + 'gets right output': function (minified) { + assert.equal(minified.styles, '.a{margin:0}.b{margin:10px;padding:0}.c{margin:0}'); + } + }, + 'semantic merging - on': { + 'topic': function () { + return new CleanCSS({ semanticMerging: true }).minify('.a{margin:0}.b{margin:10px;padding:0}.c{margin:0}'); + }, + 'gets right output': function (minified) { + assert.equal(minified.styles, '.a,.c{margin:0}.b{margin:10px;padding:0}'); + } } }, 'source map': { diff --git a/test/selectors/optimizer-test.js b/test/selectors/optimizer-test.js index 4c5a9e35..d2c2c08a 100644 --- a/test/selectors/optimizer-test.js +++ b/test/selectors/optimizer-test.js @@ -235,6 +235,43 @@ vows.describe(SelectorsOptimizer) ] }, { advanced: true }) ) + .addBatch( + optimizerContext('selectors - semantic merging mode', { + 'simple': [ + '.a{color:red}.b{color:#000}.c{color:red}', + '.a,.c{color:red}.b{color:#000}' + ], + 'BEM - modifiers #1': [ + '.block{color:red}.block__element{color:#000}.block__element--modifier{color:red}', + '.block{color:red}.block__element{color:#000}.block__element--modifier{color:red}' + ], + 'BEM - modifiers #2': [ + '.block1{color:red}.block1__element,.block2{color:#000}.block1__element--modifier{color:red}', + '.block1{color:red}.block1__element,.block2{color:#000}.block1__element--modifier{color:red}' + ], + 'BEM - modifiers #3': [ + '.block1{color:red}.block1--modifier,.block2{color:#000}.block1--another-modifier{color:red}', + '.block1{color:red}.block1--modifier,.block2{color:#000}.block1--another-modifier{color:red}' + ], + 'BEM - tail merging': [ + '.block1{color:red}.block1__element{color:#000}.block1__element--modifier{color:red}a{color:red}.block2__element--modifier{color:red}', + '.block1{color:red}.block1__element{color:#000}.block1__element--modifier,.block2__element--modifier,a{color:red}' + ], + 'BEM - two blocks #1': [ + '.block1__element{color:#000}.block2{color:red}.block2__element{color:#000}.block2__element--modifier{color:red}', + '.block1__element,.block2__element{color:#000}.block2,.block2__element--modifier{color:red}' + ], + 'BEM - two blocks #2': [ + '.block1__element{color:#000}.block1__element--modifier{color:red}.block2{color:red}.block2__element{color:#000}.block2__element--modifier{color:red}', + '.block1__element,.block2__element{color:#000}.block1__element--modifier,.block2,.block2__element--modifier{color:red}' + ], + 'BEM - complex traversing #1': [ + '.block1__element{color:#000}.block1__element--modifier{color:red}.block2{color:#000;display:block;width:100%}', + '.block1__element{color:#000}.block1__element--modifier{color:red}.block2{color:#000;display:block;width:100%}' + // '.block1__element,.block2{color:#000}.block1__element--modifier{color:red}.block2{display:block;width:100%}' - pending #588 + ] + }, { advanced: true, semanticMerging: true }) + ) .addBatch( optimizerContext('properties', { 'empty body': [ -- 2.34.1