@seanox/aspect-js
Version:
full stack JavaScript framework for SPAs incl. reactivity rendering, mvc / mvvm, models, expression language, datasource, virtual paths, unit test and some more
1,486 lines (1,310 loc) • 205 kB
JavaScript
/**
* 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) 2023 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.
*
* @author Seanox Software Solutions
* @version 1.6.0 20230326
*/
// 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.
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);
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 also use 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 levels of the namespace
* @return the created or already existing object(-level)
* @throws An error occurs 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 levels of the namespace
* @param value to initialize/set
* @return the created or already existing object(-level)
* @throws An error occurs 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 levels of the namespace
* @return the determined object(-level)
* @throws An error occurs 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 levels of the namespace
* @return true if the namespace exists
* @throws An error occurs in case of invalid levels or syntax
*/
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 node(s)
* @param exclusive existing children will be removed first
*/
(() => {
const _appendChild = Element.prototype.appendChild;
Element.prototype.appendChild = function(node, exclusive) {
if (exclusive)
this.innerHTML = "";
if (node instanceof Node) {
_appendChild.call(this, node);
} else 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]);
} else _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 size optional, default is 16
*/
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.
*/
(() => {
compliant("Math.serial", () =>
_serial.toString());
const _offset = -946684800000;
const _serial = {timing:new Date().getTime() + _offset, number:0,
toString() {
const timing = new Date().getTime() + _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 text text to be literalized
* @return a literal pattern for the specified text
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
compliant("String.prototype.hashCode", function() {
if (this.hash !== undefined
&& this.hash !== 0)
return this.hash;
this.hash = 0;
let hops = 0;
for (let loop = 0; loop < this.length; loop++) {
const temp = 31 *this.hash +this.charCodeAt(loop);
if (!Number.isSafeInteger(temp)) {
hops++;
this.hash = Number.MAX_SAFE_INTEGER -this.hash +this.charCodeAt(loop);
} else this.hash = temp;
}
this.hash = Math.abs(this.hash).toString(36);
this.hash = this.hash.length.toString(36) + this.hash;
this.hash = (this.hash + hops.toString(36)).toUpperCase();
return this.hash;
});
/**
* Enhancement of the JavaScript API
* Adds a decoding of slash sequences (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.
*/
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.
*/
compliant("window.location.pathcontext");
Object.defineProperty(window.location, "pathcontext", {
value: window.location.pathname.replace(/\/([^\/]*\.[^\/]*){0,}$/g, "") || "/"
});
/**
* 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.
*/
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.
*
* @author Seanox Software Solutions
* @version 1.6.0 20230317
*/
(() => {
/** Path of the DataSource for: data (sub-directory of work path) */
const DATA = window.location.combine(window.location.pathcontext, "/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 locale
* @throws Error in the 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 xml locator or XMLDocument
* @param style locator or XMLDocument
* @param meta optional parameters for the XSLTProcessor
* @return the transformation result as a DocumentFragment
*/
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 locators locator
* @param transform locator of the transformation style
* With the boolean true, the style is derived from the locator by
* using the file extension xslt.
* @param meta optional parameters for the XSLTProcessor
* @return the fetched data as XMLDocument or as DocumentFragment, if
* the transformation is used
* @throws Error in the 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 collector name of the collector element in the XMLDocument
* @param locators Array or VarArg with locators
* @return the created XMLDocument, otherwise null
* @throws Error in the case of invalid arguments
*/
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.
*/
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 locale = [];
locale = locale.concat(navigator.language);
if (navigator.languages !== undefined)
locale = locale.concat(navigator.languages);
Array.from(locale).forEach((language) => {
language = language.match(/^[a-z]+/i, "");
if (language && !locale.includes(language[0]))
locale.push(language[0]);
});
locale = locale.map((language) =>
language.trim().toLowerCase());
locale = locale.filter((item, index) =>
locale.indexOf(item) === index);
if (locale.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");
locale.push(DataSource.locales[0]);
locale = locale.filter(function(locale) {
return DataSource.locales.includes(locale);
});
DataSource.locales.selection = locale.length ? locale[0] : DataSource.locales[0];
})();
/**
* (Resource)Messages is a static DataSource extension for internationalization
* and localization. The implementation is based on a set of key-value or
* label-value data which is stored in the locales.xml of the DataSource.
*
* + data
* + de
* + en
* - locales.xml
* + modules
* + resources
* - index.html
*
* The elements for the supported languages are organized in locales in this
* file. Locales is a set of supported country codes. In each country code, the
* key values are recorded as label entries.
*
* <?xml version="1.0"?>
* <locales>
* <de>
* <label key="contact.title" value="Kontakt"/>
* <label key="contact.development.title">Entwicklung</label>
* ...
* </de>
* <en default="true">
* <label key="contact.title" value="Contact"/>
* <label key="contact.development.title">Development</label>
* ...
* </en>
* </locales>
*
* The language is selected automatically on the basis of the language setting
* of the browser. If the language set there is not supported, the language
* declared as 'default' is used.
*
* After loading the application, Messages are available as an associative
* array and can be used directly in JavaScript and Markup via Expression
* Language.
*
* Messages["contact.title"];
*
* <h1 output="{{Messages['contact.title']}}"/>
*
* In addition, the object message is also provided. Unlike Messages, message is
* an object tree analogous to the keys from Messages. The dot in the keys is
* the indicator of the levels in the tree.
*
* messages.contact.title;
*
* <h1 output="{{messages.contact.title}}"/>
*
* Both objects are only available if there are also labels.
*
* @author Seanox Software Solutions
* @version 1.6.0 20230304
*/
(() => {
compliant("messages", {});
compliant("Messages", {});
const _localize = DataSource.localize;
DataSource.localize = (locale) => {
_localize(locale);
delete window.messages;
delete window.Messages;
window.Messages = {
customize(label, ...values) {
let text = Messages[label] || "";
for (let index = 0; index < values.length; index++)
text = text.replace(new RegExp("\\{" + index + "\\}", "g"), values[index]);
return text.replace(/\{\d+\}/g, "");
}
}
const map = new Map();
const xpath = "/locales/" + DataSource.locale + "/label";
const result = DataSource.data.evaluate(xpath, DataSource.data, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
for (let node = result.iterateNext(); node; node = result.iterateNext())
map.set((node.getAttribute("key") || "").trim(),
((node.getAttribute("value") || "").trim()
|| (node.textContent || "").trim()).unescape());
new Map([...map.entries()].sort()).forEach((value, key) => {
const match = key.match(/^(?:((?:\w+\.)*\w+)\.)*(\w+)$/);
if (match) {
// In order for the object tree to branch from each level, each
// level must be an object. Therefore, an anonymous object is
// used for the level, which returns the actual text via
// Object.prototype.toString().
const namespace = "messages" + (match[1] ? "." + match[1] : "");
Object.defineProperty(Namespace.use(namespace), match[2], {
value: {toString() {return value;}}
});
Object.defineProperty(Namespace.use("Messages"), key, {
value
});
}
});
};
// Messages are based on DataSources. To initialize, DataSource.localize()
// must be overwritten and loading of the key-value pairs is embedded.
if (DataSource.data
&& DataSource.locale
&& DataSource.locales
&& DataSource.locales.includes(DataSource.locale))
DataSource.localize(DataSource.locale);
})();
/**
* 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.
*
* @author Seanox Software Solutions
* @version 1.6.0 20230408
*/
(() => {
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 script
* @return the return value from the script
*/
eval(script) {
// 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 script
* @return return value of the script, if available
*/
run(script) {
if (script.trim())
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.
*
* @author Seanox Software Solutions
* @version 1.6.0 20230330
*/
(() => {
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 serial
* @param expression
* @return the return value of the interpreted expression or an error if
* an error or error has occurred
*/
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 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 expression
* @param depth
* @return the created JavaScript
*/
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);
// 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) => "\r" + patches[placeholder] + "\n");
// masked quotation marks will be restored.
structure = structure.replaceAll("\r\\u0022\n", '\\"');
structure = structure.replaceAll("\r\\u0027\n", "\\'");
structure = structure.replaceAll("\r\\u0060\n", "\\`");
// 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(/((['\"\`]).*?\2)/gs,
(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] -> document.getElementById(element)
// #element -> document.getElementById(element)
//
// The version with square brackets is for more complex element
// IDs that do not follow the JavaScript syntax for variables.
//
// The order is important because the complex element ID, if the
// target uses unique identifiers, may contain a # that should
// not be misinterpreted.
expression = expression.replace(/#\[([^\[\]]*)\]/