builder/lib/tasks/generateThemeDesignerResources.js

import posixPath from "node:path/posix";
import {getLogger} from "@ui5/logger";
const log = getLogger("builder:tasks:generateThemeDesignerResources");
import libraryLessGenerator from "../processors/libraryLessGenerator.js";
import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized";
import Resource from "@ui5/fs/Resource";
import fsInterface from "@ui5/fs/fsInterface";

/**
 * Returns a relative path from the given themeFolder to the root namespace.
 *
 * When combining the given themeFolder with the returned relative path it
 * resolves to "/resources/". However the "/resources/" part is not important
 * here as it doesn't exist within the theming engine environment where the
 * UI5 resources are part of a "UI5" folder (e.g. "UI5/sap/ui/core/") that
 * is next to a "Base" folder.
 *
 * @example
 * getPathToRoot("/resources/sap/ui/foo/themes/base")
 * > "../../../../../"
 *
 * @param {string} themeFolder Virtual path including /resources/
 * @returns {string} Relative path to root namespace
 */
function getPathToRoot(themeFolder) {
	// -2 for initial "/"" and "resources/"
	return "../".repeat(themeFolder.split("/").length - 2);
}

/**
 * Generates an less import statement for the given <code>filePath</code>
 *
 * @param {string} filePath The path to the desired file
 * @returns {string} The less import statement
 */
function lessImport(filePath) {
	return `@import "${filePath}";\n`;
}

function generateLibraryDotTheming({namespace, version, hasThemes}) {
	const dotTheming = {
		sEntity: "Library",
		sId: namespace,
		sVersion: version
	};

	if (namespace === "sap/ui/core") {
		dotTheming.aFiles = [
			"library",
			"global", // Additional entry compared to UI5 root .theming
			"css_variables",
		];
	}
	if (!hasThemes) {
		// Set ignore flag when there are no themes at all
		// This is important in case a library used to contain themes that have been removed
		// in a later version of the library.
		dotTheming.bIgnore = true;
	}

	return new Resource({
		path: `/resources/${namespace}/.theming`,
		string: JSON.stringify(dotTheming, null, 2)
	});
}

