diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..b604b75 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,20 @@ +{ + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "impliedStrict": true + } + }, + "env": { + "node": true + }, + "rules": { + "semi": [2, "always"], + "indent": [1, "tab", {"SwitchCase": 1}], + "brace-style": [2, "stroustrup"], + "no-array-constructor": [2], + "space-before-function-paren": [2, "always"], + "no-eq-null": 2, + } +} diff --git a/.gitignore b/.gitignore index 22575a0..3fb8c18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -node_modules -test -.DS_Store -LICENSE-MIT -Gruntfile.js -npm-debug.log \ No newline at end of file +node_modules +test +.DS_Store +LICENSE* +npm-debug.log +.tern-project diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfd34c1 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# CSS to LESS +Convert css to less + +```shell +npm install -g css2less +``` + +## Usage: +>+ `$ css2less [options] ` + +### CLI Options ### +#### --indent-size +Type: `number` +Default value: `1` +Desc: Indent size. + +#### --indent-symbol +Type: `string` +Default value: `\t` +Desc: Indent symbol. + +#### --selector-separator +Type: `string` +Default value: `,\n` +Desc: Selector separator. + +#### --block-separator +Type: `string` +Default value: `\n` +Desc: Separator between blocks. + +#### --block-on-newline +Type: `boolean` +Default value: `false` +Desc: Start first '{' from the new line + +#### --update-colors +Type: `boolean` +Default value: `true` +Desc: Use variables for colors. + +#### --vendor-mixins +Type: `boolean` +Default value: `true` +Desc: Create function for vendor styles. + +#### --variables-path +Type: `string` +Desc: Path to 'variables.less' file where will be all colors stored. + Defaultly was colors stored on the top of each file, but with this given path will be generated with name prepended by relative path where 'variables.less' was stored. + +## Pure JavaScript usage example: +```javascript +var fs = require('fs'); +var css2less = require('css2less'); + +fs.createReadStream(cssFilePath) + .pipe(new css2less(options)) + .pipe(fs.createWriteStream(lessFilePath)); +``` diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..46a2904 --- /dev/null +++ b/cli.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * css2less - entry point - command line interface + * + * Converter of pure CSS into the structured LESS keeping all the imports & comments + * and optionally extracting all the colors into variables. + * Original code by Serhey Shmyg, continued and extended by Martin Bórik. + */ + +const appname = 'css2less'; + +const fs = require('fs'); +const path = require('path'); +const meow = require('meow'); +const promisePipe = require("promisepipe"); + +const css2less = require('./index.js'); +const utils = require('./utils.js'); + +let cli = meow(` + Usage: + $ ${appname} [options] + + Options: + --indent-size Indent size (default 1) + --indent-symbol Indentation symbol (default: tab character) + --selector-separator String separator between selectors (default: comma and newline) + --block-separator Separator between blocks (default: newline character) + --block-on-newline Start first '{' from the newline after selector. + --update-colors Create variables for colors. + --vendor-mixins Create function for vendor styles. + -var, --variables-path Path to 'variables.less' file where will be all colors stored. + Defaultly was colors stored on the top of each file, but with + this given path will be generated with name prepended by + relative path where 'variables.less' was stored. + + -h, --help Show help + -v, --version Version number +`, { + string: [ 'variables-path', 'indent-symbol', 'selector-separator', 'block-separator' ], + boolean: [ 'update-colors', 'vendor-mixins' ], + default: { + updateColors: true, + vendorMixins: false + }, + stopEarly: true, + alias: { + var: 'variables-path', + h: 'help', + v: 'version' + } +}); + +if (!cli.input.length) + cli.showHelp(); + +// file processor... +utils.processFiles(cli.input, file => { + const filePath = path.resolve(process.cwd(), file); + const opt = Object.assign({ filePathway: [] }, cli.flags); + + try { + if (!fs.statSync(filePath).isFile()) { + throw new Error('ENOTFILE'); + } + } + catch (err) { + return Promise.reject( + new Error(`Invalid file: '${file}' (${err.message || err.code})`) + ); + } + + const ext = path.extname(file); + if (ext.toLowerCase() !== '.css') { + return Promise.reject( + new Error(`Invalid file: '${file}': Not a proper extension!`) + ); + } + + const fileDir = path.dirname(filePath); + const fileBaseName = path.basename(file, ext); + const lessFile = path.join(fileDir, fileBaseName + '.less'); + + opt.absFilePath = opt.absBasePath = utils.path2posix(path.resolve(fileDir)); + + if (opt.variablesPath) { + const varDir = path.dirname(opt.variablesPath); + const varRelPath = path.relative(varDir, fileDir); + + if (varRelPath.length > 0) { + opt.filePathway = varRelPath.split(path.sep); + } + + opt.filePathway.push(fileBaseName); + opt.absBasePath = utils.path2posix(path.resolve(varDir)); + } + + console.log(`Converting '${file}'...`); + return promisePipe( + fs.createReadStream(filePath), + new css2less(opt), + fs.createWriteStream(lessFile) + ).then(stream => { + console.log(`> stored into '${lessFile}'.`); + }, err => { + console.error("> error: ", err.originalError); + fs.unlinkSync(lessFile); + }); +}); diff --git a/csscolors.json b/csscolors.json new file mode 100644 index 0000000..33a0bca --- /dev/null +++ b/csscolors.json @@ -0,0 +1,150 @@ +{ + "aliceblue": "#f0f8ff", + "antiquewhite": "#faebd7", + "aqua": "#00ffff", + "aquamarine": "#7fffd4", + "azure": "#f0ffff", + "beige": "#f5f5dc", + "bisque": "#ffe4c4", + "black": "#000000", + "blanchedalmond": "#ffebcd", + "blue": "#0000ff", + "blueviolet": "#8a2be2", + "brown": "#a52a2a", + "burlywood": "#deb887", + "cadetblue": "#5f9ea0", + "chartreuse": "#7fff00", + "chocolate": "#d2691e", + "coral": "#ff7f50", + "cornflowerblue": "#6495ed", + "cornsilk": "#fff8dc", + "crimson": "#dc143c", + "cyan": "#00ffff", + "darkblue": "#00008b", + "darkcyan": "#008b8b", + "darkgoldenrod": "#b8860b", + "darkgray": "#a9a9a9", + "darkgrey": "#a9a9a9", + "darkgreen": "#006400", + "darkkhaki": "#bdb76b", + "darkmagenta": "#8b008b", + "darkolivegreen": "#556b2f", + "darkorange": "#ff8c00", + "darkorchid": "#9932cc", + "darkred": "#8b0000", + "darksalmon": "#e9967a", + "darkseagreen": "#8fbc8f", + "darkslateblue": "#483d8b", + "darkslategray": "#2f4f4f", + "darkslategrey": "#2f4f4f", + "darkturquoise": "#00ced1", + "darkviolet": "#9400d3", + "deeppink": "#ff1493", + "deepskyblue": "#00bfff", + "dimgray": "#696969", + "dimgrey": "#696969", + "dodgerblue": "#1e90ff", + "firebrick": "#b22222", + "floralwhite": "#fffaf0", + "forestgreen": "#228b22", + "fuchsia": "#ff00ff", + "gainsboro": "#dcdcdc", + "ghostwhite": "#f8f8ff", + "gold": "#ffd700", + "goldenrod": "#daa520", + "gray": "#808080", + "grey": "#808080", + "green": "#008000", + "greenyellow": "#adff2f", + "honeydew": "#f0fff0", + "hotpink": "#ff69b4", + "indianred": "#cd5c5c", + "indigo": "#4b0082", + "ivory": "#fffff0", + "khaki": "#f0e68c", + "lavender": "#e6e6fa", + "lavenderblush": "#fff0f5", + "lawngreen": "#7cfc00", + "lemonchiffon": "#fffacd", + "lightblue": "#add8e6", + "lightcoral": "#f08080", + "lightcyan": "#e0ffff", + "lightgoldenrodyellow": "#fafad2", + "lightgray": "#d3d3d3", + "lightgrey": "#d3d3d3", + "lightgreen": "#90ee90", + "lightpink": "#ffb6c1", + "lightsalmon": "#ffa07a", + "lightseagreen": "#20b2aa", + "lightskyblue": "#87cefa", + "lightslategray": "#778899", + "lightslategrey": "#778899", + "lightsteelblue": "#b0c4de", + "lightyellow": "#ffffe0", + "lime": "#00ff00", + "limegreen": "#32cd32", + "linen": "#faf0e6", + "magenta": "#ff00ff", + "maroon": "#800000", + "mediumaquamarine": "#66cdaa", + "mediumblue": "#0000cd", + "mediumorchid": "#ba55d3", + "mediumpurple": "#9370db", + "mediumseagreen": "#3cb371", + "mediumslateblue": "#7b68ee", + "mediumspringgreen": "#00fa9a", + "mediumturquoise": "#48d1cc", + "mediumvioletred": "#c71585", + "midnightblue": "#191970", + "mintcream": "#f5fffa", + "mistyrose": "#ffe4e1", + "moccasin": "#ffe4b5", + "navajowhite": "#ffdead", + "navy": "#000080", + "oldlace": "#fdf5e6", + "olive": "#808000", + "olivedrab": "#6b8e23", + "orange": "#ffa500", + "orangered": "#ff4500", + "orchid": "#da70d6", + "palegoldenrod": "#eee8aa", + "palegreen": "#98fb98", + "paleturquoise": "#afeeee", + "palevioletred": "#db7093", + "papayawhip": "#ffefd5", + "peachpuff": "#ffdab9", + "peru": "#cd853f", + "pink": "#ffc0cb", + "plum": "#dda0dd", + "powderblue": "#b0e0e6", + "purple": "#800080", + "rebeccapurple": "#663399", + "red": "#ff0000", + "rosybrown": "#bc8f8f", + "royalblue": "#4169e1", + "saddlebrown": "#8b4513", + "salmon": "#fa8072", + "sandybrown": "#f4a460", + "seagreen": "#2e8b57", + "seashell": "#fff5ee", + "sienna": "#a0522d", + "silver": "#c0c0c0", + "skyblue": "#87ceeb", + "slateblue": "#6a5acd", + "slategray": "#708090", + "slategrey": "#708090", + "snow": "#fffafa", + "springgreen": "#00ff7f", + "steelblue": "#4682b4", + "tan": "#d2b48c", + "teal": "#008080", + "thistle": "#d8bfd8", + "tomato": "#ff6347", + "turquoise": "#40e0d0", + "violet": "#ee82ee", + "wheat": "#f5deb3", + "white": "#ffffff", + "whitesmoke": "#f5f5f5", + "yellow": "#ffff00", + "yellowgreen": "#9acd32" +} diff --git a/cssprops.json b/cssprops.json new file mode 100644 index 0000000..d16024d --- /dev/null +++ b/cssprops.json @@ -0,0 +1 @@ +["align-content:", "align-items:", "align-self:", "animation:", "animation-delay:", "animation-direction:", "animation-duration:", "animation-fill-mode:", "animation-iteration-count:", "animation-name:", "animation-play-state:", "animation-timing-function:", "backface-visibility:", "background:", "background-attachment:", "background-blend-mode:", "background-clip:", "background-color:", "background-image:", "background-origin:", "background-position:", "background-repeat:", "background-size:", "border:", "border-bottom:", "border-bottom-color:", "border-bottom-left-radius:", "border-bottom-right-radius:", "border-bottom-style:", "border-bottom-width:", "border-collapse:", "border-color:", "border-image:", "border-image-outset:", "border-image-repeat:", "border-image-slice:", "border-image-source:", "border-image-width:", "border-left:", "border-left-color:", "border-left-style:", "border-left-width:", "border-radius:", "border-right:", "border-right-color:", "border-right-style:", "border-right-width:", "border-spacing:", "border-style:", "border-top:", "border-top-color:", "border-top-left-radius:", "border-top-right-radius:", "border-top-style:", "border-top-width:", "border-width:", "bottom:", "box-shadow:", "box-sizing:", "caption-side:", "clear:", "clip:", "color:", "column-count:", "column-fill:", "column-gap:", "column-rule:", "column-rule-color:", "column-rule-style:", "column-rule-width:", "column-span:", "column-width:", "columns:", "content:", "counter-increment:", "counter-reset:", "cursor:", "direction:", "display:", "empty-cells:", "filter:", "flex:", "flex-basis:", "flex-direction:", "flex-flow:", "flex-grow:", "flex-shrink:", "flex-wrap:", "float:", "font:", "@font-face:", "font-family:", "font-size:", "font-size-adjust:", "font-stretch:", "font-style:", "font-variant:", "font-weight:", "hanging-punctuation:", "height:", "justify-content:", "@keyframes:", "left:", "letter-spacing:", "line-height:", "list-style:", "list-style-image:", "list-style-position:", "list-style-type:", "margin:", "margin-bottom:", "margin-left:", "margin-right:", "margin-top:", "max-height:", "max-width:", "@media:", "min-height:", "min-width:", "nav-down:", "nav-index:", "nav-left:", "nav-right:", "nav-up:", "opacity:", "order:", "outline:", "outline-color:", "outline-offset:", "outline-style:", "outline-width:", "overflow:", "overflow-x:", "overflow-y:", "padding:", "padding-bottom:", "padding-left:", "padding-right:", "padding-top:", "page-break-after:", "page-break-before:", "page-break-inside:", "perspective:", "perspective-origin:", "position:", "quotes:", "resize:", "right:", "tab-size:", "table-layout:", "text-align:", "text-align-last:", "text-decoration:", "text-decoration-color:", "text-decoration-line:", "text-decoration-style:", "text-indent:", "text-justify:", "text-overflow:", "text-shadow:", "text-transform:", "top:", "transform:", "transform-origin:", "transform-style:", "transition:", "transition-delay:", "transition-duration:", "transition-property:", "transition-timing-function:", "unicode-bidi:", "vertical-align:", "visibility:", "white-space:", "width:", "word-break:", "word-spacing:", "word-wrap:", "z-index:"] \ No newline at end of file diff --git a/index.js b/index.js index 2a756e8..e6276df 100644 --- a/index.js +++ b/index.js @@ -1,476 +1,522 @@ -Array.lambda = function (s) { - if (typeof s !== "string") { - return s; - } +/** + * css2less - main code of the converter implemented into transform stream + * + * Converter of pure CSS into the structured LESS keeping all the imports & comments + * and optionally extracting all the colors into variables. + * Original code by Serhey Shmyg, continued and extended by Martin Bórik. + */ + +const _ = require('lodash'); +const stream = require('stream'); +const path = require('path'); +const fs = require('fs'); + +const cssc = require('./csscolors.json'); +const cssp = require('./cssprops.json'); +const { stringSplitAndTrim, repeatReplaceUntil, path2posix } = require('./utils'); + +//----------------------------------------------------------------------------- +class css2less extends stream.Transform { + constructor (options) { + let opt = _.defaults({}, options, { + filePathway: [], + encoding: 'utf8', + vendorPrefixesList: ['moz', 'o', 'ms', 'webkit'], + indentSymbol: '\t', + indentSize: 1, + selectorSeparator: ',\n', + blockFromNewLine: false, + blockSeparator: '\n', + updateColors: true, + vendorMixins: false, + nameValueSeparator: ': ' + }); - s = s.replace("=>", "`return ") + ";"; - s = s.split("`"); - s = Function.apply(null, s); + super({ + objectMode: true, + highWaterMark: 16, + encoding: opt.encoding + }); - return s; -}; -Array.prototype.any = function (d) { - var result; + this.options = opt; - d = Array.lambda(d); + this.vendorPrefixesReg = new RegExp('^-(' + opt.vendorPrefixesList.join('|') + ')-', 'gi'); + this.rgbaMatchReg = /(((0|\d{1,}px)\s+?){3})?rgba?\((\d{1,3},\s*?){2}\d{1,3}(,\s*?0?\.\d+?)?\)$/gi; + this.introCommentRegex = /\/\*(?:[^*]|[\r\n]|(?:\*+(?:[^*/]|[\r\n])))*\*+\/\s*/; + this.markedCommentReg = /^\u2588.+\u2502$/; - if (d) { - this.forEach(function (it) { - if (d(it)) { - result = it; - return { stop: true }; - } - }); - } else { - result = this[0]; + this.css = ''; + this.tree = {}; + this.less = []; + this.vars = {}; + this.vars_index = 0; + this.vendorMixins = {}; + this.commentsMapper = []; } - return result ? true : false; -} -Array.prototype.select = function (d) { - var result = []; - - d = Array.lambda(d); + _transform (chunk, enc, done) { + this.css += chunk.toString(this.options.encoding); + done(); + } - this.forEach(function (it, i) { - result.push(d(it, i)); - }); + _flush (done) { + this.generateTree(); + this.renderLess(); + this.finalize(); - return result; -}; -Array.prototype.where = function (d) { - var result = []; + done(); + } - d = Array.lambda(d); +//----------------------------------------------------------------------------- + getIndent (size) { + let result = ''; + let max = size || this.options.indentSize; - this.forEach(function (it, i) { - if (d(it, i)) { - result.push(it); + for (let n = 0; n < max; n++) { + result += this.options.indentSymbol; } - }); - return result; -}; - -var css2less = function (css, options) { - var me = this; + return result; + } - var ctor = function () { - me.css = css || ""; - me.options = { - cssColors: ["aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgrey", "darkgreen", "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen", "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "grey", "green", "greenyellow", "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgrey", "lightgreen", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen", "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white", "whitesmoke", "yellow", "yellowgreen"], - vendorPrefixesList: ["-moz", "-o", "-ms", "-webkit"], - //vendorPrefixesReg: /^(-moz|-o|-ms|-webkit)-/gi, - indentSymbol: "\t", - indentSize: 1, - selectorSeparator: ",\n", - blockFromNewLine: false, - blockSeparator: "\n", - updateColors: false, - vendorMixins: true, - nameValueSeparator: ": " - }; - var vendorPrefixesListStr = me.options.vendorPrefixesList.join('|'); - - me.options.vendorPrefixesReg = new RegExp('^(' + vendorPrefixesListStr + ')-', 'gi'); - - for (var i in me.options) { - if (typeof options[i] === 'undefined') continue; - me.options[i] = options[i]; + convertIfVariable (value, key) { + if (/(^@var)|(^\d+(\.\d+)?(%|p[ctx]|e[mx]|[cm]m|in)?$)/i.test(value)) { + return value; } - me.tree = {}; - me.less = []; - me.colors = {}; - me.colors_index = 0; - me.vendorMixins = {}; - }, - isBase64 = function(str) { - return str.indexOf('base64') !== -1; - }; - - me.processLess = function () { - me.cleanup(); + // find the named value or convert hex triplet e.g. #639 to full hex color... + value = _.get(cssc, value.toLowerCase()) || value.replace(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i, '#$1$1$2$2$3$3'); - if (!me.css) { - return false; + let matches; + let processed = false; + if (/^#[0-9a-f]{6}$/i.test(value)) { + processed = true; + value = value.toLowerCase(); } + else if (!!(matches = /^url\(([\"']?)((?:\\\1|.)+?)\1\)$/i.exec(value))) { + processed = true; + + // path relativizer - try to find portion of url path in base file path + let file = path2posix(matches[2]).replace(/^\/([^\/]+?\/)+/, (match => { + let head = match.substr(0, match.length - 1); + let tail = ''; + let relative = ''; + + do { + if (~this.options.absFilePath.indexOf(head)) { + return relative + tail; + } - me.generateTree(); - me.renderLess(); - me.less = me.less.join(""); - - return true; - }; - - me.cleanup = function () { - me.tree = {}; - me.less = []; - } + let lastSlash = head.lastIndexOf('/'); + tail = head.substr(lastSlash + 1) + '/' + tail; + relative = '../' + relative; - me.convertRules = function (data) { - var arr = data.split(/[;]/gi).select("val=>val.trim()").where("val=>val"), - base64Index = false; + head = head.substr(0, lastSlash); + } while (head.length > 1); - arr.forEach(function(item, i) { - if ( isBase64(item) ) base64Index = i; - }); - - if (base64Index) { - arr[base64Index - 1] = arr[base64Index - 1] + ';' + arr[base64Index]; - arr.splice(base64Index, 1); - } - - return arr; - }; - - me.color = function (value) { - value = value.trim(); + return match; + })); - if (me.options.cssColors.indexOf(value) >= 0 || /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/gi.test(value) || /(rgba?)\(.*\)/gi.test(value)) { - return true; + let base = path.resolve(this.options.absFilePath, file); + value = `url("${path2posix(path.relative(this.options.absBasePath, base))}")`; } - return false; - }; - - me.convertIfColor = function (color) { - color = color.trim(); - - if (me.color(color)) { - if (!me.colors[color]) { - me.colors[color] = "@color" + me.colors_index; - me.colors_index++; + if (processed || this.rgbaMatchReg.test(value)) { + let usages = []; + if (!this.vendorPrefixesReg.test(key)) { + usages = this.currentProcessingSelector.map(value => `${key} @ ${value}`); } - return me.colors[color]; - } - - return color; - }; + if (this.vars[value]) { + Array.prototype.push.apply(this.vars[value].usages, usages); + } + else { + this.vars_index++; + this.vars[value] = { + 'id': '@var-' + this.options.filePathway.concat(this.vars_index).join('-'), + 'usages': usages + }; + } - me.matchColor = function (style) { - var rules = me.convertRules(style); - var result = []; + return this.vars[value].id; + } - rules.forEach(function (r, i) { - var parts = r.split(/[:]/gi); - var key = parts[0].trim(); - var value = parts[1].trim(); + return value; + } - result.push(i > 0 ? "\n" : "", key); + matchVariable (style) { + let rules = stringSplitAndTrim(style, /\;(?!base64)/gi); + let result = []; + rules.forEach((rule, i) => { + let [ key, value ] = stringSplitAndTrim(rule, /\:(?!image)/gi); if (!value) { return; } - var oldValues = value.split(/\s+/gi); - var newValues = oldValues.select(function (v) { - return me.convertIfColor(v); - }); - - result.push(me.options.nameValueSeparator, newValues.join(" "), ";"); - }); + value = value.replace(this.rgbaMatchReg, m => this.convertIfVariable(m, key)); + let values = value.split(/\s+/gi).map(v => this.convertIfVariable(v, key)); - return result.join(""); - }; + result.push((i ? '\n' : '') + key, + this.options.nameValueSeparator, + values.join(' ') + ';'); - me.matchVendorPrefixMixin = function (style) { - var normal_rules = {}; - var prefixed_rules = {}; - var rules = me.convertRules(style); + }, this); - for (var i = 0; i < rules.length; i++) { - var e = rules[i].trim(); - - var parts = e.split(/[:]/gi); + return result.join(''); + } - if ( isBase64(e) ) parts = [parts[0], parts[1] + parts[2]]; + matchVendorPrefixMixin (style) { + let normal_rules = {}; + let prefixed_rules = {}; + let rules = stringSplitAndTrim(style, /\;(?!base64)/gi); - var key = parts[0].trim(); - var value = parts[1].trim(); + rules.forEach(rule => { + let [ key, value ] = stringSplitAndTrim(rule, /\:(?!image)/gi); if (!value) { - normal_rules[key] = ""; - } else if (me.options.vendorPrefixesReg.test(key)) { - var rule_key = key.replace(me.options.vendorPrefixesReg, ""); - var values = value.split(/\s+/gi); - var newValue = []; - - for (var j = 0; j < values.length; j++) { - newValue.push(values[j].trim()); - } - - newValue = newValue.join(" "); + normal_rules[key] = ''; + } + else if (this.vendorPrefixesReg.test(key)) { + let rule_key = key.replace(this.vendorPrefixesReg, ''); + let newValue = value.replace(/\s+/gi, ' ').trim(); if (prefixed_rules[rule_key] && prefixed_rules[rule_key] != newValue) { return style; } prefixed_rules[rule_key] = newValue; - } else { + } + else { normal_rules[key] = value; } - } + }, this); - for (var k in prefixed_rules) { - var v = prefixed_rules[k]; + _.forOwn(prefixed_rules, (value, k) => { + let v = stringSplitAndTrim(value, /\s+/gi); - v = v.split(/\s+/gi).select("val=>val.trim()").where("val=>val"); - - if (!me.vendorMixins[k]) { - me.vendorMixins[k] = v.length; - } + if (!this.vendorMixins[k]) + this.vendorMixins[k] = v.length; if (normal_rules[k]) { delete normal_rules[k]; - normal_rules[".vp-" + k + "(" + v.join(", ") + ")"] = ""; - } - } - var result = []; - - for (var k in normal_rules) { - var v = normal_rules[k]; - var r = [k]; - - if (v) { - r.push(me.options.nameValueSeparator, v, ";\n"); + let newKey = `.vp-${k}(${v.join(', ')})`; + normal_rules[newKey] = ''; } + }); - result.push(r.join("")); - } + let result = []; + _.forOwn(normal_rules, (value, rule) => { + if (value.trim()) { + rule += this.options.nameValueSeparator + value + ';\n'; + } + result.push(rule); + }); - return result.join(""); - }; + return result.join(''); + } - me.addRule = function (tree, selectors, style) { + addRule (tree, selectors, style) { if (!style) { return; } - if (!selectors || !selectors.length) { - if (me.options.updateColors) { - style = me.matchColor(style) - } - - if (me.options.vendorMixins) { - style = me.matchVendorPrefixMixin(style); + if (!(selectors && selectors.length)) { + // test if it's not just comment in the rule... + if (!this.markedCommentReg.test(style)) { + if (this.options.updateColors) { + style = this.matchVariable(style); + } + if (this.options.vendorMixins) { + style = this.matchVendorPrefixMixin(style); + } } if (!tree.style) { - tree.style = style; - } else { - tree.style += style; + tree.style = ''; } - } else { - var first = selectors[0].split(/\s*[,]\s*/gi).select("val=>val.trim()").where("val=>val").join(me.options.selectorSeparator); - + tree.style += (style || ''); + } + else { + let first = stringSplitAndTrim(selectors[0], /\s*[,]\s*/gi) + .join(this.options.selectorSeparator); + if (!tree.children) { tree.children = []; } - if (!tree[first]) { - tree[first] = {}; - } - - var node = tree[first]; + let node = tree[first] || (tree[first] = {}); selectors.splice(0, 1); tree.children.push(node); - me.addRule(node, selectors, style); + + this.addRule(node, selectors, style); } - }; + } - me.generateTree = function () { - var csss = me.css.split(/\n/gi); - var temp = csss.select("val=>val.trim()").where("val=>val"); + commentKeeperCallback (flags, char, content) { + flags.done = true; - temp = temp.join(""); - temp = temp.replace(/[/][*]+[^\*]*[*]+[/]/gi, ""); - temp = temp.replace(/[^{}]+[{]\s*[}]/ig, " "); - temp = temp.split(/[{}]/gi).where("val=>val"); + const idx = this.commentsMapper.length || 1; + const mark = `\u2588${idx}\u2502`; - var styles = []; + this.commentsMapper[idx] = content.trim(); - for (var i = 0; i < temp.length; i++) { - if (i % 2 === 0) { - styles.push([temp[i]]); - } else { - styles[styles.length - 1].push(temp[i]); - } + if (char === ';') { + return mark + char; } - for (var i = 0; i < styles.length; i++) { - var style = styles[i]; - var rules = style[0]; + return char + mark; + } - if (rules.indexOf(">") >= 0) { - rules = rules.replace(/\s*>\s*/gi, " &>"); + generateTree () { + let temp = this.css.trim().replace(this.introCommentRegex, (match, index) => { + if (index === 0) { + this.introComment = match.trim(); + return ''; } + return match; + }); - if (rules.indexOf("@import") >= 0) { - var import_rule = rules.match(/@import.*;/gi)[0]; - rules = rules.replace(/@import.*;/gi, ""); - me.addRule(me.tree, [], import_rule); + temp = stringSplitAndTrim( + repeatReplaceUntil(temp, + /(^|[}{\u2502]|(?:\u2502?;))\s*\/\*((?:[^*]|[\r\n]|(\*+([^*/]|[\r\n])))*)\*+\/\s*/gi, + this.commentKeeperCallback.bind(this) + ), /\n/g) + .join('') + .replace(/@import\s+(?:url\()?([\"'])((?:\\\1|.)+?)\1[^;]*?;/gi, + (match, q, path) => { + let lessPath = path.replace('.css', '.less'); + this.less.push(`@import "${lessPath}";\n`); + return ''; + }).replace(/[^\{\}]+\{\s*\}/g, ' '); + + let styleDefs = []; + let styles = stringSplitAndTrim(temp, /[\{\}]/g); + + styles.forEach((val, i) => { + if (!(i & 1)) { + styleDefs.push([val]); } + else { + styleDefs[styleDefs.length - 1].push(val); + } + }); - if (rules.indexOf(",") >= 0) { - me.addRule(me.tree, [rules], style[1]); - } else { - var rules_split = rules.replace(/[:]/gi, " &:").split(/\s+/gi).select("val=>val.trim()").where("val=>val").select(function (it, i) { - return it.replace(/[&][>]/gi, "& > "); - }); + styleDefs.forEach(style => { + let rule = style[0]; + let rules = [ rule ]; - me.addRule(me.tree, rules_split, style[1]); - } - } - }; + // store current processing selector(s) to put into variable's comment... + this.currentProcessingSelector = stringSplitAndTrim(rule, /\s*[,]\s*/gi) + .map(s => s.replace(/\u2588\d+\u2502/g, '').replace(/\s+/g, ' ')); - me.buildMixinList = function (indent) { - var less = []; - for (var k in me.vendorMixins) { - var v = me.vendorMixins[k]; - var args = []; + if (!~rule.indexOf(',')) { + rule = rule.replace(/\s*([+~>])\s*/gi, ' &$1') + .replace(/(\w):(?!(:?\-)|not|dir|lang|first|last|nth|only)/gi, '$1 &:'); - for (var i = 0; i < v; i++) { - args.push("@p" + i); + rules = stringSplitAndTrim(rule, /\s+/gi) + .map(it => it.replace(/&([+~>])/gi, '$1 ')); } - less.push(".vp-", k, "(", args.join(", "), ")"); + this.addRule(this.tree, rules, style[1]); + + }, this); + } + + buildMixinList (indent) { + let less = []; - if (me.options.blockFromNewLine) { - less.push("\n"); - } else { - less.push(" "); + _.forOwn(this.vendorMixins, (v, k) => { + let args = []; + + for (let i = 0; i < v; i++) { + args.push('@p' + i); } - less.push("{\n"); + less.push(`.vp-${k}(${args.join(', ')})`); + less.push(this.options.blockFromNewLine ? '\n' : ' '); - me.options.vendorPrefixesList.forEach(function (vp, i) { - less.push(getIndent(indent + me.options.indentSize)); - less.push(vp, "-", k, me.options.nameValueSeparator, args.join(" "), ";\n"); - }); + less.push('{\n'); + this.options.vendorPrefixesList.forEach((vp, i) => { + less.push(this.getIndent(indent + this.options.indentSize)); + less.push(`-${vp}-${k}${this.options.nameValueSeparator}${args.join(' ')};\n`); + }, this); - less.push(getIndent(indent + me.options.indentSize), k, me.options.nameValueSeparator, args.join(" "), ";\n"); - less.push(/*getIndent(indent), */"}\n"); - } + less.push(this.getIndent(indent + this.options.indentSize)); + less.push(`${k}${this.options.nameValueSeparator}${args.join(' ')};\n`); + less.push('}\n'); + }); - if (less.any()) { - less.push("\n"); + if (less.length) { + less.push('\n'); } - return less.join(""); + return less.join(''); }; - me.renderLess = function (tree, indent) { + renderLess (tree, indent) { indent = indent || 0; if (!tree) { - for (var k in me.colors) { - var v = me.colors[k]; - me.less.push(v, me.options.nameValueSeparator, k, ";\n"); - } + let colorVariables = []; + _.forOwn(this.vars, (v, k) => { + let usg = v.usages.sort(); + if (usg.length > 1) { + colorVariables.push(`\n/*\n * ${usg.join('\n * ')}\n */\n`); + } + else { + colorVariables.push(usg.length ? `\n/* ${usg[0]} */\n`: ''); + } - if (me.colors_index > 0) { - me.less.push("\n"); + colorVariables.push(`${v.id}${this.options.nameValueSeparator}${k};\n`); + }); + + if (this.options.variablesPath) { + fs.appendFile(this.options.variablesPath, colorVariables.join('').trim() + '\n', ()=>{}); + if (this.vars_index > 0) { + this.less.unshift(`@import (reference) "${path.basename(this.options.variablesPath)}";\n\n`); + this.less.push('\n'); + } + } + else if (this.vars_index > 0) { + this.less.push.apply(this.less, colorVariables.filter((v, i) => (i & 1))); + this.less.push('\n'); } - if (me.options.vendorMixins) { - me.less.push(me.buildMixinList(indent)); + if (this.introComment) { + this.less.unshift(this.introComment + '\n\n'); + } + if (this.options.vendorMixins) { + this.less.push(this.buildMixinList(indent)); } - tree = me.tree; + tree = this.tree; } - var index = 0; + let index = 0; - for (var i in tree) { - if (i == "children") { + for (let i in tree) { + if (i == 'children') { continue; } - var element = tree[i]; - var children = element.children; + let element = tree[i]; + let children = element.children; - if (i == "style") { - me.less.push(me.convertRules(element).join(";\n"), "\n"); - } else { + if (i == 'style') { + let rules = stringSplitAndTrim(element, /\;(?!base64)/gi); + this.less.push(rules.join(';\n') + '\n'); + } + else { if (index > 0) { - me.less.push(me.options.blockSeparator); + this.less.push(this.options.blockSeparator); } - //Selector indent - if (indent > 0) me.less.push(getIndent(indent), i); - else me.less.push(/*getIndent(indent),*/ i); - - if (me.options.blockFromNewLine) { - me.less.push("\n", getIndent(indent)); - } else { - me.less.push(" "); + if (indent > 0) { + this.less.push(this.getIndent(indent)); } + this.less.push(i); - me.less.push("{\n"); + if (this.options.blockFromNewLine) { + this.less.push('\n' + this.getIndent(indent)); + } + else { + this.less.push(' '); + } - var style = element.style; - delete element.style; + this.less.push('{\n'); - if (style) { - var temp = me.convertRules(style); + if (element.style) { + let style = element.style; + if (typeof element.style !== 'string') { + console.warn("element.style invalid type!\n`%s`", JSON.stringify(element, null, '\t')); + } - temp = temp.select(function (it, i) { - return getIndent(indent + me.options.indentSize) + it + ";"; - }); + delete element.style; + let ind = this.getIndent(indent + this.options.indentSize); - me.less.push(temp.join("\n"), "\n"); + // test if it's just comment in the rule... + if (this.markedCommentReg.test(style)) { + this.less.push(ind + style); + } + else { + this.less.push(stringSplitAndTrim(style, /\;(?!base64)/gi) + .map(it => `${ind}${it};`) + .join('\n') + '\n'); + } if (children && children.length) { - me.less.push(me.options.blockSeparator); + this.less.push(this.options.blockSeparator); } } - me.renderLess(element, indent + me.options.indentSize); + this.renderLess(element, indent + this.options.indentSize); + + if (indent > 0) { + this.less.push(this.getIndent(indent)); + } + this.less.push('}\n'); - if (indent > 0) me.less.push(getIndent(indent), "}\n"); - else me.less.push(/*getIndent(indent),*/ "}\n"); - index++; } } - }; + } - function getIndent(size) { - size = size || me.options.indentSize; + finalize () { + let output = this.less.join(''); + + if (this.commentsMapper.length > 0) { + // revert all marked comment into output... + output = repeatReplaceUntil(output, + /(^[\t ]+)?\u2588(\d+)\u2502((?!\u2588);?)/gm, + (flags, indent, id, suffix, pos, haystack) => { + flags.done = true; + + indent = indent || ''; + + let comment = this.commentsMapper[+id]; + let commentedPropertyIndent = ''; + + // commented css property + if (cssp.some(rule => comment.indexOf(rule) === 0)) { + commentedPropertyIndent = indent; + for (let i = pos - 1; i > 0; --i) { + if (~'\n\r'.indexOf(haystack[i])) { + commentedPropertyIndent = haystack.substr(++i, 80).match(/^\s*/)[0]; + break; + } + } + } + // multiline comment + else if (~comment.indexOf('\n')) { + comment = comment.replace(/\n/g, `\n${indent}`) + .replace(/^(\*\s+)/, `\n${indent} $1`) + '\n'; + } - var result = '', - indent; + comment = `/* ${comment.replace(/^\*\s+/, '')} */`; + if (suffix === ';') { + if (commentedPropertyIndent) { + comment = `;\n${commentedPropertyIndent}${comment}`; + indent = ''; + } + else { + comment = '; ' + comment; + } + } + else { + comment += `\n${indent}`; + } - var max = size, - n = 0; - for (; n < max; n++) { - result += me.options.indentSymbol; + return indent + comment; + } + ); } - return result; - /*return (new Array(size)).select(function (it, i) { - return me.options.indentSymbol; - }).join("");*/ + this.push(output); } - - ctor(); }; - - -module.exports = function(cssString, options) { - if (!cssString) { - console.log('cssString require'); - return false; - } - - var lessInst = new css2less(cssString, options || {}); - - lessInst.processLess(); - - return lessInst.less; -}; \ No newline at end of file +//----------------------------------------------------------------------------- +module.exports = css2less; diff --git a/package.json b/package.json index 888edba..5396e23 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,36 @@ { "name": "css2less", - "version": "0.1.4", + "version": "0.5.0", "description": "Convert css to less", - "homepage": "https://github.com/serheyShmyg/css2less", - "author": { + "homepage": "https://github.com/mborik/css2less", + "authors": { "name": "Serhey Shmyg", "email": "serhey.shmyg.all@gmail.com" }, - "repository": { + "contributors": [ + { + "name": "Martin Bórik", + "email": "mborik@users.sourceforge.net" + } + ], + "repository": { "type": "git", - "url": "https://github.com/serheyShmyg/css2less.git" + "url": "https://github.com/mborik/css2less.git" }, "bugs": { - "url": "https://github.com/serheyShmyg/css2less/issues" + "url": "https://github.com/mborik/css2less/issues" }, "main": "index.js", + "bin": { + "css2less": "./cli.js" + }, "scripts": { - "start" : "gulp", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "exit 1" }, "license": "ISC", - "dependencies": {} + "dependencies": { + "lodash": "^4.16.2", + "meow": "^3.7.0", + "promisepipe": "^2.0.0" + } } diff --git a/readme.md b/readme.md deleted file mode 100644 index 60ceeda..0000000 --- a/readme.md +++ /dev/null @@ -1,60 +0,0 @@ -#CSS to LESS -Convert css to less - -```shell -npm install css2less --save-dev -``` - -###Options: -#### options.indentSize -Type: `Number` -Default value: `1` -Desc: Indent size. - -#### options.vendorPrefixesList -Type: `Array` -Default value: `["-moz", "-o", "-ms", "-webkit"]` -Desc: List of vendor prefixes. - -#### options.indentSymbol -Type: `String` -Default value: `\t` -Desc: Indent symbol. - -#### options.selectorSeparator -Type: `String` -Default value: `,\n` -Desc: Selector separator. - -#### options.blockFromNewLine -Type: `Bolean` -Default value: `false` -Desc: Start first '{' from the new line - -#### options.blockSeparator -Type: `String` -Default value: `\n` -Desc: Separator between blocks. - -#### options.updateColors -Type: `Bolean` -Default value: `false` -Desc: Use variables for colors. - -#### options.vendorMixins -Type: `Boolean` -Default value: `true` -Desc: Create function for vendor styles. - -##Example -```javascript -var css2less = require('css2less'), - cssString = 'a {color:green; text-decoration:none; } a:hover {color:lime; } a:active {text-decoration:underline; }', - options = {}, - result; - -result = css2less(cssString, options); -console.log(result); -``` - - \ No newline at end of file diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..0682987 --- /dev/null +++ b/utils.js @@ -0,0 +1,33 @@ +/** + * css2less - some handy utilities + * + * Converter of pure CSS into the structured LESS keeping all the imports & comments + * and optionally extracting all the colors into variables. + * Original code by Serhey Shmyg, continued and extended by Martin Bórik. + */ + +const _ = require('lodash'); + +module.exports = { + 'stringSplitAndTrim': (str, del) => _.compact( + str.split(del).map((item) => item.trim()) + ), + + 'repeatReplaceUntil': (haystack, needle, callback) => { + let flags = {}; + do { + flags.done = false; + haystack = haystack.replace(needle, + (m, ...found) => callback.apply(null, found.unshift(flags) && found) + ); + } while (flags.done); + + return haystack; + }, + + 'processFiles': (files, callback) => files.reduce((promise, file) => { + return promise.then(() => callback(file)); + }, Promise.resolve()), + + 'path2posix': (str) => str.replace(/\\/g, '/') +};