UNPKG

ui5-tooling-modules

Version:
1,354 lines (1,224 loc) 51.3 kB
const { lt: semverLessThan } = require("semver"); const SimpleLogger = require("./SimpleLogger"); const JSDocSerializer = require("./JSDocSerializer"); const DTSSerializer = require("./DTSSerializer"); const WebComponentRegistryHelper = require("./WebComponentRegistryHelper"); // List of all supported primitive types including "integer" const primitiveTypes = ["object", "boolean", "number", "integer", "bigint", "string", "null"]; // Known native browser elements used as types in web components const nativeBrowserElements = ["DataTransfer", "Date", "Event", "File", "FileList"]; const logger = SimpleLogger.create("🧬 WCR"); /** * Camelize the dashes. * Used to transform event names. * @param {string} str the string to camelize * @returns {string} the camelized string */ const camelize = (str) => { return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); }; /** * Converts slashes in a string to dots. * @param {string} s the slahed string * @returns {string} the dotted string */ function slash2dot(s) { return s?.replace(/\//g, "."); } /** * Calculates the name for the UI5 level getter function based on the web components native function name. * @param {string} functionName the name of the web components native function * @returns {string} the generated UI5 getter function name */ const calculateGetterName = (functionName) => { return "get" + functionName.substring(0, 1).toUpperCase() + functionName.substring(1); }; // TODO: we need to make a proper check for association types const WEBC_ASSOCIATION_TYPE = "HTMLElement | string | undefined"; const WEBC_ASSOCIATION_TYPE_ALT = "HTMLElement | string | null | undefined"; let _registry = {}; let _classAliases = {}; // TODO: Make "classes" into... a class :) // Get's rid of passing the "classDef" and the "ui5metadata" around. class RegistryEntry { #customElementsMetadata = {}; constructor({ customElementsMetadata, namespace, scopeSuffix, npmPackagePath, version, customJSDocTags, frameworkVersion, isOpenUI5OrSAPUI5Lib, isUI5WebComponents }) { this.#customElementsMetadata = customElementsMetadata; this.namespace = namespace; this.scopeSuffix = scopeSuffix; this.npmPackagePath = npmPackagePath; this.qualifiedNamespace = slash2dot(this.namespace); //this.moduleBasePath = moduleBasePath; //this.qualifiedNamespace = `${moduleBasePath ? slash2dot(this.moduleBasePath) + "." : ""}${slash2dot(removeScopePrefix ? this.namespace.replace(/^@/, "") : this.namespace)}`; // TODO: The following conversion of "-" to "_" is a workaround for testing the UI5 JSDoc build. // Only needed until we solve the escaping issue of segments like "@ui5" or "webcomponents-fiori". // this.qualifiedNamespace = this.qualifiedNamespace.replace(/-/g, "_"); this.version = version; this.customJSDocTags = customJSDocTags; // libray version is used for version dependent generations this.frameworkVersion = frameworkVersion || "0.0.0"; this.isOpenUI5OrSAPUI5Lib = !!isOpenUI5OrSAPUI5Lib; this.isUI5WebComponents = !!isUI5WebComponents; this.customElements = {}; this.classes = {}; this.classesByModule = {}; this.enums = {}; this.interfaces = {}; this.#processMetadata(); JSDocSerializer.prepare(this); DTSSerializer.prepare(this); logger.log(`Metadata processed for package ${namespace}.`); // Module base path: ${moduleBasePath}.`); } #deriveUi5ClassNames(classDef) { // Calculate fully qualified class name based on the module name from the custom elements manifest // e.g. dist/Avatar.js -> dist.Avatar let convertedClassName = classDef.module.replace(/\//g, "."); const _derivedUi5ClassName = convertedClassName.replace(/\.(js|ts)$/, ""); const _ui5QualifiedName = `${this.qualifiedNamespace}.${_derivedUi5ClassName}`; // TODO: Ideally not needed in the future once we have a solution for escaping in the UI5 JDSDoc build // Also remember to remove the "@ui5-module-override" directives in the HBS templates! const _ui5QualifiedNameSlashes = _ui5QualifiedName.replace(/\./g, "/"); return { _derivedUi5ClassName, _ui5QualifiedName, _ui5QualifiedNameSlashes, }; } #processMetadata() { // [1] parsing the metadata this.#customElementsMetadata.modules?.forEach((module) => { // [1.1] collect declarations and exports per module const moduleContent = { module, classes: {}, enums: {}, interfaces: {} }; module.declarations?.forEach(this.#parseDeclaration.bind(this, moduleContent)); module.exports?.forEach(this.#parseExports.bind(this, moduleContent)); // [1.2] transform raw module content into sensible structure for building UI5 metadata for (const className in moduleContent.classes) { const classDef = moduleContent.classes[className]; if (classDef.module) { // calculate fully qualified class names Object.assign(classDef, this.#deriveUi5ClassNames(classDef)); // store classes under their module + export key const cacheKey = WebComponentRegistryHelper.deriveCacheKey(classDef); this.classes[cacheKey] = classDef; } else { logger.warn(`class without module: ${classDef}`); } } // store enums like classes -> s.a. deriveCacheKey(...) for (const enumName in moduleContent.enums) { const enumDef = moduleContent.enums[enumName]; // enum definitions do not automatically track their module path in the custom-elements-metadata! enumDef.module = module.path; const classNames = this.#deriveUi5ClassNames(enumDef); enumDef._ui5QualifiedName = `${classNames._ui5QualifiedName}`; enumDef._ui5QualifiedNameSlashes = `${classNames._ui5QualifiedNameSlashes}`; const cacheKey = WebComponentRegistryHelper.deriveCacheKey(enumDef); this.enums[cacheKey] = enumDef; } // store interfaces like classes -> s.a. deriveCacheKey(...) for (const interfaceName in moduleContent.interfaces) { const interfaceDef = moduleContent.interfaces[interfaceName]; // interface definitions do not automatically track their module path in the custom-elements-metadata! interfaceDef.module = module.path; // Interfaces are typically exported in a module by a different name (e.g. "IButton" lives // inside "dist/Button.js"). The dotted UI5 type name appends the interface name to the // class-qualified name, while the slashed module reference keeps the module path slashed // and only the named export trailing the dot (cf. prefixnsAsModule). const classNames = this.#deriveUi5ClassNames(interfaceDef); interfaceDef._ui5QualifiedName = `${classNames._ui5QualifiedName}.${interfaceName}`; interfaceDef._ui5QualifiedNameSlashes = `${classNames._ui5QualifiedNameSlashes}.${interfaceName}`; const cacheKey = WebComponentRegistryHelper.deriveCacheKey(interfaceDef); this.interfaces[cacheKey] = interfaceDef; } }); // [2] prepare enum objects this.#prepareEnums(); // [3] prepare interface definitions this.#prepareInterfaces(); // [4] Prepare classes Object.keys(this.classes).forEach((className) => { const classDef = this.classes[className]; classDef._ui5implements ??= []; this.#connectSuperclass(classDef); this.#calculateScopedTagName(classDef); }); // [5] create UI5 metadata for each classed based on the parsed custom elements metadata // Note: The order is important! We connected the superclasses first and only then create its metadata. // We need a fully constructed parent chain later for ensuring UI5 defaults (refer to #ensureDefaults) Object.keys(this.classes).forEach((className) => { const classDef = this.classes[className]; this.#initClass(classDef); // TODO: We should not create metadata for classes without a superclass. //if (classDef.superclass) { this.#createUI5Metadata(classDef); //} }); } /** * Parses the declarations of a module defined in the custom-elements manifest. * The parsing result will be stored inside the <code>moduleContent</code> object for later processing. * * Note: This method is bound to "this", which is the RegistryEntry instance. * * @param {object} moduleContent simple object holding the content of the currently processed module. * Structure: {classes:{...}, enums:{...}, interfaces:{...}} * @param {object} declarationDef the declaration definition object from the parsed custom-elements manifest, raw content */ #parseDeclaration(moduleContent, declarationDef) { switch (declarationDef.kind) { case "class": moduleContent.classes[declarationDef.name] = declarationDef; break; case "enum": moduleContent.enums[declarationDef.name] = declarationDef; break; case "interface": moduleContent.interfaces[declarationDef.name] = declarationDef; break; default: logger.error("unknown declaration kind:", declarationDef.kind); } } /** * Parses the exports of a module defined in the custom-elements manifest. * The parsing result will be stored inside the <code>moduleContent</code> object for later processing. * * Note: This method is bound to "this", which is the RegistryEntry instance. * * @param {object} moduleContent simple object holding the content of the currently processed module. * Structure: {classes:{...}, enums:{...}, interfaces:{...}} * @param {object} exportDef the export definition object from the parse custom-elements manifest, raw content */ #parseExports(moduleContent, exportDef) { // try to identify a class declaration for given module export (can be undefined) const exportName = exportDef.declaration.name; const correspondingClass = moduleContent.classes[exportName]; if (correspondingClass) { // find module name correspondingClass.module = exportDef.declaration?.module; // we track both npm-package name and the namespace // they are identical for now, might change later correspondingClass.namespace = correspondingClass.package = this.namespace; } // track a list of all custom elements (~web components with a tag-name) for debugging only if (exportDef.kind === "custom-element-definition") { this.customElements[correspondingClass.tagName] = correspondingClass || {}; } } #connectSuperclass(classDef) { if (classDef.superclass?.name?.includes(".")) { const superclassName = classDef.superclass.name; const [npmPackage, ...nameParts] = superclassName.split("."); classDef.superclass = { name: nameParts.join("."), _ui5QualifiedName: nameParts.join("."), // TODO: what is this? package: npmPackage, file: `dist/${nameParts.join("/")}.js`, }; logger.warn(`The class '${this.namespace}/${classDef.name}' detected superclass '${classDef.superclass.package}/${classDef.superclass.name}' from string '${superclassName}'!`); } if (classDef.superclass) { // determine superclass package (can be cross-package reference) const refPackage = WebComponentRegistry.getPackage(classDef.superclass.package) || this; // Reg. resolving superclass names: we have 2 situations // 1. class resolved in the same package -> we have a qualified name already 👍 // 2. class resolved cross package: here we still have the raw metadata superclass info object at hand and // need to resolve the name for the first time. // However: The cross package must already be completely processed because of the dependency chain (roll-up plugin takes care of this) //const superclassQualifiedName = classDef.superclass._ui5QualifiedName ?? refPackage.#deriveUi5ClassNames(classDef.superclass)._ui5QualifiedName; const superclassLookupName = WebComponentRegistryHelper.deriveCacheKey(classDef.superclass); // Note: This sanitization is needed for some module where the metadata is not consistent // e.g. "media-chrome" references the module names sometimes with a leading slash. const superclassLookupNameFallback = superclassLookupName?.startsWith("/") ? superclassLookupName.substring(1).replace(".js", ".ts") : superclassLookupName; let superclassRef = refPackage.classes[superclassLookupName] || refPackage.classes[superclassLookupNameFallback]; if (!superclassRef) { logger.error(`The class '${classDef._ui5QualifiedName}' has an unknown superclass '${superclassLookupName}' using default '@ui5/webcomponents-base/UI5Element'!`); const refPackage = WebComponentRegistry.getPackage(WebComponentRegistryHelper.UI5_ELEMENT_NAMESPACE); superclassRef = (refPackage || this).classes[WebComponentRegistryHelper.UI5_ELEMENT_CACHE_KEY]; classDef.superclass = superclassRef; } else { this.#connectSuperclass(superclassRef); classDef.superclass = superclassRef; } } else if (WebComponentRegistryHelper.isCustomElement(classDef)) { /* logger.warn( `⚠️ The class '${this.namespace}/${classDef.name}' is a custom element not extending '${WebComponentRegistryHelper.UI5_ELEMENT_NAMESPACE}/${WebComponentRegistryHelper.UI5_ELEMENT_CLASS_NAME}!`, ); */ // non UI5Elements are extending the base class for Web Components classDef.superclass = { module: "sap/ui/core/webc/WebComponent.js", name: "sap.ui.core.webc.WebComponent", }; } } prefixns(str) { return `${this.qualifiedNamespace}.${str}`; } prefixnsAsModule(str) { // a named export is referenced with a dot, e.g. "module:@ui5/webcomponents.IAvatarGroupItem" return `module:${this.namespace}.${str}`; } #calculateScopedTagName(classDef) { // only scope UI5Element subclasses if (this.scopeSuffix && WebComponentRegistryHelper.isUI5ElementSubclass(classDef)) { if (classDef.tagName) { classDef.scopedTagName = `${classDef.tagName}-${this.scopeSuffix}`; } } } // --- UI5 Metadata transformer below --- #normalizeType(type) { if (type) { const lowerCaseName = type.toLowerCase(); if (lowerCaseName === "number") { return "float"; } else if (lowerCaseName === "integer") { return "int"; } return lowerCaseName; } return "string"; } #parseComplexType(typeInfo, packageRef) { // TODO: the ui5 names are wrong: the ui5 names cannot only be derived from the module, // but have to take the "name" into account, but still construct the name from the module path (minus "the_last_part.js") const ui5names = packageRef.#deriveUi5ClassNames(typeInfo); const cacheKey = WebComponentRegistryHelper.deriveCacheKey(typeInfo); const base = { dtsType: typeInfo.name, // TODO: enum/interfaces namespace currently cannot work with the "correct" fully qualified name. // the quick fix for now is to use the simplistic concatenated (and wrong) prefixed name :( ui5Type: ui5names._ui5QualifiedName, moduleType: ui5names._ui5QualifiedNameSlashes, //packageRef.prefixnsAsModule(type), packageName: packageRef.namespace, }; if (packageRef.interfaces[cacheKey]) { return { ...base, ui5Type: `${ui5names._ui5QualifiedName}.${typeInfo.name}`, isInterface: true, }; } else if (packageRef.enums[cacheKey]) { return { ...base, moduleType: `module:${ui5names._ui5QualifiedNameSlashes}`, //packageRef.prefixnsAsModule(type), isEnum: true, }; } else if (packageRef.classes[cacheKey]) { const classDef = packageRef.classes[cacheKey]; // TODO: In case we have a reference to @ui5/webcomponent-base/UI5Element -> We need to point to sap.ui.core.webc.WebComponent return { ...base, ui5Type: classDef._ui5QualifiedName, moduleType: `module:${classDef._ui5QualifiedNameSlashes}`, packageName: classDef._ui5QualifiedNameSlashes, isClass: true, }; } } #extractUi5Type(typeInfo) { const typeDef = { origType: typeInfo?.text, types: [], }; // we did not receive a type string from the metadata, e.g. for method params // no further type checks needed if (!typeInfo?.text) { return typeDef; } const deriveType = (type) => { // we only respect 1 cache key, as UI5 only supports 1 type per property/aggregation -> first one wins const typeInfoRef = typeInfo?.references?.[0] || { module: `dist/${type}.js`, name: type }; const typeCacheKey = WebComponentRegistryHelper.deriveCacheKey(typeInfoRef); const hasCacheContent = this.classes[typeCacheKey] || this.enums[typeCacheKey] || this.interfaces[typeCacheKey]; // [Complex types]: // we have a reference to something, or we find it directly in our own package's caches: classes, enums, interfaces if (typeInfo?.references?.length > 0 || hasCacheContent) { // Since the UI5 runtime only allows for 1 single type per property/aggregation, we take the first reference type = typeInfoRef.name; if (typeInfoRef.package === WebComponentRegistryHelper.UI5_ELEMENT_NAMESPACE) { // case 0: we just have a reference to the UI5Element base class in the @ui5/webcomponent-base package // This means that the Web Component can only nest other Web Component subclasses in that slot. if (type === WebComponentRegistryHelper.UI5_ELEMENT_CLASS_NAME) { return { dtsType: "WebComponent", packageName: "sap/ui/core", moduleType: "module:sap/ui/core/webc/WebComponent", ui5Type: "sap.ui.core.webc.WebComponent", isClass: true, }; } // case 1a: native webc ValueState ==> core ValueState if (type === "ValueState") { return { dtsType: "ValueState", packageName: "sap/ui/core/library", moduleType: "module:sap/ui/core/ValueState", ui5Type: "sap.ui.core.ValueState", isEnum: true, }; } } // case 1b: complex type is enum, interface or class let complexType; if (typeInfoRef.module) { // some types are not having a module complexType = this.#parseComplexType(typeInfoRef, this); if (!complexType && this.namespace !== typeInfoRef.package) { // case 1c: check for cross package type reference const refPackage = WebComponentRegistry.getPackage(typeInfoRef.package); if (refPackage) { complexType = this.#parseComplexType(typeInfoRef, refPackage); } else { logger.log(`Reference package '${typeInfoRef.package}' for complex type '${type}' not found`); } } else if (!complexType && typeInfoRef) { // case 1d: not able to find the type but there is a reference ==> try to import original webc type from the importing module itself const refClass = this.classes[typeCacheKey]; //typeInfoRef.module.match(/\/(.*).js$/)?.[1] if (refClass) { return { dtsType: type, ui5Type: "any", packageName: refClass._ui5QualifiedNameSlashes, globalImport: true, }; } } } if (!complexType) { typeDef.isUnclear = true; } return ( complexType || { // case 2: Couldn't determine type => fallback to any isUnclear: true, dtsType: "any", ui5Type: "any", } ); } else if (type === "HTMLElement" || type === "Node") { // case 3: HTMLElement || Node => sap/ui/core/Control return { dtsType: "Control", packageName: "sap/ui/core/Control", moduleType: "module:sap/ui/core/Control", ui5Type: "sap.ui.core.Control", isClass: true, }; } else if (primitiveTypes.includes(type.toLowerCase())) { // case 4: primitive types return { dtsType: this.#normalizeType(type), ui5Type: this.#normalizeType(type), }; } else if (nativeBrowserElements.includes(type)) { // case 5: native browser elements return { dtsType: type, ui5Type: "object", }; } else { const aWebcomponentPackages = WebComponentRegistry.getPackages(); for (const sPackage in aWebcomponentPackages) { if (!Object.hasOwn(aWebcomponentPackages, sPackage)) continue; const refPackage = WebComponentRegistry.getPackage(aWebcomponentPackages[sPackage]); const complexType = this.#parseComplexType(typeInfoRef, refPackage); if (complexType) { return complexType; } } typeDef.isUnclear = true; // case 6: Unclear return { isUnclear: true, dtsType: "any", ui5Type: "any", }; } }; // An original type can look like this: "Array<Foo|Bar>|string|undefined" // we determine if we have a toplevel union type // result will look like this: types = ["Array<Foo|Bar>", "string", "undefined"] const types = typeInfo.text.match(/(?:(?:Set|Promise|Array)<[^>]*>|[^|])+/g) || []; for (let name of types) { name = name.trim(); const arrayTypeMatch = name.match(/(?:Set|Promise|Array)<(.*)>/i); const multiple = !!arrayTypeMatch; const origType = arrayTypeMatch?.[1] || name; if (origType !== "undefined") { // multiple === true ==> is array type (Set|Promise|Array<Foo>), but not necessarily a union type in the set|promise|array (Set|Promise|Array<Foo|Bar>) // multiple === false ==> can be a union type, e.g. "Foo | Bar | ..."! typeDef.types.push({ origType, multiple, dedicatedTypes: origType.split("|").map((s) => deriveType(s.trim())), }); } } // global multiple flag sits on typeDef const multiple = typeDef.types[0].multiple || false; // Special handling for Associations types if (typeDef.origType === WEBC_ASSOCIATION_TYPE || typeDef.origType === WEBC_ASSOCIATION_TYPE_ALT) { typeDef.ui5TypeInfo = { ui5Type: typeDef.types[0].ui5Type, moduleType: typeDef.types[0].moduleType, isComplexType: true, }; return typeDef; } const hasNoUnionType = (obj) => obj.dedicatedTypes.length <= 1; // Either single or multiple union type (e.g Array<type1 | type2> or type1 | type2) if (!typeDef.types.every(hasNoUnionType)) { const refType = typeDef.types[0].dedicatedTypes[0]; if (refType.isClass || refType.isInterface) { typeDef.ui5TypeInfo = { multiple, ui5Type: "sap.ui.core.webc.WebComponent", moduleType: "module:sap/ui/core/webc/WebComponent", isComplexType: true, }; return typeDef; } } // unpack UI5 type // as UI5 can only handle one type (unlike TS) const refType = typeDef.types[0].dedicatedTypes[0]; typeDef.ui5TypeInfo = { multiple, ui5Type: refType.ui5Type, moduleType: refType.moduleType, isComplexType: refType.isClass || refType.isInterface, }; return typeDef; } /** * Checks the given property definition object for a special UI5 mapping. * The specific formatter are implemented in "sap.ui.core.webc.WebComponent". * @param {object} classDef the class definition object from the custom elements metadata * @param {object} propDef the property definition object from the custom elements metadata * @param {object} ui5metadata the UI5 metadata object * @returns {boolean} whether the property needs a special mapping */ #checkForSpecialMapping(classDef, propDef, ui5metadata) { if (propDef.name === "accessibleNameRef") { ui5metadata.associations["ariaLabelledBy"] = { type: "sap.ui.core.Control", multiple: true, mapping: { type: "property", to: "accessibleNameRef", formatter: "_getAriaLabelledByForRendering", }, }; // Since we change the naming from "accessibleNameRef" to "ariaLabelledBy", we can't pass the propDef directly to the JSDocSerializer! JSDocSerializer.writeDoc(classDef, "associations", { name: "ariaLabelledBy", description: propDef.description || "" }); DTSSerializer.writeDts(classDef, "associations", { name: "ariaLabelledBy", description: propDef.description, types: [ { origType: "Array<HTMLElement>", multiple: true, dedicatedTypes: [ { dtsType: "Control", packageName: "sap/ui/core/Control", moduleType: "module:sap/ui/core/Control", ui5Type: "sap.ui.core.Control", isClass: true, }, ], }, ], }); return true; } else if (propDef.name === "disabled") { // "disabled" maps to "enabled" in UI5 // we also need the UI5 EnabledPropagator classDef._ui5specifics.needsEnabledPropagator = true; ui5metadata.properties["enabled"] = { type: "boolean", defaultValue: "true", mapping: { type: "property", to: "disabled", formatter: "_mapEnabled", }, }; // Since we flip the naming from "disabled" to "enabled", we can't pass the propDef directly to the JSDocSerializer! JSDocSerializer.writeDoc(classDef, "properties", { name: "enabled", description: propDef.description || "" }); DTSSerializer.writeDts(classDef, "properties", { name: "enabled", description: propDef.description, types: [ { dedicatedTypes: [ { dtsType: "boolean", }, ], }, ], defaultValue: "true", }); return true; } return false; } #processMembers(classDef, ui5metadata, propDef) { // field -> property or association if (propDef.kind === "field") { const typeDef = this.#extractUi5Type(propDef.type); const isAssociation = typeDef.origType === WEBC_ASSOCIATION_TYPE || typeDef.origType === WEBC_ASSOCIATION_TYPE_ALT; // Some properties might need a special UI5 mapping, e.g. "accesibleNameRef" const hasSpecialMapping = this.#checkForSpecialMapping(classDef, propDef, ui5metadata); if (hasSpecialMapping) { return; } // Skip tooltip property since tooltip is already covered by sap/ui/core/Element if (propDef.name === "tooltip") { return; } // DEBUG if (typeDef.isUnclear) { logger.warn( `🤔 unclear type - ${classDef.name} - ${isAssociation ? "association" : "property"} '${propDef.name}' has unclear type '${typeDef.origType}' -> defaulting to 'any', multiple: ${typeDef.ui5TypeInfo.multiple}`, ); } // If any property of a class is a form relevant property, the UI5 control class must implement the "sap.ui.core.IFormContent" interface classDef._ui5implementsFormContent ??= propDef._ui5formProperty; if (propDef.readonly) { // calculated readonly fields -> UI5's WebComponentsMetadata class will generate getters // e.g. AvatarGroup#getColorScheme ui5metadata.getters.push(propDef.name); // jsdoc must contain the generated name at UI5 runtime JSDocSerializer.writeDoc(classDef, "getters", { name: propDef.name, getterName: calculateGetterName(propDef.name), description: propDef.description || "", }); DTSSerializer.writeDts(classDef, "properties", { name: propDef.name, description: propDef.description, types: typeDef.types, readonly: true, }); } else if (isAssociation) { ui5metadata.associations[propDef.name] = { type: typeDef.ui5TypeInfo.ui5Type, mapping: { type: "property", // assoc. are always property mappings to: propDef.name, // the name of the webc's attribute }, }; JSDocSerializer.writeDoc(classDef, "associations", propDef); DTSSerializer.writeDts(classDef, "associations", { name: propDef.name, description: propDef.description, types: typeDef.types, }); } else { if (typeDef.ui5TypeInfo?.isComplexType) { logger.warn(`[interface or class type given for property] ${classDef.name} - property ${propDef.name}`); } let mapping = "property"; if (typeDef.ui5TypeInfo?.ui5Type === "sap.ui.core.ValueState") { // the UI5 valueState needs the Core's enum typing and some special mapping to // convert the "sap.ui.core.ValueState" to the web component's variant. mapping = { formatter: "_mapValueState", parser: "_parseValueState", }; } let typeString; if (!typeDef.ui5TypeInfo?.ui5Type) { typeString = "any"; } else { typeString = `${typeDef.ui5TypeInfo.ui5Type}${typeDef.ui5TypeInfo.multiple ? "[]" : ""}`; } ui5metadata.properties[propDef.name] = { type: typeString, mapping, defaultValue: propDef.default, }; JSDocSerializer.writeDoc(classDef, "properties", { name: propDef.name, description: propDef.description || "", moduleType: typeDef.ui5TypeInfo?.moduleType, }); DTSSerializer.writeDts(classDef, "properties", { name: propDef.name, description: propDef.description, types: typeDef.types, defaultValue: propDef.default, }); } } else if (propDef.kind === "method") { // Methods are proxied through the core.webc.WebComponent base class // e.g. DatePicker#isValid or Toolbar#isOverflowOpen ui5metadata.methods.push(propDef.name); // method have parameters (unlike getters) const { parsedParams, jsDocParams } = this.#parseMethodParameters(classDef, propDef); JSDocSerializer.writeDoc(classDef, "methods", { name: propDef.name, description: propDef.description || "", parameters: jsDocParams, }); DTSSerializer.writeDts(classDef, "methods", { name: propDef.name, description: propDef.description, params: parsedParams, }); } } #processSlots(classDef, ui5metadata, slotDef) { let aggregationName = slotDef.name; let slotName = slotDef.name; let aggregationType; // web component allows text node content // TODO: How to figure this out "natively" without "_ui5propertyName"? // A text content must become mapped to the "text" ui5 property. // Or: What if the XMLTP learns to parse text nodes correctly for WebComponent subclasses // -> we could move the text-node content into the "text" property of the UI5 wrapper. if (slotDef._ui5propertyName === "text") { logger.log(`${classDef.name} - slot '${slotName}' allows text content (text-node slot)`); // nothing to do for now, we enforced a ui5 property named "text" later return; } const typeDef = this.#extractUi5Type(slotDef._ui5type); if (typeDef.ui5TypeInfo?.isComplexType) { //logger.log(`[interface/class type]: '${typeInfo.ui5Type}', multiple: ${typeInfo.multiple}`); aggregationType = typeDef.ui5TypeInfo.ui5Type; } const leadingDtsType = typeDef.types[0]?.dedicatedTypes[0]; if (typeDef.ui5TypeInfo?.ui5Type === "any" && leadingDtsType?.dtsType !== "any") { logger.log( `🤔 unclear type - Detected DTS type '${leadingDtsType.dtsType}' for slot '${slotName}' in webcomponent '${classDef.name}' but did not detect a proper UI5 type. Missing interface? Fallback DTS type to 'any'.`, ); typeDef.types[0].dedicatedTypes[0] = { dtsType: "any", ui5Type: "any", isUnclear: true, }; } // DEBUG if (typeDef.isUnclear) { logger.warn( `🤔 unclear type - ${classDef._ui5QualifiedName} - aggregation '${slotDef.name}' has unclear type '${typeDef.origType}' -> defaulting to 'any', multiple: ${typeDef.ui5TypeInfo.multiple}`, ); } // The "default" slot will most likely be transformed into the "content" in UI5 if (aggregationName === "default") { // but if a "_ui5propertyName" is defined, we regard it as a named aggregation! if (slotDef._ui5propertyName) { aggregationName = slotDef._ui5propertyName; } else { aggregationName = "content"; } // on webc level the default slot has no name slotName = undefined; ui5metadata.defaultAggregation = aggregationName; } ui5metadata.aggregations[aggregationName] = { type: aggregationType, multiple: true, slot: slotName, }; // note: in case we changed the name of the aggregation (e.g. default), we can't pass the slotDef directly! JSDocSerializer.writeDoc(classDef, "aggregations", { name: aggregationName, description: slotDef.description || "", moduleType: typeDef.ui5TypeInfo?.moduleType, }); DTSSerializer.writeDts(classDef, "aggregations", { name: aggregationName, description: slotDef.description, types: typeDef.types, }); } /** * Parses the given event definition object and extracts the event parameters. * The event parameters are also tracking their respective JSDoc description texts. * @param {object} classDef the class definition object from the custom elements metadata * @param {object} eventDef the event definition object from the custom elements metadata * @returns {object} the parsed event parameters */ #parseEventParameters(classDef, eventDef) { const parameters = eventDef._ui5parameters; const parsedParams = {}; const jsDocParams = {}; parameters?.forEach((param) => { const typeDef = this.#extractUi5Type(param.type); // DEBUG if (typeDef.isUnclear) { logger.warn(`🤔 unclear type - ${classDef.name} - event '${eventDef.name}' has unclear type '${typeDef.origType}' -> defaulting to 'any', multiple: ${typeDef.ui5TypeInfo.multiple}`); } parsedParams[param.name] = { type: typeDef.ui5TypeInfo.ui5Type, types: typeDef.types, dtsParamDescription: param.description, }; jsDocParams[param.name] = { description: param.description || "", }; }); return { parsedParams, jsDocParams }; } #parseMethodParameters(classDef, methodDef) { const parameters = methodDef.parameters; const jsDocParams = {}; const parsedParams = {}; parameters?.forEach((param) => { const typeDef = this.#extractUi5Type(param.type); // DEBUG if (typeDef.isUnclear) { logger.warn(`🤔 unclear type - ${classDef.name} - method '${methodDef.name}' has unclear type '${typeDef.origType}' -> defaulting to 'any', multiple: ${typeDef.ui5TypeInfo.multiple}`); } jsDocParams[param.name] = { name: param.name, type: typeDef.ui5TypeInfo?.ui5Type || "any", // TODO: is this correct? description: param.description || "", }; parsedParams[param.name] = { types: typeDef.types, description: param.description, }; }); return { parsedParams, jsDocParams }; } #processEvents(classDef, ui5metadata, eventDef) { // Same as with other entities we track the UI5 Metadata and the JSDoc separately const { parsedParams, jsDocParams } = this.#parseEventParameters(classDef, eventDef); const camelizedName = camelize(eventDef.name || ""); const existsAsProperty = ui5metadata.properties[camelizedName]; const eventName = existsAsProperty ? camelize(`on-${eventDef.name}`) : camelizedName; ui5metadata.events[eventName] = { allowPreventDefault: eventDef._ui5allowPreventDefault, enableEventBubbling: eventDef._ui5Bubbles, parameters: parsedParams, // create a mapping for the event name in case of a property with the same name exists mapping: existsAsProperty ? camelizedName : undefined, }; JSDocSerializer.writeDoc(classDef, "events", { name: eventName, description: eventDef.description, parameters: jsDocParams, }); DTSSerializer.writeDts(classDef, "events", { name: camelizedName, description: eventDef.description, parameters: parsedParams, }); } #processUI5Interfaces(classDef, ui5metadata) { let jsdocInterfaces; const unknowInterfaces = new Set(); if (Array.isArray(classDef._ui5implements)) { jsdocInterfaces ??= []; classDef._ui5implements.forEach((interfaceDef) => { if (this.interfaces[WebComponentRegistryHelper.deriveCacheKey(interfaceDef)]) { interfaceDef = this.interfaces[WebComponentRegistryHelper.deriveCacheKey(interfaceDef)]; ui5metadata.interfaces.push(interfaceDef._ui5QualifiedName); jsdocInterfaces.push(`module:${interfaceDef._ui5QualifiedNameSlashes}`); } else { jsdocInterfaces.push(this.prefixns(interfaceDef.name)); unknowInterfaces.add(interfaceDef.name); logger.warn(`🤔 unknown interface - interface ${interfaceDef.name} is not part of the metadata`); } }); } // classes with properties marked as "_ui5formProperty" need to implement IFormContent if (classDef._ui5implementsFormContent) { ui5metadata.interfaces.push("sap.ui.core.IFormContent"); classDef._ui5implements.push({ name: "IFormContent", package: "sap/ui/core/library", }); jsdocInterfaces ??= []; jsdocInterfaces.push("sap.ui.core.IFormContent"); } classDef._ui5QualifiedInterfaceNamesSlashes = jsdocInterfaces; classDef._unkownInterfaces = unknowInterfaces; } /** * Validates if the given property name is defined somewhere in the parent chain * @param {object} classDef the starting class for which we will traverse the parent chain * @param {string} propName the property to validate * @returns {boolean} whether the property exists in the parent chain */ #ui5PropertyExistsInParentChain(classDef, propName) { // we need to stop the recursion on the very top level // The runtime base class does NOT provide any inherited properties! if (classDef.name === WebComponentRegistryHelper.UI5_ELEMENT_CLASS_NAME) { return false; } // check self const hasProp = !!classDef?._ui5metadata?.properties?.[propName]; if (hasProp) { return true; } else { // if we didn't find the property on this class, we go one step higher in the chain if (classDef?.superclass) { return this.#ui5PropertyExistsInParentChain(classDef.superclass, propName); } else { // finally nothing found return false; } } } #ensureDefaults(classDef, ui5metadata) { if (!this.#ui5PropertyExistsInParentChain(classDef, "text")) { // a text property must exist and be mapped to "textContent" ui5metadata.properties["text"] = { type: "string", mapping: "textContent", }; const description = "The text-content of the Web Component."; JSDocSerializer.writeDoc(classDef, "properties", { name: "text", description }); DTSSerializer.writeDts(classDef, "properties", { name: "text", description, types: [ { dedicatedTypes: [{ dtsType: "string" }], }, ], }); } // cssProperties: [ "width", "height", "display" ] if (ui5metadata.tag && !this.#ui5PropertyExistsInParentChain(classDef, "width")) { ui5metadata.properties["width"] = { type: "sap.ui.core.CSSSize", mapping: "style", }; const description = "The 'width' of the Web Component in <code>sap.ui.core.CSSSize</code>."; JSDocSerializer.writeDoc(classDef, "properties", { name: "width", description }); DTSSerializer.writeDts(classDef, "properties", { name: "width", description, types: [ { dedicatedTypes: [ { ui5Type: "sap.ui.core.CSSSize", packageName: "sap/ui/core/library", dtsType: "CSSSize", }, ], }, ], }); } if (ui5metadata.tag && !this.#ui5PropertyExistsInParentChain(classDef, "height")) { ui5metadata.properties["height"] = { type: "sap.ui.core.CSSSize", mapping: "style", }; const description = "The 'height' of the Web Component in <code>sap.ui.core.CSSSize</code>."; JSDocSerializer.writeDoc(classDef, "properties", { name: "height", description }); DTSSerializer.writeDts(classDef, "properties", { name: "height", description, types: [ { dedicatedTypes: [ { ui5Type: "sap.ui.core.CSSSize", packageName: "sap/ui/core/library", dtsType: "CSSSize", }, ], }, ], }); } } /** * Some web components need additional patches to comply with UI5 framework requirements, * e.g. applying the LabelEnablement mixin * * Most things patched in this function should eventually be available generically in the custom elements metadta. * * @param {object} classDef the class definition object * @param {object} ui5metadata the UI5 metadata object */ // TODO make DTS ready #patchUI5Specifics(classDef, ui5metadata) { // The tag of UI5 web components might be scoped with a suffix depending on the project configuration // we need to make tag.includes(...) checks instead of strict comparisons! let { tag } = ui5metadata; tag ??= ""; // ensure we have a string to work with, as some classes don't have a tag e.g. abstract base classes // TODO: This whole method needs to be adapted to correctly write JSDoc // TODO: externalize special cases like Label, MultiInput, ShellBar etc. into e.g. .ui5webcrc config file // within the project wrapping the web components (e.g. the application or the library project) // The label has a couple of specifics that are not fully reflected in the custom elements. if (tag.includes("ui5-label")) { // the ui5-label has as default slot, but no aggregations on the retrofit layer... ui5metadata.aggregations = []; // the "for" attribute is called "labelFor" in the retrofit... ui5metadata.associations["labelFor"] = { type: "sap.ui.core.Control", multiple: false, mapping: { type: "property", to: "for", }, }; delete ui5metadata.properties["for"]; // Any "Label" control needs a special UI5-only interface ui5metadata.interfaces.push("sap.ui.core.Label"); classDef._ui5implements.push({ name: "Label", package: "sap/ui/core/library", }); // Additionally, all such controls must apply the "sap/ui/core/LabelEnablement" (see "../templates/WrapperControl.hbs") classDef._ui5specifics.needsLabelEnablement = true; DTSSerializer.updateDts( classDef, { name: "for", entity: "properties", }, { name: "labelFor", entity: "associations", types: [ { dedicatedTypes: [ { dtsType: "Control", packageName: "sap/ui/core/Control", moduleType: "module:sap/ui/core/Control", ui5Type: "sap.ui.core.Control", isClass: true, }, ], }, ], }, ); } else if (tag.includes("ui5-multi-input")) { // TODO: Multi Input needs to implement the functions defined in "sap.ui.core.ISemanticFormContent"... ui5metadata.interfaces.push("sap.ui.core.ISemanticFormContent"); classDef._ui5implements.push({ name: "ISemanticFormContent", package: "sap/ui/core/library", }); } else if (tag.includes("ui5-shellbar")) { ui5metadata.interfaces.push("sap.m.IBar"); classDef._ui5implements.push({ name: "IBar", package: "sap/m/library", }); // IToolHeader is deprecated with framework version 1.135 -> The interface must be omitted if (semverLessThan(this.frameworkVersion, "1.135.0")) { ui5metadata.interfaces.push("sap.tnt.IToolHeader"); classDef._ui5implements.push({ name: "IToolHeader", package: "sap/tnt/library", }); } } // If a "valueStateMessage" slot is present, we need a special property mapping // and correct the "valueState" property's typing if (ui5metadata.aggregations["valueStateMessage"]) { if (ui5metadata.properties["valueState"]) { // there will not be an aggregation in UI5, but rather a string mapped property! delete ui5metadata.aggregations["valueStateMessage"]; ui5metadata.properties["valueStateText"] = { name: "valueStateText", type: "string", defaultValue: "", mapping: { type: "slot", slotName: "valueStateMessage", // "mapping.to" describes the result in the webc DOM to: "div", }, }; // mixin support for handling of backend messages classDef._ui5specifics.needsMessageMixin = true; DTSSerializer.updateDts( classDef, { name: "valueStateMessage", entity: "aggregations", }, { name: "valueStateText", entity: "properties", types: [ { dedicatedTypes: [ { dtsType: "string", ui5Type: "string", }, ], }, ], }, ); } else { // this is an interesting inconsistency that does not occur in the UI5 web components // we report it here for custom web component development logger.warn( `The class '${classDef._ui5QualifiedName}' defines a slot called 'valueStateMessage', but does not provide a corresponding 'valueState' property! A UI5 control expects both to be present for correct 'valueState' handling.`, ); } } } /** * Initializes basic properties on a class definition. * The properties are expected during the WebComponentRegistry's metadata analysis. * * @param {object} classDef the class definition */ #initClass(classDef) { classDef._ui5metadata = { namespace: this.namespace, qualifiedNamespace: this.qualifiedNamespace, tag: classDef.scopedTagName || classDef.tagName, interfaces: [], properties: {}, aggregations: {}, associations: {}, events: {}, getters: [], methods: [], }; // we track a couple of UI5 specifics like interfaces and mixins separately classDef._ui5specifics = {}; } #createUI5Metadata(classDef) { const ui5metadata = classDef._ui5metadata; // we track the JSDoc extracted from the custom elements manifest separately, // as they are not part of the runtime metadata JSDocSerializer.initClass(classDef); DTSSerializer.initClass(classDef); classDef.members?.forEach((propDef) => { this.#processMembers(classDef, ui5metadata, propDef); }); classDef.slots?.forEach((slotDef) => { this.#processSlots(classDef, ui5metadata, slotDef); }); classDef.events?.forEach((eventDef) => { this.#processEvents(classDef, ui5metadata, eventDef); }); this.#processUI5Interfaces(classDef, ui5metadata); this.#ensureDefaults(classDef, ui5metadata); this.#patchUI5Specifics(classDef, ui5metadata); DTSSerializer.writeClassBody(classDef); } /** * Prepares the UI5 enum objects and enriches them with appropriate JSDoc. * Used in the respective HBS template. */ #prepareEnums() { Object.keys(this.enums).forEach((enumCacheKey) => { const enumDef = this.enums[enumCacheKey]; const enumValues = []; const enumMembers = this.enums[enumCacheKey].members; enumMembers?.forEach((member) => { // Key<>Value must be identical! enumValues.push({ name: member.name, description: member.description || "" }); }); // derive correct names const names = this.#deriveUi5ClassNames(enumDef); // prepare enum info object for HBS template later this.enums[enumCacheKey] = { name: enumDef.name, _ui5QualifiedName: names._ui5QualifiedName, // TODO: Ideally not needed in the future once we have a solution for escaping in the UI5 JDSDoc build // Also remember to remove the "@ui5-module-override" directives in the HBS templates! _ui5QualifiedNameSlashes: names._ui5QualifiedNameSlashes, _derivedUi5ClassName: names._derivedUi5ClassName, description: this.enums[enumCacheKey].description || "", values: enumValues, }; }); } /** * Prepares the UI5 interface definitions and enriches them with appropriate JSDoc. * Used in the respective HBS template. */ #prepareInterfaces() { Object.keys(this.interfaces).forEach((interfaceCacheKey) => { // derive correct names const interfaceDef = this.interfaces[interfaceCacheKey]; this.interfaces[interfaceCacheKey] = { name: interfaceDef.name, _ui5QualifiedName: interfaceDef._ui5QualifiedName, // TODO: Ideally not needed in the future once we have a solution for escaping in the UI5 JDSDoc build // Also remember to remove the "@ui5-module-override" directives in the HBS templates! _ui5QualifiedNameSlashes: interfaceDef._ui5QualifiedNameSlashes, description: this.interfaces[interfaceCacheKey].description || "", }; }); } } /** * Checks whether we should skips the *.d.ts file and/or JSDoc comment generation. * Configured via the respective pluginOptions in the ui5.yaml: * - pluginOptions/webcomponents/skipDtsGeneration * - pluginOptions/webcomponents/skipJsDocGeneration * @param {object} settings the settings object */ function checkSerializerPluginActivation(settings = {}) { if (settings.skipDtsGeneration) { DTSSerializer.deactivate(); } if (settings.skipJsDocGeneration) { JSDocSerializer.deactivate(); } } const WebComponentRegistry = { /** * Registers a web component package with the given metadata. * The metadata is expected to be in the custom elements metadata format. * The metadata is used to generate the UI5 metadata and JSDoc comments. * Optionally, the metadata is also used to generate *.d.ts files and JSDoc comments. * * @param {object} options the options object * @param {object} options.customElementsMetadata the custom elements metadata object * @param {string} options.namespace the namespace of the web component package * @param {string} options.library the library of the web component package * @param {string} options.frameworkVersion the UI5 framework version used * @param {boolean} options.isUI5WebComponents whether it is a package based on UI5 Web Components or not * @param {boolean} options.isOpenUI5OrSAPUI5Lib whether it is a OpenUI5 or SAPUI5 UI library (if a library project) * @param {string} options.scopeSuffix the scope suffix for the web component package * @param {string} options.npmPackagePath the npm package path of the web component package * @param {string} options.version the version of the web component package * @param {string} [options.customJSDocTags] list with additional JSDoc tags to add (passed via the pluginOptions in ui5.yaml) * @param {boolean} [options.skipDtsGeneration] whether to skip the *.d.ts generation (passed via the pluginOptions in ui5.yaml) * @param {boolean} [options.skipJSDoc] whether to skip the JSDoc generation (passed via the pluginOptions in ui5.yaml) * @returns the final WebComponentRegistryEntry for the given namespace */ register({ customElementsMetadata, namespace, library, frameworkVersion, isUI5WebComponents, isOpenUI5OrSAPUI5Lib, scopeSuffix, npmPackagePath, version, customJSDocTags = ["private"], skipDtsGeneration = true, skipJSDoc = true, }) { checkSerializerPluginActivation({ skipDtsGeneration, skipJSDoc, }); let entry = _registry[namespace]; if (!entry) { entry = _registry[namespace] = new RegistryEntry({ customElementsMetadata, namespace, scopeSuffix, npmPackagePath, vers