@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
302 lines (273 loc) • 8.94 kB
JavaScript
import * as fx from 'fontoxpath';
export class XPathUtil {
/**
* creates DOM Nodes from an XPath locationpath expression. Support namespaced and un-namespaced
* nodes.
* E.g. 'foo/bar' creates an element 'foo' with an child element 'bar'
* 'foo/@bar' creates a 'foo' element with an 'bar' attribute
*
* supports multiple steps
*
* @param xpath
* @param doc
* @param fore
* @return {*}
*/
static createElementFromXPath(xpath, doc, fore) {
if (!doc) {
doc = document.implementation.createDocument(null, null, null); // Create a new XML document if not provided
}
const parts = xpath.split('/');
let rootNode = null;
let currentNode = null;
for (const part of parts) {
if (!part) continue; // Skip empty parts (e.g., leading slashes)
// Handle attributes
if (part.startsWith('@')) {
const attrName = part.slice(1); // Strip '@'
if (!currentNode) {
throw new Error('Cannot create an attribute without a parent element.');
}
currentNode.setAttribute(attrName, '');
} else {
// Handle namespaces if present
const [prefix, localName] = part.includes(':') ? part.split(':') : [null, part];
const namespace = prefix ? XPathUtil.lookupNamespace(fore, prefix) : null;
const newElement = namespace
? doc.createElementNS(namespace, part)
: doc.createElement(localName);
if (!rootNode) {
rootNode = newElement; // Set as the root node
} else {
currentNode.appendChild(newElement);
}
currentNode = newElement;
}
}
if (!rootNode) {
throw new Error('Invalid XPath; no root element could be created.');
}
return rootNode;
}
/**
* looks up namespace on ownerForm. Though not strictly in the sense of resolving namespaces in XML, the
* fx-fore element is a convenient place to put namespace declarations for 2 reasons:
* - this way namespaces are scoped to a Fore element
* - as fx-fore is a web component we can add our xmlns attributes as we got no restrictions to attributes
* though strictly speaking they are no xmlns declarations and just serve the purpose of namespace lookup.
*
* @param boundElement
* @param prefix
* @return {string}
*/
static lookupNamespace(ownerForm, prefix) {
return ownerForm.getAttribute(`xmlns:${prefix}`);
}
static querySelectorAll(querySelector, start) {
const queue = [start];
const found = [];
while (queue.length) {
const item = queue.shift();
for (const child of Array.from(item.children).reverse()) {
queue.unshift(child);
}
if (item.matches && item.matches('template')) {
queue.unshift(item.content);
}
if (item.matches && item.matches(querySelector)) {
found.push(item);
}
}
return found;
}
/**
* Alternative to `contains` that respects shadowroots
* @param {Node} ancestor
* @param {Node} descendant
* @returns {boolean}
*/
static contains(ancestor, descendant) {
while (descendant) {
if (descendant === ancestor) {
return true;
}
if (descendant.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
// We are passing a shadow root boundary
descendant = descendant.host;
} else {
descendant = descendant.parentNode;
}
}
return false;
}
/**
* Alternative to `closest` that respects subcontrol boundaries
*
* @param {string} querySelector
* @param {Node} start
* @returns {HTMLElement}
*/
static getClosest(querySelector, start) {
while ((start && !start.matches) || !start.matches(querySelector)) {
if (start.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
// We are passing a shadow root boundary
start = start.host;
continue;
}
if (start.nodeType === Node.ATTRIBUTE_NODE) {
// We are passing an attribute
start = start.ownerElement;
continue;
}
if (start.nodeType === Node.TEXT_NODE) {
start = start.parentNode;
}
if (start.matches('fx-fore')) {
// Subform reached. Bail out
return null;
}
start = start.parentNode;
if (!start) {
return null;
}
}
return start;
}
/**
* returns next bound element upwards in tree
* @param {Node} start where to start the search
* @returns {*|null}
*/
static getParentBindingElement(start) {
/* if (start.parentNode.host) {
const { host } = start.parentNode;
if (host.hasAttribute('ref')) {
return host;
}
} else */
if (
start.parentNode &&
(start.parentNode.nodeType !== Node.DOCUMENT_NODE ||
start.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE)
) {
return this.getClosest('[ref],fx-repeatitem', start.parentNode);
}
return null;
}
/**
* Checks whether the specified path expression is an absolute path.
*
* @param {string} path the path expression.
* @returns {boolean} <code>true</code> if specified path expression is an absolute
* path, otherwise <code>false</code>.
*/
static isAbsolutePath(path) {
return (
path != null && (path.startsWith('/') || path.startsWith('instance(') || path.startsWith('$'))
);
}
/**
* @param {string} ref
*/
static isSelfReference(ref) {
return ref === '.' || ref === './text()' || ref === 'text()' || ref === '' || ref === null;
}
/**
* returns the instance id from a complete XPath using `instance()` function.
*
* Will return 'default' in case no ref is given at all or the `instance()` function is called without arg.
*
* Otherwise instance id is extracted from function and returned. If all fails null is returned.
* @param {string} ref
* @param {HTMLElement} boundElement The element related to this ref. Used to resolve variables
* @returns {string}
*/
static getInstanceId(ref, boundElement) {
if (!ref) {
return 'default';
}
if (ref.startsWith('instance()')) {
return 'default';
}
if (ref.startsWith('instance(')) {
const result = ref.substring(ref.indexOf('(') + 1);
return result.substring(1, result.indexOf(')') - 1);
}
if (ref.startsWith('$')) {
// this variable might actually point to an instance
const variableName = ref.match(/\$(?<variableName>[a-zA-Z0-9\-\_]+).*/)?.groups?.variableName;
let closestActualFormElement = boundElement;
while (closestActualFormElement && !('inScopeVariables' in closestActualFormElement)) {
closestActualFormElement =
closestActualFormElement.nodeType === Node.ATTRIBUTE_NODE
? closestActualFormElement.ownerElement
: closestActualFormElement.parentNode;
}
const correspondingVariable = closestActualFormElement?.inScopeVariables?.get(variableName);
if (!correspondingVariable) {
return null;
}
return this.getInstanceId(correspondingVariable.valueQuery, correspondingVariable);
}
return null;
}
/**
* @param {HTMLElement} boundElement
* @param {string} path
* @returns {string}
*/
static resolveInstance(boundElement, path) {
let instanceId = XPathUtil.getInstanceId(path, boundElement);
if (!instanceId) {
instanceId = XPathUtil.getInstanceId(boundElement.getAttribute('ref'), boundElement);
}
if (instanceId !== null) {
return instanceId;
}
const parentBinding = XPathUtil.getParentBindingElement(boundElement);
if (parentBinding) {
return this.resolveInstance(parentBinding, path);
}
return 'default';
}
/**
* @param {Node} node
* @returns string
*/
static getDocPath(node) {
const path = fx.evaluateXPathToString('path()', node);
// Path is like `$default/x[1]/y[1]`
const shortened = XPathUtil.shortenPath(path);
return shortened.startsWith('/') ? `${shortened}` : `/${shortened}`;
}
/**
* @param {Node} node
* @param {string} instanceId
* @returns string
*/
static getPath(node, instanceId) {
const path = fx.evaluateXPathToString('path()', node);
// Path is like `$default/x[1]/y[1]`
const shortened = XPathUtil.shortenPath(path);
return shortened.startsWith('/') ? `$${instanceId}${shortened}` : `$${instanceId}/${shortened}`;
}
/**
* @param {string} path
* @returns string
*/
static shortenPath(path) {
const tmp = path.replaceAll(/(Q{(.*?)\})/g, '');
// cut off leading slash
const tmp1 = tmp.substring(1, tmp.length);
// ### cut-off root node ref
return tmp1.substring(tmp1.indexOf('/'), tmp.length);
}
/**
* @param {string} dep
* @returns {string}
*/
static getBasePath(dep) {
const split = dep.split(':');
return split[0];
}
}