less-openui5
Version:
Build OpenUI5 themes with Less.js
403 lines (331 loc) • 13.7 kB
JavaScript
const path = require("path");
const css = require("@adobe/css-tools");
const diff = require("./diff");
const scope = require("./scope");
const fileUtilsFactory = require("./fileUtils");
const Compiler = require("./Compiler");
const themingParametersDataUri = require("./themingParameters/dataUri");
// Workaround for a performance issue in the "css" parser module when used in combination
// with the "colors" module that enhances the String prototype.
// See: https://github.com/reworkcss/css/issues/88
//
// This function removes all properties added by "colors" and returns a function
// that restores them afterwards.
// To be used before/after calling "css.parse"
function cleanupStringPrototype() {
const customGetters = {}; const customProps = {}; const s = ""; let key;
for (key in s) {
if (!Object.prototype.hasOwnProperty.call(s, key)) {
const getter = String.prototype.__lookupGetter__(key);
if (typeof getter === "function") {
customGetters[key] = getter;
} else {
customProps[key] = String.prototype[key];
}
if (typeof Reflect !== "undefined") {
Reflect.deleteProperty(String.prototype, key);
} else {
delete String.prototype[key];
}
}
}
return function restore() {
for (key in customGetters) {
if (Object.prototype.hasOwnProperty.call(customGetters, key)) {
String.prototype.__defineGetter__(key, customGetters[key]);
}
}
for (key in customProps) {
if (Object.prototype.hasOwnProperty.call(customProps, key)) {
// eslint-disable-next-line no-extend-native
String.prototype[key] = customProps[key];
}
}
};
}
function dotThemingFileToPath(s) {
if (s.indexOf(".") > -1) {
s = "../" + s.replace(/\./g, "/");
}
return s;
}
const Builder = function(options) {
this.themeCacheMapping = {};
this.customFs = options ? options.fs : null;
this.fileUtils = fileUtilsFactory(this.customFs);
};
Builder.prototype.getThemeCache = function(rootPath) {
return this.themeCacheMapping[rootPath];
};
Builder.prototype.setThemeCache = function(rootPath, cache) {
return this.themeCacheMapping[rootPath] = cache;
};
Builder.prototype.clearCache = function() {
this.themeCacheMapping = {};
};
Builder.prototype.cacheTheme = function(result) {
const that = this;
// Theme can only be cached if list of imports is available
if (result.imports.length === 0) {
return Promise.resolve(result);
}
// Rootpath of theme is always the first entry
const rootpath = result.imports[0];
return that.fileUtils.statFiles(result.imports).then(function(stats) {
that.setThemeCache(rootpath, {
result: result,
stats: stats
});
return result;
});
};
/**
* Runs a theme build
* @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;
// Assign default options
options = Object.assign({
lessInput: null,
lessInputPath: null,
rtl: true,
rootPaths: [],
parser: {},
compiler: {},
library: {},
scope: {}
}, options);
if (options.compiler.sourceMap) {
return Promise.reject(new Error("compiler.sourceMap option is not supported!"));
}
if (options.compiler.cleancss) {
return Promise.reject(new Error("compiler.cleancss option is not supported! Please use 'clean-css' directly."));
}
// Set default of "relativeUrls" parser option to "true" (less default is "false")
if (!Object.prototype.hasOwnProperty.call(options.parser, "relativeUrls")) {
options.parser.relativeUrls = true;
}
const compiler = new Compiler({
options,
fileUtils: this.fileUtils,
customFs: this.customFs
});
function addInlineParameters(result) {
return themingParametersDataUri.addInlineParameters({result, options});
}
function getScopeVariables(options) {
const sScopeName = options.scopeName;
const oVariablesBase = options.baseVariables;
const oVariablesEmbedded = options.embeddedVariables;
const oVariablesDiff = {};
for (const sKey in oVariablesEmbedded) {
if (sKey in oVariablesBase) {
if (oVariablesBase[sKey] != oVariablesEmbedded[sKey]) {
oVariablesDiff[sKey] = oVariablesEmbedded[sKey];
}
}
}
// Merge variables
const oVariables = {};
oVariables["default"] = oVariablesBase;
oVariables["scopes"] = {};
oVariables["scopes"][sScopeName] = oVariablesDiff;
return oVariables;
}
function compileWithScope(scopeOptions) {
// 1. Compile base + embedded files (both default + RTL variants)
return Promise.all([
that.fileUtils.readFile(scopeOptions.embeddedCompareFilePath, options.rootPaths).then(function(config) {
if (!config) {
throw new Error("Could not find embeddedCompareFile at path '" + scopeOptions.embeddedCompareFilePath + "'");
}
return compiler.compile(config);
}),
that.fileUtils.readFile(scopeOptions.embeddedFilePath, options.rootPaths).then(function(config) {
if (!config) {
throw new Error("Could not find embeddedFile at path '" + scopeOptions.embeddedFilePath + "'");
}
return compiler.compile(config);
})
]).then(function(results) {
return {
embeddedCompare: results[0],
embedded: results[1]
};
}).then(function(results) {
const sScopeName = scopeOptions.selector;
function applyScope(embeddedCompareCss, embeddedCss, bRtl) {
const restoreStringPrototype = cleanupStringPrototype();
// Create diff object between embeddedCompare and embedded
const oBase = css.parse(embeddedCompareCss);
const oEmbedded = css.parse(embeddedCss);
restoreStringPrototype();
const oDiff = diff(oBase, oEmbedded);
// Create scope
const sScopeSelector = "." + sScopeName;
const oScope = scope(oDiff.diff, sScopeSelector);
let oCssScopeRoot;
if (oDiff.stack) {
oCssScopeRoot = scope.scopeCssRoot(oDiff.stack.stylesheet.rules, sScopeName);
if (oCssScopeRoot) {
oScope.stylesheet.rules.unshift(oCssScopeRoot);
}
}
// Append scope + stack to embeddedCompareFile (actually target file, which is currently always the same i.e. "library.css")
// The stack gets appended to the embeddedFile only
let sAppend = css.stringify(oScope, {
compress: options.compiler && options.compiler.compress === true
});
if (scopeOptions.baseFilePath !== options.lessInputPath && oDiff.stack && oDiff.stack.stylesheet.rules.length > 0) {
sAppend += "\n" + css.stringify(oDiff.stack, {
compress: options.compiler && options.compiler.compress === true
});
}
return sAppend + "\n";
}
results.embeddedCompare.css += applyScope(results.embeddedCompare.css, results.embedded.css);
if (options.rtl) {
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,
embeddedVariables: results.embedded.variables,
scopeName: sScopeName
});
results.embeddedCompare.allVariables = getScopeVariables({
baseVariables: results.embeddedCompare.allVariables,
embeddedVariables: results.embedded.allVariables,
scopeName: sScopeName
});
const concatImports = function(aImportsBase, aImportsEmbedded) {
const aConcats = aImportsBase.concat(aImportsEmbedded);
return aConcats.filter(function(sImport, sIndex) {
return aConcats.indexOf(sImport) == sIndex;
});
};
if (scopeOptions.baseFilePath !== options.lessInputPath) {
results.embeddedCompare.imports = concatImports(results.embedded.imports, results.embeddedCompare.imports);
} else {
results.embeddedCompare.imports = concatImports(results.embeddedCompare.imports, results.embedded.imports);
}
// 6. Resolve promise with complete result object (css, cssRtl, variables, imports)
return results.embeddedCompare;
});
}
function readDotTheming(dotThemingInputPath) {
return that.fileUtils.readFile(dotThemingInputPath, options.rootPaths).then(function(result) {
let dotTheming;
let dotThemingFilePath;
if (result) {
dotTheming = JSON.parse(result.content);
dotThemingFilePath = result.path;
}
if (dotTheming && dotTheming.mCssScopes) {
// Currently only the "library" target is supported
const cssScope = dotTheming.mCssScopes["library"];
if (cssScope) {
const aScopes = cssScope.aScopes;
const oScopeConfig = aScopes[0]; // Currenlty only one scope is supported
const sBaseFile = dotThemingFileToPath(cssScope.sBaseFile);
const sEmbeddedCompareFile = dotThemingFileToPath(oScopeConfig.sEmbeddedCompareFile);
const sEmbeddedFile = dotThemingFileToPath(oScopeConfig.sEmbeddedFile);
// Currently, only support the use case when "sBaseFile" equals "sEmbeddedCompareFile"
if (sBaseFile !== sEmbeddedCompareFile) {
throw new Error("Unsupported content in \"" + dotThemingInputPath + "\": " +
"\"sBaseFile\" (\"" + cssScope.sBaseFile + "\") must be identical with " +
"\"sEmbeddedCompareFile\" (\"" + oScopeConfig.sEmbeddedCompareFile + "\")");
}
const baseFilePath = path.posix.join(themeDir, sBaseFile) + ".source.less";
const embeddedCompareFilePath = path.posix.join(themeDir, sEmbeddedCompareFile) + ".source.less";
const embeddedFilePath = path.posix.join(themeDir, sEmbeddedFile) + ".source.less";
// 1. Compile base + embedded files (both default + RTL variants)
return compileWithScope({
selector: oScopeConfig.sSelector,
embeddedFilePath: embeddedFilePath,
embeddedCompareFilePath: embeddedCompareFilePath,
baseFilePath: baseFilePath
}).then(function(embeddedCompare) {
embeddedCompare.imports.push(dotThemingFilePath);
return embeddedCompare;
});
}
}
// No css diffing and scoping
return that.fileUtils.readFile(options.lessInputPath, options.rootPaths).then((config) => {
return compiler.compile(config);
});
});
}
if (options.lessInput && options.lessInputPath) {
return Promise.reject(new Error("Please only provide either `lessInput` or `lessInputPath` but not both."));
}
if (!options.lessInput && !options.lessInputPath) {
return Promise.reject(new Error("Missing required option. Please provide either `lessInput` or `lessInputPath`."));
}
if (options.lessInput) {
return compiler.compile({
content: options.lessInput
}).then(addInlineParameters).then(that.cacheTheme.bind(that));
} else {
// TODO: refactor
// eslint-disable-next-line no-var
var themeDir = path.posix.dirname(options.lessInputPath);
// Always use the sap/ui/core library to lookup .theming files
let coreThemeDir;
if (options.library && typeof options.library.name === "string") {
const libraryNamespace = options.library.name.replace(/\./g, "/");
coreThemeDir = themeDir.replace(libraryNamespace, "sap/ui/core");
} else {
coreThemeDir = themeDir.replace(/^.*\/themes\//, "/sap/ui/core/themes/");
}
const dotThemingInputPath = path.posix.join(coreThemeDir, ".theming");
return that.fileUtils.findFile(options.lessInputPath, options.rootPaths).then(function(fileInfo) {
if (!fileInfo) {
throw new Error("`lessInputPath` " + options.lessInputPath + " could not be found.");
}
// check theme has been already cached
let themeCache;
if (fileInfo.path) {
themeCache = that.getThemeCache(fileInfo.path);
}
const scopeOptions = options.scope;
// Compile theme if not cached or RTL is requested and missing in cache
if (!themeCache || (options.rtl && !themeCache.result.cssRtl)) {
if (scopeOptions.selector && scopeOptions.embeddedFilePath && scopeOptions.embeddedCompareFilePath && scopeOptions.baseFilePath) {
return compileWithScope(scopeOptions).then(addInlineParameters).then(that.cacheTheme.bind(that));
}
return readDotTheming(dotThemingInputPath).then(addInlineParameters).then(that.cacheTheme.bind(that));
} else {
return that.fileUtils.statFiles(themeCache.result.imports).then(function(newStats) {
for (let i = 0; i < newStats.length; i++) {
// check if .theming and less files have changed since last less compilation
if (!newStats[i] || newStats[i].stat.mtime.getTime() !== themeCache.stats[i].stat.mtime.getTime()) {
if (scopeOptions.selector && scopeOptions.embeddedFilePath && scopeOptions.embeddedCompareFilePath && scopeOptions.baseFilePath) {
return compileWithScope(scopeOptions).then(addInlineParameters).then(that.cacheTheme.bind(that));
}
return readDotTheming(dotThemingInputPath).then(addInlineParameters).then(that.cacheTheme.bind(that));
}
}
// serve from cache
return themeCache.result;
});
}
});
}
};
module.exports.Builder = Builder;
;