@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
444 lines (394 loc) • 13.7 kB
JavaScript
import * as fx from 'fontoxpath';
export class XPathUtil {
/**
* Recursively check AST for any dynamic expression components.
*/
static containsDynamicContent(astNode) {
if (!astNode) return false;
// Location paths, function calls, or variable refs are dynamic
if (
astNode.type === 'pathExpression' ||
astNode.type === 'functionCall' ||
astNode.type === 'variableReference'
) {
return true;
}
// Recursively check any child expressions
for (const key in astNode) {
if (astNode[key] && typeof astNode[key] === 'object') {
if (Array.isArray(astNode[key])) {
for (const item of astNode[key]) {
if (XPathUtil.containsDynamicContent(item)) return true;
}
} else if (XPathUtil.containsDynamicContent(astNode[key])) return true;
}
}
return false;
}
/**
* 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 {XMLDocument}
* @param fore
* @param namespaceResolver {function} optional namespace resolver function
* @return {Node|Attr}
*/
static createNodesFromXPath(xpath, doc, fore, namespaceResolver = null) {
const resolveNamespace = namespaceResolver || (() => undefined);
if (!doc) {
doc = document.implementation.createDocument(null, null, null); // Create a new XML document if not provided
}
const parts = [];
let scratch = '';
let isInPredicate = false;
for (const char of xpath.split('')) {
if (!isInPredicate) {
// We are not in a predicate, the slash will terminate our step.
if (char === '/') {
parts.push(scratch);
scratch = '';
continue;
}
scratch += char;
if (char === '[') {
isInPredicate = true;
}
continue;
}
// We are in a predicate! So the only interesting token is ']', which means we're out of one.
scratch += char;
if (char === ']') {
isInPredicate = false;
}
}
// Flush the last step
parts.push(scratch);
let rootNode = null;
let currentNode = null;
for (const part of parts) {
if (!part) continue; // Skip empty parts (e.g., leading slashes)
if (part === '.') {
// A '.' does not introduce new elements
continue;
}
// Handle attributes
if (part.startsWith('@')) {
const attrName = part.slice(1); // Strip '@'
if (!currentNode) {
return doc.createAttribute(attrName, '');
}
currentNode.setAttribute(attrName, '');
} else {
// We are a predicate selector! Handle it
// This regex matches strings like:
// - listBibl
// - tei:listBibl
// - listBibl[@type="foo"]
// - listBibl[@type="foo"][@class="bar"]
// It will also match strings like
// - listBibl[ancestor-or-self::foo]
// which will be filtered out later.
const result = part.match(/^(?<name>[\w:-]+)(?<predicates>(\[[^]*\])*)$/);
if (!result) {
throw new Error(
`No element could be made from the XPath step ${part}. It must be of these forms: 'localName', 'prefix:name', 'name[@attr="value"]' et cetera.`,
);
}
const { name, predicates } = result.groups;
// Handle namespaces if present
const [prefix, localName] = name.includes(':') ? name.split(':') : [null, name];
const namespace = resolveNamespace(prefix);
const newElement = namespace
? doc.createElementNS(namespace, localName)
: doc.createElement(localName);
if (predicates) {
const predicateExtractionRegex =
/(\[@(?<name>[\w:-]*)\s?=\s?["'](?<value>[^"']*)['"]\])+/g;
const parsedPredicates = predicates
.matchAll(predicateExtractionRegex)
.map(match => ({ attrName: match.groups.name, value: match.groups.value }));
for (const { attrName, value } of parsedPredicates) {
newElement.setAttribute(attrName, value);
}
}
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) {
// JSON lens case
if (start && start.__jsonlens__ === true) {
let current = start.parent;
while (current) {
if (current.bindingElement) return current.bindingElement;
current = current.parent;
}
return null;
}
// DOM case
let node = start?.parentNode;
while (
node &&
node.nodeType !== Node.DOCUMENT_NODE &&
node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
) {
if (node.matches?.('[ref],fx-repeatitem')) {
return node;
}
node = node.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('$') || 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) {
const refStr = typeof ref === 'string' ? ref.trim() : '';
// Variant A: instance-vars ($default, $foo) must count as instance references
// for dependency tracking, otherwise repeats won't refresh when they change.
try {
const host =
boundElement?.nodeType === Node.ATTRIBUTE_NODE ? boundElement.ownerElement : boundElement;
const fore = host?.closest?.('fx-fore');
const bindings = fore?._instanceVarBindings;
if (bindings) {
// $default => default instance
if (/\$default(?![\w.-])/.test(refStr)) return 'default';
// $<id> => instance <id> (only if it's a known binding key)
for (const k of Object.keys(bindings)) {
if (k === 'default') continue;
const re = new RegExp(
`\\$${k.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}(?![\\w.-])`,
);
if (re.test(refStr)) return k;
}
}
} catch (_e) {
// ignore
}
// Explicit "default instance" selector
if (refStr.startsWith('instance()')) {
return 'default';
}
// Explicit instance('id') selector at the START of the expression only
// (Do NOT use refStr.includes('instance(') because predicates may reference other instances.)
{
const m = refStr.match(/^instance\(\s*(['"])(?<id>.*?)\1\s*\)/);
if (m?.groups?.id != null) {
return m.groups.id;
}
}
// Variable indirection (may ultimately point to instance(...))
if (refStr.startsWith('$')) {
const variableName = refStr.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);
}
// If we can't decide from the ref itself (relative paths, '/', '.', missing ref, fx-repeatitem),
// inherit from the nearest ancestor that *does* have a ref or explicit instance().
const parentBinding = XPathUtil.getParentBindingElement(boundElement);
if (parentBinding) {
// If this is a repeatitem boundary with no ref, keep climbing
if (parentBinding.matches?.('fx-repeatitem') && !parentBinding.getAttribute?.('ref')) {
return this.getInstanceId(null, parentBinding);
}
const parentRef = parentBinding.getAttribute?.('ref');
if (parentRef) {
return this.getInstanceId(parentRef, parentBinding);
}
// Parent binding exists but has no ref (rare, but safe): keep climbing
return this.getInstanceId(null, parentBinding);
}
// No parent binding => top of scope. If ref wasn't explicit, default.
return 'default';
}
/**
* @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 {string} path
* @returns string
*/
static shortenPath(path) {
const tmp = path.replaceAll(/(Q{(.*?)\})/g, '');
if (tmp === 'root()') return tmp;
// 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];
}
}