@ui5/builder
Version:
UI5 CLI - Builder
418 lines (366 loc) • 14.4 kB
JavaScript
import xml2js from "xml2js";
import {fromUI5LegacyName, fromRequireJSName} from "../utils/ModuleName.js";
import JSTokenizer from "../utils/JSTokenizer.js";
import {getLogger} from "@ui5/logger";
const log = getLogger("lbt:analyzer:XMLTemplateAnalyzer");
// ---------------------------------------------------------------------------------------------------------
/*
* TODOS
* - find better way to distinguish between aggregation tags and control tags
* (currently, existence in pool is used to recognize controls)
* - support alternative namespace URLs for libraries (as used by XSD files)
* - make set of view types configurable
* - plugin mechanism to support other special controls
* - move UI5 specific constants to UI5ClientConstants?
*/
// ---------------------------------------------------------------------------------------------------------
const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const TEMPLATING_NAMESPACE = "http://schemas.sap.com/sapui5/extension/sap.ui.core.template/1";
const TEMPLATING_CONDITONAL_TAGS = /^(?:if|repeat)$/;
const PATTERN_LIBRARY_NAMESPACES = /^([a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)$/;
// component container
const COMPONENTCONTAINER_MODULE = "sap/ui/core/ComponentContainer.js";
const COMPONENTCONTAINER_COMPONENTNAME_ATTRIBUTE = "name";
// fragment definition
const FRAGMENTDEFINITION_MODULE = "sap/ui/core/FragmentDefinition.js";
// fragment
const FRAGMENT_MODULE = "sap/ui/core/Fragment.js";
const FRAGMENT_FRAGMENTNAME_ATTRIBUTE = "fragmentName";
const FRAGMENT_TYPE_ATTRIBUTE = "type";
// different view types
const VIEW_MODULE = "sap/ui/core/mvc/View.js";
const HTMLVIEW_MODULE = "sap/ui/core/mvc/HTMLView.js";
const JSVIEW_MODULE = "sap/ui/core/mvc/JSView.js";
const JSONVIEW_MODULE = "sap/ui/core/mvc/JSONView.js";
const XMLVIEW_MODULE = "sap/ui/core/mvc/XMLView.js";
const TEMPLATEVIEW_MODULE = "sap/ui/core/mvc/TemplateView.js";
const ANYVIEW_VIEWNAME_ATTRIBUTE = "viewName";
const XMLVIEW_CONTROLLERNAME_ATTRIBUTE = "controllerName";
const XMLVIEW_RESBUNDLENAME_ATTRIBUTE = "resourceBundleName";
const XMLVIEW_CORE_REQUIRE_ATTRIBUTE_NS = {
uri: "sap.ui.core",
local: "require"
};
const VIEW_TYPE_ATTRIBUTE = "type";
/*
* Helper to simplify access to node attributes.
*/
function getAttribute(node, attr) {
return (node.$ && node.$[attr] && node.$[attr].value) || null;
}
function getAttributeNS(node, attrNS) {
const attr = Object.values(node.$ || []).find((n) => {
return n.uri === attrNS.uri && n.local === attrNS.local;
});
return (attr && attr.value) || null;
}
/**
* A dependency analyzer for XMLViews and XMLFragments.
*
* Parses the XML, collects controls and adds them as dependency to the ModuleInfo object.
* Additionally, some special dependencies are handled:
* <ul>
* <li>controller of the view</li>
* <li>resource bundle (note: locale dependent dependencies can't be modeled yet in ModuleInfo</li>
* <li>component referenced via ComponentContainer control</li>
* <li>embedded fragments or views</li>
* </ul>
*
* In an XMLView, there usually exist 3 categories of element nodes: controls, aggregations
* of cardinality 'multiple' and non-UI5 nodes (e.g. XHTML or SVG). The third category usually
* can be identified by its namespace. To distinguish between the first and the second
* category, this analyzer uses a ResourcePool (provided by the caller and usually derived from the
* library classpath). When the qualified node name is contained in the pool, it is assumed to
* represent a control, otherwise it is ignored.
*
* In certain cases this might give wrong results, but loading the metadata for each control
* to implement the exactly same logic as used in the runtime XMLTemplateProcessor would be to
* expensive and require too much runtime.
*
* @author Frank Weigel
* @since 1.23.0
* @private
*/
class XMLTemplateAnalyzer {
constructor(pool) {
this._pool = pool;
this._parser = new xml2js.Parser({
explicitRoot: false,
explicitChildren: true,
preserveChildrenOrder: true,
xmlns: true
});
this.busy = false;
}
/**
* Add a dependency if it is new.
*
* @param {string} moduleName
* @param {boolean} conditional
*/
_addDependency(moduleName, conditional) {
// don't add references to 'self'
if ( this.info.name === moduleName ) {
return;
}
// don't add properties with data binding syntax
if (moduleName.includes("{") || moduleName.includes("}")) {
return;
}
this.info.addDependency(moduleName, conditional);
}
/**
* Enrich the given ModuleInfo for an XMLView.
*
* @param {string} xml xml string to be analyzed
* @param {ModuleInfo} info ModuleInfo to enrich
* @returns {Promise<ModuleInfo>} the created ModuleInfo
*/
analyzeView(xml, info) {
return this._analyze(xml, info, false);
}
/**
* Enrich the given ModuleInfo for a fragment (XML).
*
* @param {string} xml xml string to be analyzed
* @param {ModuleInfo} info ModuleInfo to enrich
* @returns {Promise<ModuleInfo>} the created ModuleInfo
*/
analyzeFragment(xml, info) {
return this._analyze(xml, info, true);
}
_analyze(xml, info, isFragment) {
if ( this.busy ) {
// TODO delegate to fresh instances instead
throw new Error("XMLTemplateAnalyzer is unexpectedly busy");
}
this.info = info;
this.conditional = false;
this.templateTag = false;
this.promises = [];
this.busy = true;
return new Promise( (resolve, reject) => {
this._parser.parseString(xml, (err, result) => {
// parse error
if ( err ) {
this.busy = false;
reject(new Error(`Error while parsing XML document ${info.name}: ${err.message}`));
return;
}
if ( !result ) {
// Handle empty xml views/fragments
reject(new Error("Invalid empty XML document: " + info.name));
return;
}
// console.log(result);
// clear();
if ( isFragment ) {
// all fragments implicitly depend on the fragment class
this.info.addImplicitDependency(FRAGMENT_MODULE);
this._analyzeNode(result);
} else {
// views require a special handling of the root node
this._analyzeViewRootNode(result);
}
Promise.all(this.promises).then( () => {
this.busy = false;
resolve(info);
});
// console.log("Collected info for %s:", info.name, info);
});
});
}
_analyzeViewRootNode(node) {
this.info.addImplicitDependency(XMLVIEW_MODULE);
const controllerName = getAttribute(node, XMLVIEW_CONTROLLERNAME_ATTRIBUTE);
if ( controllerName ) {
this._addDependency( fromUI5LegacyName(controllerName, ".controller.js"), this.conditional );
}
const resourceBundleName = getAttribute(node, XMLVIEW_RESBUNDLENAME_ATTRIBUTE);
if ( resourceBundleName ) {
const resourceBundleModuleName = fromUI5LegacyName(resourceBundleName, ".properties");
log.verbose(`Found dependency to resource bundle ${resourceBundleModuleName}`);
// TODO locale dependent dependencies: this._addDependency(resourceBundleModuleName);
this._addDependency( resourceBundleModuleName, this.conditional );
}
this._analyzeCoreRequire(node);
this._analyzeChildren(node);
}
_analyzeNode(node) {
const namespace = node.$ns.uri || "";
const localName = node.$ns.local;
const oldConditional = this.conditional;
const oldTemplateTag = this.templateTag;
if ( namespace === TEMPLATING_NAMESPACE ) {
if ( TEMPLATING_CONDITONAL_TAGS.test(localName) ) {
this.conditional = true;
}
this.templateTag = true;
} else if ( namespace === XHTML_NAMESPACE || namespace === SVG_NAMESPACE ) {
// ignore XHTML and SVG nodes
} else if ( PATTERN_LIBRARY_NAMESPACES.test(namespace) ) {
// looks like a UI5 library or package name
const moduleName = fromUI5LegacyName( (namespace ? namespace + "." : "") + localName );
this._analyzeCoreRequire(node);
// ignore FragmentDefinition (also skipped by runtime XMLTemplateProcessor)
if ( FRAGMENTDEFINITION_MODULE !== moduleName ) {
this.promises.push(this._analyzeModuleDependency(node, moduleName, this.conditional));
}
}
this._analyzeChildren(node);
// restore conditional and templateTag state of the outer block
this.conditional = oldConditional;
this.templateTag = oldTemplateTag;
}
_analyzeChildren(node) {
if ( Array.isArray(node.$$) ) {
node.$$.forEach( (child) => {
return this._analyzeNode( child);
});
}
}
_analyzeCoreRequire(node) {
const coreRequire = getAttributeNS(node, XMLVIEW_CORE_REQUIRE_ATTRIBUTE_NS);
let requireContext;
if ( coreRequire ) {
// expression binding syntax within coreRequire and a template parent node
// These expressions cannot be parsed using parseJS and if within a template tag
// represent an expression binding which needs to be evaluated before analysis
// e.g. "{= '{Handler: \'' + ${myActions > handlerModule} + '\'}'}"
if ((coreRequire.startsWith("{=") || coreRequire.startsWith("{:=")) && this.templateTag) {
log.verbose(
`Ignoring core:require: '${coreRequire}' on Node ${node.$ns.uri}:${node.$ns.local} contains ` +
`an expression binding and is within a 'template' Node`
);
return;
}
try {
requireContext = JSTokenizer.parseJS(coreRequire);
} catch (e) {
log.error(
`Ignoring core:require: '${coreRequire}' can't be parsed on Node ` +
`${node.$ns.uri}:${node.$ns.local}: ${e.message}`
);
log.verbose(e.stack);
}
if ( requireContext ) {
Object.keys(requireContext).forEach((key) => {
const requireJsName = requireContext[key];
if ( requireJsName && typeof requireJsName === "string" ) {
this._addDependency(fromRequireJSName(requireJsName), this.conditional);
} else {
log.error(`Ignoring core:require: '${key}' refers to invalid module name '${requireJsName}'`);
}
});
}
}
}
async _analyzeModuleDependency(node, moduleName, conditional) {
try {
await this._pool.findResource(moduleName);
this._addDependency(moduleName, conditional);
// handle special controls that reference other entities via name
// - (HTML|JS|JSON|XML)View reference another view by 'viewName'
// - ComponentContainer reference another component by 'componentName'
// - Fragment references a fragment by 'fragmentName' . 'type'
if ( moduleName === COMPONENTCONTAINER_MODULE ) {
const componentName = getAttribute(node, COMPONENTCONTAINER_COMPONENTNAME_ATTRIBUTE);
if ( componentName ) {
const componentModuleName =
fromUI5LegacyName( componentName, "/Component.js" );
this._addDependency(componentModuleName, conditional);
}
// TODO what about component.json? handle it transitively via Component.js?
} else if ( moduleName === FRAGMENT_MODULE ) {
const fragmentName = getAttribute(node, FRAGMENT_FRAGMENTNAME_ATTRIBUTE);
const type = getAttribute(node, FRAGMENT_TYPE_ATTRIBUTE);
if ( fragmentName && type ) {
const fragmentModuleName =
fromUI5LegacyName( fragmentName, this._getFragmentExtension(type) );
// console.log("child fragment detected %s", fragmentModuleName);
this._addDependency(fragmentModuleName, conditional);
}
} else if ( moduleName === HTMLVIEW_MODULE ) {
const viewName = getAttribute(node, ANYVIEW_VIEWNAME_ATTRIBUTE);
if ( viewName ) {
const childViewModuleName = fromUI5LegacyName( viewName, ".view.html" );
// console.log("child view detected %s", childViewModuleName);
this._addDependency(childViewModuleName, conditional);
}
} else if ( moduleName === JSVIEW_MODULE ) {
const viewName = getAttribute(node, ANYVIEW_VIEWNAME_ATTRIBUTE);
if ( viewName ) {
const childViewModuleName = fromUI5LegacyName( viewName, ".view.js" );
// console.log("child view detected %s", childViewModuleName);
this._addDependency(childViewModuleName, conditional);
}
} else if ( moduleName === JSONVIEW_MODULE ) {
const viewName = getAttribute(node, ANYVIEW_VIEWNAME_ATTRIBUTE);
if ( viewName ) {
const childViewModuleName = fromUI5LegacyName( viewName, ".view.json" );
// console.log("child view detected %s", childViewModuleName);
this._addDependency(childViewModuleName, conditional);
}
} else if ( moduleName === XMLVIEW_MODULE ) {
const viewName = getAttribute(node, ANYVIEW_VIEWNAME_ATTRIBUTE);
if ( viewName ) {
const childViewModuleName = fromUI5LegacyName( viewName, ".view.xml" );
// console.log("child view detected %s", childViewModuleName);
this._addDependency(childViewModuleName, conditional);
}
} else if ( moduleName === TEMPLATEVIEW_MODULE ) {
const viewName = getAttribute(node, ANYVIEW_VIEWNAME_ATTRIBUTE);
if ( viewName ) {
const childViewModuleName = fromUI5LegacyName( viewName, ".view.tmpl" );
// console.log("child view detected %s", childViewModuleName);
this._addDependency(childViewModuleName, conditional);
}
} else if ( moduleName === VIEW_MODULE ) {
const viewName = getAttribute(node, ANYVIEW_VIEWNAME_ATTRIBUTE);
if ( viewName ) {
let childViewModuleName;
if (viewName.startsWith("module:")) {
childViewModuleName = viewName.slice("module:".length) + ".js";
} else {
const viewType = getAttribute(node, VIEW_TYPE_ATTRIBUTE);
let viewTypeExtension;
switch (viewType) {
case "JS":
viewTypeExtension = ".view.js";
break;
case "JSON":
viewTypeExtension = ".view.json";
break;
case "Template":
viewTypeExtension = ".view.tmpl";
break;
case "XML":
viewTypeExtension = ".view.xml";
break;
case "HTML":
viewTypeExtension = ".view.html";
break;
default:
log.warn(`Unable to analyze sap.ui5/rootView: Unknown type '${viewType}'`);
}
if (viewTypeExtension) {
childViewModuleName = fromUI5LegacyName(viewName, viewTypeExtension);
}
}
if (childViewModuleName) {
// console.log("child view detected %s", childViewModuleName);
this._addDependency(childViewModuleName, conditional);
}
}
}
} catch {
// ignore missing resources
// console.warn( "node not found %s", moduleName);
}
}
_getFragmentExtension(type) {
return ".fragment." + type.toLowerCase();
}
}
export default XMLTemplateAnalyzer;