UNPKG

@seanox/aspect-js

Version:

full stack JavaScript framework for SPAs incl. reactivity rendering, mvc / mvvm, models, expression language, datasource, routing, paths, unit test and some more

1,400 lines (1,231 loc) 249 kB
/** * LIZENZBEDINGUNGEN - Seanox Software Solutions ist ein Open-Source-Projekt, * im Folgenden Seanox Software Solutions oder kurz Seanox genannt. * Diese Software unterliegt der Version 2 der Apache License. * * Seanox aspect-js, fullstack for single page applications * Copyright (C) 2025 Seanox Software Solutions * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. * * * DESCRIPTION * ---- * General extension of the JavaScript API. */ (() => { "use strict"; /** * Compliant takes over the task that the existing JavaScript API can be * manipulated in a controlled way. Controlled means that errors occur when * trying to overwrite existing objects and functions. Originally, the * mechanism was removed after loading the page, but the feature has proven * to be convenient for other modules and therefore remains. * * In the code, the method is used in an unconventional form. * * compliant("Composite"); * compliant(null, window.Composite = {...}); * compliant("Object.prototype.ordinal"); * compliant(null, Object.prototype.ordinal = function() {...} * * This is only for the IDE so that syntax completion has a chance there. * This syntax will be simplified and corrected in the build process for the * releases. * * @param {string|null} context Context of object or function to manipulate * If null, only the payload is returned. * @param {*} payload Payload to assign to the context * @returns {*} Assigned payload * @throws {Error} If the compliant function or context is already exists */ if (window.compliant !== undefined) throw new Error("JavaScript incompatibility detected for: compliant"); window.compliant = (context, payload) => { if (context === null || context === undefined) return payload; if (new Function(`return typeof ${context}`)() !== "undefined") throw new Error("JavaScript incompatibility detected for: " + context); if (context.match(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/)) context = `window["${context}"]`; return eval(`${context} = payload`); }; /** * Comparable to packages in other programming languages, namespaces can be * used for hierarchical structuring of components, resources and business * logic. Although packages are not a feature of JavaScript, they can be * mapped at the object level by concatenating objects into an object tree. * Here, each level of the object tree forms a namespace, which can also be * considered a domain. * * As is typical for the identifiers of objects, namespaces so use word * characters, means letters, numbers and underscores separated by a dot. As * a special feature, arrays are also supported. If a layer in the namespace * uses an integer, this layer is used as an array. */ compliant("Namespace", { /** Pattern for the namespace separator */ get PATTERN_NAMESPACE_SEPARATOR() {return /\./;}, /** Pattern for a valid namespace level at the beginning */ get PATTERN_NAMESPACE_LEVEL_START() {return /^[_a-z\$][\w\$]*$/i;}, /** Pattern for a valid namespace level */ get PATTERN_NAMESPACE_LEVEL() {return /^[\w\$]+$/;}, /** * Creates a namespace to the passed object, strings and numbers, if the * namespace contains arrays and the numbers can be used as index. * Levels of the namespace levels are separated by a dot. Levels can as * fragments also contain dots. Without arguments the global namespace * window is returned. * * The method has the following various signatures: * Namespace.use(); * Namespace.use(string); * Namespace.use(string, ...string|number); * Namespace.use(object); * Namespace.use(object, ...string|number); * * @param {...(string|number|object)} levels Levels of the namespace * @returns {object} The created or already existing object (level) * @throws {Error} In case of invalid data types or syntax */ use(...levels) { if (levels.length <= 0) return window; _filter(...levels); let offset = levels.length; let namespace = null; if (levels.length > 0 && typeof levels[0] === "object") namespace = levels.shift(); offset -= levels.length; levels = levels.join("."); levels.split(Namespace.PATTERN_NAMESPACE_SEPARATOR).forEach((level, index, array) => { const pattern = index === 0 && namespace === null ? Namespace.PATTERN_NAMESPACE_LEVEL_START : Namespace.PATTERN_NAMESPACE_LEVEL; if (!level.match(pattern)) throw new Error(`Invalid namespace at level ${index +1}${level && level.trim() ? ": " + level.trim() : ""}`); // Composites use IDs which causes corresponding DOM objects // (Element) in the global namespace if there are no // corresponding data objects (models). Because namespaces are // based on data objects, if an element appears, we assume that // a data object does not exist and the recursive search is // aborted as unsuccessful. if (index === 0 && namespace === null) { namespace = _populate(namespace, level); if (namespace !== undefined && !(namespace instanceof Element)) return; namespace = window; } const item = _populate(namespace, level); const type = typeof item; if (type !== "undefined" && type !== "object") throw new TypeError(`Invalid namespace type at level ${index +1 +offset}: ${type}`); if (item === undefined || item === null || item instanceof Element) if (index < array.length -1 && array[index +1].match(/^\d+$/)) namespace[level] = []; else namespace[level] = {}; namespace = _populate(namespace, level); }); return namespace; }, /** * Creates a namespace with an initial value to the passed object, * strings and numbers, if the namespace contains arrays and the numbers * can be used as index. Levels of the namespace levels are separated by * a dot. Levels can as fragments also contain dots. Without arguments, * the global namespace window is used. * * The method has the following various signatures: * Namespace.create(string, value); * Namespace.create(string, ...string|number, value); * Namespace.create(object, value); * Namespace.create(object, ...string|number, value); * * @param {...(string|number|object)} levels Levels of the namespace * @param {*} value Value to initialize/set * @returns {object} The created or already existing object (level) * @throws {Error} In case of invalid data types or syntax */ create(...levels) { if (levels.length < 2) throw new Error("Invalid namespace for creation: Namespace and/or value is missing"); const value = levels.pop(); levels = _filter(...levels); const level = levels.pop(); const namespace = Namespace.use(...levels); if (namespace === null) return null; namespace[level] = value; return _populate(namespace, level); }, /** * Resolves a namespace and returns the determined object(-level). * If the namespace does not exist, undefined is returned. * * The method has the following various signatures: * Namespace.lookup(); * Namespace.lookup(string); * Namespace.lookup(string, ...string|number); * Namespace.lookup(object); * Namespace.lookup(object, ...string|number); * * @param {...(string|number|object)} levels Levels of the namespace * @returns {object|undefined} The determined object(-level) * @throws {Error} In case of invalid data types or syntax */ lookup(...levels) { if (levels.length <= 0) return window; _filter(...levels); let offset = levels.length; let namespace = null; if (levels.length > 0 && typeof levels[0] === "object") namespace = levels.shift(); offset -= levels.length; levels = levels.join("."); levels = levels.split(Namespace.PATTERN_NAMESPACE_SEPARATOR); for (let index = 0; index < levels.length; index++) { const level = levels[index]; const pattern = index +offset === 0 ? Namespace.PATTERN_NAMESPACE_LEVEL_START : Namespace.PATTERN_NAMESPACE_LEVEL; if (!level.match(pattern)) throw new Error(`Invalid namespace at level ${index +1 +offset}${level && level.trim() ? ": " + level.trim() : ""}`); // Composites use IDs which causes corresponding DOM objects // (Element) in the global namespace if there are no // corresponding data objects (models). Because namespaces are // based on data objects, if an element appears, we assume that // a data object does not exist and the recursive search is // aborted as unsuccessful. if (index === 0 && namespace === null) { namespace = _populate(namespace, level); if (namespace !== undefined) continue; namespace = window; } namespace = _populate(namespace, level); if (namespace === undefined || namespace === null) return namespace; if (namespace instanceof Element) return undefined; } return namespace; }, /** * Checks whether a namespace exists based on the passed object, strings * and numbers, if the namespace contains arrays and the numbers can be * used as index. Levels of the namespace chain are separated by a dot. * Levels can also be fragments that contain dots. Without arguments the * global namespace window is used. * * The method has the following various signatures: * Namespace.exists(); * Namespace.exists(string); * Namespace.exists(string, ...string|number); * Namespace.exists(object); * Namespace.exists(object, ...string|number); * * @param {...(string|number|object)} levels Levels of the namespace * @returns {boolean} True if the namespace exists */ exists(...levels) { if (levels.length < 1) return false; return Object.usable(Namespace.lookup(...levels)); } }); const _filter = (...levels) => { const chain = []; levels.forEach((level, index) => { if (index === 0 && typeof level !== "object" && typeof level !== "string") throw new TypeError(`Invalid namespace at level ${index +1}: ${typeof level}`); if (index === 0 && level === null) throw new TypeError(`Invalid namespace at level ${index +1}: null`); if (index > 0 && typeof level !== "string" && typeof level !== "number") throw new TypeError(`Invalid namespace at level ${index +1}: ${typeof level}`); level = typeof level === "string" ? level.split(Namespace.PATTERN_NAMESPACE_SEPARATOR) : [level]; chain.push(...level); }); return chain; }; const _populate = (namespace, level) => { if (namespace && namespace !== window) return namespace[level]; try {return eval(`typeof ${level} !== "undefined" ? ${level} : undefined`); } catch (error) { if (error instanceof ReferenceError && namespace === window) return window[level]; throw error; } } /** * Enhancement of the JavaScript API * Modifies the method to support node and nodes as NodeList and Array. If * the option exclusive is used, existing children will be removed first. * @param {(Node|NodeList|Array)} node Node(s) to be modified * @param {boolean} [exclusive=false] True, removes existing children */ const _appendChild = Element.prototype.appendChild; Element.prototype.appendChild = function(node, exclusive) { if (exclusive) this.innerHTML = ""; if (node instanceof Node) return _appendChild.call(this, node); if (Array.isArray(node) || node instanceof NodeList || (Symbol && Symbol.iterator && node && typeof node[Symbol.iterator])) { node = Array.from(node); for (let loop = 0; loop < node.length; loop++) _appendChild.call(this, node[loop]); return node; } return _appendChild.call(this, node); }; /** * Enhancement of the JavaScript API * Adds a static function to create an alphanumeric unique (U)UID with fixed * size. The quality of the ID is dependent of the length. * @param {number} [size=16] Optional size of the unique ID * @returns {string} The generated alphanumeric unique ID */ compliant("Math.unique", (size) => { size = size || 16; if (size < 0) size = 16; let unique = ""; for (let loop = 0; loop < size; loop++) { const random = Math.floor(Math.random() * Math.floor(26)); if ((Math.floor(Math.random() *Math.floor(26))) % 2 === 0) unique += String(random % 10); else unique += String.fromCharCode(65 +random); } return unique; }); /** * Enhancement of the JavaScript API * Adds a static function to create a time based alphanumeric serial that is * chronologically sortable as text and contains the time and a counter if * serial are created at the same time. * @returns {string} The generated time-based alphanumeric serial */ compliant("Math.serial", () => _serial.toString()); const _offset = -946684800000; const _serial = {timing:Date.now() + _offset, number:0, toString() { const timing = Date.now() + _offset; this.number = this.timing === timing ? this.number +1 : 0; this.timing = timing; const serial = this.timing.toString(36); const number = this.number.toString(36); return (serial.length.toString(36) + serial + number.length.toString(36) + number).toUpperCase(); }}; /** * Enhancement of the JavaScript API * Creates a literal pattern for the specified text. Metacharacters or escape * sequences in the text thus lose their meaning. * @param {string} text Text to be literalized * @returns {string|null} Literal pattern for the specified text, or null if * the text is not usable */ compliant("RegExp.quote", (text) => { if (!Object.usable(text)) return null; return String(text).replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&"); }); /** * Enhancement of the JavaScript API * Adds a capitalize function to the String objects. * @returns {string} String with the first character capitalized */ compliant("String.prototype.capitalize", function() { if (this.length <= 0) return this; return this.charAt(0).toUpperCase() + this.slice(1); }); /** * Enhancement of the JavaScript API * Adds an uncapitalize function to the String objects. * @returns {string} The string with the first character uncapitalized */ compliant("String.prototype.uncapitalize", function() { if (this.length <= 0) return this; return this.charAt(0).toLowerCase() + this.slice(1); }); /** * Enhancement of the JavaScript API * Adds a function for encoding the string objects in hexadecimal code. * @returns {string} The hexadecimal encoded string */ compliant("String.prototype.encodeHex", function() { let result = ""; for (let loop = 0; loop < this.length; loop++) { let digit = Number(this.charCodeAt(loop)).toString(16).toUpperCase(); while (digit.length < 2) digit = "0" + digit; result += digit; } return "0x" + result; }); /** * Enhancement of the JavaScript API * Adds a function for decoding hexadecimal code to the string objects. * @returns {string} The decoded string */ compliant("String.prototype.decodeHex", function() { let text = this; if (text.match(/^0x/)) text = text.substring(2); let result = ""; for (let loop = 0; loop < text.length; loop += 2) result += String.fromCharCode(parseInt(text.substring(loop, 2), 16)); return result; }); /** * Enhancement of the JavaScript API * Adds a method for encoding Base64. * @returns {string} The Base64 encoded string. * @throws {Error} In case of a malformed character sequence. */ compliant("String.prototype.encodeBase64", function() { try { return btoa(encodeURIComponent(this).replace(/%([0-9A-F]{2})/g, (match, code) => String.fromCharCode("0x" + code))); } catch (error) { throw new Error("Malformed character sequence"); } }); /** * Enhancement of the JavaScript API * Adds a method for decoding Base64. * @returns {string} The decoded string. * @throws {Error} In case of a malformed character sequence */ compliant("String.prototype.decodeBase64", function() { try { return decodeURIComponent(atob(this).split("").map((code) => "%" + ("00" + code.charCodeAt(0).toString(16)).slice(-2)).join("")); } catch (error) { throw new Error("Malformed character sequence"); } }); /** * Enhancement of the JavaScript API * Adds an HTML encode function to the String objects. * @returns {string} The HTML encoded string */ compliant("String.prototype.encodeHtml", function() { const element = document.createElement("div"); element.textContent = this; return element.innerHTML; }); /** * Enhancement of the JavaScript API * Adds a method for calculating a hash value. * @returns {string} The calculated hash value */ compliant("String.prototype.hashCode", function() { let hash = 0; let hops = 0; for (let loop = 0; loop < this.length; loop++) { const temp = 31 *hash +this.charCodeAt(loop); if (!Number.isSafeInteger(temp)) { hops++; hash = Number.MAX_SAFE_INTEGER -hash +this.charCodeAt(loop); } else hash = temp; } hash = Math.abs(hash).toString(36); hash = hash.length.toString(36) + hash; hash = (hash + hops.toString(36)).toUpperCase(); return hash; }); /** * Enhancement of the JavaScript API * Adds a decoding of slash sequences (control characters). * @returns {string} The decoded string with processed control characters */ compliant("String.prototype.unescape", function() { let text = this .replace(/\r/g, "\\r") .replace(/\n/g, "\\n") .replace(/^(["'])/, "\$1") .replace(/([^\\])((?:\\{2})*)(?=["'])/g, "$1$2\\"); return eval(`"${text}"`); }); /** * Enhancement of the JavaScript API * Adds a property to get the UID for the window instance. * @returns {string} The unique identifier (UID) for the window instance */ compliant("window.serial"); Object.defineProperty(window, "serial", { value: Math.serial() }); /** * Enhancement of the JavaScript API * Adds a property to get the context path. The context path is a part of * the request URI and can be compared with the current working directory. * @returns {string} The context path of the request URI. */ compliant("window.location.contextPath"); Object.defineProperty(window.location, "contextPath", { value: ((location) => location.substring(0, location.lastIndexOf("/")) + "/" )(window.location.pathname || "/") }); /** * Enhancement of the JavaScript API * Adds a method to combine paths to a new one. The result will always start * with a slash but ends without it. * @param {...string} paths Paths to be combined * @returns {string} The combined path. */ compliant("window.location.combine", (...paths) => "/" + paths.join("/") .replace(/[\/\\]+/g, "/") .replace(/(^\/+)|(\/+$)/g, "")); })(); /** * DataSource is a NoSQL approach to data storage based on XML data in * combination with multilingual data separation, optional aggregation and * transformation. * A combination of the approaches of a read only DBS and a CMS. * * Files are defined by locator. * A locator is a URL (xml://... or xslt://...) that is used absolute and * relative to the DataSource directory, but does not contain a locale * (language specification) in the path. The locale is determined automatically * for the language setting of the browser, or if this is not supported, the * standard from the locales.xml in the DataSource directory is used. * * DataSource is based on static data. * Therefore, the implementation uses a cache to minimize network access. * * The data is queried with XPath, the result can be concatenated and * aggregated and the result can be transformed with XSLT. */ (() => { "use strict"; /** Path of the DataSource for: data (sub-directory of work path) */ const DATA = window.location.combine(window.location.contextPath, "/data"); /** * Pattern for a DataSource locator, based on the URL syntax but only * the parts schema and path are used. A path segment begins with a word * character _ a-z 0-9, optionally more word characters and additionally * - can follow, but can not end with the - character. Paths are * separated by the / character. */ const PATTERN_LOCATOR = /^(?:([a-z]+):\/+)(\/((\w+)|(\w+(\-+\w+)+)))+$/; /** Pattern to detect JavaScript elements */ const PATTERN_JAVASCRIPT = /^\s*text\s*\/\s*javascript\s*$/i; /** Pattern to detect a word (_ 0-9 a-z A-Z -) */ const PATTERN_WORD = /(^\w+$)|(^((\w+\-+(?=\w))+)\w*$)/; /** Constant for attribute type */ const ATTRIBUTE_TYPE = "type"; compliant("DataSource", { /** The currently used language. */ get locale() {return DataSource.locales ? DataSource.locales.selection : null;}, /** * Changes the localization of the DataSource. * Only locales from locales.xml can be used, other values cause an * error. * @param {string} locale Locale to be set * @throws {TypeError} In case of invalid locale type * @throws {Error} In case of missing DataSource data or locales * @throws {Error} In case of invalid locales */ localize(locale) { if (!DataSource.data || !DataSource.locales) throw new Error("Locale not available"); if (typeof locale !== "string") throw new TypeError("Invalid locale: " + typeof locale); locale = (locale || "").trim().toLowerCase(); if (!locale || !DataSource.locales.includes(locale)) throw new Error("Locale not available"); DataSource.locales.selection = locale; }, /** * Transforms an XMLDocument based on a passed stylesheet. * The data and the stylesheet can be passed as Locator, XMLDocument an * in mix. The result as a DocumentFragment. Optionally, a meta-object * or a map with parameters for the XSLTProcessor can be passed. * @param {string|XMLDocument} xml Locator or XMLDocument to be * transformed * @param {string|XMLDocument} style Locator or XMLDocument stylesheet * @param {Object} [meta] Optional parameters for the XSLTProcessor * @returns {DocumentFragment} The transformation result as a * DocumentFragment @throws {TypeError} In case of invalid xml document and/or stylesheet */ transform(xml, style, meta) { if (typeof xml === "string" && xml.match(PATTERN_LOCATOR)) xml = DataSource.fetch(xml); if (typeof style === "string" && style.match(PATTERN_LOCATOR)) style = DataSource.fetch(style); if (!(xml instanceof XMLDocument)) throw new TypeError("Invalid xml document"); if (!(style instanceof XMLDocument)) throw new TypeError("Invalid xml stylesheet"); const processor = new XSLTProcessor(); processor.importStylesheet(style); if (meta && typeof meta === "object") { const set = typeof meta[Symbol.iterator] !== "function" ? Object.entries(meta) : meta for (const [key, value] of set) if (typeof meta[key] !== "function") processor.setParameter(null, key, value); } // Attribute escape converts text to HTML. Without, HTML tag symbols // < and > are masked and output as text. let escape = xml.evaluate("string(/*/@escape)", xml, null, XPathResult.ANY_TYPE, null).stringValue; escape = !!escape.match(/^yes|on|true|1$/i); // Workaround for some browsers, e.g. MS Edge, if they have problems // with !DOCTYPE + !ENTITY. Therefore the document is copied so that // the DOCTYPE declaration is omitted. let result = processor.transformToDocument(xml.clone()); let nodes = result.querySelectorAll(escape ? "*" : "*[escape]"); nodes.forEach((node) => { if (escape || (node.getAttribute("escape") || "on").match(/^yes|on|true|1$/i)) { const content = node.innerHTML; if (content.indexOf("<") < 0 && content.indexOf(">") < 0) node.innerHTML = node.textContent; } node.removeAttribute("escape"); }); // JavaScript are automatically changed to composite/javascript // during the import. Therefore, imported scripts are not executed // directly, but only by the renderer. This is important in // combination with ATTRIBUTE_CONDITION. nodes = result.querySelectorAll("script[type],script:not([type])"); nodes.forEach((node) => { if (!node.hasAttribute(ATTRIBUTE_TYPE) || (node.getAttribute(ATTRIBUTE_TYPE) || "").match(PATTERN_JAVASCRIPT)) node.setAttribute("type", "composite/javascript"); }); nodes = result.childNodes; if (result.body) nodes = result.body.childNodes; else if (result.firstChild && result.firstChild.nodeName.match(/^transformiix\b/i)) nodes = result.firstChild.childNodes; const fragment = document.createDocumentFragment(); nodes = Array.from(nodes); for (let loop = 0; loop < nodes.length; loop++) fragment.appendChild(nodes[loop]); return fragment; }, /** * Fetch the data to a locator as XMLDocument. Optionally the data can * be transformed via XSLT, for which a meta-object or map with * parameters for the XSLTProcessor can be passed. When using the * transformation, the return type changes to a DocumentFragment. * @param {string} locator Locator to fetch data for. * @param {string|boolean} transform Locator of the transformation * style. If boolean true, the style is derived from the locator * using the file extension xslt. * @param {Object} [meta] Optional parameters for the XSLTProcessor. * @returns {XMLDocument|DocumentFragment} The fetched data as an * XMLDocument or a DocumentFragment if transformation is used * @throws {Error} In case of invalid arguments */ fetch(locator, transform, meta) { if (typeof locator !== "string" || !locator.match(PATTERN_LOCATOR)) throw new Error("Invalid locator: " + String(locator)); const type = locator.match(PATTERN_LOCATOR)[1]; const path = locator.match(PATTERN_LOCATOR)[2]; if (arguments.length === 1) { let data = DATA + "/" + DataSource.locale + "/" + path + "." + type; data = data.replace(/\/+/g, "/"); const hash = data.hashCode(); if (_cache.hasOwnProperty(hash)) return _cache[hash]; const request = new XMLHttpRequest(); request.overrideMimeType("application/xslt+xml"); request.open("GET", data, false); request.send(); if (request.status !== 200) throw new Error(`HTTP status ${request.status} for ${request.responseURL}`); data = request.responseXML; _cache[hash] = data; return data.clone(); } if (!type.match(/^xml$/) && transform) throw new Error("Transformation is not supported for this locator"); const data = DataSource.fetch(locator); if (!transform) return data.clone(); let style = locator.replace(/(^((\w+\-+(?=\w))+)\w*)|(^\w+)/, "xslt"); if (typeof transform !== "boolean") { style = transform; if (typeof style !== "string" || !style.match(PATTERN_LOCATOR)) throw new Error("Invalid style: " + String(style)); } return DataSource.transform(data, DataSource.fetch(style), meta); }, /** * Collects and concatenates multiple XML files in a new XMLDocument. * * The method has the following various signatures: * DataSource.collect(locator, ...); * DataSource.collect(collector, [locators]); * * @param {string} collector Name of the collector element in the * XMLDocument * @param {Array|string} locators Array or VarArg with locators * @returns {XMLDocument|null} The created XMLDocument, otherwise null * @throws {TypeError} In case of invalid arguments, collector, * collection entry */ collect(...variants) { if (variants.length <= 0) return null; let collection = []; let collector = "collection"; if (variants.length === 2 && typeof variants[0] === "string" && Array.isArray(variants[1])) { if (!variants[0].match(PATTERN_WORD)) throw new TypeError("Invalid collector"); collector = variants[0]; collection = Array.from(variants[1]); } else if (variants.length === 1 && Array.isArray(variants[0])) { collection = collection.concat(variants[0]); } else collection = Array.from(variants); let hash = collector.hashCode() + ":" + collection.join().hashCode(); collection.forEach((entry) => hash += ":" + String(entry).hashCode()); if (_cache.hasOwnProperty(hash)) return _cache[hash].clone(); const root = document.implementation.createDocument(null, collector, null); collection.forEach((entry) => { if (typeof entry !== "string") throw new TypeError("Invalid collection entry"); root.documentElement.appendChild(DataSource.fetch(entry).documentElement.cloneNode(true)); }); _cache[hash] = root; return root.clone(); } }); /** * Enhancement of the JavaScript API * Adds a method for cloning a XMLDocument. * @returns {XMLDocument} The cloned XMLDocument */ compliant("XMLDocument.prototype.clone", function() { const clone = this.implementation.createDocument(null, null); clone.appendChild(clone.importNode(this.documentElement, true)); return clone; }); // XML/XSLT data cache const _cache = {}; Object.defineProperty(DataSource, "cache", { value: {} }); // DataSource.locales // List of available locales (as standard marked are at the beginning) Object.defineProperty(DataSource, "locales", { value: [], enumerable: true }); let locales = []; [navigator.language].concat(navigator.languages || []).forEach(language => { language = language.toLowerCase().trim(); locales.push(language); language = language.replace(/-.*$/, ""); if (!locales.includes(language)) locales.push(language); }); if (locales.length <= 0) throw new Error("Locale not available"); const request = new XMLHttpRequest(); request.overrideMimeType("text/plain"); request.open("GET", DATA + "/locales.xml", false); request.send(); // DataSource.data // Cache of locales.xml, can also be used by other components Object.defineProperty(DataSource, "data", { value: request.status === 200 ? new DOMParser().parseFromString(request.responseText,"text/xml") : null }); if (!DataSource.data && request.status !== 404) throw new Error("Locale not available"); if (!DataSource.data) return; let xml = DataSource.data; let nodes = xml.evaluate("/locales/*[@default]", xml, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null); for (let node = nodes.iterateNext(); node; node = nodes.iterateNext()) { let name = node.nodeName.toLowerCase(); if (!DataSource.locales.includes(name)) DataSource.locales.push(name); } nodes = xml.evaluate("/locales/*", xml, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null); for (let node = nodes.iterateNext(); node; node = nodes.iterateNext()) { const name = node.nodeName.toLowerCase(); if (!DataSource.locales.includes(name)) DataSource.locales.push(name); } if (DataSource.locales.length <= 0) throw new Error("Locale not available"); locales.push(DataSource.locales[0]); locales = locales.filter((locale) => DataSource.locales.includes(locale)); DataSource.locales.selection = locales.length ? locales[0] : DataSource.locales[0]; })(); /** * Expression language and composite JavaScript are two important components. * Both are based on JavaScript enriched with macros. In addition, Composite * JavaScript can be loaded at runtime and can itself load other Composite * JavaScript scripts. Because in the end everything is based on a simple eval * command, it was important to isolate the execution of the scripts so that * internal methods and constants cannot be accessed unintentionally. */ (() => { compliant("Scripting", { /** * As a special feature, Composite JavaScript supports macros. * * Macros are based on a keyword starting with a hash symbol followed by * arguments separated by spaces. Macros end with the next line break, a * semicolon or with the end of the file. * * * #import * ---- * Expects a space-separated list of composite modules whose path must * be relative to the URL. * * #import io/api/connector and/much more * * Composite modules consist of the optional resources CSS, JS and HTML. * The #import macro can only load CSS and JS. The behavior is the same * as when loading composites in the markup. The server status 404 does * not cause an error, because all resources of a composite are * optional, also JavaScript. Server states other than 200 and 404 cause * an error. CSS resources are added to the HEAD and lead to an error if * no HEAD element exists in the DOM. Markup (HTML) is not loaded * because no target can be set for the output. The macro can be used * multiple in the Composite JavaScript. * * * #export * ---- * Expects a space-separated list of exports. Export are variables or * constants in a module that are made usable for the global scope. * * #export connector and much more * * Primarily, an export argument is the name of the variable or constant * in the module. Optionally, the name can be extended by an @ symbol to * include the destination in the global scope. * * #export connector@io.example * * The macro #module is intended for debugging. It writes the following * text as debug output to the console. The browser displays this output * with source, which can then be used as an entry point for debugging. * * * #module * ---- * Expected a space-separated list of words to be output in the debug * level of the browser console. The output is a string expression and * supports the corresponding syntax. * * #module console debug output * * * #use * ---- * Expected to see a space-separated list of namespaces to create if * they don't already exist. * * #use namespaces to be created * * * (?...) * ---- * Tolerant expressions are also a macro, although with different * syntax. The logic enclosed in the parenthesis with question marks is * executed fault-tolerantly. In case of an error the logic corresponds * to the value false without causing an error itself, except for syntax * errors. * * @param {string} script * @returns {*} the return value from the script */ eval(script) { if (typeof script !== "string") throw new TypeError("Invalid data type"); // Performance is important here. // The implementation parses and replaces macros in one pass. // It was important to exclude literals and comments. // - ignore: /*...*/ // - ignore: //...([\r\n]|$) // - ignore: '...' // - ignore: "..." // - ignore: `...` // - detect: (^|\W)#(import|export|module)\s+...(\W|$) // - detect: \(\s*\?...\) let pattern; let brackets; for (let cursor = 0; cursor < script.length; cursor++) { let digit = script.charAt(cursor); if (cursor >= script.length && !pattern) continue; // The macro for the tolerant logic is a bit more complicated, // because round brackets have to be counted here. Therefore the // parsing runs parallel to the other macros. In addition, the // syntax is undefined by optional whitepsaces between ( and ?). if (brackets < 0) { if (digit === "?") { brackets = 1; let macro = "_tolerate(()=>"; script = script.substring(0, cursor) + macro + script.substring(cursor +1); cursor += macro.length; continue; } if (!digit.match(/\s/)) brackets = 0; } if (digit === "\\") { cursor++ continue; } if (pattern) { if (pattern === script.substring(cursor, cursor + pattern.length) || (pattern === "\n" && digit === "\r")) pattern = null; continue; } switch (digit) { case "/": digit = script.charAt(cursor +1); if (digit === "/") pattern = "\n"; if (digit === "*") pattern = "*/"; continue; case "(": if (brackets > 0) brackets++; else brackets = -1; continue; case ")": if (brackets <= 0) continue; if (--brackets > 0) continue; let macro = ")"; script = script.substring(0, cursor) + macro + script.substring(cursor); cursor += macro.length; continue; case "\'": case "\"": case "\`": pattern = digit; continue; case "#": let string = script.substring(cursor -1, cursor +10); let match = string.match(/(^|\W)(#(?:import|export|module|use))\s/); if (match) { let macro = match[2]; for (let offset = cursor +macro.length; offset <= script.length; offset++) { string = script.charAt(offset); if (!string.match(/[;\r\n]/) && offset < script.length) continue; let parameters = script.substring(cursor +macro.length, offset).trim(); switch (macro) { case "#import": if (!parameters.match(/^(\w+(\/\w+)*)(\s+(\w+(\/\w+)*))*$/)) throw new Error(("Invalid macro: #import " + parameters).trim()); const imports = parameters.split(/\s+/).map(entry => "\"" + entry + "\""); macro = "_import(...[" + imports.join(",") + "])"; break; case "#export": const exports = []; const pattern = /^([_a-z]\w*)(?:@((?:[_a-z]\w*)(?:\.[_a-z]\w*)*))?$/i; parameters.split(/\s+/).forEach(entry => { const match = entry.match(pattern); if (!match) throw new Error(("Invalid macro: #export " + parameters).trim()); parameters = [match[1], "\"" + match[1] + "\""]; if (match[2]) parameters.push("\"" + match[2] + "\""); exports.push("[" + parameters.join(",") + "]"); }); macro = "_export(...[" + exports.join(",") + "])"; break; case "#module": macro = parameters.replace(/\\/g, "\\\\") .replace(/`/g, "\\`").trim(); if (macro) macro = "console.debug(`Module: " + macro + "`)"; break; case "#use": if (!parameters.match(/^([_a-z]\w*)(\.[_a-z]\w*)*(\s+([_a-z]\w*)(\.[_a-z]\w*)*)*$/i)) throw new Error(("Invalid macro: #use " + parameters).trim()); const uses = parameters.split(/\s+/).map(entry => "\"" + entry + "\""); macro = "_use(...[" + uses.join(",") + "])"; break; } script = script.substring(0, cursor -1) + (match[1] || "") + macro + script.substring(offset); cursor += macro.length; break; } } continue; default: continue; } } return this.run(script); }, /** * Executes a script isolated in this context, so that no unwanted * access to internals is possible. * @param {string} script * @returns {*} return value of the script, if available */ run(script) { if (typeof script !== "string") throw new TypeError("Invalid data type"); if (!script.trim()) return; with (Composite.render.context) return eval(script); } }); const _import = (...imports) => { // Because it is an internal method, an additional validation of the // exports as data structure was omitted. imports.forEach(include => Composite.load(Composite.MODULES + "/" + include + ".js", true)); }; const _export = (...exports) => { // Because it is an internal method, an additional validation of the // exports as data structure was omitted. exports.forEach(parameters => { let context = window; (parameters[2] ? parameters[2].split(/\./) : []).forEach(parameter => { if (typeof context[parameter] === "undefined") context[parameter] = {}; context = context[parameter] }); const lookup = context[parameters[1]]; if (typeof lookup !== "undefined" && !(lookup instanceof Element) && !(lookup instanceof HTMLCollection)) throw new Error("Context for export is already in use: " + parameters[1] + (parameters[2] ? "@" + parameters[2] : "")); context[parameters[1]] = parameters[0]; }); } const _use = (...uses) => { uses.forEach(use => Namespace.use(use)); } const _tolerate = (invocation) => { try {return invocation.call(window); } catch (error) { return false; } }; })(); /** * Expressions or the Expression Language (EL) is a simple access to the * client-side JavaScript and thus to the models and components. In the * expressions the complete JavaScript API is supported, which is enhanced with * additional keywords, so that also the numerous arithmetic and logical * operators can be used. * * The expression language can be used from the HTML element BODY on in the * complete markup as free text, as well as in all attributes. Exceptions are * the HTML elements STYLE and SCRIPT whose content is not supported by the * expression language. */ (() => { "use strict"; compliant("Expression", { /** * Interprets the passed expression. In case of an error, the error is * returned and no error is thrown. A serial can be specified * optionally. The serial is an alias for caching compiled expressions. * Without, the expressions are always compiled. The function uses * variable parameters and has the following signatures: * * function(expression) * function(serial, expression) * * @param {string} [serial] Optional serial for caching expressions * @param {string} expression Expression to be interpreted. * @returns {*} The return value of the interpreted expression, or an * error if an error has occurred * @throws {Error} In case of invalid data types or syntax. */ eval(...variants) { let expression; if (variants.length > 1) expression = String(variants[1]); else if (variants.length > 0) expression = String(variants[0]); let serial; if (variants.length > 1 && variants[0]) serial = String(variants[0]); let script = serial ? _cache.get(serial) : null; if (!script) script = _parse(TYPE_MIXED, expression); if (serial) _cache.set(serial, script); try {return Scripting.run(script); } catch (error) { console.error(error.message + "\n\t" + script); return new Error(error.message + " in " + script); } } }); /** Cache (expression/script) */ const _cache = new Map(); const TYPE_MIXED = 0; const TYPE_LITERAL = 1; const TYPE_TEXT = 2; const TYPE_SCRIPT = 3; const KEYWORDS = ["and", "&&", "or", "||", "not", "!", "eq", "==", "eeq", "===", "ne", "!=", "nee", "!==", "lt", "<", "gt", ">", "le", "<=", "ge", ">=", "empty", "!", "div", "/", "mod", "%"]; const PATTERN_KEYWORDS = new RegExp("(^|[^\\w\\.])(" + KEYWORDS.filter((keyword, index) => index % 2 === 0).join("|") + ")(?=[^\\w\\.]|$)", "ig"); const _fill = (expression, patches) => expression.replace(/[\t\r](\d+)\n/g, (match, id) => patches[id]); /** * Analyzes and finds the components of an expression and creates a * JavaScript from them. Created scripts are cached a reused as needed. * @param {string} expression Expression to analyze * @param {number} [depth=0] Depth of the analysis * @param {Array} [patches=[]] Patches to apply * @returns {string} The created JavaScript * @throws {Error} In case of an error in the expression structure */ const _parse = (type, expression, patches = []) => { switch (type) { case TYPE_MIXED: // replace all line breaks and merge them with a space, so the // characters CR\r and LF\n can be used as internal markers expression = expression.replace(/[\r\n]/g, " ").trim(); let structure = expression; // find all places that contain scripts structure = structure.replace(/\{\{(.*?)\}\}/g, (match, script) => _parse(TYPE_SCRIPT, script, patches)); // find all places outside the detected script replacements at // the beginning ^...\r and between \n...\r and at the end \n...$ structure = structure.replace(/((?:[^\n]+$)|(?:[^\n]+(?=\r)))/g, (match, text) => _parse(TYPE_TEXT, text, patches)); // if everything is replaced, the expression must have the // following structure, deviations are syntax errors if (!structure.match(/^(\r(\d+)\n)*$/)) throw Error("Error in the expression structure\n\t" + expression); const mixed = !expression.startsWith("{{") || !expression.endsWith("}}") || expression.substring(2).includes("{{") || expression.substring(0, expression.length -2).includes("}}"); // placeholders must be filled, since they were created // recursively, they do not have to be filled recursively structure = structure.replace(/(?:\r(\d+)\n)/g, (match, placeholder) => mixed ? "\r(" + patches[placeholder] + ")\n" : "\r" + patches[placeholder] + "\n"); // masked quotation marks will be restored. structure = structure.replace(/\r\\u0022\n/g, '\\"'); structure = structure.replace(/\r\\u0027\n/g, "\\'"); structure = structure.replace(/\r\\u0060\n/g, "\\`"); // splices still need to be made scriptable structure = structure.replace(/(\n\r)+/g, " + "); structure = structure.replace(/(^\r)|(\n$)/g, ""); // CR/LF of markers must be removed so that the end of line is not // interpreted as separation of commands or logic: a\nb == a;b structure = structure.replace(/[\r\n]+/g, " "); return structure case TYPE_TEXT: expression = "\"" + expression + "\""; case TYPE_LITERAL: patches.push(expression); return "\r" + (patches.length -1) + "\n"; case TYPE_SCRIPT: expression = expression.trim(); if (!expression) return ""; // Mask escaped quotes (single and double) so that they are not // mistakenly found by the parser as delimiting string/phrase. // rule: search for an odd number of slashes followed by quotes expression = expression.replace(/(^|[^\\])((?:\\{2})*)(\\\")/g, "$1$2\r\\u0022\n"); expression = expression.replace(/(^|[^\\])((?:\\{2})*)(\\\')/g, "$1$2\r\\u0027\n"); expression = expression.replace(/(^|[^\\])((?:\\{2})*)(\\\`)/g, "$1$2\r\\u0060\n"); // Replace all literals "..." / '...' / `...` with placeholders. // This simplifies analysis because text can contain anything // and the parser would have to constantly distinguish between // logic and text. If the literals are replaced by numeric // placeholders, only logic remains. Important is a flexible // processing, because the order of ', " and ` is not defined. expression = expression.replace(/((['\"\`])[.\s\S]*?\2)/g, (match, literal) => _parse(TYPE_LITERAL, literal, patches)); // without literals, tabs have no relevance and can be replaced // by spaces, and we have and additional internal marker expression = expression.replace(/\t+/g, " "); // mapping of keywords to operators // IMPORTANT: KEYWORDS ARE CASE-INSENSITIVE // and && empty ! div / // eq == eeq === ge >= // gt > le <= lt < // mod % ne != nee !== // not ! or || expression = expression.replace(PATTERN_KEYWORDS, (match, group1, group2) => group1 + KEYWORDS[KEYWORDS.indexOf(group2.toLowerCase()) +1] ); // Keywords must be replaced by placeholders so that they are // not interpreted as variables. Keywords are case-insensitive. expression = expression.replace(/(^|[^\w\.])(true|false|null|undefined|new|instanceof|typeof)(?=[^\w\.]|$)/ig, (match, group1 = "", group2 = "") => group1 + group2.toLowerCase()); // element expressions are translated into JavaScript // // #element -> docume