diff --git a/packages/base/package-scripts.cjs b/packages/base/package-scripts.cjs index 48d60a4d69fe..b02f2dc37b6e 100644 --- a/packages/base/package-scripts.cjs +++ b/packages/base/package-scripts.cjs @@ -70,12 +70,14 @@ 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"`, + default: "ui5nps generateAPI.generateCEM generateAPI.validateCEM generateAPI.resolveCEM generateAPI.resolveCEMinternal", + generateCEM: `ui5nps-script "${LIB}cem/cem.js" analyze --config "${LIB}cem/custom-elements-manifest.config.mjs"`, + validateCEM: `ui5nps-script "${LIB}cem/validate.js"`, + resolveCEM: `ui5nps-script "${LIB}cem/resolver/resolver.mjs"`, + resolveCEMinternal: `ui5nps-script "${LIB}cem/resolver/resolver.mjs" --internal`, }, watch: { default: 'ui5nps-p watch.src watch.styles', // concurently diff --git a/packages/tools/components-package/nps.js b/packages/tools/components-package/nps.js index 29c642229529..249f147493a8 100644 --- a/packages/tools/components-package/nps.js +++ b/packages/tools/components-package/nps.js @@ -172,9 +172,11 @@ const getScripts = (options) => { bundle: `ui5nps-script ${LIB}dev-server/dev-server.mjs ${viteConfig}`, }, generateAPI: { - default: tsOption ? "ui5nps generateAPI.generateCEM generateAPI.validateCEM" : "", + default: tsOption ? "ui5nps generateAPI.generateCEM generateAPI.validateCEM generateAPI.resolveCEM generateAPI.resolveCEMinternal" : "", generateCEM: `ui5nps-script "${LIB}cem/cem.js" analyze --config "${LIB}cem/custom-elements-manifest.config.mjs"`, validateCEM: `ui5nps-script "${LIB}cem/validate.js"`, + resolveCEM: `ui5nps-script "${LIB}cem/resolver/resolver.mjs"`, + resolveCEMinternal: `ui5nps-script "${LIB}cem/resolver/resolver.mjs" --internal`, }, }; diff --git a/packages/tools/lib/cem/resolver/resolver.mjs b/packages/tools/lib/cem/resolver/resolver.mjs new file mode 100644 index 000000000000..81543f10bb1f --- /dev/null +++ b/packages/tools/lib/cem/resolver/resolver.mjs @@ -0,0 +1,248 @@ +import { getDeclaration, getManifest, getPackageJSON } from "./utils.mjs"; +import { pathToFileURL, fileURLToPath } from "url"; +import { writeFile } from "fs/promises"; +import path from "path"; + +/** + * Types of properties that can be inherited from parent classes + */ +const INHERITABLE_TYPES = ['slots', 'cssParts', 'cssProperties', 'attributes', 'members', 'events', 'cssStates']; + +/** + * Base class name that should not pass down its properties + */ +const BASE_CLASS_NAME = "UI5Element"; + +/** + * Cache to avoid resolving the same declaration multiple times + */ +const declarationCache = new Map(); + +/** + * Parses command line arguments + * @param {string[]} args - Command line arguments + * @returns {Object} Parsed arguments object + */ +function parseArguments(args) { + const options = { + internal: false + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--internal") { + options.internal = true; + } + } + + return options; +} + +/** + * Gets the appropriate manifest file name based on internal flag + * @param {boolean} internal - Whether to use internal manifest + * @returns {string} Manifest file name + */ +function getManifestFileName(internal) { + return internal ? "custom-elements-internal.json" : "custom-elements.json"; +} + +/** + * Creates inheritance metadata for a property + * @param {Object} superclass - The superclass information + * @returns {Object} Inheritance metadata + */ +function createInheritanceMetadata(superclass) { + return { + package: superclass.package, + module: superclass.module, + name: superclass.name + }; +} + +/** + * Merges properties from superclass into current class + * @param {Object} klass - Current class declaration + * @param {Object} superklass - Superclass declaration + * @param {Object} superclassInfo - Superclass metadata + */ +function mergeInheritedProperties(klass, superklass, superclassInfo) { + if (superklass.name === BASE_CLASS_NAME) { + return; + } + + INHERITABLE_TYPES.forEach(type => { + const superProperties = superklass[type]; + if (!superProperties?.length) { + return; + } + + const klassProperties = klass[type] || []; + const updatedProperties = [...klassProperties]; + + superProperties.forEach(superItem => { + const superclassItem = { ...superItem }; + const existingItemIndex = klassProperties.findIndex(item => item.name === superclassItem.name); + + if (existingItemIndex !== -1) { + // Override existing property while preserving superclass data + updatedProperties[existingItemIndex] = { + ...superclassItem, + ...klassProperties[existingItemIndex], + }; + } else { + // Add inherited property with metadata + superclassItem.inheritedFrom = createInheritanceMetadata(superclassInfo); + updatedProperties.push(superclassItem); + } + }); + + klass[type] = updatedProperties; + }); +} + +/** + * Resolves a class declaration by merging inherited properties from its superclass + * @param {Object} declaration - The class declaration to resolve + * @param {Object} options - Additional options (for future extensibility) + * @returns {Promise} Resolved declaration or null + */ +async function resolveDeclaration(declaration, options = {}) { + if (!declaration) { + return null; + } + + // Only process class declarations with superclasses + if (declaration.kind !== "class" || !declaration.superclass) { + return declaration; + } + + // Create cache key for this declaration + const cacheKey = `${declaration.superclass.package}:${declaration.superclass.module}:${declaration.superclass.name}`; + + let superklass; + if (declarationCache.has(cacheKey)) { + superklass = declarationCache.get(cacheKey); + } else { + try { + const superDeclaration = await getDeclaration( + declaration.superclass.package, + declaration.superclass.module, + declaration.superclass.name + ); + + superklass = await resolveDeclaration(superDeclaration); + declarationCache.set(cacheKey, superklass); + } catch (error) { + console.warn(`Failed to resolve superclass ${declaration.superclass.name} from ${declaration.superclass.module} in package ${declaration.superclass.package}: ${error.message}`); + return declaration; + } + } + + if (!superklass) { + console.warn(`Could not resolve superclass ${declaration.superclass.name} from ${declaration.superclass.module} in package ${declaration.superclass.package}`); + return declaration; + } + + // Merge inherited properties + mergeInheritedProperties(declaration, superklass, declaration.superclass); + + return declaration; +} + +/** + * Processes all declarations in a module + * @param {Object} mod - The module to process + * @param {Object} info - Module processing context + * @returns {Promise} Processed module + */ +async function processModule(mod, info = {}) { + const moduleInfo = { + ...info, + modulePath: mod.path, + }; + + if (!mod.declarations?.length) { + return mod; + } + + // Process declarations sequentially to maintain order and handle dependencies + for (const declaration of mod.declarations) { + try { + await resolveDeclaration(declaration, moduleInfo); + } catch (error) { + console.error(`Error processing declaration ${declaration.name} in module ${mod.path}: ${error.message}`); + } + } + + return mod; +} + +/** + * Main function that processes all modules in a package + * @param {Array} args - Command line arguments + */ +async function main(args = []) { + try { + // Parse command line arguments + const options = parseArguments(args.slice(2)); // Skip node and script path + + // Clear cache at the start of each run + declarationCache.clear(); + + const packageJSON = await getPackageJSON(); + const packageName = packageJSON.name; + + // Determine which manifest file to use based on internal flag + const manifestFileName = getManifestFileName(options.internal); + const customElementFile = packageJSON.customElements || "custom-elements.json"; + + // If using internal flag, we need to check for the internal manifest file + let actualManifestFile = customElementFile; + if (options.internal) { + actualManifestFile = customElementFile.replace("custom-elements.json", manifestFileName); + } + + const manifest = await getManifest(packageName, actualManifestFile); + + if (!manifest.modules?.length) { + console.warn(`No modules found in manifest ${actualManifestFile} for ${packageName}`); + return; + } + + // Process all modules + const processingInfo = { packageName, ...options }; + const processingPromises = manifest.modules.map(mod => processModule(mod, processingInfo)); + await Promise.all(processingPromises); + + // Write updated manifest to the same file we read from + const packageRoot = path.dirname(fileURLToPath(import.meta.resolve(`${packageName}/package.json`))); + const outputPath = path.join(packageRoot, actualManifestFile); + + await writeFile(outputPath, JSON.stringify(manifest, null, 2)); + + const internalNote = options.internal ? " (internal manifest)" : ""; + console.log(`Successfully updated ${outputPath}${internalNote}`); + } catch (error) { + console.error(`Error in main process: ${error.message}`); + process.exit(1); + } +} + +// Handle direct execution +const filePath = process.argv[1]; +const fileUrl = pathToFileURL(filePath).href; + +if (import.meta.url === fileUrl) { + main(process.argv).catch(error => { + console.error(`Unhandled error: ${error.message}`); + process.exit(1); + }); +} + +export default { + _ui5mainFn: main, + resolveDeclaration, + processModule, + parseArguments +}; \ No newline at end of file diff --git a/packages/tools/lib/cem/resolver/utils.mjs b/packages/tools/lib/cem/resolver/utils.mjs new file mode 100644 index 000000000000..e447a3c2d099 --- /dev/null +++ b/packages/tools/lib/cem/resolver/utils.mjs @@ -0,0 +1,153 @@ +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +/** + * Cache for package.json files to avoid repeated file reads + */ +const packageJSONMap = new Map(); + +/** + * Cache for manifest files to avoid repeated file reads + */ +const manifestJSONMap = new Map(); + +/** + * Resolves and caches package.json for a given package + * @param {string} [packageName] - The package name to resolve, or current package if not provided + * @returns {Promise} The parsed package.json content + * @throws {Error} If package.json cannot be read or parsed + */ +async function getPackageJSON(packageName) { + try { + let packageJSON; + let currentPackageName; + + if (packageName) { + if (packageJSONMap.has(packageName)) { + return packageJSONMap.get(packageName); + } + + const packageRoot = path.dirname(fileURLToPath(import.meta.resolve(`${packageName}/package.json`))); + const packageJSONPath = path.join(packageRoot, "package.json"); + const content = await fs.readFile(packageJSONPath, "utf-8"); + packageJSON = JSON.parse(content); + currentPackageName = packageJSON.name; + } else { + const packageJSONPath = path.join(process.cwd(), "package.json"); + const content = await fs.readFile(packageJSONPath, "utf-8"); + packageJSON = JSON.parse(content); + currentPackageName = packageJSON.name; + } + + if (!packageJSONMap.has(currentPackageName)) { + packageJSONMap.set(currentPackageName, packageJSON); + } + + return packageJSONMap.get(currentPackageName); + } catch (error) { + throw new Error(`Failed to read package.json for ${packageName || 'current package'}: ${error.message}`); + } +} + +/** + * Resolves and caches custom elements manifest for a given package + * @param {string} [packageName] - The package name to resolve, or current package if not provided + * @param {string} [manifestFile] - Optional custom manifest file name to use instead of package.json customElements field + * @returns {Promise} The parsed custom elements manifest content + * @throws {Error} If manifest cannot be read or parsed + */ +async function getManifest(packageName, manifestFile) { + try { + const packageJSON = await getPackageJSON(packageName); + const customElementFile = manifestFile || packageJSON.customElements; + + if (!customElementFile) { + throw new Error(`No customElements field found in package.json for ${packageJSON.name}`); + } + + const currentPackageName = packageJSON.name; + const cacheKey = `${currentPackageName}:${customElementFile}`; + + if (manifestJSONMap.has(cacheKey)) { + return manifestJSONMap.get(cacheKey); + } + + let manifestPath; + if (packageName) { + const packageRoot = path.dirname(fileURLToPath(import.meta.resolve(`${packageName}/package.json`))); + manifestPath = path.join(packageRoot, customElementFile); + } else { + manifestPath = path.join(process.cwd(), customElementFile); + } + + const content = await fs.readFile(manifestPath, "utf-8"); + const customElementJSON = JSON.parse(content); + + if (!manifestJSONMap.has(cacheKey)) { + manifestJSONMap.set(cacheKey, customElementJSON); + } + + return manifestJSONMap.get(cacheKey); + } catch (error) { + throw new Error(`Failed to read manifest for ${packageName || 'current package'}: ${error.message}`); + } +} + +/** + * Finds a declaration by name within a specific module and package + * @param {string} packageName - The package containing the declaration + * @param {string} modulePath - The module path containing the declaration + * @param {string} importName - The name of the export to find + * @returns {Promise} The declaration object or null if not found + * @throws {Error} If manifest cannot be retrieved + */ +async function getDeclaration(packageName, modulePath, importName) { + try { + const manifest = await getManifest(packageName); + + if (!manifest.modules?.length) { + return null; + } + + // Find the target module + const targetModule = manifest.modules.find(mod => mod.path === modulePath); + if (!targetModule) { + return null; + } + + // Find the export + const targetExport = targetModule.exports?.find(exp => exp.name === importName); + if (!targetExport?.declaration) { + return null; + } + + // Find the declaration + const declaration = targetModule.declarations?.find(decl => + decl.name === targetExport.declaration.name + ); + + if (!declaration) { + console.warn(`Declaration ${targetExport.declaration.name} not found in module ${modulePath} of package ${packageName}`); + } + + return declaration || null; + } catch (error) { + throw new Error(`Failed to get declaration ${importName} from ${modulePath} in ${packageName}: ${error.message}`); + } +} + +/** + * Clears all caches - useful for testing or when packages change + */ +function clearCaches() { + packageJSONMap.clear(); + manifestJSONMap.clear(); +} + +export { + getManifest, + getPackageJSON, + getDeclaration, + clearCaches +}; \ No newline at end of file diff --git a/packages/tools/lib/cem/utils.mjs b/packages/tools/lib/cem/utils.mjs index b78e31d13013..8b61134065e1 100644 --- a/packages/tools/lib/cem/utils.mjs +++ b/packages/tools/lib/cem/utils.mjs @@ -192,6 +192,50 @@ const normalizeTagType = (type) => { const packageJSON = JSON.parse(fs.readFileSync("./package.json")); +const findImportName = (ts, sourceFile, typeName) => { + const localStatements = [ + ts.SyntaxKind.EnumDeclaration, + ts.SyntaxKind.InterfaceDeclaration, + ts.SyntaxKind.ClassDeclaration, + ts.SyntaxKind.TypeAliasDeclaration, + ]; + + const isLocalDeclared = sourceFile.statements.some( + (statement) => + localStatements.includes(statement.kind) && statement?.name?.text === typeName + ); + + if (isLocalDeclared) { + return typeName + } else { + const importStatements = sourceFile.statements?.filter( + (statement) => statement.kind === ts.SyntaxKind.ImportDeclaration + ); + + const importDelcaration = importStatements.find((statement) => { + if (statement.importClause?.name?.text === typeName) { + return true; + } + + return statement.importClause?.namedBindings?.elements?.find( + (element) => element.name?.text === typeName + ); + }); + + if (importDelcaration?.importClause?.name?.text === typeName) { + return "default"; + } + + const importSpecifier = importDelcaration?.importClause?.namedBindings?.elements?.find( + (element) => element.name?.text === typeName + ); + + if (importSpecifier) { + return importSpecifier.propertyName === "default" ? "default" : importSpecifier.propertyName?.text || importSpecifier.name?.text; + } + } +}; + const getReference = (ts, type, classNode, modulePath) => { let sourceFile = classNode.parent; @@ -214,7 +258,7 @@ const getReference = (ts, type, classNode, modulePath) => { )?.replace(`${packageName}/`, ""); return packageName && { - name: typeName, + name: findImportName(ts, sourceFile, typeName, modulePath), package: packageName, module: importPath, }; diff --git a/packages/tools/lib/cem/validate.js b/packages/tools/lib/cem/validate.js index 4a97b8806603..aa3c3315c4ed 100644 --- a/packages/tools/lib/cem/validate.js +++ b/packages/tools/lib/cem/validate.js @@ -18,6 +18,8 @@ const validateFn = async () => { filter(e => moduleDoc.declarations.find(d => d.name === e.declaration.name && ["class", "function", "variable", "enum"].includes(d.kind)) || e.name === "default"); }) + inputDataInternal.modules = inputDataInternal.modules.filter(moduleDoc => moduleDoc.declarations.length > 0).filter(moduleDoc => moduleDoc.exports.length > 0); + const clearProps = (data) => { if (Array.isArray(data)) { for (let i = 0; i < data.length; i++) {