@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
689 lines (627 loc) • 28.8 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) 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
* ----
*
* The presentation of the page can be organized in Seanox aspect-js in views,
* which are addressed via paths (routes). For this purpose, the routing
* supports a hierarchical directory structure based on the IDs of the nested
* composites in the markup. The routing then controls the visibility and
* permission for accessing the views via paths - the so-called view flow. For
* the view flow and the permission, the routing actively uses the DOM to insert
* and remove the views depending on the situation.
*
*
* TERMS
* ----
*
* Page
* ----
* In a single page application, the page is the elementary framework and
* runtime environment of the entire application.
*
* View
* ----
* A view is the primary projection of models/components/content. This
* projection can contain additional substructures in the form of views and
* sub-views. Views can be static, always shown, or controlled by path and
* permissions. Paths address the complete chain of nested views and shows the
* parent views in addition to the target view.
*
* View Flow
* ----
* View flow describes the access control and the sequence of views. The routing
* provides interfaces, events, permission concepts and interceptors with which
* the view flow can be controlled and influenced.
*
* Paths
* ----
* Paths are used for navigation, routing and controlling the view flow. The
* target can be a view or a function if using interceptors. For SPAs
* (Single-Page Applications), the anchor part of the URL is used for navigation
* and routes.
*
* Similar to a file system, absolute and relative paths are also supported
* here. Paths consist of case-sensitive words that only use 7-bit ASCII
* characters above the space character. Characters outside this range are URL
* encoded. The words are separated by the hash character (#).
*
* Repeated use of the separator (#) allows jumps back in the path to be mapped.
* The number of repetitions indicates the number of returns in the direction of
* the root.
*/
(() => {
"use strict";
// Status of the activation of routing
// The status cannot be changed again after (de)activation and is only set
// initially when the page is loaded.
let _routing_active = undefined;
// Map with all supported interceptors
const _interceptors = new Array();
// Array with the path history (optimized)
const _history = new Array();
const Browser = {
/**
* Returns the current working path. This assumes that the URL contains
* at least one hash, otherwise the method returns null.
* @returns {string|null} the current path, otherwise null
*/
get location() {
if (window.location.hash !== "")
return window.location.hash;
return _locate(window.location.href);
}
}
const _locate = (location) => {
const match = location.match(/#.*$/);
return match ? match[0] : null;
}
compliant("Routing");
compliant(null, window.Routing = {
/** Constant for attribute route */
get ATTRIBUTE_ROUTE() {return "route";},
/**
* Returns the current working path normalized. This assumes that the
* URL contains at least one hash, otherwise the method returns null.
* @returns {string|null} the current path, otherwise null
*/
get location() {
let location = Browser.location;
if (location != null
&& (/^(#{2,}|[^#])/).test(location)) {
let parent = "#";
if (_history.length > 0)
parent = _history[_history.length -1];
return Path.normalize(parent, location);
}
return Path.normalize(location);
},
/**
* Returns the current navigation history and automatically recognizes
* when there are jumps back to previous destinations. In such cases,
* all subsequent entries are removed to ensure that the history remains
* consistent and up to date.
* @returns {string[]} navigation history as array
*/
get history() {
return _history;
},
/**
* Routes to the given path. In difference to the forward method, route
* is not executed directly, instead the change is triggered
* asynchronous by the location hash.
* @param {string} path
*/
route(path) {
if (path === undefined
|| (typeof path !== "string"
&& path !== null))
throw new TypeError("Invalid data type");
path = Path.normalize(path);
if (path === null
|| path === Browser.location)
return;
Composite.asynchron((path) => {
window.location.href = path;
}, path);
},
/**
* Forwards to the given path. In difference to the route method, the
* forwarding is executed directly, instead the navigate method triggers
* asynchronous forwarding by changing the location hash.
* @param {string} path
*/
forward(path) {
if (path === undefined
|| (typeof path !== "string"
&& path !== null))
throw new TypeError("Invalid data type");
path = Path.normalize(path);
if (path === null
|| path === Browser.location)
return;
const event = new Event("hashchange",{bubbles:false, cancelable:true});
event.oldURL = Browser.location;
event.newURL = path;
window.dispatchEvent(event);
},
/**
* Checks the approval to keep or remove the composite through the
* routing in the DOM. For approval, the model corresponding to a
* composite can implement the approve method, which can use different
* return values: undefined, true and false.
*
* With the return values true and false, the permit method in the model
* makes the decision. Otherwise and even if the model does not
* implement a permit method, the decision is left to the routing, which
* checks the coverage of the path from the composite. Covered means
* that the specified path must be contained from the root of the
* current working path.
*
* @param {string} path path of the composite
* @param {string} composite composite ID of the element in the markup
* @returns {boolean} true if the composite element is approved,
* otherwise false
*/
approve(path, composite) {
if (typeof path !== "string")
throw new TypeError("Invalid data type");
if (typeof composite !== "string")
throw new TypeError("Invalid data type");
path = Path.normalize(path);
if (path === null
|| path === "#")
return false;
composite = composite.match(Composite.PATTERN_COMPOSITE_ID);
if (!composite)
return false;
const model = (composite[1] || "").trim();
const namespace = (composite[2] || "").replace(/:/g, ".");
const scope = namespace.length > 0 ? namespace + "." + model : model;
const object = (function(context, namespace) {
return namespace.split('.').reduce(function(scope, target) {
return scope && scope[target];
}, context);
})(window, scope) || (Object.exists(scope) ? Object.use(scope) : undefined);
if (object == null
|| typeof object !== "object"
|| typeof object.permit !== "function")
return path !== undefined
&& Path.covers(path);
const approval = object.permit();
if (approval === undefined)
return path !== undefined
&& Path.covers(path);
return approval === true;
},
/**
* Add an interceptor. An interceptor consists of a path and an actor.
* The path can be either a string or a regular expression (RegExp),
* and the actor must be a function. Interceptors are useful for
* reacting to paths and possibly influencing the routing in relation to
* the paths.
* @param {string|RegExp} path path or route that needs customization.
* It can be a string or a regular expression.
* @param {function} actor function that acts as an interceptor for the
* specified path when the specified path is addressed.
* @throws {TypeError} If the `path` is neither a string nor a RegExp
*/
customize(path, actor) {
if (typeof path !== "string"
&& !(path instanceof RegExp))
throw new TypeError("Invalid data type");
if (actor == null
|| typeof actor !== "function")
throw new TypeError("Invalid object type");
_interceptors.push({path:path, actor:actor});
},
/**
* Determines the closest matching location in relation to the closest
* composite to the current location. If Routing is inactive, the method
* will be returned undefined.
* @param {(boolean|Object)} [meta=false] optional true or a metadata
* object to be filled; in both cases, a meta-object with locate
* and, if available, an array with data is also returned
* @returns {string|undefined|Object} the resolved location if Routing
* is active; otherwise, returns undefined
*/
locate(meta = false) {
if (!_routing_active)
return undefined;
const location = Routing.location;
const locate = _lookup(_lookup(location));
if ((meta == null || typeof meta !== "object")
&& meta !== true)
return locate;
if (typeof meta !== "object")
meta = {};
meta.locate = locate;
meta.data = location.substring(locate.length)
.replace(/^#+/, "")
.split(/#+/);
return meta;
}
});
/**
* The method accepts a path as a string and determines the corresponding
* element that is best covered by this path (Path-to-Element). The path can
* be longer than the actual target, similar to the concepts PATH_TRANSLATED
* and PATH_INFO in CGI.
*
* Alternatively, the method can accept a specific element and determine the
* corresponding path in the markup (Element-to-Path).
*
* @param {string|Element} lookup
* @returns {undefined|string|Element} the path as string or undefined for
* Element-To-Path or the element for Path-to-Element
*/
const _lookup = (lookup) => {
if (lookup instanceof Element) {
let path = "";
for (let element = lookup; element; element = element.parentElement) {
if (!element.hasAttribute(Composite.ATTRIBUTE_COMPOSITE)
|| !element.hasAttribute(Composite.ATTRIBUTE_ID)
|| !element.hasAttribute(Routing.ATTRIBUTE_ROUTE))
continue;
const composite = element.getAttribute(Composite.ATTRIBUTE_ID);
const match = composite.match(Composite.PATTERN_COMPOSITE_ID);
if (!match)
throw new Error(`Invalid composite id${composite ? ": " + composite : ""}`);
path = "#" + match[1] + path;
}
return path || "#";
}
const marker = `[${Composite.ATTRIBUTE_COMPOSITE}][${Routing.ATTRIBUTE_ROUTE}]`;
const path = lookup.split("#").slice(1).map(entry =>
`[id="${entry}"]${marker},[id^="${entry}@"]${marker}`);
while (path.length > 0) {
const element = document.querySelector(path.join(">"));
if (element instanceof Element)
return element;
path.pop();
}
return document.body;
};
const _render = (element, focus = false) => {
Composite.render(element);
if (focus) {
Composite.asynchron((element) => {
if (typeof element.focus === "function")
element.focus();
}, element);
}
};
/**
* Establishes a listener that detects changes to the URL hash. The method
* corrects invalid and unauthorized paths by forwarding them to next valid
* path and organizes partial rendering.
*/
window.addEventListener("hashchange", (event) => {
if (!_routing_active)
return;
// Interceptors
// - order of execution corresponds to the order of registration
// - all interceptors are always checked and executed if they match
// - no entry in history
// - can change the new hash/path, but please use replace
// - following interceptors use the possibly changed hash/path
// - on the first explicit false, terminates the logic in hashchange
const oldHash = _locate(event.oldURL);
const newHash = _locate(event.newURL);
for (const interceptor of _interceptors) {
if (typeof interceptor.path === "string") {
if (!Path.PATTERN_PATH.test(interceptor.path))
continue;
if (interceptor.path.endsWith("#")) {
if (!newHash.startsWith(interceptor.path))
continue;
} else {
if (newHash !== interceptor.path
&& !newHash.startsWith(interceptor.path + "#"))
continue;
}
} else if (interceptor.path instanceof RegExp) {
if (!interceptor.path.test(newHash))
continue;
} else continue;
if (typeof interceptor.actor === "function"
&& interceptor.actor(oldHash, newHash) === false)
return;
}
const location = Routing.location || "#";
if (location !== Browser.location) {
window.location.replace(location);
return;
}
// Maintaining the history.
// For recursions, the history is discarded after the first occurrence.
const index = _history.indexOf(location);
if (index >= 0)
_history.length = index;
_history.push(location);
// Decision matrix
// - invalid path(s) / undefined, then do nothing
// - no path match / null, then render body and focus
// - Composite old and new are the same, then render and focus new
// - Composite old and new are not equal and one is body, then render
// body and focus new
// - Composite old includes new, then render old and focus new
// - Composite new includes old, then render new and focus new
// - Composite old and new unequal, then render old, then render new and
// focus new
const locationOld = Path.normalize(_locate(event.oldURL));
const locationNew = Path.normalize(_locate(event.newURL));
const locationMatch = Path.matches(locationOld, locationNew);
if (locationMatch === undefined)
return;
if (locationMatch === null) {
_render(document.body, true);
return;
}
const locationOldElement = _lookup(locationOld);
const locationNewElement = _lookup(locationNew);
if (locationOldElement === locationNewElement) {
_render(locationNewElement, true);
return;
}
if (locationOldElement === document.body
|| locationNewElement === document.body) {
_render(document.body);
Composite.asynchron((element) => {
if (typeof element.focus === "function")
element.focus();
}, locationNewElement);
return;
}
if (locationOldElement.contains(locationNewElement)) {
_render(locationOldElement);
Composite.asynchron((element) => {
if (typeof element.focus === "function")
element.focus();
}, locationNewElement);
return;
}
if (locationNewElement.contains(locationOldElement)) {
_render(locationNewElement, true);
return;
}
_render(locationOldElement);
_render(locationNewElement, true);
});
/**
* Rendering filter for all composite elements. The filter causes that for
* each composite element determined by the renderer, an additional
* condition is added to the Routing. This condition is used to show and
* hide the composite elements in the DOM. What happens by physically adding
* and removing. The elements are identified by the composite ID.
*/
Composite.customize((element) => {
if (element instanceof Element
&& element.hasAttribute("route")
&& element.getAttribute("route") !== "")
console.warn("Ignore value for attribute route");
if (_routing_active === undefined) {
// Activates routing during the initial rendering via the boolean
// attribute route. It must not have a value, otherwise it is
// ignored and routing is not activated. The decision was
// deliberate, so that interpretations such as route="off" do not
// cause false expectations and misunderstandings.
_routing_active = document.body.hasAttribute("route");
if (document.body.hasAttribute("route")
&& document.body.getAttribute("route") !== "")
console.warn("Ignore value for attribute route");
if (!_routing_active)
return;
// Without path, is forwarded to the root. The fact that the
// interface can be called without a path if it wants to use the
// routing must be taken into account in the declaration of the
// markup and in the implementation. This logic is not included
// here! With path, the event must be triggered initially so that
// any custom interceptors are addressed with the initial path.
if (Browser.location) {
const event = new Event("hashchange",{bubbles:false, cancelable:true});
event.oldURL = "";
event.newURL = Browser.location;
window.dispatchEvent(event);
} else Routing.route("#");
}
if (!_routing_active
|| !(element instanceof Element)
|| !element.hasAttribute(Composite.ATTRIBUTE_COMPOSITE)
|| !element.hasAttribute(Routing.ATTRIBUTE_ROUTE))
return;
const composite = (element.getAttribute(Composite.ATTRIBUTE_ID) || '').trim();
const path = _lookup(element);
let script = null;
if (element.hasAttribute(Composite.ATTRIBUTE_CONDITION)) {
script = element.getAttribute(Composite.ATTRIBUTE_CONDITION).trim();
if (script.match(Composite.PATTERN_EXPRESSION_CONTAINS))
script = script.replace(Composite.PATTERN_EXPRESSION_CONTAINS, (match) => {
match = match.substring(2, match.length -2).trim();
return `{{Routing.approve("${path}", ${composite}") and (${match})}}`;
});
}
if (!script)
script = `{{Routing.approve("${path}", "${composite}")}}`;
element.setAttribute(Composite.ATTRIBUTE_CONDITION, script);
});
/**
* Static component for the use of paths (routes). Paths are used for
* navigation, routing and controlling the view flow. The target can be a
* view or a function if using interceptors.
*/
compliant("Path");
compliant(null, window.Path = {
// Pattern for a valid path in the 7-bit ASCII range
get PATTERN_PATH() {return /^#[\x21-\x7E]*$/},
// Pattern for a string in the 7-bit ASCII range
get PATTERN_ASCII() {return /^[\x21-\x7E]*$/},
/**
* Compares two paths and returns the common part. This method compares
* path and compare. If the paths match, the method returns the common
* part of the paths. If there is no match, null is returned. When
* trying with paths without content (null and empty string), the method
* will not return a value.
* @param {string} path
* @param {string} compare
* @returns {undefined|null|string}
*/
matches(path, compare) {
if (path === undefined
|| (typeof path !== "string"
&& path !== null)
|| compare === undefined
|| (typeof compare !== "string")
&& compare !== null)
throw new TypeError("Invalid data type");
if (path == null
|| path.length <= 0
|| compare == null
|| compare.length <= 0)
return;
path = path.split(/(?=#)/);
compare = compare.split(/(?=#)/);
const length = Math.min(path.length, compare.length);
for (let index = 0; index < length; index++) {
if (path[index] !== compare[index]) {
path.length = index;
break;
}
}
return path.join("") || null;
},
/**
* Checks whether the specified path is covered by the current working
* path. Covered means that the specified path must be contained from
* the root of the current working path. This assumes that the URL
* contains at least one hash, otherwise the method returns false.
* @param {string} path to be checked
* @returns {boolean} true if the path is covered by the current path
*/
covers(path) {
if (path === undefined
|| (typeof path !== "string")
&& path !== null)
throw new TypeError("Invalid data type");
path = Path.normalize(path);
if (!Routing.location
|| path == null
|| path.trim() === "")
return false;
if (path === "#")
return true;
return (Routing.location + "#").startsWith(path + "#");
},
/**
* Normalizes a path. Paths consist of words that only use 7-bit ASCII
* characters above the space character. The words are separated by the
* hash character (#). There are absolute and relative paths:
*
* - Absolute Paths start with the root, represented by a leading
* hash sign (#).
*
* - Relative Paths are based on the current path and begin with either
* a word or a return. Return jumps also use the hash sign, whereby
* the number of repetitions indicates the number of return jumps. If
* the URL does not contain at least one hash and therefore has no
* working path, the root path is used as the working path.
*
* The return value is always a balanced canonical path, starting with
* the root.
*
* Examples (root #x#y#z):
*
* #a#b#c#d#e##f #a#b#c#d#f
* #a#b#c#d#e###f #a#b#c#f
* ###f #x#f
* ####f #f
* empty #x#y#z
* # #x#y#z
* a#b#c #x#y#z#a#b#c
*
* Invalid roots and paths cause an error.
*
* The method has the following various signatures:
* function(root, path)
* function(root, path, ...)
* function(path)
*
* @param {string} [root] optional, otherwise current location is used
* @param {string} path to normalize
* @returns {string} the normalize path
* @throws {Error} An error occurs in the following cases:
* - Invalid root and/or path
*/
normalize(...variants) {
if (variants == null
|| variants.length <= 0)
return null;
if (variants.length === 1
&& variants[0] == null)
return null;
variants.every(item => {
if (item == null
|| typeof item === "string")
return true;
throw new TypeError("Invalid data type");
});
variants = variants.filter((item, index) =>
item != null
&& item.trim() !== ""
&& !(index > 0 && item === "#"));
variants.forEach(item => {
if (!Path.PATTERN_ASCII.test(item))
throw new Error(`Invalid path element: ${item}`)});
variants = ((paths) =>
paths.map((item, index) => {
item = item == null ? "" : item.trim()
item = item.replace(/([^#])#$/, '$1');
if (index > 0 && item.startsWith('#'))
return item.substring(1);
return item;
})
)(variants);
let location = Browser.location;
if (location == null
|| location.trim() === "")
location = "#";
if (variants.length > 0
&& variants[0] == null)
variants[0] = location;
if (variants.length > 0
&& !variants[0].startsWith('#'))
variants.unshift(location);
if (variants.length <= 0)
variants.unshift(location);
let path = variants.join("#");
if (path.startsWith("##"))
path = location + path;
if (!path.match(Path.PATTERN_PATH))
throw new Error(`Invalid path${String(path).trim() ? ": " + path : ""}`);
const pattern = /#[^#]+#{2}/;
while (path.match(pattern))
path = path.replace(pattern, "#");
path = "#" + path.replace(/(^#+)|(#+)$/g, "");
return path;
}
});
})();