diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js
index 70e7bf32ae4..cd0441bbef2 100644
--- a/packages/project/lib/build/TaskRunner.js
+++ b/packages/project/lib/build/TaskRunner.js
@@ -53,6 +53,9 @@ class TaskRunner {
case "library":
buildDefinition = "./definitions/library.js";
break;
+ case "component":
+ buildDefinition = "./definitions/application.js";
+ break;
case "module":
buildDefinition = "./definitions/module.js";
break;
diff --git a/packages/project/lib/specifications/Specification.js b/packages/project/lib/specifications/Specification.js
index 71553e73af7..02bdc58036e 100644
--- a/packages/project/lib/specifications/Specification.js
+++ b/packages/project/lib/specifications/Specification.js
@@ -39,6 +39,9 @@ class Specification {
case "application": {
return createAndInitializeSpec("types/Application.js", parameters);
}
+ case "component": {
+ return createAndInitializeSpec("types/Component.js", parameters);
+ }
case "library": {
return createAndInitializeSpec("types/Library.js", parameters);
}
diff --git a/packages/project/lib/specifications/types/Component.js b/packages/project/lib/specifications/types/Component.js
new file mode 100644
index 00000000000..fbf77f94ec8
--- /dev/null
+++ b/packages/project/lib/specifications/types/Component.js
@@ -0,0 +1,303 @@
+import fsPath from "node:path";
+import posixPath from "node:path/posix";
+import ComponentProject from "../ComponentProject.js";
+import {createReader} from "@ui5/fs/resourceFactory";
+
+/**
+ * Component
+ *
+ * @public
+ * @class
+ * @alias @ui5/project/specifications/types/Component
+ * @extends @ui5/project/specifications/ComponentProject
+ * @hideconstructor
+ */
+class Component extends ComponentProject {
+ constructor(parameters) {
+ super(parameters);
+
+ this._pManifests = Object.create(null);
+
+ this._srcPath = "src";
+ this._testPath = "test";
+ this._testPathExists = false;
+
+ this._propertiesFilesSourceEncoding = "UTF-8";
+ }
+
+ /* === Attributes === */
+
+ /**
+ * Get the cachebuster signature type configuration of the project
+ *
+ * @returns {string} time or hash
+ */
+ getCachebusterSignatureType() {
+ return this._config.builder && this._config.builder.cachebuster &&
+ this._config.builder.cachebuster.signatureType || "time";
+ }
+
+ /**
+ * Get the path of the project's source directory. This might not be POSIX-style on some platforms.
+ *
+ * @public
+ * @returns {string} Absolute path to the source directory of the project
+ */
+ getSourcePath() {
+ return fsPath.join(this.getRootPath(), this._srcPath);
+ }
+
+ getSourcePaths() {
+ const paths = [this.getSourcePath()];
+ if (this._testPathExists) {
+ paths.push(fsPath.join(this.getRootPath(), this._testPath));
+ }
+ return paths;
+ }
+
+ getVirtualPath(sourceFilePath) {
+ const sourcePath = this.getSourcePath();
+ if (sourceFilePath.startsWith(sourcePath)) {
+ const relSourceFilePath = fsPath.relative(sourcePath, sourceFilePath);
+ let virBasePath = "/resources/";
+ if (!this._isSourceNamespaced) {
+ virBasePath += `${this._namespace}/`;
+ }
+ return posixPath.join(virBasePath, relSourceFilePath);
+ }
+
+ const testPath = fsPath.join(this.getRootPath(), this._testPath);
+ if (sourceFilePath.startsWith(testPath)) {
+ const relSourceFilePath = fsPath.relative(testPath, sourceFilePath);
+ let virBasePath = "/test-resources/";
+ if (!this._isSourceNamespaced) {
+ virBasePath += `${this._namespace}/`;
+ }
+ return posixPath.join(virBasePath, relSourceFilePath);
+ }
+
+ throw new Error(
+ `Unable to convert source path ${sourceFilePath} to virtual path for project ${this.getName()}`);
+ }
+
+ /* === Resource Access === */
+ /**
+ * Get a resource reader for the sources of the project (excluding any test resources)
+ *
+ * @param {string[]} excludes List of glob patterns to exclude
+ * @returns {@ui5/fs/ReaderCollection} Reader collection
+ */
+ _getSourceReader(excludes) {
+ return createReader({
+ fsBasePath: this.getSourcePath(),
+ virBasePath: `/resources/${this._namespace}/`,
+ name: `Source reader for component project ${this.getName()}`,
+ project: this,
+ excludes
+ });
+ }
+
+ /**
+ * Get a resource reader for the test-resources of the project
+ *
+ * @param {string[]} excludes List of glob patterns to exclude
+ * @returns {@ui5/fs/ReaderCollection} Reader collection
+ */
+ _getTestReader(excludes) {
+ if (!this._testPathExists) {
+ return null;
+ }
+ const testReader = createReader({
+ fsBasePath: fsPath.join(this.getRootPath(), this._testPath),
+ virBasePath: `/test-resources/${this._namespace}/`,
+ name: `Runtime test-resources reader for component project ${this.getName()}`,
+ project: this,
+ excludes
+ });
+ return testReader;
+ }
+
+ /**
+ * Get a resource reader for the sources of the project (excluding any test resources)
+ * without a virtual base path
+ *
+ * @returns {@ui5/fs/ReaderCollection} Reader collection
+ */
+ _getRawSourceReader() {
+ return createReader({
+ fsBasePath: this.getSourcePath(),
+ virBasePath: "/",
+ name: `Raw source reader for component project ${this.getName()}`,
+ project: this
+ });
+ }
+
+ /* === Internals === */
+ /**
+ * @private
+ * @param {object} config Configuration object
+ */
+ async _configureAndValidatePaths(config) {
+ await super._configureAndValidatePaths(config);
+
+ if (config.resources && config.resources.configuration && config.resources.configuration.paths) {
+ if (config.resources.configuration.paths.src) {
+ this._srcPath = config.resources.configuration.paths.src;
+ }
+ if (config.resources.configuration.paths.test) {
+ this._testPath = config.resources.configuration.paths.test;
+ }
+ }
+ if (!(await this._dirExists("/" + this._srcPath))) {
+ throw new Error(
+ `Unable to find source directory '${this._srcPath}' in component project ${this.getName()}`);
+ }
+ this._testPathExists = await this._dirExists("/" + this._testPath);
+
+ this._log.verbose(`Path mapping for component project ${this.getName()}:`);
+ this._log.verbose(` Physical root path: ${this.getRootPath()}`);
+ this._log.verbose(` Mapped to:`);
+ this._log.verbose(` /resources/ => ${this._srcPath}`);
+ this._log.verbose(
+ ` /test-resources/ => ${this._testPath}${this._testPathExists ? "" : " [does not exist]"}`);
+ }
+
+ /**
+ * @private
+ * @param {object} config Configuration object
+ * @param {object} buildDescription Cache metadata object
+ */
+ async _parseConfiguration(config, buildDescription) {
+ await super._parseConfiguration(config, buildDescription);
+
+ if (buildDescription) {
+ this._namespace = buildDescription.namespace;
+ return;
+ }
+ this._namespace = await this._getNamespace();
+ await this._ensureComponent();
+ }
+
+ /**
+ * Determine component namespace either based on a project`s
+ * manifest.json or manifest.appdescr_variant (fallback if present)
+ *
+ * @returns {string} Namespace of the project
+ * @throws {Error} if namespace can not be determined
+ */
+ async _getNamespace() {
+ try {
+ return await this._getNamespaceFromManifestJson();
+ } catch (manifestJsonError) {
+ if (manifestJsonError.code !== "ENOENT") {
+ throw manifestJsonError;
+ }
+ // No manifest.json present
+ // => attempt fallback to manifest.appdescr_variant (typical for App Variants)
+ try {
+ return await this._getNamespaceFromManifestAppDescVariant();
+ } catch (appDescVarError) {
+ if (appDescVarError.code === "ENOENT") {
+ // Fallback not possible: No manifest.appdescr_variant present
+ // => Throw error indicating missing manifest.json
+ // (do not mention manifest.appdescr_variant since it is only
+ // relevant for the rather "uncommon" App Variants)
+ throw new Error(
+ `Could not find required manifest.json for project ` +
+ `${this.getName()}: ${manifestJsonError.message}`);
+ }
+ throw appDescVarError;
+ }
+ }
+ }
+
+ /**
+ * Determine application namespace by checking manifest.json.
+ * Any maven placeholders are resolved from the projects pom.xml
+ *
+ * @returns {string} Namespace of the project
+ * @throws {Error} if namespace can not be determined
+ */
+ async _getNamespaceFromManifestJson() {
+ const manifest = await this._getManifest("/manifest.json");
+ let appId;
+ // check for a proper sap.app/id in manifest.json to determine namespace
+ if (manifest["sap.app"] && manifest["sap.app"].id) {
+ appId = manifest["sap.app"].id;
+ } else {
+ throw new Error(
+ `No sap.app/id configuration found in manifest.json of project ${this.getName()}`);
+ }
+
+ if (this._hasMavenPlaceholder(appId)) {
+ try {
+ appId = await this._resolveMavenPlaceholder(appId);
+ } catch (err) {
+ throw new Error(
+ `Failed to resolve namespace of project ${this.getName()}: ${err.message}`);
+ }
+ }
+ const namespace = appId.replace(/\./g, "/");
+ this._log.verbose(
+ `Namespace of project ${this.getName()} is ${namespace} (from manifest.json)`);
+ return namespace;
+ }
+
+ /**
+ * Determine application namespace by checking manifest.appdescr_variant.
+ *
+ * @returns {string} Namespace of the project
+ * @throws {Error} if namespace can not be determined
+ */
+ async _getNamespaceFromManifestAppDescVariant() {
+ const manifest = await this._getManifest("/manifest.appdescr_variant");
+ let appId;
+ // check for the id property in manifest.appdescr_variant to determine namespace
+ if (manifest && manifest.id) {
+ appId = manifest.id;
+ } else {
+ throw new Error(
+ `No "id" property found in manifest.appdescr_variant of project ${this.getName()}`);
+ }
+
+ const namespace = appId.replace(/\./g, "/");
+ this._log.verbose(
+ `Namespace of project ${this.getName()} is ${namespace} (from manifest.appdescr_variant)`);
+ return namespace;
+ }
+
+ /**
+ * Reads and parses a JSON file with the provided name from the projects source directory
+ *
+ * @param {string} filePath Name of the JSON file to read. Typically "manifest.json" or "manifest.appdescr_variant"
+ * @returns {Promise