diff --git a/package.json b/package.json index 296b54b4cdf2..e60065f2f716 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "type": "module", "scripts": { "generate": "yarn workspaces foreach --all --parallel run generate", - "generateAPI": "yarn workspaces foreach --all --parallel run generateAPI", + "generateAPI": "yarn generateCEM && yarn mergeCEM && yarn validateCEM", + "generateCEM": "yarn workspaces foreach --all --parallel run generateCEM", + "mergeCEM": "yarn workspaces foreach --all --parallel run mergeCEM", + "validateCEM": "yarn workspaces foreach --all --parallel run validateCEM", "generateProd": "yarn workspaces foreach --all --parallel run generateProd", "ts": "tsc -b", "bundle": "yarn workspaces foreach --all --parallel run bundle", diff --git a/packages/ai/package.json b/packages/ai/package.json index 362ca635c3dc..df2ea53e96b9 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -22,7 +22,9 @@ "build": "wc-dev build", "watch": "wc-dev watch", "generate": "wc-dev generate", - "generateAPI": "wc-dev generateAPI", + "generateCEM": "wc-dev generateAPI.generateCEM", + "mergeCEM": "wc-dev generateAPI.mergeCEM", + "validateCEM": "wc-dev generateAPI.validateCEM", "bundle": "wc-dev build.bundle", "test": "yarn test:cypress", "test:cypress": "wc-dev test-cy-ci", diff --git a/packages/base/package-scripts.cjs b/packages/base/package-scripts.cjs index ab745ad85e00..0e52cc499561 100644 --- a/packages/base/package-scripts.cjs +++ b/packages/base/package-scripts.cjs @@ -70,12 +70,12 @@ const scripts = { "ui5": `ui5nps-script "${LIB}copy-and-watch/index.js" "dist/sap/**/*" dist/prod/sap/`, "preact": `ui5nps-script "${LIB}copy-and-watch/index.js" "dist/thirdparty/preact/**/*.js" dist/prod/thirdparty/preact/`, "assets": `ui5nps-script "${LIB}copy-and-watch/index.js" "dist/generated/assets/**/*.json" dist/prod/generated/assets/`, - } -}, + } + }, generateAPI: { - default: "ui5nps generateAPI.generateCEM generateAPI.validateCEM", generateCEM: `ui5nps-script "${LIB}/cem/cem.js" analyze --config "${LIB}cem/custom-elements-manifest.config.mjs"`, validateCEM: `ui5nps-script "${LIB}/cem/validate.js"`, + mergeCEM: `ui5nps-script "${LIB}cem/merge.mjs"`, }, watch: { default: 'ui5nps-p watch.src watch.styles', // concurently diff --git a/packages/base/package.json b/packages/base/package.json index 31364ea7e6a0..df905ba32ae7 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -48,7 +48,9 @@ "start": "wc-dev start", "build": "wc-dev build", "generate": "wc-dev generate", - "generateAPI": "wc-dev generateAPI", + "generateCEM": "wc-dev generateAPI.generateCEM", + "mergeCEM": "wc-dev generateAPI.mergeCEM", + "validateCEM": "wc-dev generateAPI.validateCEM", "generateProd": "wc-dev generateProd", "bundle": "wc-dev build.bundle", "test": "wc-dev test", diff --git a/packages/compat/package.json b/packages/compat/package.json index 155d86e3023e..019f536000d8 100644 --- a/packages/compat/package.json +++ b/packages/compat/package.json @@ -22,7 +22,9 @@ "build": "wc-dev build", "watch": "wc-dev watch", "generate": "wc-dev generate", - "generateAPI": "wc-dev generateAPI", + "generateCEM": "wc-dev generateAPI.generateCEM", + "mergeCEM": "wc-dev generateAPI.mergeCEM", + "validateCEM": "wc-dev generateAPI.validateCEM", "bundle": "wc-dev build.bundle", "test": "yarn test:cypress", "test:cypress": "wc-dev test-cy-ci", diff --git a/packages/fiori/package.json b/packages/fiori/package.json index ae49fb2e7311..d2a849e246fa 100644 --- a/packages/fiori/package.json +++ b/packages/fiori/package.json @@ -36,7 +36,9 @@ "watch": "wc-dev watch", "build": "wc-dev build", "generate": "wc-dev generate", - "generateAPI": "wc-dev generateAPI", + "generateCEM": "wc-dev generateAPI.generateCEM", + "mergeCEM": "wc-dev generateAPI.mergeCEM", + "validateCEM": "wc-dev generateAPI.validateCEM", "bundle": "wc-dev build.bundle", "test": "yarn test:cypress", "test:ssr": "node -e \"import('./test/ssr/component-imports.js')\"", diff --git a/packages/main/package.json b/packages/main/package.json index d7961ff407d3..7b5455f254c2 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -21,7 +21,9 @@ "start": "wc-dev start", "watch": "wc-dev watch", "generate": "wc-dev generate", - "generateAPI": "wc-dev generateAPI", + "generateCEM": "wc-dev generateAPI.generateCEM", + "mergeCEM": "wc-dev generateAPI.mergeCEM", + "validateCEM": "wc-dev generateAPI.validateCEM", "build": "wc-dev build", "bundle": "wc-dev build.bundle", "test": "yarn test:cypress", diff --git a/packages/tools/components-package/nps.js b/packages/tools/components-package/nps.js index c0789c7760d7..2ef832ecabc0 100644 --- a/packages/tools/components-package/nps.js +++ b/packages/tools/components-package/nps.js @@ -85,7 +85,7 @@ const getScripts = (options) => { styleRelated: "ui5nps build.styles build.jsonImports build.jsImports", }, prepare: { - default: `ui5nps clean prepare.all copy copyProps prepare.typescript generateAPI`, + default: `ui5nps clean prepare.all copy copyProps prepare.typescript`, all: `ui5nps-p build.templates build.i18n prepare.styleRelated build.illustrations`, // concurently styleRelated: "ui5nps build.styles build.jsonImports build.jsImports", typescript: tsCommandOld, @@ -164,9 +164,9 @@ const getScripts = (options) => { bundle: `ui5nps-script ${LIB}dev-server/dev-server.mjs ${viteConfig}`, }, generateAPI: { - default: tsOption ? "ui5nps generateAPI.generateCEM generateAPI.validateCEM" : "", generateCEM: `ui5nps-script "${LIB}cem/cem.js" analyze --config "${LIB}cem/custom-elements-manifest.config.mjs"`, validateCEM: `ui5nps-script "${LIB}cem/validate.js"`, + mergeCEM: `ui5nps-script "${LIB}cem/merge.mjs"`, }, }; diff --git a/packages/tools/lib/cem/merge.mjs b/packages/tools/lib/cem/merge.mjs new file mode 100644 index 000000000000..2569fd7e1b28 --- /dev/null +++ b/packages/tools/lib/cem/merge.mjs @@ -0,0 +1,220 @@ +import { pathToFileURL } from "url"; +import path from "path"; +import { createRequire } from 'module'; +import { readFile, writeFile } from "fs/promises"; + +const require = createRequire(import.meta.url); + +const UI5_BASE_CLASS = "UI5Element"; + +const main = async (argv) => { + let customElementsPath = null; + const CACHED_CEMS = new Map(); + const DECLARATION_PACKAGE = new WeakMap(); + const DECLARATION_MODULE = new WeakMap(); + + function removeInheritedFrom(obj) { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => removeInheritedFrom(item)); + } + + const result = {}; + for (const [key, value] of Object.entries(obj)) { + if (key === 'inheritedFrom') { + continue; + } + result[key] = removeInheritedFrom(value); + } + return result; + } + + async function readPackageJson(filePath) { + try { + return JSON.parse(await readFile(filePath, "utf-8")); + } catch (error) { + throw new Error(`Failed to read package.json at ${filePath}: ${error.message}`); + } + } + + async function loadPackageJson(depName) { + try { + // First try the standard require method (works when exports includes package.json) + const pkg = require(`${depName}/package.json`); + const pkgPath = require.resolve(`${depName}/package.json`); + return { path: path.dirname(pkgPath), pkg }; + } catch (e) { + // If that fails, resolve the package path and read package.json directly + try { + const packagePath = require.resolve(depName); + let currentDir = path.dirname(packagePath); + + // Navigate up to find package.json (the resolved path might be deep in dist/ or similar) + while (currentDir !== path.parse(currentDir).root) { + try { + const pkgPath = path.join(currentDir, 'package.json'); + const content = await readFile(pkgPath, 'utf-8'); + const pkg = JSON.parse(content); + + // Verify this is the correct package.json by checking the name + if (pkg.name === depName) { + return { path: currentDir, pkg }; + } + } catch { + // Continue searching up the directory tree + } + currentDir = path.dirname(currentDir); + } + } catch (resolveError) { + // console.warn(`Could not resolve ${depName}:`, resolveError.message); + } + return null; + } + } + + async function collectThirdPartyCem() { + const packageJSONPath = path.resolve(process.cwd(), "package.json"); + const packageJSON = await readPackageJson(packageJSONPath); + + const dependencyKeys = Object.keys(packageJSON).filter(key => key.toLowerCase().includes("dependencies")); + const dependencies = dependencyKeys.flatMap(key => Object.keys(packageJSON[key])); + + const thirdPartCEM = (await Promise.all(dependencies.map(async dep => { + const result = await loadPackageJson(dep); + if (!result?.pkg?.customElements) return null; + + return { + path: result.path, + name: dep, + cem: result.pkg.customElements + }; + }))).filter(Boolean); + + await Promise.all(thirdPartCEM.map(async dep => { + const cemPath = path.resolve(dep.path, dep.cem); + try { + const cemContent = JSON.parse(await readFile(cemPath, "utf-8")); + CACHED_CEMS.set(dep.name, cemContent); + } catch (error) { + console.warn(`Failed to read CEM for ${dep.name} from ${cemPath}: ${error.message}`); + } + })); + } + + async function readCurrentCEM() { + const packageJSONPath = path.resolve(process.cwd(), "package.json"); + const packageJSON = await readPackageJson(packageJSONPath); + + if (!packageJSON?.customElements) { + return null; + } + + customElementsPath = packageJSON.customElements; + const cemPath = path.resolve(process.cwd(), customElementsPath); + + try { + const cemContent = JSON.parse(await readFile(cemPath, "utf-8")); + CACHED_CEMS.set(packageJSON.name, cemContent); + return cemContent; + } catch (error) { + throw new Error(`Failed to read CEM from ${cemPath}: ${error.message}`); + } + } + + async function resolveReference(ref) { + const pkg = CACHED_CEMS.get(ref.package); + + if (!pkg) { + return null; + } + + const mod = (pkg.modules || []).find(m => m.path === ref.module); + + if (!mod) { + return null; + } + + const declaration = (mod.declarations || []).find(d => d.name === ref.name); + + if (!declaration) { + return null; + } + + DECLARATION_PACKAGE.set(declaration, ref.package); + DECLARATION_MODULE.set(declaration, ref.module); + + return resolveDeclaration(declaration); + } + + async function resolveDeclaration(declaration) { + if (!declaration.superclass || declaration.superclass.name === UI5_BASE_CLASS) { + return [declaration]; + } + + const superclassDeclarations = await resolveReference(declaration.superclass); + return [declaration, superclassDeclarations].flat().filter(Boolean); + } + + const merge = async () => { + const currentCEM = await readCurrentCEM(); + if (!currentCEM) { + throw new Error("No custom elements manifest found in current project"); + } + + await collectThirdPartyCem(); + + const modules = currentCEM.modules || []; + + for (const mod of modules) { + const declarations = (mod.declarations || []).filter(d => d.kind === "class"); + + for (const declaration of declarations) { + const declarationHierarchy = await resolveDeclaration(declaration); + const allKeys = declarationHierarchy.flatMap(dec => Object.keys(dec)); + const uniqueKeys = [...new Set(allKeys)]; + const arrayKeys = uniqueKeys + .filter(key => !key.startsWith("_ui5")) + .filter(key => declarationHierarchy.some(dec => Array.isArray(dec[key]))); + + for (const key of arrayKeys) { + const allItems = declarationHierarchy.flatMap(dec => dec[key] || []); + + // Remove duplicates based on name property + const seen = new Set(); + declaration[key] = allItems.filter(item => { + if (!item.name) return true; + if (seen.has(item.name)) return false; + seen.add(item.name); + return true; + }); + } + } + } + + const cleanedCEM = removeInheritedFrom(currentCEM); + const outputPath = path.resolve(process.cwd(), customElementsPath); + + try { + await writeFile(outputPath, JSON.stringify(cleanedCEM, null, 2), "utf-8"); + console.log(`Successfully merged CEM to ${outputPath}`); + } catch (error) { + throw new Error(`Failed to write merged CEM to ${outputPath}: ${error.message}`); + } + }; + + await merge(); +} + +const filePath = process.argv[1]; +const fileUrl = pathToFileURL(filePath).href; + +if (import.meta.url === fileUrl) { + main(process.argv) +} + +export default { + _ui5mainFn: main +} \ No newline at end of file