async function generateThemeDotTheming({workspace, combo, themeFolder}) {
	const themeName = posixPath.basename(themeFolder);
	const libraryMatchPattern = /^\/resources\/(.*)\/themes\/[^/]*$/i;
	const libraryMatch = libraryMatchPattern.exec(themeFolder);
	let libraryName;
	if (libraryMatch) {
		libraryName = libraryMatch[1].replace(/\//g, ".");
	} else {
		throw new Error(`Failed to extract library name from theme folder path: ${themeFolder}`);
	}

	const dotThemingTargetPath = posixPath.join(themeFolder, ".theming");
	if (libraryName === "sap.ui.core") {
		// sap.ui.core should always have a .theming file for all themes

		if (await workspace.byPath(dotThemingTargetPath)) {
			// .theming file present, skip further processing
			return;
		} else {
			throw new Error(`.theming file for theme ${themeName} missing in sap.ui.core library source`);
		}
	}

	let newDotThemingResource;
	const coreDotThemingResource = await combo.byPath(`/resources/sap/ui/core/themes/${themeName}/.theming`);

	if (coreDotThemingResource) {
		// Copy .theming file from core
		newDotThemingResource = await coreDotThemingResource.clone();
		newDotThemingResource.setPath(dotThemingTargetPath);
	} else {
		// No core .theming file found for this theme => Generate a .theming file
		const dotTheming = {
			sEntity: "Theme",
			sId: themeName,
			sVendor: "SAP"
		};

		if (themeName !== "base") {
			dotTheming.oExtends = "base";
		}

		newDotThemingResource = new Resource({
			path: dotThemingTargetPath,
			string: JSON.stringify(dotTheming, null, 2)
		});
	}
	return newDotThemingResource;
}

async function createCssVariablesLessResource({workspace, combo, themeFolder}) {
	const pathToRoot = getPathToRoot(themeFolder);
	const cssVariablesSourceLessFile = "css_variables.source.less";
	const cssVariablesLessFile = "css_variables.less";

	// posix as it is a virtual path (separated with /)
	const themeName = posixPath.basename(themeFolder);
	// The "base" theme of the baseLib is called "baseTheme"
	const baseLibThemeName = themeName === "base" ? "baseTheme" : themeName;

	// Some themes do not have a base.less file (e.g. sap_hcb)
	const hasBaseLess = !!(await combo.byPath(`/resources/sap/ui/core/themes/${themeName}/base.less`));

	let cssVariablesLess =
`/* NOTE: This file was generated as an optimized version of "${cssVariablesSourceLessFile}" \
for the Theme Designer. */\n\n`;

	if (themeName !== "base") {
		const cssVariablesSourceLessResource = await workspace.byPath(
			posixPath.join(themeFolder, cssVariablesSourceLessFile)
		);

		if (!cssVariablesSourceLessResource) {
			throw new Error(`Could not find file "${cssVariablesSourceLessFile}" in theme "${themeFolder}"`);
		}

		const cssVariablesSourceLess = await cssVariablesSourceLessResource.getString();

		cssVariablesLess += lessImport(`../base/${cssVariablesLessFile}`);
		cssVariablesLess += `
/* START "${cssVariablesSourceLessFile}" */
${cssVariablesSourceLess}
/* END "${cssVariablesSourceLessFile}" */

`;
	}

	if (hasBaseLess) {
		cssVariablesLess += lessImport(`${pathToRoot}../Base/baseLib/${baseLibThemeName}/base.less`);
	}
	cssVariablesLess += lessImport(`${pathToRoot}sap/ui/core/themes/${themeName}/global.less`);

	return new Resource({
		path: posixPath.join(themeFolder, cssVariablesLessFile),
		string: cssVariablesLess
	});
}

async function generateCssVariablesLess({workspace, combo, namespace}) {
	let cssVariablesSourceLessResourcePattern;
	if (namespace) {
		// In case of a library only check for themes directly below the namespace
		cssVariablesSourceLessResourcePattern = `/resources/${namespace}/themes/*/css_variables.source.less`;
	} else {
		// In case of a theme-library check for all "themes"
		cssVariablesSourceLessResourcePattern = `/resources/**/themes/*/css_variables.source.less`;
	}

	const cssVariablesSourceLessResource = await workspace.byGlob(cssVariablesSourceLessResourcePattern);

	const hasCssVariables = cssVariablesSourceLessResource.length > 0;

	if (hasCssVariables) {
		await Promise.all(
			cssVariablesSourceLessResource.map(async (cssVariableSourceLess) => {
				const themeFolder = posixPath.dirname(cssVariableSourceLess.getPath());
				log.verbose(`Generating css_variables.less for theme ${themeFolder}`);
				const r = await createCssVariablesLessResource({
					workspace, combo, themeFolder
				});
				return await workspace.write(r);
			})
		);
	}
}

/**
 * @public
 * @module @ui5/builder/tasks/generateThemeDesignerResources
 */

/* eslint "jsdoc/check-param-names": ["error", {"disableExtraPropertyReporting":true}] */
/**
 * Generates resources required for integration with the SAP Theme Designer.
 *
 * @public
 * @function default
 * @static
 *
 * @param {object} parameters Parameters
 * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files
 * @param {@ui5/fs/AbstractReader} parameters.dependencies Reader or Collection to read dependency files
 * @param {object} parameters.options Options
 * @param {string} parameters.options.projectName Project name
 * @param {string} parameters.options.version Project version
 * @param {string} [parameters.options.projectNamespace] If the project is of type <code>library</code>,
 * 														 provide its namespace.
 * Omit for type <code>theme-library</code>
 * @returns {Promise<undefined>} Promise resolving with <code>undefined</code> once data has been written
 */
export default async function({workspace, dependencies, options}) {
	const {projectName, version} = options;
	// Backward compatibility: "namespace" option got renamed to "projectNamespace"
	const namespace = options.projectNamespace || options.namespace;

	// Skip sap.ui.documentation since it is not intended to be available in SAP Theme Designer to create custom themes
	if (namespace === "sap/ui/documentation") {
		return;
	}

	let librarySourceLessPattern;
	if (namespace) {
		// In case of a library only check for themes directly below the namespace
		librarySourceLessPattern = `/resources/${namespace}/themes/*/library.source.less`;
	} else {
		// In case of a theme-library check for all "themes"
		librarySourceLessPattern = `/resources/**/themes/*/library.source.less`;
	}

	const librarySourceLessResources = await workspace.byGlob(librarySourceLessPattern);

	const hasThemes = librarySourceLessResources.length > 0;

	// library .theming file
	// Only for type "library". Type "theme-library" does not provide a namespace
	// Also needs to be created in case a library does not have any themes (see bIgnore flag)
	if (namespace) {
		log.verbose(`Generating .theming for namespace ${namespace}`);
		const libraryDotThemingResource = generateLibraryDotTheming({
			namespace,
			version,
			hasThemes
		});
		await workspace.write(libraryDotThemingResource);
	}

	if (!hasThemes) {
		// Skip further processing as there are no themes
		return;
	}

	const combo = new ReaderCollectionPrioritized({
		name: `generateThemeDesignerResources - prioritize workspace over dependencies: ${projectName}`,
		readers: [workspace, dependencies]
	});

	// theme .theming files
	const themeDotThemingFiles = await Promise.all(
		librarySourceLessResources.map((librarySourceLess) => {
			const themeFolder = posixPath.dirname(librarySourceLess.getPath());
			log.verbose(`Generating .theming for theme ${themeFolder}`);
			return generateThemeDotTheming({
				workspace, combo, themeFolder
			});
		})
	);
	await Promise.all(
		themeDotThemingFiles.map(async (resource) => {
			if (resource) {
				await workspace.write(resource);
			}
		})
	);

	// library.less files
	const libraryLessResources = await libraryLessGenerator({
		resources: librarySourceLessResources,
		fs: fsInterface(combo),
	});
	await Promise.all(
		libraryLessResources.map((resource) => workspace.write(resource))
	);

	// css_variables.less
	await generateCssVariablesLess({workspace, combo, namespace});
}