const log = require("@ui5/logger").getLogger("builder:processors:libraryLessGenerator");
const {promisify} = require("util");
const posixPath = require("path").posix;
const Resource = require("@ui5/fs").Resource;
const IMPORT_PATTERN = /@import .*"(.*)";/g;
const BASE_LESS_PATTERN = /^\/resources\/sap\/ui\/core\/themes\/([^/]+)\/base\.less$/;
const GLOBAL_LESS_PATTERN = /^\/resources\/sap\/ui\/core\/themes\/([^/]+)\/global\.less$/;
class ImportError extends Error {
constructor(message) {
super();
this.name = "ImportError";
this.message = message;
}
}
class LibraryLessGenerator {
constructor({fs}) {
const readFile = promisify(fs.readFile);
this.readFile = async (filePath) => readFile(filePath, {encoding: "utf8"});
}
async generate({filePath, fileContent}) {
return `/* NOTE: This file was generated as an optimized version of ` +
`"library.source.less" for the Theme Designer. */\n\n` +
await this.resolveLessImports({
filePath,
fileContent
});
}
static getPathToRoot(baseDir) {
return posixPath.relative(baseDir, "/") + "/";
}
async resolveLessImports({filePath, fileContent}) {
const imports = this.findLessImports(fileContent);
if (!imports.length) {
// Skip processing when no imports are found
return fileContent;
}
const replacements = await Promise.all(imports.map(async (importMatch) => {
const baseDir = posixPath.dirname(filePath);
const resolvedFilePath = posixPath.resolve(baseDir, importMatch.path);
try {
importMatch.content = await this.resolveLessImport(importMatch.path, resolvedFilePath, baseDir);
} catch (error) {
if (error instanceof ImportError) {
// Add message of import errors after the import statements
// Currently those errors should not break the build (see comments in resolveLessImport)
importMatch.content = importMatch.fullMatch + ` /* ${error} */`;
} else {
throw error;
}
}
return importMatch;
}));
// Apply replacements in reverse order to not modify the relevant indices
const array = Array.from(fileContent);
for (let i = replacements.length - 1; i >= 0; i--) {
const replacement = replacements[i];
if (!replacement.content) {
continue;
}
array.splice(
/* index */ replacement.matchStart,
/* count */ replacement.matchLength,
/* insert */ replacement.content
);
}
return array.join("");
}
async resolveLessImport(originalFilePath, resolvedFilePath, baseDir) {
// Rewrite base.less imports
const baseLessMatch = BASE_LESS_PATTERN.exec(resolvedFilePath);
if (baseLessMatch) {
let baseLessThemeName = baseLessMatch[1];
if (baseLessThemeName === "base") {
baseLessThemeName = "baseTheme";
}
const baseLessPath = LibraryLessGenerator.getPathToRoot(baseDir) +
"Base/baseLib/" + baseLessThemeName + "/base.less";
return "@import \"" + baseLessPath + "\"; /* ORIGINAL IMPORT PATH: \"" + originalFilePath + "\" */\n";
}
// Rewrite library imports to correct file name
if (posixPath.basename(resolvedFilePath) === "library.source.less") {
return `@import "${originalFilePath.replace(/library\.source\.less$/, "library.less")}";`;
}
// Excluding global.less within sap.ui.core
// It must be imported by the Theme Designer (also see declaration in sap/ui/core/.theming)
if (GLOBAL_LESS_PATTERN.test(resolvedFilePath)) {
return null;
}
/*
* Log error in case of files which are not in the same directory as the current file because
* inlining them would break relative URLs.
* A possible solution would be to rewrite relative URLs when inlining the content.
*
* Keeping the import will cause errors since only "library.less" and "global.less" are
* configured to be available to the Theme Designer (.theming generated in generateThemeDesignerResources).
* However, the previous implementation did not break the build.
* In many cases the library.less file is currently not relevant so breaking the build would cause
* unnecessary issues.
*
*/
const relativeFilePath = posixPath.relative(baseDir, resolvedFilePath);
if (relativeFilePath.includes(posixPath.sep)) {
log.error(
`Could not inline import '${resolvedFilePath}' outside of theme directory '${baseDir}'. ` +
`Stylesheets must be located in the theme directory (no sub-directories). ` +
`The generated '${baseDir}/library.less' will cause errors when compiled with the Theme Designer.`
);
// Throw error to be added as comment to the import statement
throw new ImportError("Could not inline import outside of theme directory");
}
let importedFileContent;
try {
importedFileContent = await this.readFile(resolvedFilePath);
} catch (err) {
if (err.code === "ENOENT") {
throw new Error(
`libraryLessGenerator: Unable to resolve import '${originalFilePath}' from '${baseDir}'\n` +
err.message
);
} else {
throw err;
}
}
return `/* START "${originalFilePath}" */\n` +
await this.resolveLessImports({
filePath: resolvedFilePath,
fileContent: importedFileContent
}) +
`\n/* END "${originalFilePath}" */\n`;
}
findLessImports(fileContent) {
const imports = [];
let match;
while ((match = IMPORT_PATTERN.exec(fileContent)) !== null) {
imports.push({
path: match[1],
fullMatch: match[0],
matchStart: match.index,
matchLength: match[0].length
});
}
return imports;
}
}
/**
* Creates a "library.less" file for the SAP Theme Designer based on a "library.source.less" file.
*
* <ul>
* <li>Bundles all *.less file of the theme by replacing the import with the corresponding file content</li>
* <li>Imports to "base.less" are adopted so that they point to the "BaseLib" that is available within
* the Theme Designer infrastructure</li>
* <li>Imports to "global.less" are kept as they should not be bundled</li>
* <li>Imports to "library.source.less" are adopted to "library.less"</li>
* </ul>
*
* @public
* @alias module:@ui5/builder.processors.libraryLessGenerator
* @param {object} parameters Parameters
* @param {module:@ui5/fs.Resource[]} parameters.resources List of <code>library.source.less</code>
* resources
* @param {fs|module:@ui5/fs.fsInterface} parameters.fs Node fs or custom
* [fs interface]{@link module:resources/module:@ui5/fs.fsInterface}
* @returns {Promise<module:@ui5/fs.Resource[]>} Promise resolving with library.less resources
*/
module.exports = async function({resources, fs}) {
const generator = new LibraryLessGenerator({fs});
return Promise.all(resources.map(async (librarySourceLessResource) => {
const filePath = librarySourceLessResource.getPath();
const fileContent = await librarySourceLessResource.getString();
log.verbose(`Generating library.less file based on ${filePath}`);
const libraryLessFileContent = await generator.generate({filePath, fileContent});
const libraryLessFilePath = posixPath.join(posixPath.dirname(filePath), "library.less");
return new Resource({
path: libraryLessFilePath,
string: libraryLessFileContent
});
}));
};
// Export class for testing only
/* istanbul ignore else */
if (process.env.NODE_ENV === "test") {
module.exports._LibraryLessGenerator = LibraryLessGenerator;
}