From e17d4a0e4e325bf98241bc41b8efe40119701ff7 Mon Sep 17 00:00:00 2001 From: Jakub Pawlowicz Date: Thu, 22 Dec 2016 15:41:58 +0100 Subject: [PATCH] Fixes #209 - adds output formatting via `beautify` option. Why: * So clean-css can be used to format CSS nicely as well. --- History.md | 1 + bin/cleancss | 2 + lib/clean.js | 1 + lib/optimizer/basic.js | 3 +- lib/optimizer/merge-adjacent.js | 2 +- lib/optimizer/merge-non-adjacent-by-body.js | 2 +- lib/optimizer/tidy-rules.js | 16 +++-- lib/writer/helpers.js | 73 +++++++++++++++++---- lib/writer/simple.js | 3 + lib/writer/source-maps.js | 3 + test/binary-test.js | 7 ++ test/integration-test.js | 56 ++++++++++++++++ 12 files changed, 147 insertions(+), 22 deletions(-) diff --git a/History.md b/History.md index 2af7aed6..d5524322 100644 --- a/History.md +++ b/History.md @@ -7,6 +7,7 @@ * Replaces the old tokenizer with a new one which doesn't use any escaping. * Replaces the old `@import` inlining with one on top of the new tokenizer. * Simplifies URL rebasing with a single `rebaseTo` option in API or inferred from `--output` in CLI. +* Fixed issue [#209](https://github.com/jakubpawlowicz/clean-css/issues/209) - adds output formatting via `beautify` flag. * Fixed issue [#432](https://github.com/jakubpawlowicz/clean-css/issues/432) - adds URLs normalization. * Fixed issue [#657](https://github.com/jakubpawlowicz/clean-css/issues/657) - adds property name validation. * Fixed issue [#686](https://github.com/jakubpawlowicz/clean-css/issues/686) - adds rounding precision for all units. diff --git a/bin/cleancss b/bin/cleancss index a46d2a2c..2be995c0 100755 --- a/bin/cleancss +++ b/bin/cleancss @@ -21,6 +21,7 @@ commands .option('-o, --output [output-file]', 'Use [output-file] as output instead of STDOUT') .option('-s, --skip-import', 'Disable @import processing') .option('-t, --timeout [seconds]', 'Per connection timeout when fetching remote @imports (defaults to 5 seconds)') + .option('--beautify', 'Formats output CSS by using indentation and one rule or property per line') .option('--rounding-precision [n]', 'Rounds pixel values to `N` decimal places. -1 disables rounding (defaults to -1)') .option('--s0', 'Remove all special comments, i.e. /*! comment */') .option('--s1', 'Remove all special comments but the first one') @@ -63,6 +64,7 @@ var debugMode = commands.debug; var options = { advanced: commands.skipAdvanced ? false : true, aggressiveMerging: commands.skipAggressiveMerging ? false : true, + beautify: commands.beautify, compatibility: commands.compatibility, inliner: commands.timeout ? { timeout: parseFloat(commands.timeout) * 1000 } : undefined, keepBreaks: !!commands.keepLineBreaks, diff --git a/lib/clean.js b/lib/clean.js index b7898faf..31c6a705 100644 --- a/lib/clean.js +++ b/lib/clean.js @@ -27,6 +27,7 @@ var CleanCSS = module.exports = function CleanCSS(options) { this.options = { advanced: undefined === options.advanced ? true : !!options.advanced, aggressiveMerging: undefined === options.aggressiveMerging ? true : !!options.aggressiveMerging, + beautify: undefined === options.beautify ? false : !!options.beautify, benchmark: options.benchmark, compatibility: compatibility(options.compatibility), inliner: options.inliner || {}, diff --git a/lib/optimizer/basic.js b/lib/optimizer/basic.js index 0220b437..f4923dd6 100644 --- a/lib/optimizer/basic.js +++ b/lib/optimizer/basic.js @@ -550,6 +550,7 @@ function basicOptimize(tokens, context) { var ie7Hack = options.compatibility.selectors.ie7Hack; var adjacentSpace = options.compatibility.selectors.adjacentSpace; var spaceAfterClosingBrace = options.compatibility.properties.spaceAfterClosingBrace; + var beautify = options.beautify; var mayHaveCharset = false; var afterRules = false; @@ -578,7 +579,7 @@ function basicOptimize(tokens, context) { optimizeComment(token, options); break; case Token.RULE: - token[1] = tidyRules(token[1], !ie7Hack, adjacentSpace, context.warnings); + token[1] = tidyRules(token[1], !ie7Hack, adjacentSpace, beautify, context.warnings); optimizeBody(token[2], context); afterRules = true; break; diff --git a/lib/optimizer/merge-adjacent.js b/lib/optimizer/merge-adjacent.js index f10397d0..167494bc 100644 --- a/lib/optimizer/merge-adjacent.js +++ b/lib/optimizer/merge-adjacent.js @@ -27,7 +27,7 @@ function mergeAdjacent(tokens, context) { token[2] = []; } else if (lastToken[0] == Token.RULE && serializeBody(token[2]) == serializeBody(lastToken[2]) && !isSpecial(options, serializeRules(token[1])) && !isSpecial(options, serializeRules(lastToken[1]))) { - lastToken[1] = tidyRules(lastToken[1].concat(token[1]), false, adjacentSpace, context.warnings); + lastToken[1] = tidyRules(lastToken[1].concat(token[1]), false, adjacentSpace, false, context.warnings); token[2] = []; } else { lastToken = token; diff --git a/lib/optimizer/merge-non-adjacent-by-body.js b/lib/optimizer/merge-non-adjacent-by-body.js index 467f7d45..e420784d 100644 --- a/lib/optimizer/merge-non-adjacent-by-body.js +++ b/lib/optimizer/merge-non-adjacent-by-body.js @@ -50,7 +50,7 @@ function mergeNonAdjacentByBody(tokens, context) { var oldToken = candidates[candidateBody]; if (oldToken && !isSpecial(options, serializeRules(token[1])) && !isSpecial(options, serializeRules(oldToken[1]))) { token[1] = token[2].length > 0 ? - tidyRules(oldToken[1].concat(token[1]), false, adjacentSpace, context.warnings) : + tidyRules(oldToken[1].concat(token[1]), false, adjacentSpace, false, context.warnings) : oldToken[1].concat(token[1]); oldToken[2] = []; diff --git a/lib/optimizer/tidy-rules.js b/lib/optimizer/tidy-rules.js index 9ef6e2a1..38bbb3b0 100644 --- a/lib/optimizer/tidy-rules.js +++ b/lib/optimizer/tidy-rules.js @@ -42,7 +42,7 @@ function hasInvalidCharactersWithoutQuotes(value) { return value.indexOf(Marker.CLOSE_BRACE) > -1; } -function removeWhitespace(value) { +function removeWhitespace(value, beautify) { var stripped = []; var character; var isNewLineNix; @@ -102,17 +102,23 @@ function removeWhitespace(value) { } else if (character == Marker.DOUBLE_QUOTE && isQuoted) { stripped.push(character); isDoubleQuoted = false; - } else if (isWhitespace && wasRelation) { + } else if (isWhitespace && wasRelation && !beautify) { continue; + } else if (!isWhitespace && wasRelation && beautify) { + stripped.push(Marker.SPACE); + stripped.push(character); } else if (isWhitespace && (isAttribute || roundBracketLevel > 0) && !isQuoted) { // skip space } else if (isWhitespace && wasWhitespace && !isQuoted) { // skip extra space } else if ((isNewLineWin || isNewLineNix) && (isAttribute || roundBracketLevel > 0) && isQuoted) { // skip newline - } else if (isRelation && wasWhitespace) { + } else if (isRelation && wasWhitespace && !beautify) { stripped.pop(); stripped.push(character); + } else if (isRelation && !wasWhitespace && beautify) { + stripped.push(Marker.SPACE); + stripped.push(character); } else if (isWhitespace) { stripped.push(Marker.SPACE); } else { @@ -138,7 +144,7 @@ function ruleSorter(s1, s2) { return s1[1] > s2[1] ? 1 : -1; } -function tidyRules(rules, removeUnsupported, adjacentSpace, warnings) { +function tidyRules(rules, removeUnsupported, adjacentSpace, beautify, warnings) { var list = []; var repeated = []; @@ -151,7 +157,7 @@ function tidyRules(rules, removeUnsupported, adjacentSpace, warnings) { continue; } - reduced = removeWhitespace(reduced); + reduced = removeWhitespace(reduced, beautify); reduced = removeQuotes(reduced); if (adjacentSpace && reduced.indexOf('nav') > 0) { diff --git a/lib/writer/helpers.js b/lib/writer/helpers.js index 2111b3c6..b48dadbd 100644 --- a/lib/writer/helpers.js +++ b/lib/writer/helpers.js @@ -4,6 +4,8 @@ var emptyCharacter = ''; var Marker = require('../tokenizer/marker'); var Token = require('../tokenizer/token'); +var INDENT_BY = 2; // spaces + function supportsAfterClosingBrace(token) { return token[1][1] == 'background' || token[1][1] == 'transform' || token[1][1] == 'src'; } @@ -47,7 +49,7 @@ function rules(tokens, context) { store(tokens[i], context); if (i < l - 1) { - store(Marker.COMMA, context); + store(comma(context), context); } } } @@ -75,20 +77,23 @@ function lastPropertyIndex(tokens) { function property(tokens, position, lastPropertyAt, context) { var store = context.store; var token = tokens[position]; + var isPropertyBlock = token[2][0] == Token.PROPERTY_BLOCK; + var needsSemicolon = position < lastPropertyAt || isPropertyBlock; + var isLast = position === lastPropertyAt; switch (token[0]) { case Token.AT_RULE: store(token, context); - store(position < lastPropertyAt ? Marker.SEMICOLON : '', context); + store(position < lastPropertyAt ? semicolon(context, false) : emptyCharacter, context); break; case Token.COMMENT: store(token, context); break; case Token.PROPERTY: store(token[1], context); - store(Marker.COLON, context); + store(colon(context), context); value(token, context); - store(position < lastPropertyAt || token[2][0] == Token.PROPERTY_BLOCK ? Marker.SEMICOLON : '', context); + store(needsSemicolon ? semicolon(context, isLast) : emptyCharacter, context); } } @@ -97,9 +102,9 @@ function value(token, context) { var j, m; if (token[2][0] == Token.PROPERTY_BLOCK) { - store(Marker.OPEN_BRACE, context); + store(openBrace(context, false), context); body(token[2][1], context); - store(Marker.CLOSE_BRACE, context); + store(closeBrace(context, true), context); } else { for (j = 2, m = token.length; j < m; j++) { store(token[j], context); @@ -111,42 +116,82 @@ function value(token, context) { } } +function openBrace(context, needsPrefixSpace) { + if (context.beautify) { + context.indent += INDENT_BY; + context.indentSpaces = Marker.SPACE.repeat(context.indent); + return (needsPrefixSpace ? Marker.SPACE : emptyCharacter) + Marker.OPEN_BRACE + lineBreak + context.indentSpaces; + } else { + return Marker.OPEN_BRACE; + } +} + +function closeBrace(context, isLast) { + if (context.beautify) { + context.indent -= INDENT_BY; + context.indentSpaces = Marker.SPACE.repeat(context.indent); + return lineBreak + context.indentSpaces + Marker.CLOSE_BRACE + (isLast ? emptyCharacter : lineBreak + context.indentSpaces); + } else { + return Marker.CLOSE_BRACE; + } +} + +function colon(context) { + return context.beautify ? + Marker.COLON + Marker.SPACE : + Marker.COLON; +} + +function semicolon(context, isLast) { + return context.beautify ? + Marker.SEMICOLON + (isLast ? emptyCharacter : lineBreak + context.indentSpaces) : + Marker.SEMICOLON; +} + +function comma(context) { + return context.beautify ? + Marker.COMMA + lineBreak + context.indentSpaces : + Marker.COMMA; +} + function all(tokens, context) { - var joinCharacter = context.keepBreaks ? lineBreak : emptyCharacter; + var joinCharacter = context.keepBreaks && !context.beautify ? lineBreak : emptyCharacter; var store = context.store; var token; + var isLast; var i, l; for (i = 0, l = tokens.length; i < l; i++) { token = tokens[i]; + isLast = i == l - 1; switch (token[0]) { case Token.AT_RULE: store(token, context); - store(Marker.SEMICOLON, context); + store(semicolon(context, isLast), context); break; case Token.AT_RULE_BLOCK: rules(token[1], context); - store(Marker.OPEN_BRACE, context); + store(openBrace(context, true), context); body(token[2], context); - store(Marker.CLOSE_BRACE, context); + store(closeBrace(context, isLast), context); break; case Token.BLOCK: rules(token[1], context); - store(Marker.OPEN_BRACE, context); + store(openBrace(context, true), context); store(joinCharacter, context); all(token[2], context); store(joinCharacter, context); - store(Marker.CLOSE_BRACE, context); + store(closeBrace(context, isLast), context); break; case Token.COMMENT: store(token, context); break; case Token.RULE: rules(token[1], context); - store(Marker.OPEN_BRACE, context); + store(openBrace(context, true), context); body(token[2], context); - store(Marker.CLOSE_BRACE, context); + store(closeBrace(context, isLast), context); break; } diff --git a/lib/writer/simple.js b/lib/writer/simple.js index 7b3ffbd0..10db72d2 100644 --- a/lib/writer/simple.js +++ b/lib/writer/simple.js @@ -6,6 +6,9 @@ function store(token, serializeContext) { function serializeStyles(tokens, context) { var serializeContext = { + beautify: context.options.beautify, + indent: 0, + indentSpaces: '', keepBreaks: context.options.keepBreaks, output: [], spaceAfterClosingBrace: context.options.compatibility.properties.spaceAfterClosingBrace, diff --git a/lib/writer/source-maps.js b/lib/writer/source-maps.js index 094efd95..c7374b2f 100644 --- a/lib/writer/source-maps.js +++ b/lib/writer/source-maps.js @@ -62,8 +62,11 @@ function trackMapping(mapping, serializeContext) { function serializeStylesAndSourceMap(tokens, context) { var serializeContext = { + beautify: context.options.beautify, column: 0, keepBreaks: context.options.keepBreaks, + indent: 0, + indentSpaces: '', inlineSources: context.options.sourceMapInlineSources, line: 1, output: [], diff --git a/test/binary-test.js b/test/binary-test.js index 8db812d0..b1c7ca6c 100644 --- a/test/binary-test.js +++ b/test/binary-test.js @@ -86,6 +86,13 @@ vows.describe('./bin/cleancss') } }) }) + .addBatch({ + 'beautify': pipedContext('a{color: #f00}', '--beautify', { + 'outputs right styles': function (error, stdout) { + assert.equal(stdout, 'a {\n color: red\n}'); + } + }) + }) .addBatch({ 'strip all but first comment': pipedContext('/*!1st*//*! 2nd */a{display:block}', '--s1', { 'should keep the 2nd comment': function (error, stdout) { diff --git a/test/integration-test.js b/test/integration-test.js index 2ef35194..01f4861b 100644 --- a/test/integration-test.js +++ b/test/integration-test.js @@ -2559,4 +2559,60 @@ vows.describe('integration tests') ] }) ) + .addBatch( + optimizerContext('beautify formatting', { + 'rule': [ + 'a{color:red}', + 'a {' + lineBreak + ' color: red' + lineBreak + '}' + ], + 'rules': [ + 'a{color:red}p{color:#000;width:100%}', + 'a {' + lineBreak + ' color: red' + lineBreak + '}' + lineBreak + 'p {' + lineBreak + ' color: #000;' + lineBreak + ' width: 100%' + lineBreak + '}' + ], + 'multi-scope rule': [ + 'a,div{color:red}', + 'a,' + lineBreak + 'div {' + lineBreak + ' color: red' + lineBreak + '}' + ], + 'relation rule': [ + '.one>.two{color:red}', + '.one > .two {' + lineBreak + ' color: red' + lineBreak + '}' + ], + 'at rule block': [ + '@font-face{font-family:test;src:url(/fonts/test.woff)}', + '@font-face {' + lineBreak + ' font-family: test;' + lineBreak + ' src: url(/fonts/test.woff)' + lineBreak + '}' + ], + 'nested rule block': [ + '@media screen{a{color:red}}', + '@media screen {' + lineBreak + ' a {' + lineBreak + ' color: red' + lineBreak + ' }' + lineBreak + '}' + ], + 'nested rule block rules': [ + '@media screen{a{color:red}div{color:#000}}', + '@media screen {' + lineBreak + ' a {' + lineBreak + ' color: red' + lineBreak + ' }' + lineBreak + ' div {' + lineBreak + ' color: #000' + lineBreak + ' }' + lineBreak + '}' + ], + 'variable': [ + 'a{--my-toolbar:{margin:10px;padding:10px};}', + 'a {' + lineBreak + ' --my-toolbar: {' + lineBreak + ' margin: 10px;' + lineBreak + ' padding: 10px' + lineBreak + ' };' + lineBreak + '}' + ], + 'variable and rules': [ + 'a{--my-toolbar:{margin:10px;padding:10px};color:red}', + 'a {' + lineBreak + ' --my-toolbar: {' + lineBreak + ' margin: 10px;' + lineBreak + ' padding: 10px' + lineBreak + ' };' + lineBreak + ' color: red' + lineBreak + '}' + ], + 'at-rule and rules': [ + 'a{display:block;@apply(--rule1);color:red}', + 'a {' + lineBreak + ' display: block;' + lineBreak + ' @apply(--rule1);' + lineBreak + ' color: red' + lineBreak + '}' + ] + }, { beautify: true }) + ) + .addBatch( + optimizerContext('beautify formatting and keep breaks', { + 'rule': [ + 'a{color:red}', + 'a {' + lineBreak + ' color: red' + lineBreak + '}' + ], + 'nested rule block rules': [ + '@media screen{a{color:red}div{color:#000}}', + '@media screen {' + lineBreak + ' a {' + lineBreak + ' color: red' + lineBreak + ' }' + lineBreak + ' div {' + lineBreak + ' color: #000' + lineBreak + ' }' + lineBreak + '}' + ] + }, { beautify: true, keepBreaks: true }) + ) .export(module); -- 2.34.1