builder/lib/processors/libraryLessGenerator.js

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;
}