@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
1,617 lines (1,364 loc) • 75.2 kB
JavaScript
// src/xpath-evaluation.js
// NOTE: This file is intentionally written as plain JS (no JSX) and must parse under Vite.
import {
evaluateXPath as fxEvaluateXPath,
evaluateXPathToBoolean as fxEvaluateXPathToBoolean,
evaluateXPathToFirstNode as fxEvaluateXPathToFirstNode,
evaluateXPathToNodes as fxEvaluateXPathToNodes,
evaluateXPathToNumber as fxEvaluateXPathToNumber,
evaluateXPathToString as fxEvaluateXPathToString,
evaluateXPathToStrings as fxEvaluateXPathToStrings,
parseScript,
registerCustomXPathFunction,
registerXQueryModule,
Language,
} from 'fontoxpath';
import * as fx from 'fontoxpath';
import { XPathUtil } from './xpath-util.js';
import { prettifyXml } from './functions/common-function.js';
import { JSONDomFacade } from './json/JSONDomFacade.js';
const XFORMS_NAMESPACE_URI = 'http://www.w3.org/2002/xforms';
const createdNamespaceResolversByXPathQueryAndNode = new Map();
const __jsonDomFacade = new JSONDomFacade();
// ------------------------------------------------------------
// Helpers: Fore/model/instance
// ------------------------------------------------------------
function _getOwningFore(node) {
let n = node;
if (!n) return null;
// Prefer ForeElementMixin API when available (handles shadow/slot traversal correctly)
if (typeof n.getOwnerForm === 'function') {
try {
const fore = n.getOwnerForm();
if (fore) return fore;
} catch (_e) {
// ignore
}
}
if (n.nodeType === Node.ATTRIBUTE_NODE) n = n.ownerElement;
if (n.nodeType === Node.TEXT_NODE) n = n.parentNode;
// cross shadow
if (n?.parentNode?.nodeType === Node.DOCUMENT_FRAGMENT_NODE) n = n.parentNode.host;
// Element.closest works across light DOM; for shadow, we normalized to host above
return n?.closest ? n.closest('fx-fore') : null;
}
function _getModelFromFormElement(formElement) {
if (!formElement) return null;
if (typeof formElement.getModel === 'function') {
try {
return formElement.getModel();
} catch (_e) {}
}
const fore = _getOwningFore(formElement);
if (fore && typeof fore.getModel === 'function') {
try {
return fore.getModel();
} catch (_e) {
return null;
}
}
return null;
}
function _getInstanceFromFormElement(formElement, instanceId) {
const model = _getModelFromFormElement(formElement);
if (!model || typeof model.getInstance !== 'function') return null;
try {
return model.getInstance(instanceId);
} catch (_e) {
return null;
}
}
// IMPORTANT: source of truth is instance.type / @type
function _isJsonInstance(instance) {
if (!instance) return false;
const t =
(typeof instance.getAttribute === 'function' && instance.getAttribute('type')) ||
instance.type ||
'';
return t === 'json';
}
function _isJsonNode(n) {
return !!n && typeof n === 'object' && n.__jsonlens__ === true;
}
function _getJsonRootNode(instance) {
return instance?.nodeset && _isJsonNode(instance.nodeset) ? instance.nodeset : null;
}
/**
* Avoid calling any instance getters here.
* Some FxInstance implementations rebuild lenses / trigger evaluation in getters,
* which can recurse into XPath evaluation and overflow the stack.
*/
function _getRawJsonRootValue(instance) {
if (!instance) return null;
// Canonical backing field in FxInstance
if (instance._instanceData !== undefined) return instance._instanceData;
// Alternate field name
if (instance.jsonData !== undefined) return instance.jsonData;
// Last fallback: unwrap a JSONNode root
if (instance.nodeset && instance.nodeset.__jsonlens__ === true) return instance.nodeset.value;
return null;
}
// ------------------------------------------------------------
// Index('repeat') without XPath evaluation (prevents recursion)
// ------------------------------------------------------------
function _matchIndexExpr(expr) {
const s = String(expr ?? '').trim();
const m = s.match(/^index\s*\(\s*(['"])(.*?)\1\s*\)\s*$/);
return m ? m[2] : null;
}
export function resolveId(id, sourceObject, nodeName = null) {
const query =
'outermost(ancestor-or-self::fx-fore[1]/(descendant::fx-fore|descendant::*[@id = $id]))[not(self::fx-fore)]';
if (sourceObject.nodeType === Node.TEXT_NODE) {
sourceObject = sourceObject.parentNode;
}
if (sourceObject.nodeType === Node.ATTRIBUTE_NODE) {
sourceObject = sourceObject.ownerElement;
}
if (sourceObject.parentNode?.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
sourceObject = sourceObject.parentNode.host;
}
const ownerForm =
sourceObject.localName === 'fx-fore' ? sourceObject : sourceObject.closest('fx-fore');
const elementsWithId = ownerForm.querySelectorAll(`[id='${id}']`);
if (elementsWithId.length === 1) {
const targetObject = elementsWithId[0];
if (nodeName && targetObject.localName !== nodeName) return null;
return targetObject;
}
const allMatchingTargetObjects = fxEvaluateXPathToNodes(
query,
sourceObject,
null,
{ id },
{ namespaceResolver: xhtmlNamespaceResolver },
);
if (allMatchingTargetObjects.length === 0) return null;
if (
allMatchingTargetObjects.length === 1 &&
fxEvaluateXPathToBoolean(
'(ancestor::fx-fore | ancestor::fx-repeat)[last()]/self::fx-fore',
allMatchingTargetObjects[0],
null,
null,
{ namespaceResolver: xhtmlNamespaceResolver },
)
) {
const targetObject = allMatchingTargetObjects[0];
if (nodeName && targetObject.localName !== nodeName) return null;
return targetObject;
}
for (const ancestorRepeatItem of fxEvaluateXPathToNodes(
'ancestor::fx-repeatitem => reverse()',
sourceObject,
null,
null,
{ namespaceResolver: xhtmlNamespaceResolver },
)) {
const foundTargetObjects = allMatchingTargetObjects.filter(to =>
XPathUtil.contains(ancestorRepeatItem, to),
);
switch (foundTargetObjects.length) {
case 0:
break;
case 1: {
const targetObject = foundTargetObjects[0];
if (nodeName && targetObject.localName !== nodeName) return null;
return targetObject;
}
default: {
const targetObject = foundTargetObjects.find(to =>
fxEvaluateXPathToNodes(
'every $ancestor of ancestor::fx-repeatitem satisfies $ancestor is $ancestor/../child::fx-repeatitem[../@repeat-index]',
to,
null,
{},
),
);
if (!targetObject) return null;
if (nodeName && targetObject.localName !== nodeName) return null;
return targetObject;
}
}
}
return null;
}
/**
* Resolve index('repeatId') without evaluating XPath (prevents recursion).
* Returns:
* - null => not an index() expr
* - number => resolved index (defaults to 1)
*/
function tryResolveIndexExpr(expr, formElementOrNode) {
try {
const repeatId = _matchIndexExpr(expr);
if (!repeatId) return null;
const source = formElementOrNode?.nodeType
? formElementOrNode
: _getOwningFore(formElementOrNode);
const repeat =
(source && resolveId(repeatId, source, 'fx-repeat')) ||
_getOwningFore(source)?.querySelector?.(`#${CSS.escape(repeatId)}`);
if (!repeat) return 1;
const attr = repeat.getAttribute('index') ?? repeat.getAttribute('repeat-index');
let idx = Number(attr);
if (!Number.isFinite(idx) || idx < 1) {
if (typeof repeat.getIndex === 'function') idx = Number(repeat.getIndex());
else idx = Number(repeat.index);
}
return Number.isFinite(idx) && idx >= 1 ? idx : 1;
} catch (_e) {
return null;
}
}
// ------------------------------------------------------------
// JSON lookup handling
// ------------------------------------------------------------
function _toXQueryMapItem(value) {
if (value == null) return value;
if (Array.isArray(value)) {
return value.map(v => _toXQueryMapItem(v));
}
if (value instanceof Map) {
const m = new Map();
for (const [k, v] of value.entries()) m.set(k, _toXQueryMapItem(v));
return m;
}
// plain object -> Map
if (typeof value === 'object' && !value.nodeType && value.__jsonlens__ !== true) {
const m = new Map();
for (const [k, v] of Object.entries(value)) m.set(k, _toXQueryMapItem(v));
return m;
}
return value;
}
function _resolveLookupOnMapItem(base, rest) {
// Resolve XQuery 3.1 lookup steps like "?ui?query" against Map/Array/plain objects.
// Returns the resolved JS value (primitive/Map/Array/object) or null.
if (base == null) return null;
let s = String(rest ?? '').trim();
if (!s) return base;
// Allow prefixes like '.?ui?query'
if (s.startsWith('.')) s = s.slice(1);
if (!s.startsWith('?')) return base;
const steps = s
.split('?')
.filter(Boolean)
.map(p => String(p).trim())
.filter(Boolean);
let cur = base;
const getProp = (obj, key) => {
if (obj == null) return null;
if (obj instanceof Map) return obj.get(key);
if (Array.isArray(obj)) {
// numeric key for arrays
const n = Number(key);
if (Number.isFinite(n)) return obj[n - 1];
return null;
}
if (typeof obj === 'object') return obj[key];
return null;
};
for (const raw of steps) {
if (cur == null) return null;
if (raw === '*') {
// Star lookup returns the current collection as-is
continue;
}
// Support bracket index: prop[3]
const bm = raw.match(/^(.*?)\[(.+)\]$/);
if (bm) {
const prop = bm[1].trim();
const idxExpr = bm[2].trim();
const container = prop ? getProp(cur, prop) : cur;
if (container == null) return null;
const idx1 = _resolveBracketIndex1(idxExpr, null) ?? Number(idxExpr);
if (!Number.isFinite(idx1) || idx1 < 1) return null;
if (Array.isArray(container)) {
cur = container[idx1 - 1];
continue;
}
if (container instanceof Map) {
// Map with numeric keys is rare; treat as array-like if values are array
const v = container.get(idx1);
cur = v !== undefined ? v : null;
continue;
}
return null;
}
cur = getProp(cur, raw);
}
return cur;
}
function _looksLikeLookupExpr(expr) {
const s = String(expr ?? '').trim();
return s.includes('?');
}
/**
* Split "…?*[(predicate)]" => { base: "…?*", predicate: "(predicate)" }
* Works for:
* instance('data')?movies?*[true()]
* ?movies?*[instance('data')?ui?query = 'Ma']
*/
function _splitStarPredicate(expr) {
const s = String(expr ?? '').trim();
const m = s.match(/^(.*\?\*)\s*\[\s*([\s\S]+?)\s*\]\s*$/);
if (!m) return null;
return { base: m[1].trim(), predicate: m[2].trim() };
}
/**
* Determine whether expression is a "simple navigation" lens path that can be resolved
* by JSONNode.get chain:
* - no "*[predicate]" (handled separately)
* - no operators
* - no function calls beyond instance()/index()
* - predicates allowed ONLY in form "prop[NUMBER]" or "prop[index('repeat')]"
*/
function _isSimpleLookupExpr(expr) {
const s = String(expr ?? '').trim();
// star predicate is not simple (handled by _splitStarPredicate path)
if (/\?\*\s*\[/.test(s)) return false;
// operators => not simple
if (/[=<>!]=|[=<>]/.test(s)) return false;
// function calls other than instance()/index() => not simple
const parens = s.match(/[a-zA-Z_][\w.-]*\s*\(/g) || [];
const otherCalls = parens.filter(m => !/^instance\s*\(/.test(m) && !/^index\s*\(/.test(m));
if (otherCalls.length) return false;
// bracket predicates allowed only as array access
if (/\[[\s\S]*\]/.test(s)) {
const steps = s.split('?').filter(Boolean);
for (const step of steps) {
const bm = step.match(/^(.*?)\[(.+)\]$/);
if (!bm) continue;
const inside = bm[2].trim();
if (/^\d+$/.test(inside)) continue;
if (_matchIndexExpr(inside)) continue;
// allow index("x") too
if (/^index\s*\(\s*(['"])(.*?)\1\s*\)\s*$/.test(inside)) continue;
return false;
}
}
return true;
}
function _parseSimpleLookupPath(expr) {
const s = String(expr ?? '').trim();
let instanceId = null;
let rest = s;
const instExplicit = s.match(/^instance\s*\(\s*(['"])(.*?)\1\s*\)\s*(\?.*)$/);
if (instExplicit) {
instanceId = instExplicit[2];
rest = instExplicit[3];
} else {
const instDefault = s.match(/^instance\s*\(\s*\)\s*(\?.*)$/);
if (instDefault) {
instanceId = 'default';
rest = instDefault[1];
} else if (s.startsWith('.?')) rest = s.slice(1);
else if (!s.startsWith('?')) return null;
}
const steps = rest
.split('?')
.filter(Boolean)
.map(part => part.trim())
.filter(Boolean);
return { instanceId, steps, hasExplicitInstance: !!instExplicit };
}
function _getInstanceIdForLookupExpr(expr0, formElement) {
const parsed = _parseSimpleLookupPath(expr0);
if (parsed && parsed.instanceId) return parsed.instanceId;
return XPathUtil.getInstanceId(expr0, formElement);
}
function _isRelativeJsonLookup(expr0, contextNode) {
const s = String(expr0 ?? '').trim();
// relative lookup: starts with ? or .?
if (!(s.startsWith('?') || s.startsWith('.?'))) return false;
return _isJsonNode(contextNode);
}
function _resolveBracketIndex1(idxExpr, formElement) {
const t = String(idxExpr ?? '').trim();
if (/^\d+$/.test(t)) return Number(t);
// index('movies')
const rid = _matchIndexExpr(t);
if (rid) return tryResolveIndexExpr(`index('${rid}')`, formElement) ?? 1;
// index("movies")
const m = t.match(/^index\s*\(\s*(['"])(.*?)\1\s*\)\s*$/);
if (m) return tryResolveIndexExpr(`index('${m[2]}')`, formElement) ?? 1;
const n = Number(t);
return Number.isFinite(n) ? n : null;
}
function _resolveSimpleLookupToJsonNode(expr, contextNode, formElement) {
const parsed = _parseSimpleLookupPath(expr);
if (!parsed) return null;
const trimmed = String(expr ?? '').trim();
const isExplicitInstance = trimmed.startsWith('instance(');
// IMPORTANT: always return a NEW array for children (copy),
// otherwise fx-repeat may keep a cached reference and miss inserts/deletes.
const getChildren = n => {
if (!n) return [];
const kids =
typeof n.getChildren === 'function'
? n.getChildren() || []
: Array.isArray(n.children)
? n.children
: [];
return Array.from(kids);
};
let node = null;
if (parsed.instanceId) {
const instance = _getInstanceFromFormElement(formElement, parsed.instanceId);
if (!_isJsonInstance(instance)) return null;
node = _getJsonRootNode(instance);
if (!node) return null;
} else if (!isExplicitInstance && _isJsonNode(contextNode)) {
node = contextNode;
} else {
const fallbackId = XPathUtil.getInstanceId(expr, formElement) || 'default';
const instance = _getInstanceFromFormElement(formElement, fallbackId);
if (!_isJsonInstance(instance)) return null;
node = _getJsonRootNode(instance);
if (!node) return null;
}
for (const rawStep of parsed.steps) {
if (!node) return null;
const step = String(rawStep);
if (step === '*') {
return getChildren(node);
}
if (/^\d+$/.test(step)) {
const idx0 = Number(step) - 1;
node = node.get?.(idx0) || null;
continue;
}
const bm = step.match(/^(.*?)\[(.+)\]$/);
if (bm) {
const prop = bm[1].trim();
const idxExpr = bm[2].trim();
const container = prop ? (typeof node.get === 'function' ? node.get(prop) : null) : node;
if (!container) return null;
const arrVal = container.value;
if (!Array.isArray(arrVal)) return null;
const idx1 = _resolveBracketIndex1(idxExpr, formElement);
if (!Number.isFinite(idx1) || idx1 < 1) return null;
const idx0 = idx1 - 1;
node = container.get?.(idx0) || null;
continue;
}
node = node.get?.(step) || null;
}
if (!node) return null;
if (Array.isArray(node.value)) return getChildren(node);
return node;
}
// ------------------------------------------------------------
// RAW JSON evaluation helpers (FontoXPath over JS values)
// ------------------------------------------------------------
function getVariablesInScope(formElement) {
let closestActualFormElement = formElement;
while (closestActualFormElement && !('inScopeVariables' in closestActualFormElement)) {
closestActualFormElement =
closestActualFormElement.nodeType === Node.ATTRIBUTE_NODE
? closestActualFormElement.ownerElement
: closestActualFormElement.parentNode;
}
const scopeNode = closestActualFormElement || formElement;
const fore =
(scopeNode && typeof scopeNode.getOwnerForm === 'function' && scopeNode.getOwnerForm()) ||
_getOwningFore(scopeNode);
const variables = {};
// Helper: get :scope > fx-instance list from a fore's model without triggering instance getters
const getLocalInstances = aFore => {
if (!aFore) return [];
const model =
(typeof aFore.getModel === 'function' && aFore.getModel()) ||
aFore.shadowRoot?.querySelector?.('fx-model') ||
aFore.querySelector?.('fx-model') ||
null;
if (!model) return [];
return Array.from(model.querySelectorAll(':scope > fx-instance'));
};
const buildBindingsFromInstances = instEls => {
if (!instEls || !instEls.length) return null;
const b = Object.create(null);
// default = first instance in doc order
const first = instEls[0];
const firstIsJson = _isJsonInstance(first);
b.default = firstIsJson
? _getRawJsonRootValue(first)
: _getInstanceDefaultContextNoSideEffects(first);
// $<id> for explicitly id'ed instances
for (const inst of instEls) {
const id = (inst.getAttribute && inst.getAttribute('id')) || '';
if (!id) continue;
if (id === 'default') continue;
b[id] = _isJsonInstance(inst)
? _getRawJsonRootValue(inst)
: _getInstanceDefaultContextNoSideEffects(inst);
}
return b;
};
// 1) Implicit instance vars: $default and $<id>
let instanceBindings = fore && fore._instanceVarBindings;
// Ensure we have at least a `default` binding; if not, build it.
if (fore && (!instanceBindings || !('default' in instanceBindings))) {
try {
const localInst = getLocalInstances(fore);
const built = buildBindingsFromInstances(localInst);
if (built && built.default !== undefined && built.default !== null) {
fore._instanceVarBindings = built;
instanceBindings = built;
} else {
// NEW: fallback to nearest ancestor fore that has a SHARED instance as default
let p =
fore.parentNode?.nodeType === Node.DOCUMENT_FRAGMENT_NODE
? fore.parentNode.host
: fore.parentNode;
while (p) {
const parentFore = p.closest ? p.closest('fx-fore') : null;
if (!parentFore) break;
const parentInst = getLocalInstances(parentFore).filter(
i => i.hasAttribute && i.hasAttribute('shared'),
);
const parentBuilt = buildBindingsFromInstances(parentInst);
if (parentBuilt && parentBuilt.default !== undefined && parentBuilt.default !== null) {
// Do NOT cache this onto the child fore; it’s a fallback view, not ownership.
instanceBindings = parentBuilt;
break;
}
p =
parentFore.parentNode?.nodeType === Node.DOCUMENT_FRAGMENT_NODE
? parentFore.parentNode.host
: parentFore.parentNode;
}
}
} catch (_e) {
// ignore
}
}
if (instanceBindings) {
for (const [k, v] of Object.entries(instanceBindings)) {
variables[k] = _toXQueryMapItem(v);
}
}
// 2) Explicit in-scope variables (fx-var or other injectors) override implicit ones
if (closestActualFormElement && closestActualFormElement.inScopeVariables) {
for (const key of closestActualFormElement.inScopeVariables.keys()) {
const varElementOrValue = closestActualFormElement.inScopeVariables.get(key);
if (!varElementOrValue) continue;
if (varElementOrValue.nodeType) {
const el = varElementOrValue;
// Preserve implicit binding for $default when fx-var simply re-declares default instance
if (
el.nodeName === 'FX-VAR' &&
fore &&
fore._instanceVarBindings &&
key in fore._instanceVarBindings
) {
const vexpr = String(el.getAttribute('value') || '').trim();
const isDefaultInstanceExpr =
/^instance\s*\(\s*\)\s*$/.test(vexpr) ||
/^instance\s*\(\s*(['"])default\1\s*\)\s*$/.test(vexpr);
if (isDefaultInstanceExpr) continue;
}
if (el.nodeName === 'FX-VAR') {
if (el._isRefreshing) continue;
if ('_value' in el) {
variables[key] = el._value;
continue;
}
if ('_computedValue' in el) {
variables[key] = el._computedValue;
continue;
}
}
variables[key] = el.value;
} else {
variables[key] = varElementOrValue;
}
}
}
// Prevent self-recursive fx-var evaluation
if (formElement && formElement.nodeName === 'FX-VAR') {
const selfName = (formElement.getAttribute('name') || '').trim();
if (selfName) delete variables[selfName];
}
return variables;
}
// ------------------------------------------------------------
// Namespace resolver infra (XML only)
// ------------------------------------------------------------
const xhtmlNamespaceResolver = prefix => {
if (!prefix) return 'http://www.w3.org/1999/xhtml';
return undefined;
};
export function isInShadow(node) {
return node.getRootNode() instanceof ShadowRoot;
}
const xmlDocument = new DOMParser().parseFromString('<xml />', 'text/xml');
const instanceReferencesByQuery = new Map();
function findInstanceReferences(xpathQuery) {
if (!xpathQuery.includes('instance')) return [];
if (instanceReferencesByQuery.has(xpathQuery)) return instanceReferencesByQuery.get(xpathQuery);
const xpathAST = parseScript(xpathQuery, {}, xmlDocument);
const instanceReferences = fxEvaluateXPathToStrings(
`descendant::xqx:functionCallExpr
[xqx:functionName = "instance"]
/xqx:arguments
/xqx:stringConstantExpr
/xqx:value`,
xpathAST,
null,
{},
{
namespaceResolver: prefix =>
prefix === 'xqx' ? 'http://www.w3.org/2005/XQueryX' : undefined,
},
);
instanceReferencesByQuery.set(xpathQuery, instanceReferences);
return instanceReferences;
}
function getCachedNamespaceResolver(xpath, node) {
if (!createdNamespaceResolversByXPathQueryAndNode.has(xpath)) return null;
return createdNamespaceResolversByXPathQueryAndNode.get(xpath).get(node) || null;
}
function setCachedNamespaceResolver(xpath, node, resolver) {
if (!createdNamespaceResolversByXPathQueryAndNode.has(xpath)) {
createdNamespaceResolversByXPathQueryAndNode.set(xpath, new Map());
}
createdNamespaceResolversByXPathQueryAndNode.get(xpath).set(node, resolver);
}
export function createNamespaceResolver(xpathQuery, formElement) {
const cachedResolver = getCachedNamespaceResolver(xpathQuery, formElement);
if (cachedResolver) return cachedResolver;
const provisionalResolver = prefix => (prefix ? undefined : '');
setCachedNamespaceResolver(xpathQuery, formElement, provisionalResolver);
let instanceReferences = findInstanceReferences(xpathQuery);
const closestRefExcludingSelf = el => {
if (!el) return null;
let n = el;
if (n.nodeType === Node.ATTRIBUTE_NODE) n = n.ownerElement;
if (n?.parentNode?.nodeType === Node.DOCUMENT_FRAGMENT_NODE) n = n.parentNode.host;
let start = n?.parentNode;
if (start?.nodeType === Node.DOCUMENT_FRAGMENT_NODE) start = start.host;
return start?.closest ? start.closest('[ref]') : null;
};
if (instanceReferences.length === 0) {
const ancestorComponent = closestRefExcludingSelf(formElement);
if (ancestorComponent && ancestorComponent !== formElement) {
const ancestorRef = ancestorComponent.getAttribute('ref');
if (ancestorRef && ancestorRef !== xpathQuery) {
const resolver = createNamespaceResolver(ancestorRef, ancestorComponent);
setCachedNamespaceResolver(xpathQuery, formElement, resolver);
return resolver;
}
}
instanceReferences = ['default'];
}
if (instanceReferences.length === 1) {
let instance;
if (instanceReferences[0] === 'default') {
const actualForeElement = fxEvaluateXPathToFirstNode(
'ancestor-or-self::fx-fore[1]',
formElement,
null,
null,
{ namespaceResolver: xhtmlNamespaceResolver },
);
instance = actualForeElement && actualForeElement.querySelector('fx-instance');
} else {
instance = resolveId(instanceReferences[0], formElement, 'fx-instance');
}
if (instance && instance.hasAttribute('xpath-default-namespace')) {
const xpathDefaultNamespace = instance.getAttribute('xpath-default-namespace');
const resolveNamespacePrefix = prefix => (!prefix ? xpathDefaultNamespace : undefined);
setCachedNamespaceResolver(xpathQuery, formElement, resolveNamespacePrefix);
return resolveNamespacePrefix;
}
}
const xpathDefaultNamespace =
fxEvaluateXPathToString('ancestor-or-self::*/@xpath-default-namespace[last()]', formElement) ||
'';
const resolveNamespacePrefix = function resolveNamespacePrefix(prefix) {
if (prefix === '') return xpathDefaultNamespace;
return fxEvaluateXPathToString(
'ancestor-or-self::*/@*[name() = "xmlns:" || $prefix][last()]',
formElement,
null,
{ prefix },
);
};
setCachedNamespaceResolver(xpathQuery, formElement, resolveNamespacePrefix);
return resolveNamespacePrefix;
}
function createNamespaceResolverForNode(query, contextNode, formElement) {
if (((contextNode && contextNode.ownerDocument) || contextNode) === window.document) {
return xhtmlNamespaceResolver;
}
return createNamespaceResolver(query, formElement);
}
// ------------------------------------------------------------
// Function resolver
// ------------------------------------------------------------
export const globallyDeclaredFunctionLocalNames = [];
function functionNameResolver({ prefix, localName }, _arity) {
switch (localName) {
case 'context':
case 'base64encode':
case 'boolean-from-string':
case 'current':
case 'depends':
case 'event':
case 'fore-attr':
case 'index':
case 'instance':
case 'json2xml':
case 'xml2Json':
case 'log':
case 'parse':
case 'local-date':
case 'local-dateTime':
case 'logtree':
case 'uri':
case 'uri-fragment':
case 'uri-host':
case 'uri-param':
case 'uri-path':
case 'uri-relpath':
case 'uri-port':
case 'uri-query':
case 'uri-scheme':
case 'uri-scheme-specific-part':
return { namespaceURI: XFORMS_NAMESPACE_URI, localName };
default:
if (prefix === '' && globallyDeclaredFunctionLocalNames.includes(localName)) {
return { namespaceURI: 'http://www.w3.org/2005/xquery-local-functions', localName };
}
if (prefix === 'fn' || prefix === '') {
return { namespaceURI: 'http://www.w3.org/2005/xpath-functions', localName };
}
if (prefix === 'local') {
return { namespaceURI: 'http://www.w3.org/2005/xquery-local-functions', localName };
}
return null;
}
}
// ------------------------------------------------------------
// JSON star-predicate filtering using FontoXPath per item
// ------------------------------------------------------------
function _jsonAtomicFromResolved(resolved) {
if (resolved === null || resolved === undefined) return '';
// Arrays: join atomic values with spaces (XPath-ish).
if (Array.isArray(resolved)) {
return resolved
.map(r => _jsonAtomicFromResolved(r))
.filter(s => s !== null && s !== undefined && s !== '')
.join(' ');
}
// JSONNode: prefer getValue() if present.
if (_isJsonNode(resolved)) {
const v = typeof resolved.getValue === 'function' ? resolved.getValue() : resolved.value;
if (v === null || v === undefined) return '';
const t = typeof v;
if (t === 'string') return v;
if (t === 'number' || t === 'boolean' || t === 'bigint') return String(v);
try {
return JSON.stringify(v);
} catch (_e) {
return '';
}
}
// Primitives
const t = typeof resolved;
if (t === 'string') return resolved;
if (t === 'number' || t === 'boolean' || t === 'bigint') return String(resolved);
// Other objects
try {
return JSON.stringify(resolved);
} catch (_e) {
return String(resolved);
}
}
// Paste this over the existing function in src/xpath-evaluation.js
function _materializeInstanceLookupsInPredicate(predicateExpr, currentJsonNode, formElement) {
const src = String(predicateExpr ?? '');
let out = '';
const extraVars = {};
let varCount = 0;
const inScope = getVariablesInScope(formElement);
let inSingle = false;
let inDouble = false;
const isBoundary = ch =>
ch === undefined ||
ch === null ||
/\s/.test(ch) ||
ch === ',' ||
ch === ')' ||
ch === ']' ||
ch === '+' ||
ch === '-' ||
ch === '*' ||
ch === '=' ||
ch === '>' ||
ch === '<' ||
ch === '!' ||
ch === '|' ||
ch === '&';
function readLookupTail(start) {
let k = start;
if (src[k] === '.') {
if (src[k + 1] !== '?') return null;
k += 1;
}
if (src[k] !== '?') return null;
let bracketDepth = 0;
let inS = false;
let inD = false;
while (k < src.length) {
const ch = src[k];
if (ch === "'" && !inD) {
inS = !inS;
k += 1;
continue;
}
if (ch === '"' && !inS) {
inD = !inD;
k += 1;
continue;
}
if (inS || inD) {
k += 1;
continue;
}
if (ch === '[') bracketDepth += 1;
else if (ch === ']') {
if (bracketDepth > 0) bracketDepth -= 1;
else break;
}
if (bracketDepth === 0 && k !== start && isBoundary(ch)) break;
k += 1;
}
return { raw: src.slice(start, k), end: k };
}
function readInstanceLensAt(start) {
if (!src.slice(start).match(/^instance\s*\(/)) return null;
let j = start;
let inS = false;
let inD = false;
let depth = 0;
while (j < src.length) {
const ch = src[j];
if (ch === "'" && !inD) {
inS = !inS;
j += 1;
continue;
}
if (ch === '"' && !inS) {
inD = !inD;
j += 1;
continue;
}
if (inS || inD) {
j += 1;
continue;
}
if (ch === '(') depth += 1;
else if (ch === ')') {
depth -= 1;
if (depth === 0) break;
}
j += 1;
}
if (j >= src.length) return null;
let k = j + 1;
while (k < src.length && /\s/.test(src[k])) k += 1;
const tail = readLookupTail(k);
if (!tail) return null;
return { raw: src.slice(start, tail.end), end: tail.end };
}
function readVariableLensAt(start) {
if (src[start] !== '$') return null;
let j = start + 1;
if (!/[A-Za-z_]/.test(src[j] || '')) return null;
j += 1;
while (j < src.length && /[\w.-]/.test(src[j])) j += 1;
let k = j;
while (k < src.length && /\s/.test(src[k])) k += 1;
const tail = readLookupTail(k);
if (!tail) {
return { raw: src.slice(start, j), end: j, name: src.slice(start + 1, j), tail: '' };
}
return {
raw: src.slice(start, tail.end),
end: tail.end,
name: src.slice(start + 1, j),
tail: src.slice(k, tail.end),
};
}
function resolveVariableLens(rawVarExpr) {
const m = String(rawVarExpr).match(/^\$([A-Za-z_][\w.-]*)([\s\S]*)$/);
if (!m) return null;
const varName = m[1];
const rest = (m[2] || '').trim();
const base = Object.prototype.hasOwnProperty.call(inScope, varName) ? inScope[varName] : null;
if (rest && (rest.startsWith('?') || rest.startsWith('.?'))) {
if (base && _isJsonNode(base)) {
const resolved = _resolveSimpleLookupToJsonNode(rest, base, formElement);
return _jsonAtomicFromResolved(resolved);
}
const resolved = _resolveLookupOnMapItem(base, rest);
return _jsonAtomicFromResolved(resolved);
}
return _jsonAtomicFromResolved(base);
}
function resolveRelativeLookup(rawLookupExpr) {
const resolved = _resolveSimpleLookupToJsonNode(rawLookupExpr, currentJsonNode, formElement);
return _jsonAtomicFromResolved(resolved);
}
for (let i = 0; i < src.length; i += 1) {
const ch = src[i];
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
out += ch;
continue;
}
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
out += ch;
continue;
}
if (!inSingle && !inDouble) {
// 1) instance('x')?foo?bar
const instLens = readInstanceLensAt(i);
if (instLens && _looksLikeLookupExpr(instLens.raw) && _isSimpleLookupExpr(instLens.raw)) {
const vname = `__fxp${varCount++}`;
const resolved = _resolveSimpleLookupToJsonNode(instLens.raw, currentJsonNode, formElement);
extraVars[vname] = _jsonAtomicFromResolved(resolved);
out += `$${vname}`;
i = instLens.end - 1;
continue;
}
// 2) $default?ui?query (or plain $var)
const varLens = readVariableLensAt(i);
if (varLens) {
const vname = `__fxp${varCount++}`;
extraVars[vname] = resolveVariableLens(varLens.raw);
out += `$${vname}`;
i = varLens.end - 1;
continue;
}
// 3) relative lookup like ?title / .?title
const relLens = readLookupTail(i);
if (relLens && _looksLikeLookupExpr(relLens.raw) && _isSimpleLookupExpr(relLens.raw)) {
const vname = `__fxp${varCount++}`;
extraVars[vname] = resolveRelativeLookup(relLens.raw);
out += `$${vname}`;
i = relLens.end - 1;
continue;
}
}
out += ch;
}
return { expr: out.trim(), extraVars };
}
function _filterJsonNodesByPredicate(nodes, predicateExpr, formElement, variables = {}) {
const pred = String(predicateExpr ?? '').trim();
if (pred === 'true()' || pred === 'true') return (nodes || []).filter(_isJsonNode);
if (pred === 'false()' || pred === 'false') return [];
// Keep the existing special fast-paths (they’re fine as an optimization)
const containsDot = pred.match(/^contains\s*\(\s*\.\s*,\s*([\s\S]+)\s*\)\s*$/);
if (containsDot) {
const rhs = containsDot[1].trim();
const inScope = getVariablesInScope(formElement);
const toPlain = v => {
if (v == null) return v;
if (_isJsonNode(v)) return toPlain(v.value);
if (Array.isArray(v)) return v.map(toPlain);
if (v instanceof Map) {
const o = Object.create(null);
for (const [k, vv] of v.entries()) o[String(k)] = toPlain(vv);
return o;
}
if (typeof v === 'object' && !v.nodeType) {
const o = Object.create(null);
for (const [k, vv] of Object.entries(v)) o[k] = toPlain(vv);
return o;
}
return v;
};
const toStringSafe = v => {
const vv = toPlain(v);
if (vv === null || vv === undefined) return '';
if (typeof vv === 'string' || typeof vv === 'number' || typeof vv === 'boolean')
return String(vv);
try {
return JSON.stringify(vv);
} catch (_e) {
return String(vv);
}
};
const resolveExprToString = (rawExpr, itemNode) => {
const expr = String(rawExpr ?? '').trim();
const quoted = expr.match(/^(['"])([\s\S]*)\1$/);
if (quoted) return String(quoted[2] ?? '');
const varLens = expr.match(/^\$([A-Za-z_][\w.-]*)([\s\S]*)$/);
if (varLens) {
const varName = varLens[1];
const rest = (varLens[2] || '').trim();
const base =
(variables && varName in variables ? variables[varName] : null) ??
(inScope && varName in inScope ? inScope[varName] : null);
if (rest && (rest.startsWith('?') || rest.startsWith('.?'))) {
if (base && _isJsonNode(base)) {
const resolved = _resolveSimpleLookupToJsonNode(rest, base, formElement);
return toStringSafe(_jsonAtomicFromResolved(resolved));
}
const resolved = _resolveLookupOnMapItem(base, rest);
return toStringSafe(_jsonAtomicFromResolved(resolved));
}
return toStringSafe(_jsonAtomicFromResolved(base));
}
if (expr === '.') {
const v = typeof itemNode.getValue === 'function' ? itemNode.getValue() : itemNode.value;
return toStringSafe(v);
}
if (_looksLikeLookupExpr(expr)) {
const resolved = _resolveSimpleLookupToJsonNode(expr, itemNode, formElement);
return toStringSafe(_jsonAtomicFromResolved(resolved));
}
if (expr.startsWith('$')) {
const key = expr.slice(1);
const v =
(variables && key in variables ? variables[key] : null) ??
(inScope && key in inScope ? inScope[key] : null);
return toStringSafe(_jsonAtomicFromResolved(v));
}
return expr;
};
return (nodes || []).filter(n => {
if (!_isJsonNode(n)) return false;
const needle = resolveExprToString(rhs, n);
if (needle === '') return true;
const raw = typeof n.getValue === 'function' ? n.getValue() : n.value;
const haystack = toStringSafe(raw);
return String(haystack || '').includes(String(needle));
});
}
// --- GENERAL CASE (the important fix) ---
const inScope = getVariablesInScope(formElement);
const out = [];
for (const n of nodes || []) {
if (!_isJsonNode(n)) continue;
try {
const { expr: predExpr, extraVars } = _materializeInstanceLookupsInPredicate(
pred,
n,
formElement,
);
const mergedVars = { ...inScope, ...variables, ...extraVars };
// Evaluate predicate in RAW context (no JSONDomFacade).
// This avoids JSONDomFacade quirks and matches the behavior of the working instance() variant.
const rawContext = typeof n.getValue === 'function' ? n.getValue() : n.value;
const ok = fxEvaluateXPathToBoolean(predExpr, rawContext, null, mergedVars, {
currentContext: { formElement, jsonMode: 'raw' },
moduleImports: { xf: XFORMS_NAMESPACE_URI },
functionNameResolver,
namespaceResolver: null,
language: Language.XPATH_3_1_LANGUAGE,
xmlSerializer: new XMLSerializer(),
});
if (ok) out.push(n);
} catch (e) {
// IMPORTANT: don't swallow silently — but keep it non-fatal.
// This will immediately show you if variable resolution or predicate rewriting is still wrong.
console.warn('[Fore] JSON predicate failed:', pred, '=>', e);
}
}
return out;
}
// ------------------------------------------------------------
// Exported evaluation helpers
// ------------------------------------------------------------
export function evaluateXPath(
xpath,
contextNode,
formElement,
variables = {},
options = {},
domFacade = null,
) {
const expr0 = String(xpath ?? '').trim();
try {
const idx = tryResolveIndexExpr(expr0, formElement);
if (idx !== null) return [idx];
if (_isJsonNode(contextNode) && expr0 === '.') {
return [contextNode];
}
if (_isJsonNode(contextNode) && !_looksLikeLookupExpr(expr0)) {
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPath(
expr0,
contextNode,
__jsonDomFacade,
{ ...variablesInScope, ...variables },
fxEvaluateXPath.ALL_RESULTS_TYPE,
{
debug: true,
currentContext: { formElement, variables },
moduleImports: { xf: XFORMS_NAMESPACE_URI },
functionNameResolver,
namespaceResolver: null,
language: Language.XPATH_3_1_LANGUAGE,
xmlSerializer: new XMLSerializer(),
...options,
},
);
}
if (_looksLikeLookupExpr(expr0)) {
// --- NEW: support variable lookups like $default?ui?query ---
const varLens = expr0.match(/^\$([A-Za-z_][\w.-]*)(.*)$/);
if (varLens) {
const varName = varLens[1];
const rest = varLens[2] || '';
const inScope = getVariablesInScope(formElement);
const base =
(variables && Object.prototype.hasOwnProperty.call(variables, varName)
? variables[varName]
: null) ??
(inScope && Object.prototype.hasOwnProperty.call(inScope, varName)
? inScope[varName]
: null);
if (!rest) return base == null ? [] : [base];
if (rest.startsWith('?') || rest.startsWith('.?')) {
if (base && _isJsonNode(base)) {
const resolved = _resolveSimpleLookupToJsonNode(rest, base, formElement);
if (resolved === null) return [];
if (Array.isArray(resolved)) return resolved;
return [resolved];
}
if (typeof _resolveLookupOnMapItem === 'function') {
const resolved = _resolveLookupOnMapItem(base, rest);
if (resolved == null) return [];
return Array.isArray(resolved) ? resolved : [resolved];
}
}
return base == null ? [] : [base];
}
// --- END NEW ---
const relativeJson = _isRelativeJsonLookup(expr0, contextNode);
const instanceId = relativeJson ? null : _getInstanceIdForLookupExpr(expr0, formElement);
const instance = relativeJson ? null : _getInstanceFromFormElement(formElement, instanceId);
if (!relativeJson && !instance) {
formElement?.dispatchEvent?.(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Instance with id '${instanceId}' not found for expression '${expr0}'`,
expr: expr0,
level: 'Error',
},
}),
);
}
if (relativeJson || _isJsonInstance(instance)) {
const sp = _splitStarPredicate(expr0);
if (sp) {
const baseResolved = _resolveSimpleLookupToJsonNode(sp.base, contextNode, formElement);
const baseNodes = Array.isArray(baseResolved)
? baseResolved
: baseResolved
? [baseResolved]
: [];
return _filterJsonNodesByPredicate(baseNodes, sp.predicate, formElement, variables);
}
if (_isSimpleLookupExpr(expr0)) {
const resolved = _resolveSimpleLookupToJsonNode(expr0, contextNode, formElement);
if (resolved === null) return [];
if (Array.isArray(resolved)) return resolved;
return [resolved];
}
return [];
}
}
const namespaceResolver = createNamespaceResolverForNode(expr0, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPath(
expr0,
contextNode,
domFacade,
{ ...variablesInScope, ...variables },
fxEvaluateXPath.ALL_RESULTS_TYPE,
{
debug: true,
currentContext: { formElement, variables },
moduleImports: { xf: XFORMS_NAMESPACE_URI },
functionNameResolver,
namespaceResolver,
language: Language.XPATH_3_1_LANGUAGE,
xmlSerializer: new XMLSerializer(),
...options,
},
);
} catch (e) {
formElement?.dispatchEvent?.(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed: ${e}`,
expr: xpath,
level: 'Error',
},
}),
);
return [];
}
}
export function evaluateXPathToFirstNode(xpath, contextNode, formElement) {
const expr0 = String(xpath ?? '').trim();
try {
if (_isJsonNode(contextNode) && expr0 === '.') {
return contextNode;
}
if (_isJsonNode(contextNode) && !_looksLikeLookupExpr(expr0)) {
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPathToFirstNode(expr0, contextNode, __jsonDomFacade, variablesInScope, {
currentContext: { formElement },
functionNameResolver,
moduleImports: { xf: XFORMS_NAMESPACE_URI },
namespaceResolver: null,
language: Language.XPATH_3_1_LANGUAGE,
xmlSerializer: new XMLSerializer(),
});
}
if (_looksLikeLookupExpr(expr0)) {
const relativeJson = _isRelativeJsonLookup(expr0, contextNode);
const instanceId = relativeJson ? null : _getInstanceIdForLookupExpr(expr0, formElement);
const instance = relativeJson ? null : _getInstanceFromFormElement(formElement, instanceId);
if (relativeJson || _isJsonInstance(instance)) {
const sp = _splitStarPredicate(expr0);
if (sp) {
const baseResolved = _resolveSimpleLookupToJsonNode(sp.base, contextNode, formElement);
const baseNodes = Array.isArray(baseResolved)
? baseResolved
: baseResolved
? [baseResolved]
: [];
const filtered = _filterJsonNodesByPredicate(baseNodes, sp.predicate, formElement);
return filtered[0] || null;
}
if (_isSimpleLookupExpr(expr0)) {
const resolved = _resolveSimpleLookupToJsonNode(expr0, contextNode, formElement);
if (!resolved) return null;
if (Array.isArray(resolved)) return resolved[0] || null;
return resolved;
}
return null;
}
}
const namespaceResolver = createNamespaceResolverForNode(expr0, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPathToFirstNode(expr0, contextNode, null, variablesInScope, {
currentContext: { formElement },
functionNameResolver,
moduleImports: { xf: XFORMS_NAMESPACE_URI },
namespaceResolver,
language: Language.XPATH_3_1_LANGUAGE,
xmlSerializer: new XMLSerializer(),
});
} catch (e) {
formElement?.dispatchEvent?.(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed: ${e}`,
expr: xpath,
level: 'Error',
},
}),
);
return null;
}
}
export function evaluateXPathToNodes(xpath, contextNode, formElement) {
const expr0 = String(xpath ?? '').trim();
try {
if (_isJsonNode(contextNode) && expr0 === '.') {
return [contextNode];
}
if (_isJsonNode(contextNode) && !_looksLikeLookupExpr(expr0)) {
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPathToNodes(expr0, contextNode, __jsonDomFacade, variablesInScope, {
currentContext: { formElement },
functionNameResolver,
moduleImports: { xf: XFORMS_NAMESPACE_URI },
namespaceResolver: null,
language: Language.XPATH_3_1_LANGUAGE,
xmlSerializer: new XMLSerializer(),
});
}
if (_looksLikeLookupExpr(expr0)) {
const relativeJson = _isRelativeJsonLookup(expr0, contextNode);
const instanceId = relativeJson ? null : _getInstanceIdForLookupExpr(expr0, formElement);
const instance = relativeJson ? null : _getInstanceFromFormElement(formElement, instanceId);
if (relativeJson || _isJsonInstance(instance)) {
const sp = _splitStarPredicate(expr0);
if (sp) {
const baseResolved = _resolveSimpleLookupToJsonNode(sp.base, contextNode, formElement);
const baseNodes = Array.isArray(baseResolved)
? baseResolved
: baseResolved
? [baseResolved]
: [];
return _filterJsonNodesByPredicate(baseNodes, sp.predicate, formElement);
}
if (_isSimpleLookupExpr(expr0)) {
const resolved = _resolveSimpleLookupToJsonNode(expr0, contextNode, formElement);
if (!resolved) return [];
if (Array.isArray(resolved)) return resolved;
return [resolved];
}
return [];
}
}
const namespaceResolver = createNamespaceResolverForNode(expr0, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPathToNodes(expr0, contextNode, null, variablesInScope, {
currentContext: { formElement },
functionNameResolver,
moduleImports: { xf: XFORMS_NAMESPACE_URI },
namespaceResolver,
language: Language.XPATH_3_1_LANGUAGE,
xmlSerializer: new XMLSerializer(),
});
} catch (e) {
formElement?.dispatchEvent?.(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed: ${e}`,
expr: xpath,
level: 'Error',
},
}),
);
return [];
}
}
export function evaluateXPathToBoolean(xpath, contextNode, formElement) {
const expr0 = String(xpath ?? '').trim();
try {
const idx = tryResolveIndexExpr(expr0, formElement);
if (idx !== null) return Boolean(idx);
if (_isJsonNode(contextNode) && expr0 === '.') {
return true;
}
// ------------------------------------------------------------
// JSON CONTEXT
// ------------------------------------------------------------
if (_isJsonNode(contextNode)) {