diff --git a/README.md b/README.md index 47703bac..e3290786 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,13 @@ Default: `true` Create mirrored css for right-to-left support. +##### cssVariables + +Type `boolean` +Default: `false` + +Create files for experimental CSS Variables support ([`cssSkeleton`](#cssSkeleton), [`cssSkeletonRtl`](#cssSkeletonRtl), [`cssVariablesSource`](#cssVariablesSource), [`cssVariables`](#cssVariables-1)). + ##### rootPaths Type: `array` of `string` @@ -212,6 +219,34 @@ Type: `array` Paths to files imported via import directives. +##### cssSkeleton + +Type: `string` +Only available when [`options.cssVariables`](#cssVariables) is set to `true` + +CSS with references to CSS Variables. + +##### cssSkeletonRtl + +Type: `string` +Only available when [`options.cssVariables`](#cssVariables) and [`options.rtl`](#rtl) are set to `true` + +Mirrored (right-to-left) CSS with references to CSS Variables (if `rtl` option was enabled). + +##### cssVariablesSource + +Type: `string` +Only available when [`options.cssVariables`](#cssVariables) is set to `true` + +Source file used to compile the `cssVariables` output. + +##### cssVariables + +Type: `string` +Only available when [`options.cssVariables`](#cssVariables) is set to `true` + +Definition of CSS Variables based on LESS variables. + ### .clearCache() Clears all cached build results. Use this method to prevent high memory consumption when building many themes within the same process. diff --git a/lib/index.js b/lib/index.js index 97a9d5e8..4c308dd9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -26,7 +26,6 @@ const scope = require("./scope"); const fileUtilsFactory = require("./fileUtils"); // Plugins -const RTLPlugin = require("./plugin/rtl"); const ImportCollectorPlugin = require("./plugin/import-collector"); const VariableCollectorPlugin = require("./plugin/variable-collector"); @@ -118,6 +117,15 @@ Builder.prototype.cacheTheme = function(result) { }); }; +/** + * Creates a themebuild + * @param {object} options + * @param {object} options.compiler compiler object as passed to less + * @param {boolean} options.cssVariables whether or not to enable css variables output + * @param {string} options.lessInput less string input + * @param {string} options.lessInputPath less file input + * @returns {{css: string, cssRtl: string, variables: {}, imports: [], cssSkeleton: string, cssSkeletonRtl: string, cssVariables: string, cssVariablesSource: string }} + */ Builder.prototype.build = function(options) { const that = this; @@ -162,7 +170,7 @@ Builder.prototype.build = function(options) { that.fileUtils.readFile(pathname, options.rootPaths).then(function(result) { if (!result) { // eslint-disable-next-line no-console - console.log("File not found"); + console.log("File not found: " + file); callback({type: "File", message: "'" + file + "' wasn't found"}); } else { try { @@ -194,6 +202,12 @@ Builder.prototype.build = function(options) { parserOptions.filename = config.localPath; } + // inject the library name as prefix (e.g. "sap.m" as "_sap_m") + if (options.library && typeof options.library.name === "string") { + const libName = config.libName = options.library.name; + config.prefix = "_" + libName.replace(/\./g, "_") + "_"; + } + // Keep filename for later usage (see ImportCollectorPlugin) as less modifies the parserOptions.filename const filename = parserOptions.filename; @@ -203,8 +217,9 @@ Builder.prototype.build = function(options) { fnFileHandler = fileHandler; } + const parser = createParser(parserOptions, fnFileHandler); + return new Promise(function(resolve, reject) { - const parser = createParser(parserOptions, fnFileHandler); parser.parse(config.content, function(err, tree) { if (err) { reject(err); @@ -224,7 +239,7 @@ Builder.prototype.build = function(options) { const oVariableCollector = new VariableCollectorPlugin(options.compiler); // render to css - result.css = tree.toCSS(assign(options.compiler, { + result.css = tree.toCSS(assign({}, options.compiler, { plugins: [oImportCollector, oVariableCollector] })); @@ -238,9 +253,15 @@ Builder.prototype.build = function(options) { result.allVariables = oVariableCollector.getAllVariables(); // also compile rtl-version if requested + let oRTL; if (options.rtl) { - result.cssRtl = tree.toCSS(assign(options.compiler, { - plugins: [new RTLPlugin()] + const RTLPlugin = require("./plugin/rtl"); + oRTL = new RTLPlugin(); + } + + if (oRTL) { + result.cssRtl = tree.toCSS(assign({}, options.compiler, { + plugins: [oRTL] })); } @@ -248,6 +269,47 @@ Builder.prototype.build = function(options) { result.imports.unshift(rootpath); } + // also compile css-variables version if requested + if (options.cssVariables) { + return new Promise(function(resolve, reject) { + // parse the content again to have a clean tree + parser.parse(config.content, function(err, tree) { + if (err) { + reject(err); + } else { + resolve(tree); + } + }); + }).then(function(tree) { + // generate the skeleton-css and the less-variables + const CSSVariablesCollectorPlugin = require("./plugin/css-variables-collector"); + const oCSSVariablesCollector = new CSSVariablesCollectorPlugin(config); + result.cssSkeleton = tree.toCSS(assign({}, options.compiler, { + plugins: [oCSSVariablesCollector] + })); + result.cssVariablesSource = oCSSVariablesCollector.toLessVariables(); + if (oRTL) { + const oCSSVariablesCollectorRTL = new CSSVariablesCollectorPlugin(config); + result.cssSkeletonRtl = tree.toCSS(assign({}, options.compiler, { + plugins: [oCSSVariablesCollectorRTL, oRTL] + })); + } + return tree; + }).then(function(tree) { + // generate the css-variables content out of the less-variables + return new Promise(function(resolve, reject) { + parser.parse(result.cssVariablesSource, function(err, tree) { + if (err) { + reject(err); + } else { + result.cssVariables = tree.toCSS(assign({}, options.compiler)); + resolve(result); + } + }); + }); + }); + } + return result; }); } @@ -273,6 +335,10 @@ Builder.prototype.build = function(options) { if (options.rtl) { result.cssRtl += parameterStyleRule; } + if (options.cssVariables) { + // for the css variables build we just add it to the variables + result.cssVariables += parameterStyleRule; + } } resolve(result); }); @@ -359,6 +425,14 @@ Builder.prototype.build = function(options) { results.embeddedCompare.cssRtl += applyScope(results.embeddedCompare.cssRtl, results.embedded.cssRtl, true); } + if (options.cssVariables) { + results.embeddedCompare.cssVariables += applyScope(results.embeddedCompare.cssVariables, results.embedded.cssVariables); + results.embeddedCompare.cssSkeleton += applyScope(results.embeddedCompare.cssSkeleton, results.embedded.cssSkeleton); + if (options.rtl) { + results.embeddedCompare.cssSkeletonRtl += applyScope(results.embeddedCompare.cssSkeletonRtl, results.embedded.cssSkeletonRtl, true); + } + } + // Create diff between embeddedCompare and embeded variables results.embeddedCompare.variables = getScopeVariables({ baseVariables: results.embeddedCompare.variables, diff --git a/lib/plugin/css-variables-collector.js b/lib/plugin/css-variables-collector.js new file mode 100644 index 00000000..7cdcda67 --- /dev/null +++ b/lib/plugin/css-variables-collector.js @@ -0,0 +1,211 @@ +// Copyright 2019 SAP SE. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http: //www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific +// language governing permissions and limitations under the License. + +"use strict"; + +const less = require("../thirdparty/less"); + +const CSSVariablesCollectorPlugin = module.exports = function(config) { + this.config = config; + // eslint-disable-next-line new-cap + this.native = new less.tree.visitor(this); + this.vars = {}; + this.calcVars = {}; + this.ruleStack = []; + this.mixinStack = []; + this.parenStack = []; + this.importStack = []; +}; + +CSSVariablesCollectorPlugin.prototype = { + + isPreEvalVisitor: true, + isReplacing: true, + + _isInMixinOrParen() { + return this.mixinStack.length > 0 || this.parenStack.length > 0; + }, + + _isVarInRule() { + return this.ruleStack.length > 0 && !this.ruleStack[this.ruleStack.length - 1].variable; + }, + + _isInGlobalOrBaseImport() { + return this.config.libName !== "sap.ui.core" && this.importStack.filter((importNode) => { + return /\/(?:global|base)\.less$/.test(importNode.importedFilename); + }).length > 0; + }, + + _isRelevant() { + return !this._isInMixinOrParen() && this._isVarInRule(); + }, + + toLessVariables() { + let lessVariables = ""; + Object.keys(this.vars).forEach((value, index) => { + lessVariables += "@" + value + ": " + this.vars[value].css + ";\n"; + }); + Object.keys(this.calcVars).forEach((value, index) => { + lessVariables += "@" + value + ": " + this.calcVars[value].css + ";\n"; + }); + lessVariables += "\n:root {\n"; + Object.keys(this.vars).forEach((value, index) => { + if (this.vars[value].export) { + lessVariables += "--" + value + ": @" + value + ";\n"; + } + }); + Object.keys(this.calcVars).forEach((value, index) => { + if (this.calcVars[value].export) { + lessVariables += "--" + value + ": @" + value + ";\n"; + } + }); + lessVariables += "}\n"; + return lessVariables; + }, + + _getCSS(node) { + let css = ""; + + // override: do not evaluate variables + less.tree.Variable.prototype.genCSS = function(env, output) { + new less.tree.Anonymous(this.name, this.index, this.currentFileInfo, this.mapLines).genCSS(env, output); + }; + // override: keep quoting for less variables + const fnQuotedgenCSS = less.tree.Quoted.prototype.genCSS; + less.tree.Quoted.prototype.genCSS = function(env, output) { + new less.tree.Anonymous((this.escaped ? "~" : "") + this.quote + this.value + this.quote, this.index, this.currentFileInfo, this.mapLines).genCSS(env, output); + }; + + // add the variable declaration to the list of vars + css = node.toCSS(); + + // reset overrides + less.tree.Variable.prototype.genCSS = undefined; + less.tree.Quoted.prototype.genCSS = fnQuotedgenCSS; + + return css; + }, + + run(root) { + return this.native.visit(root); + }, + + visitOperation(node, visitArgs) { + if (this._isRelevant()) { + return new less.tree.Call("calc", [new less.tree.Expression([node.operands[0], new less.tree.Anonymous(node.op), node.operands[1]])]); + } + return node; + }, + + visitCall(node, visitArgs) { + // if variables are used inside rules, generate a new dynamic variable for it! + const isRelevantFunction = typeof less.tree.functions[node.name] === "function" && ["rgba"].indexOf(node.name) === -1; + if (this._isRelevant() && isRelevantFunction) { + const css = this._getCSS(node); + let newName = this.config.prefix + "function_" + node.name + Object.keys(this.vars).length; + // check for duplicate value in vars already + for (const name in this.calcVars) { + if (this.calcVars[name].css === css) { + newName = name; + break; + } + } + this.calcVars[newName] = { + css: css, + export: !this._isInGlobalOrBaseImport() + }; + return new less.tree.Call("var", [new less.tree.Anonymous("--" + newName, node.index, node.currentFileInfo, node.mapLines)]); + } + return node; + }, + + visitNegative(node, visitArgs) { + // convert negative into calc function + if (this._isRelevant()) { + return new less.tree.Call("calc", [new less.tree.Expression([new less.tree.Anonymous("-1"), new less.tree.Anonymous("*"), node.value])]); + } + return node; + }, + + visitVariable(node, visitArgs) { + // convert less variables into CSS variables + if (this._isRelevant()) { + return new less.tree.Call("var", [new less.tree.Anonymous(node.name.replace(/^@/, "--", node.index, node.currentFileInfo, node.mapLines))]); + } + return node; + }, + + visitRule(node, visitArgs) { + // store the rule context for the call variable extraction + this.ruleStack.push(node); + return node; + }, + + visitRuleOut(node) { + // remove rule context + this.ruleStack.pop(); + return node; + }, + + visitMixinDefinition(node, visitArgs) { + // store the mixin context + this.mixinStack.push(node); + return node; + }, + + visitMixinDefinitionOut(node) { + // remove mixin context + this.mixinStack.pop(); + return node; + }, + + visitParen(node, visitArgs) { + // store the parenthesis context + this.parenStack.push(node); + return node; + }, + + visitParenOut(node) { + // remove parenthesis context + this.parenStack.pop(); + return node; + }, + + visitImport(node, visitArgs) { + // store the import context + this.importStack.push(node); + return node; + }, + + visitImportOut(node) { + // remove import context + this.importStack.pop(); + return node; + }, + + visitRuleset(node, visitArgs) { + node.rules.forEach((value) => { + const isVarDeclaration = value instanceof less.tree.Rule && typeof value.name === "string" && value.name.startsWith("@"); + if (!this._isInMixinOrParen() && isVarDeclaration) { + // add the variable declaration to the list of vars + this.vars[value.name.substr(1)] = { + css: this._getCSS(value.value), + export: !this._isInGlobalOrBaseImport() + }; + } + }); + return node; + } + +}; diff --git a/lib/scope.js b/lib/scope.js index 92e29c3f..b9ddc56a 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -58,8 +58,12 @@ function handleScoping(sSelector, sScopeName) { sSelector1 = sScopeName + aMatch[0]; sSelector2 = sScopeName + " " + aMatch[0]; } else { - // if selector matches custom css rule - if (aMatch[0].match(rRegex)) { + if (aMatch[0] === ":root") { + // remove the :root scope + sSelector1 = sScopeName; + sSelector2 = null; + } else if (aMatch[0].match(rRegex)) { + // selector matches custom css rule sSelector1 = aMatch[0] + sScopeName; sSelector2 = aMatch[0] + " " + sScopeName; } else { diff --git a/test/expected/complex/test-cssvars-skeleton.css b/test/expected/complex/test-cssvars-skeleton.css new file mode 100644 index 00000000..2f2005e0 --- /dev/null +++ b/test/expected/complex/test-cssvars-skeleton.css @@ -0,0 +1,14 @@ +a, +.link { + color: var(--link-color); +} +a:hover { + color: var(--link-color-hover); +} +.widget { + color: #fff; + background: var(--link-color); +} +.lazy-eval { + width: var(--var); +} diff --git a/test/expected/complex/test-cssvars-variables.css b/test/expected/complex/test-cssvars-variables.css new file mode 100644 index 00000000..13225101 --- /dev/null +++ b/test/expected/complex/test-cssvars-variables.css @@ -0,0 +1,6 @@ +:root { + --link-color: #428bca; + --link-color-hover: #3071a9; + --var: 9%; + --a: 9%; +} diff --git a/test/expected/complex/test.css b/test/expected/complex/test.css new file mode 100644 index 00000000..653be53a --- /dev/null +++ b/test/expected/complex/test.css @@ -0,0 +1,14 @@ +a, +.link { + color: #428bca; +} +a:hover { + color: #3071a9; +} +.widget { + color: #fff; + background: #428bca; +} +.lazy-eval { + width: 9%; +} diff --git a/test/expected/complex/test.min.css b/test/expected/complex/test.min.css new file mode 100644 index 00000000..a22ac0b6 --- /dev/null +++ b/test/expected/complex/test.min.css @@ -0,0 +1 @@ +.myRule{float:left;color:#f00} diff --git a/test/expected/simple/test-cssvars-skeleton.css b/test/expected/simple/test-cssvars-skeleton.css new file mode 100644 index 00000000..2ff94472 --- /dev/null +++ b/test/expected/simple/test-cssvars-skeleton.css @@ -0,0 +1,4 @@ +.myRule { + float: left; + color: var(--myColor); +} diff --git a/test/expected/simple/test-cssvars-variables.css b/test/expected/simple/test-cssvars-variables.css new file mode 100644 index 00000000..f0239f77 --- /dev/null +++ b/test/expected/simple/test-cssvars-variables.css @@ -0,0 +1,3 @@ +:root { + --myColor: #ff0000; +} diff --git a/test/fixtures/complex/test.less b/test/fixtures/complex/test.less new file mode 100644 index 00000000..a53d54cb --- /dev/null +++ b/test/fixtures/complex/test.less @@ -0,0 +1,25 @@ +// Variables +@link-color: #428bca; // sea blue +@link-color-hover: darken(@link-color, 10%); + +// Usage +a, +.link { + color: @link-color; +} +a:hover { + color: @link-color-hover; +} +.widget { + color: #fff; + background: @link-color; +} + +// Lazy Evaluation +.lazy-eval { + width: @var; + @a: 9%; +} + +@var: @a; +@a: 100%; diff --git a/test/test-css-vars.js b/test/test-css-vars.js new file mode 100644 index 00000000..495ef843 --- /dev/null +++ b/test/test-css-vars.js @@ -0,0 +1,50 @@ +// Copyright 2019 SAP SE. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http: //www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific +// language governing permissions and limitations under the License. + +/* eslint-env mocha */ +"use strict"; + +const assert = require("assert"); +const readFile = require("./common/helper").readFile; + +// tested module +const Builder = require("../").Builder; + +describe("css vars", function() { + it("should generate the correct css variables in a simple scenario", function() { + return new Builder().build({ + lessInput: readFile("test/fixtures/simple/test.less"), + cssVariables: true + }).then(function(result) { + assert.equal(result.css, readFile("test/expected/simple/test.css"), "css should be correctly generated."); + assert.equal(result.cssSkeleton, readFile("test/expected/simple/test-cssvars-skeleton.css"), + "css should be correctly generated."); + assert.equal(result.cssVariables, readFile("test/expected/simple/test-cssvars-variables.css"), + "css variables should be correctly generated."); + }); + }); + + it("should generate the correct css variables in a complex scenario", function() { + return new Builder().build({ + lessInput: readFile("test/fixtures/complex/test.less"), + cssVariables: true + }).then(function(result) { + assert.equal(result.css, readFile("test/expected/complex/test.css"), "css should be correctly generated."); + assert.equal(result.cssSkeleton, readFile("test/expected/complex/test-cssvars-skeleton.css"), + "css should be correctly generated."); + assert.equal(result.cssVariables, readFile("test/expected/complex/test-cssvars-variables.css"), + "css variables should be correctly generated."); + }); + }); +});