@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
1,367 lines (1,268 loc) • 43.5 kB
JavaScript
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,
} from 'fontoxpath';
import { XPathUtil } from './xpath-util.js';
import { prettifyXml } from './functions/common-function.js';
const XFORMS_NAMESPACE_URI = 'http://www.w3.org/2002/xforms';
const createdNamespaceResolversByXPathQueryAndNode = new Map();
// A global registry of function names that are declared in Fore by a developer using the
// `fx-function` element. These should be available without providing a prefix as well
export const globallyDeclaredFunctionLocalNames = [];
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)) {
return createdNamespaceResolversByXPathQueryAndNode.set(xpath, new Map());
}
return createdNamespaceResolversByXPathQueryAndNode.get(xpath).set(node, resolver);
}
const xhtmlNamespaceResolver = prefix => {
if (!prefix) {
return 'http://www.w3.org/1999/xhtml';
}
return undefined;
};
export function isInShadow(node) {
return node.getRootNode() instanceof ShadowRoot;
}
/**
* Resolve an id in scope. Behaves like the algorithm defined on https://www.w3.org/community/xformsusers/wiki/XForms_2.0#idref-resolve
*
* @param {string} id
* @param {Node} sourceObject
* @param {string} nodeName
*
* @returns {HTMLElement} The element with that ID, resolved with respect to repeats
*/
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 (nodeName === 'fx-instance') {
// Instance elements can only be in the `model` element
// query = 'ancestor-or-self::fx-fore[1]/fx-model/fx-instance[@id = $id]';
const fore = Fore.getFore(sourceObject);
const instances = fore.getModel().instances;
const targetInstance = instances.find(i => i.id === id);
return targetInstance;
return document.getElementById(id);
}
*/
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) {
// A single one is found. Assume no ID reuse.
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 },
)
) {
// If the target element is not repeated, then the search for the target object is trivial since
// there is only one associated with the target element that bears the matching ID. This is true
// regardless of whether or not the source object is repeated. However, if the target element is
// repeated, then additional information must be used to help select a target object from among
// those associated with the identified target element.
const targetObject = allMatchingTargetObjects[0];
if (nodeName && targetObject.localName !== nodeName) {
return null;
}
return targetObject;
}
// SPEC:
// 12.2.1 References to Elements within a repeat Element
// When the target element that is identified by the IDREF of a source object has one or more
// repeat elements as ancestors, then the set of ancestor repeats are partitioned into two
// subsets, those in common with the source element and those that are not in common. Any ancestor
// repeat elements of the target element not in common with the source element are descendants of
// the repeat elements that the source and target element have in common, if any.
// For the repeat elements that are in common, the desired target object exists in the same set of
// run-time objects that contains the source object. Then, for each ancestor repeat of the target
// element that is not in common with the source element, the current index of the repeat
// determines the set of run-time objects that contains the desired target object.
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:
// Nothing found: ignore
break;
case 1: {
// A single one is found: the target object is directly in a common repeat
const targetObject = foundTargetObjects[0];
if (nodeName && targetObject.localName !== nodeName) {
return null;
}
return targetObject;
}
default: {
// Multiple target objects are found: they are in a repeat that is not common with the
// source object We found a target object in a common repeat! We now need to find the one
// that is in the repeatitem identified at the current index
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) {
// Nothing valid found for whatever reason. This might be something dynamic?
return null;
}
if (nodeName && targetObject.localName !== nodeName) {
return null;
}
return targetObject;
}
}
}
// We found no target objects in common repeats. The id is unresolvable
return null;
}
// Make namespace resolving use the `instance` element that is related to here
const xmlDocument = new DOMParser().parseFromString('<xml />', 'text/xml');
const instanceReferencesByQuery = new Map();
function findInstanceReferences(xpathQuery) {
if (!xpathQuery.includes('instance')) {
// No call to the instance function anyway: short-circuit and prevent AST processing
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;
}
/**
* @typedef {function(string):string} NamespaceResolver
*/
/**
* @function
* Resolve a namespace. Needs a namespace prefix and the element that is most closely related to the
* XPath in which the namespace is being resolved. The prefix will be resolved by using the
* ancestry of said element.
*
* It has two ways of doing so:
*
* - If the prefix is defined in an `xmlns:XXX="YYY"` namespace declaration, it will return 'YYY'.
* - If the prefix is the empty prefix and there is an `xpath-default-namespace="YYY"` attribute in
* - the * ancestry, that attribute will be used and 'YYY' will be returned
*
* @param {string} xpathQuery
* @param {HTMLElement} formElement
* @returns {NamespaceResolver} The namespace resolver for this context
*/
function createNamespaceResolver(xpathQuery, formElement) {
const cachedResolver = getCachedNamespaceResolver(xpathQuery, formElement);
if (cachedResolver) {
return cachedResolver;
}
let instanceReferences = findInstanceReferences(xpathQuery);
if (instanceReferences.length === 0) {
// No instance functions. Look up further in the hierarchy to see if we can deduce the intended context from there
const ancestorComponent =
formElement.parentNode &&
formElement.parentNode.nodeType === formElement.ELEMENT_NODE &&
formElement.parentNode.closest('[ref]');
if (ancestorComponent) {
const resolver = createNamespaceResolver(
ancestorComponent.getAttribute('ref'),
ancestorComponent,
);
setCachedNamespaceResolver(xpathQuery, formElement, resolver);
return resolver;
}
// Nothing found: let's just assume we're supposed to use the `default` instance
instanceReferences = ['default'];
}
if (instanceReferences.length === 1) {
// console.log(`resolving ${xpathQuery} with ${instanceReferences[0]}`);
let instance;
if (instanceReferences[0] === 'default') {
/**
* @type {HTMLElement}
*/
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');
/*
console.log(
`Resolving the xpath ${xpathQuery} with the default namespace set to ${xpathDefaultNamespace}`,
);
*/
/**
* @type {NamespaceResolver}
*/
const resolveNamespacePrefix = prefix => {
if (!prefix) {
return xpathDefaultNamespace;
}
return undefined;
};
setCachedNamespaceResolver(xpathQuery, formElement, resolveNamespacePrefix);
return resolveNamespacePrefix;
}
}
if (instanceReferences.length > 1) {
console.warn(
`More than one instance is used in the query "${xpathQuery}". The default namespace resolving will be used`,
);
}
const xpathDefaultNamespace =
fxEvaluateXPathToString('ancestor-or-self::*/@xpath-default-namespace[last()]', formElement) ||
'';
/**
* @type {NamespaceResolver}
*/
const resolveNamespacePrefix = function resolveNamespacePrefix(prefix) {
if (prefix === '') {
return xpathDefaultNamespace;
}
// Note: ideally we should use Node#lookupNamespaceURI. However, the nodes we are passed are
// XML. The best we can do is emulate the `xmlns:xxx` namespace declarations by regarding them as
// attributes. Which they technically ARE NOT!
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) {
// Running a query on the HTML DOM. Don't bother resolving namespaces in any other way
return xhtmlNamespaceResolver;
}
return createNamespaceResolver(query, formElement);
}
/**
* Implementation of the functionNameResolver passed to FontoXPath to
* redirect function resolving for unprefixed functions to either the fn or the xf namespace
*/
// eslint-disable-next-line no-unused-vars
function functionNameResolver({ prefix, localName }, _arity) {
switch (localName) {
// TODO: put the full XForms library functions set here
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)) {
// The function has been declared without a prefix and is called here without a prefix.
// Just make this work. It is the developer-friendly way
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;
}
}
/**
* Get the variables in scope of the form element. These are the values of the variables that
* logically precede the formElement that declares the XPath
*
* @param {Node} formElement The element that declares the XPath
*
* @returns {Object} A key-value mapping of the variables
*/
function getVariablesInScope(formElement) {
let closestActualFormElement = formElement;
while (closestActualFormElement && !('inScopeVariables' in closestActualFormElement)) {
closestActualFormElement =
closestActualFormElement.nodeType === Node.ATTRIBUTE_NODE
? closestActualFormElement.ownerElement
: closestActualFormElement.parentNode;
}
if (!closestActualFormElement) {
return {};
}
const variables = {};
if (closestActualFormElement.inScopeVariables) {
for (const key of closestActualFormElement.inScopeVariables.keys()) {
const varElementOrValue = closestActualFormElement.inScopeVariables.get(key);
if (!varElementOrValue) {
continue;
}
if (varElementOrValue.nodeType) {
// We are a var element, set the value to the value computed there
variables[key] = varElementOrValue.value;
// variables[key] = varElementOrValue.inScopeVariables.get(key);
} else {
// We are a direct value. This is used to leak in event variables
variables[key] = varElementOrValue;
}
}
}
return variables;
}
/**
* Evaluate an XPath to _any_ type. When possible, prefer to use any other function to ensure the
* type of the output is more predictable.
*
* @param {string} xpath The XPath to run
* @param {Node} contextNode The start of the XPath
* @param {import('./ForeElementMixin.js').default} formElement The form element associated to the XPath
* @param {Object} variables Any variables to pass to the XPath
* @param {Object} options Any options to pass to the XPath
*/
/*
export function evaluateXPath(xpath, contextNode, formElement, variables = {}, options={}, domFacade = null) {
const namespaceResolver = createNamespaceResolverForNode(xpath, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPath(
xpath,
contextNode,
domFacade,
{...variablesInScope, ...variables},
fxEvaluateXPath.ALL_RESULTS_TYPE,
{
debug: true,
currentContext: {formElement, variables},
moduleImports: {
xf: XFORMS_NAMESPACE_URI,
},
functionNameResolver,
namespaceResolver,
language: options.language || evaluateXPath.XPATH_3_1
},
);
}
*/
export function evaluateXPath(xpath, contextNode, formElement, variables = {}, options = {}) {
try {
const namespaceResolver = createNamespaceResolverForNode(xpath, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
const result = fxEvaluateXPath(
xpath,
contextNode,
null,
{ ...variablesInScope, ...variables },
fxEvaluateXPath.ALL_RESULTS_TYPE,
{
debug: true,
currentContext: { formElement, variables },
moduleImports: {
xf: XFORMS_NAMESPACE_URI,
},
functionNameResolver,
namespaceResolver,
language: options.language || fxEvaluateXPath.XPATH_3_1_LANGUAGE,
},
);
// console.log('evaluateXPath',xpath, result);
return result;
} catch (e) {
formElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed`,
expr: xpath,
level: 'Error',
},
}),
);
/*
formElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
cancelable:true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed`,
expr:xpath,
level:'Error'},
}),
);
*/
// Return 'nothing' in hope the rest of the page can forgive this
return [];
}
}
/**
* Evaluate an XPath to the first Node
*
* @param {string} xpath The XPath to run
* @param {Node} contextNode The start of the XPath
* @param {import('./ForeElementMixin.js').default} formElement The form element associated to the XPath
* @returns {Node} The first node found in the XPath
*/
export function evaluateXPathToFirstNode(xpath, contextNode, formElement) {
try {
const namespaceResolver = createNamespaceResolverForNode(xpath, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
const result = fxEvaluateXPathToFirstNode(xpath, contextNode, null, variablesInScope, {
defaultFunctionNamespaceURI: XFORMS_NAMESPACE_URI,
moduleImports: {
xf: XFORMS_NAMESPACE_URI,
},
currentContext: { formElement },
functionNameResolver,
namespaceResolver,
});
// console.log('evaluateXPathToFirstNode',xpath, result);
return result;
} catch (e) {
formElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed`,
expr: xpath,
level: 'Error',
},
}),
);
}
}
/**
* Evaluate an XPath to all nodes
*
* @param {string} xpath The XPath to run
* @param {Node} contextNode The start of the XPath
* @param {import('./ForeElementMixin.js').default} formElement The form element associated to the XPath
* @return {Node[]} All nodes
*/
export function evaluateXPathToNodes(xpath, contextNode, formElement) {
try {
const namespaceResolver = createNamespaceResolverForNode(xpath, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
const result = fxEvaluateXPathToNodes(xpath, contextNode, null, variablesInScope, {
currentContext: { formElement },
functionNameResolver,
moduleImports: {
xf: XFORMS_NAMESPACE_URI,
},
namespaceResolver,
});
// console.log('evaluateXPathToNodes',xpath, result);
return result;
} catch (e) {
formElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed`,
expr: xpath,
level: 'Error',
},
}),
);
}
}
/**
* Evaluate an XPath to a boolean
*
* @param {string} xpath The XPath to run
* @param {Node} contextNode The start of the XPath
* @param {import('./ForeElementMixin.js').default} formElement The form element associated to the XPath
* @return {boolean}
*/
export function evaluateXPathToBoolean(xpath, contextNode, formElement) {
try {
const namespaceResolver = createNamespaceResolverForNode(xpath, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPathToBoolean(xpath, contextNode, null, variablesInScope, {
currentContext: { formElement },
functionNameResolver,
moduleImports: {
xf: XFORMS_NAMESPACE_URI,
},
namespaceResolver,
});
} catch (e) {
formElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed`,
expr: xpath,
level: 'Error',
},
}),
);
}
}
/**
* Evaluate an XPath to a string
*
* @param {string} xpath The XPath to run
* @param {Node} contextNode The start of the XPath
* @param {Node} formElement The form element associated to the XPath
* @param {Node} formElement The element where the XPath is defined: used for namespace resolving
* @param {import('fontoxpath').IDomFacade} [domFacade=null] A DomFacade is used in bindings to intercept DOM
* access. This is used to determine dependencies between bind elements.
* @return {string}
*/
export function evaluateXPathToString(xpath, contextNode, formElement, domFacade = null) {
try {
const namespaceResolver = createNamespaceResolverForNode(xpath, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPathToString(xpath, contextNode, domFacade, variablesInScope, {
currentContext: { formElement },
functionNameResolver,
moduleImports: {
xf: XFORMS_NAMESPACE_URI,
},
namespaceResolver,
});
} catch (e) {
formElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed`,
expr: xpath,
level: 'Error',
},
}),
);
}
}
/**
* Evaluate an XPath to a set of strings
*
* @param {string} xpath The XPath to run
* @param {Node} contextNode The start of the XPath
* @param {Node} formElement The form element associated to the XPath
* @param {Node} formElement The element where the XPath is defined: used for namespace resolving
* @param {import('fontoxpath').IDomFacade} [domFacade=null] A DomFacade is used in bindings to intercept DOM
* access. This is used to determine dependencies between bind elements.
* @return {string[]}
*/
export function evaluateXPathToStrings(xpath, contextNode, formElement, domFacade = null) {
try {
const namespaceResolver = createNamespaceResolverForNode(xpath, contextNode, formElement);
return fxEvaluateXPathToStrings(
xpath,
contextNode,
domFacade,
{},
{
currentContext: { formElement },
functionNameResolver,
moduleImports: {
xf: XFORMS_NAMESPACE_URI,
},
namespaceResolver,
},
);
} catch (e) {
formElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed`,
expr: xpath,
level: 'Error',
},
}),
);
}
}
/**
* Evaluate an XPath to a number
*
* @param {string} xpath The XPath to run
* @param {Node} contextNode The start of the XPath
* @param {Node} formElement The form element associated to the XPath
* @param {Node} formElement The element where the XPath is defined: used for namespace resolving
* @param {import('fontoxpath').IDomFacade} [domFacade=null] A DomFacade is used in bindings to intercept DOM
* access. This is used to determine dependencies between bind elements.
* @return {number}
*/
export function evaluateXPathToNumber(xpath, contextNode, formElement, domFacade = null) {
try {
const namespaceResolver = createNamespaceResolverForNode(xpath, contextNode, formElement);
const variablesInScope = getVariablesInScope(formElement);
return fxEvaluateXPathToNumber(xpath, contextNode, domFacade, variablesInScope, {
currentContext: { formElement },
functionNameResolver,
moduleImports: {
xf: XFORMS_NAMESPACE_URI,
},
namespaceResolver,
});
} catch (e) {
formElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
origin: formElement,
message: `Expression '${xpath}' failed`,
expr: xpath,
level: 'Error',
},
}),
);
}
}
const contextFunction = (dynamicContext, string) => {
const caller = dynamicContext.currentContext.formElement;
let instance = null;
if (string) {
instance = resolveId(string, caller);
} else {
instance = XPathUtil.getParentBindingElement(caller);
}
if (instance) {
if (instance.nodeName === 'FX-REPEAT') {
const { nodeset } = instance;
for (let parent = caller; parent; parent = parent.parentNode) {
if (parent.parentNode === instance) {
const offset = Array.from(parent.parentNode.children).indexOf(parent);
return nodeset[offset];
}
}
}
return instance.nodeset;
}
return caller.getInScopeContext();
};
// todo: implement
const currentFunction = (dynamicContext, string) => {
const caller = dynamicContext.currentContext.formElement;
return null;
};
const elementFunction = (dynamicContext, string) => {
const caller = dynamicContext.currentContext.formElement;
const newElement = document.createElement(string);
return newElement;
};
/**
* @param id as string
* @return instance data for given id serialized to string.
*/
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'context' },
[],
'item()?',
contextFunction,
);
/**
* @param id as string
* @return instance data for given id serialized to string.
*/
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'context' },
['xs:string'],
'item()?',
contextFunction,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'current' },
['xs:string'],
'item()?',
currentFunction,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'element' },
['xs:string'],
'item()?',
elementFunction,
);
/**
* @param id as string
* @return instance data for given id serialized to string.
*/
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'log' },
['xs:string?'],
'xs:string?',
(dynamicContext, string) => {
const { formElement } = dynamicContext.currentContext;
const instance = resolveId(string, formElement, 'fx-instance');
if (instance) {
if (instance.getAttribute('type') === 'json') {
console.warn('log() does not work for JSON yet');
// return JSON.stringify(instance.getDefaultContext());
} else {
const def = new XMLSerializer().serializeToString(instance.getDefaultContext());
return prettifyXml(def);
}
}
return null;
},
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'fore-attr' },
['xs:string?'],
'xs:string?',
(dynamicContext, string) => {
const { formElement } = dynamicContext.currentContext;
let parent = formElement;
if (formElement.nodeType === Node.TEXT_NODE) {
parent = formElement.parentNode;
}
const foreElement = parent.closest('fx-fore');
if (foreElement.hasAttribute(string)) {
return foreElement.getAttribute(string);
}
return null;
},
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'parse' },
['xs:string?'],
'element()?',
(_dynamicContext, string) => {
const parser = new DOMParser();
const out = parser.parseFromString(string, 'application/xml');
console.log('parse', out);
/*
const {formElement} = dynamicContext.currentContext;
const instance = resolveId(string, formElement, 'fx-instance');
if (instance) {
if (instance.getAttribute('type') === 'json') {
console.warn('log() does not work for JSON yet');
// return JSON.stringify(instance.getDefaultContext());
} else {
const def = new XMLSerializer().serializeToString(instance.getDefaultContext());
return Fore.prettifyXml(def);
}
}
*/
return out.firstElementChild;
},
);
function buildTree(tree, data) {
if (!data) return;
if (data.nodeType === Node.ELEMENT_NODE) {
if (data.children) {
const details = document.createElement('details');
details.setAttribute('data-path', data.nodeName);
const summary = document.createElement('summary');
let display = ` <${data.nodeName}`;
Array.from(data.attributes).forEach(attr => {
display += ` ${attr.nodeName}="${attr.nodeValue}"`;
});
let contents;
if (
data.firstChild &&
data.firstChild.nodeType === Node.TEXT_NODE &&
data.firstChild.data.trim() !== ''
) {
// console.log('whoooooooooopp');
contents = data.firstChild.nodeValue;
display += `>${contents}</${data.nodeName}>`;
} else {
display += '>';
}
summary.textContent = display;
details.appendChild(summary);
if (data.childElementCount !== 0) {
details.setAttribute('open', 'open');
} else {
summary.setAttribute('style', 'list-style:none;');
}
tree.appendChild(details);
Array.from(data.children).forEach(child => {
// if(child.nodeType === Node.ELEMENT_NODE){
// child.parentNode.appendChild(buildTree(child));
buildTree(details, child);
// }
});
}
} /* else if(data.nodeType === Node.ATTRIBUTE_NODE){
//create span for now
// const span = document.createElement('span');
// span.style.background = 'grey';
// span.textContent = data.value;
// tree.appendChild(span);
tree.setAttribute(data.nodeName,data.value);
}else {
tree.textContent = data;
} */
// return tree;
}
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'logtree' },
['xs:string?'],
'element()?',
(dynamicContext, string) => {
const { formElement } = dynamicContext.currentContext;
const instance = resolveId(string, formElement, 'fx-instance');
if (instance) {
// const def = new XMLSerializer().serializeToString(instance.getDefaultContext());
// const def = JSON.stringify(instance.getDefaultContext());
const treeDiv = document.createElement('div');
treeDiv.setAttribute('class', 'logtree');
// const datatree = buildTree(tree,instance.getDefaultContext());
// return tree.appendChild(datatree);
// return buildTree(root,instance.getDefaultContext());;
const form = dynamicContext.currentContext.formElement;
const logtree = form.querySelector('.logtree');
if (logtree) {
logtree.parentNode.removeChild(logtree);
}
const tree = buildTree(treeDiv, instance.getDefaultContext());
if (tree) {
form.appendChild(tree);
}
}
return null;
},
);
const instance = (dynamicContext, string) => {
// Spec: https://www.w3.org/TR/xforms-xpath/#The_XForms_Function_Library#The_instance.28.29_Function
// TODO: handle no string passed (null will be passed instead)
/**
* @type {import('./fx-fore.js').FxFore}
*/
const formElement = fxEvaluateXPathToFirstNode(
'ancestor-or-self::fx-fore[1]',
dynamicContext.currentContext.formElement,
null,
null,
{ namespaceResolver: xhtmlNamespaceResolver },
);
let lookup = null;
if (string === null || string === 'default') {
lookup = formElement.getModel().getDefaultInstance();
} else {
lookup = formElement.getModel().getInstance(string);
if (!lookup) {
document.querySelector('fx-fore').dispatchEvent(
new CustomEvent('error', {
composed: true,
bubbles: true,
detail: {
origin: 'functions',
message: `Instance not found '${string}'`,
level: 'Error',
},
}),
);
}
}
const context = lookup.getDefaultContext();
if (!context) {
return null;
}
return context;
};
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'index' },
['xs:string?'],
'xs:integer?',
(dynamicContext, string) => {
const { formElement } = dynamicContext.currentContext;
if (string === null) {
return 1;
}
const repeat = resolveId(string, formElement, 'fx-repeat');
// const def = instance.getInstanceData();
if (repeat) {
return repeat.getAttribute('index');
}
return Number(1);
},
);
// Note that this is not to spec. The spec enforces elements to be returned from the
// instance. However, we allow instances to actually be JSON!
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'instance' },
[],
'item()?',
domFacade => instance(domFacade, null),
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'instance' },
['xs:string?'],
'item()?',
instance,
);
const jsonToXml = (_dynamicContext, json) => {
const escapeXml = str =>
str.replace(
/[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD]/g,
char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,
);
const convert = (obj, parent) => {
const type = typeof obj;
if (type === 'number') {
parent.setAttribute('type', 'number');
parent.textContent = obj.toString();
} else if (type === 'boolean') {
parent.setAttribute('type', 'boolean');
parent.textContent = obj.toString();
} else if (obj === null) {
const node = document.createElement('_');
node.setAttribute('type', 'null');
parent.appendChild(node);
} else if (type === 'string') {
parent.textContent = escapeXml(obj);
} else if (Array.isArray(obj)) {
parent.setAttribute('type', 'array');
obj.forEach(item => {
const node = document.createElement('_');
convert(item, node);
node.textContent = item;
parent.appendChild(node);
});
} else if (type === 'object') {
parent.setAttribute('type', 'object');
Object.entries(obj).forEach(([key, value]) => {
if (value) {
const childNode = document.createElement(key.replace(/[^a-zA-Z0-9_]/g, '_'));
convert(value, childNode);
parent.appendChild(childNode);
}
});
}
};
const root = document.createElement('json');
if (Array.isArray(json)) {
root.setAttribute('type', 'array');
} else {
root.setAttribute('type', 'object');
}
convert(json, root);
// return root.outerHTML;
console.log('xml', root);
return root;
};
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'json2xml' },
['item()?'],
'item()?',
jsonToXml,
);
const xmlToJson = (_dynamicContext, xml) => {
const isElementNode = node => node.nodeType === Node.ELEMENT_NODE;
const isTextNode = node => node.nodeType === Node.TEXT_NODE;
const parseNode = node => {
if (isElementNode(node)) {
const obj = {};
if (node.hasAttributes()) {
obj.type = node.getAttribute('type');
}
if (node.childNodes.length === 1 && isTextNode(node.firstChild)) {
return node.textContent;
}
for (const child of node.childNodes) {
const childName = child.nodeName;
const childValue = parseNode(child);
if (obj[childName]) {
if (!Array.isArray(obj[childName])) {
obj[childName] = [obj[childName]];
}
obj[childName].push(childValue);
} else {
obj[childName] = childValue;
}
}
return obj;
}
if (isTextNode(node)) {
return node.textContent;
}
return undefined;
};
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xml, 'application/xml');
const root = xmlDoc.documentElement;
return parseNode(root);
};
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'xmltoJson' },
['item()?'],
'item()?',
xmlToJson,
);
/*
// Example usage:
const xml = '<json type="object"><given>Mark</given><family>Smith</family></json>';
console.log(xmlToJson(xml));
*/
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'depends' },
['node()*'],
'item()?',
(_dynamicContext, nodes) =>
// console.log('depends on : ', nodes[0]);
nodes[0],
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'event' },
['xs:string?'],
'item()?',
(dynamicContext, arg) => {
if (!arg) return null;
for (
let ancestor = dynamicContext.currentContext.formElement;
ancestor;
ancestor = ancestor.parentNode
) {
if (!ancestor.currentEvent) {
continue;
}
// We have a current event. read the property either from detail, or from the event
// itself.
// Check detail for custom events! This is how that is passed along
if (
ancestor.currentEvent.detail &&
typeof ancestor.currentEvent.detail === 'object' &&
arg in ancestor.currentEvent.detail
) {
return ancestor.currentEvent.detail[arg];
}
// arg might be `code`, so currentEvent.code should work
if (arg.includes('.')) {
return _propertyLookup(ancestor.currentEvent, arg);
}
return ancestor.currentEvent[arg] || null;
}
return null;
},
);
function _propertyLookup(obj, path) {
const parts = path.split('.');
if (parts.length == 1) {
return obj[parts[0]];
}
return _propertyLookup(obj[parts[0]], parts.slice(1).join('.'));
}
// Implement the XForms standard functions here.
registerXQueryModule(`
module namespace xf="${XFORMS_NAMESPACE_URI}";
declare %public function xf:boolean-from-string($str as xs:string) as xs:boolean {
lower-case($str) = "true" or $str = "1"
};
`);
// How to run XQUERY:
/**
registerXQueryModule(`
module namespace my-custom-namespace = "my-custom-uri";
(:~
Insert attribute somewhere
~:)
declare %public %updating function my-custom-namespace:do-something ($ele as element()) as xs:boolean {
if ($ele/@done) then false() else
(insert node
attribute done {"true"}
into $ele, true())
};
`)
// At some point:
const contextNode = null;
const pendingUpdatesAndXdmValue = evaluateUpdatingExpressionSync('ns:do-something(.)', contextNode, null, null, {moduleImports: {'ns': 'my-custom-uri'}})
console.log(pendingUpdatesAndXdmValue.xdmValue); // this is true or false, see function
executePendingUpdateList(pendingUpdatesAndXdmValue.pendingUpdateList, null, null, null);
*/
/**
* @param input as string
* @return {string}
*/
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'base64encode' },
['xs:string?'],
'xs:string?',
(_dynamicContext, string) => btoa(string),
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'local-date' },
[],
'xs:string?',
(_dynamicContext, _string) => new Date().toLocaleDateString(),
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'local-dateTime' },
[],
'xs:string?',
(_dynamicContext, _string) => new Date().toLocaleString(),
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri' },
[],
'xs:string?',
(_dynamicContext, _string) => window.location.href,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-fragment' },
[],
'xs:string?',
(_dynamicContext, _arg) => window.location.hash,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-host' },
[],
'xs:string?',
(_dynamicContext, _arg) => window.location.host,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-query' },
[],
'xs:string?',
(_dynamicContext, _arg) => window.location.search,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-relpath' },
[],
'xs:string?',
(_dynamicContext, _arg) => {
const path = new URL(window.location.href).pathname;
return path.substring(0, path.lastIndexOf('/') + 1);
},
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-path' },
[],
'xs:string?',
(_dynamicContext, _arg) => new URL(window.location.href).pathname,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-port' },
[],
'xs:string?',
(_dynamicContext, _arg) => window.location.port,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-param' },
['xs:string?'],
'xs:string?',
(_dynamicContext, arg) => {
if (!arg) return null;
const { search } = window.location;
const urlparams = new URLSearchParams(search);
const param = urlparams.get(arg);
return param || '';
},
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-scheme' },
[],
'xs:string?',
(_dynamicContext, _arg) => new URL(window.location.href).protocol,
);
registerCustomXPathFunction(
{ namespaceURI: XFORMS_NAMESPACE_URI, localName: 'uri-scheme-specific-part' },
[],
'xs:string?',
(_dynamicContext, _arg) => {
const uri = window.location.href;
return uri.substring(uri.indexOf(':') + 1, uri.length);
},
);