diff --git a/.changeset/cold-weeks-change.md b/.changeset/cold-weeks-change.md new file mode 100644 index 000000000..7da3e4780 --- /dev/null +++ b/.changeset/cold-weeks-change.md @@ -0,0 +1,13 @@ +--- +'@getodk/xforms-engine': minor +--- + +- Compute `jr:preload="uid"` on form initialization. +- Ensure submission XML incluces `instanceID` metadata. If not present in form definition, defaults to computing `jr:preload="uid"`. +- Support for use of non-default (XForms) namespaces by primary instance elements, including: + - Production of form-defined namespace declarations in submission XML; + - Preservation of form-defined namespace prefix; + - Use of namespace prefix in bind nodeset; + - Use of namespace prefix in computed expressions. +- Support for use of non-default namespaces by internal secondary instances. +- Partial support for use of non-default namespaces by external XML secondary instances. (Namespaces may be resolved to engine-internal defaults.) diff --git a/packages/scenario/test/jr-preload.test.ts b/packages/scenario/test/jr-preload.test.ts index af6e16231..6670b202c 100644 --- a/packages/scenario/test/jr-preload.test.ts +++ b/packages/scenario/test/jr-preload.test.ts @@ -38,7 +38,7 @@ describe('`jr:preload`', () => { * the {@link ComparableAnswer} (`actual` value) to utilize a custom * `toStartWith` assertion generalized over answer types. */ - it.fails('preloads [specified data in bound] elements', async () => { + it('preloads [specified data in bound] elements', async () => { const scenario = await Scenario.init( 'Preload attribute', html( diff --git a/packages/scenario/test/submission.test.ts b/packages/scenario/test/submission.test.ts index a81338123..0667b3962 100644 --- a/packages/scenario/test/submission.test.ts +++ b/packages/scenario/test/submission.test.ts @@ -1,4 +1,8 @@ -import { OPENROSA_XFORMS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import { + OPENROSA_XFORMS_NAMESPACE_URI, + OPENROSA_XFORMS_PREFIX, + XFORMS_NAMESPACE_URI, +} from '@getodk/common/constants/xmlns.ts'; import { bind, body, @@ -18,7 +22,7 @@ import { import { TagXFormsElement } from '@getodk/common/test/fixtures/xform-dsl/TagXFormsElement.ts'; import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts'; import { createUniqueId } from 'solid-js'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { assert, beforeEach, describe, expect, it } from 'vitest'; import { Scenario } from '../src/jr/Scenario.ts'; import { ANSWER_OK, ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts'; import { ReactiveScenario } from '../src/reactive/ReactiveScenario.ts'; @@ -906,4 +910,245 @@ describe('Form submission', () => { describe.todo('for multiple requests, chunked by maximum size'); }); + + describe('submission-specific metadata', () => { + type MetadataElementName = 'instanceID'; + + type MetaNamespaceURI = OPENROSA_XFORMS_NAMESPACE_URI | XFORMS_NAMESPACE_URI; + + type MetadataValueAssertion = (value: string | null) => unknown; + + const getMetaChildElement = ( + parent: ParentNode | null, + namespaceURI: MetaNamespaceURI, + localName: string + ): Element | null => { + if (parent == null) { + return null; + } + + for (const child of parent.children) { + if (child.namespaceURI === namespaceURI && child.localName === localName) { + return child; + } + } + + return null; + }; + + /** + * Normally this might be implemented as a + * {@link https://vitest.dev/guide/extending-matchers | custom "matcher" (assertion)}. + * But it's so specific to this sub-suite that it would be silly to sprawl + * it out into other parts of the codebase! + */ + const assertMetadata = ( + scenario: Scenario, + metaNamespaceURI: MetaNamespaceURI, + name: MetadataElementName, + assertion: MetadataValueAssertion + ): void => { + const serializedInstanceBody = scenario.proposed_serializeInstance(); + /** + * Important: we intentionally omit the default namespace when serializing instance XML. We need to restore it here to reliably traverse nodes when {@link metaNamespaceURI} is {@link XFORMS_NAMESPACE_URI}. + */ + const instanceXML = `${serializedInstanceBody}`; + + const parser = new DOMParser(); + const instanceDocument = parser.parseFromString(instanceXML, 'text/xml'); + const instanceElement = instanceDocument.documentElement; + const instanceRoot = instanceElement.firstElementChild; + + assert( + instanceRoot != null, + `Failed to find instance root element.\n\nActual serialized XML: ${serializedInstanceBody}\n\nActual instance DOM state: ${instanceElement.outerHTML}` + ); + + const meta = getMetaChildElement(instanceRoot, metaNamespaceURI, 'meta'); + const targetElement = getMetaChildElement(meta, metaNamespaceURI, name); + const value = targetElement?.textContent ?? null; + + assertion(value); + }; + + const PRELOAD_UID_PATTERN = + /^uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; + + const assertPreloadUIDValue = (value: string | null) => { + assert(value != null, 'Expected preload uid value to be serialized'); + expect(value, 'Expected preload uid value to match pattern').toMatch(PRELOAD_UID_PATTERN); + }; + + describe('instanceID', () => { + describe('preload="uid"', () => { + let scenario: Scenario; + + beforeEach(async () => { + // prettier-ignore + scenario = await Scenario.init('Meta instanceID preload uid', html( + head( + title('Meta instanceID preload uid'), + model( + mainInstance( + t('data id="meta-instanceid-preload-uid"', + t('inp', 'inp default value'), + /** @see note on `namespaces` sub-suite! */ + t('meta', + t('instanceID'))) + ), + bind('/data/inp').type('string'), + bind('/data/meta/instanceID').preload('uid') + ) + ), + body( + input('/data/inp', + label('inp'))) + )); + }); + + /** + * @see {@link https://getodk.github.io/xforms-spec/#preload-attributes:~:text=concatenation%20of%20%E2%80%98uuid%3A%E2%80%99%20and%20uuid()} + */ + it('is populated with a concatenation of ‘uuid:’ and uuid()', () => { + assertMetadata( + scenario, + /** @see note on `namespaces` sub-suite! */ + XFORMS_NAMESPACE_URI, + 'instanceID', + assertPreloadUIDValue + ); + }); + + it('does not change after an input value is changed', () => { + scenario.answer('/data/inp', 'any non-default value!'); + + assertMetadata( + scenario, + /** @see note on `namespaces` sub-suite! */ + XFORMS_NAMESPACE_URI, + 'instanceID', + assertPreloadUIDValue + ); + }); + }); + + /** + * NOTE: Do not read too much intent into this sub-suite coming after + * tests above with `meta` and `instanceID` in the default (XForms) + * namespace! Those tests were added first because they'll require the + * least work to make pass. The `orx` namespace _is preferred_, {@link + * https://getodk.github.io/xforms-spec/#metadata | per spec}. + * + * This fact is further emphasized by the next sub-suite, exercising + * default behavior when a `meta` subtree node (of either namespace) is + * not present. + */ + describe('namespaces', () => { + it(`preserves the ${OPENROSA_XFORMS_PREFIX} (${OPENROSA_XFORMS_NAMESPACE_URI}) namespace when used in the form definition`, async () => { + // prettier-ignore + const scenario = await Scenario.init( + 'ORX Meta ORX instanceID preload uid', + html( + head( + title('ORX Meta ORX instanceID preload uid'), + model( + mainInstance( + t('data id="orx-meta-instanceid-preload-uid"', + t('inp', 'inp default value'), + t('orx:meta', + t('orx:instanceID')) + ) + ), + bind('/data/inp').type('string'), + bind('/data/orx:meta/orx:instanceID').preload('uid') + ) + ), + body( + input('/data/inp', + label('inp'))) + )); + + assertMetadata( + scenario, + OPENROSA_XFORMS_NAMESPACE_URI, + 'instanceID', + assertPreloadUIDValue + ); + }); + + // This is redundant to other tests already exercising unprefixed names! + it.skip('preserves the default/un-prefixed namespace when used in the form definition'); + }); + + describe('defaults when absent in form definition', () => { + interface MissingInstanceIDLeafNodeCase { + readonly metaNamespaceURI: MetaNamespaceURI; + } + + describe.each([ + { metaNamespaceURI: OPENROSA_XFORMS_NAMESPACE_URI }, + { metaNamespaceURI: XFORMS_NAMESPACE_URI }, + ])('meta namespace URI: $metaNamespaceURI', ({ metaNamespaceURI }) => { + const expectedNamePrefix = + metaNamespaceURI === OPENROSA_XFORMS_NAMESPACE_URI ? 'orx:' : ''; + const metaSubtreeName = `${expectedNamePrefix}meta`; + const instanceIDName = `${expectedNamePrefix}instanceID`; + + it(`injects and populates a missing ${instanceIDName} leaf node in an existing ${metaSubtreeName} subtree`, async () => { + // prettier-ignore + const scenario = await Scenario.init( + 'ORX Meta ORX instanceID preload uid', + html( + head( + title('ORX Meta ORX instanceID preload uid'), + model( + mainInstance( + t('data id="orx-meta-instanceid-preload-uid"', + t('inp', 'inp default value'), + t(metaSubtreeName) + ) + ), + bind('/data/inp').type('string') + ) + ), + body( + input('/data/inp', + label('inp'))) + )); + + assertMetadata(scenario, metaNamespaceURI, 'instanceID', assertPreloadUIDValue); + }); + }); + + it('injects and populates an orx:meta subtree AND orx:instanceID leaf node', async () => { + // prettier-ignore + const scenario = await Scenario.init( + 'ORX Meta ORX instanceID preload uid', + html( + head( + title('ORX Meta ORX instanceID preload uid'), + model( + mainInstance( + t('data id="orx-meta-instanceid-preload-uid"', + t('inp', 'inp default value') + ) + ), + bind('/data/inp').type('string') + ) + ), + body( + input('/data/inp', + label('inp'))) + )); + + assertMetadata( + scenario, + OPENROSA_XFORMS_NAMESPACE_URI, + 'instanceID', + assertPreloadUIDValue + ); + }); + }); + }); + }); }); diff --git a/packages/xforms-engine/src/error/UnknownPreloadAttributeValueNotice.ts b/packages/xforms-engine/src/error/UnknownPreloadAttributeValueNotice.ts new file mode 100644 index 000000000..59660de24 --- /dev/null +++ b/packages/xforms-engine/src/error/UnknownPreloadAttributeValueNotice.ts @@ -0,0 +1,35 @@ +type PreloadAttributeName = 'jr:preload' | 'jr:preloadParams'; + +/** + * @todo This class is intentionally named to reflect the fact that it is not + * intended to indefinitely block loading a form! Insofar as we currently throw + * this error, the intent is to determine whether we have gaps in our support + * for + * {@link https://getodk.github.io/xforms-spec/#preload-attributes | preload attributes}. + * + * @todo Open question(s) for design around the broader error production story: + * how should we design for conditions which are _optionally errors_ (e.g. + * varying levels of strictness, use case-specific cases where certain kinds of + * errors aren't failures)? In particular, how should we design for: + * + * - Categorization that allows selecting which error conditions are applied, at + * what level of severity? + * - Blocking progress on failure-level severity, proceeding on sub-failure + * severity? + * + * Question applies to this case where we may want to error for unknown preload + * attribute values in dev/test, but may not want to error under most (all?) + * user-facing conditions. + */ +export class UnknownPreloadAttributeValueNotice extends Error { + constructor( + attributeName: PreloadAttributeName, + expectedValues: ReadonlyArray, + unknownValue: string | null + ) { + const expected = expectedValues.map((value) => JSON.stringify(value)).join(', '); + super( + `Unknown ${attributeName} value. Expected one of ${expected}, got: ${JSON.stringify(unknownValue)}` + ); + } +} diff --git a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts index 70bd00ffa..00e9ea3d0 100644 --- a/packages/xforms-engine/src/instance/abstract/InstanceNode.ts +++ b/packages/xforms-engine/src/instance/abstract/InstanceNode.ts @@ -167,7 +167,7 @@ export abstract class InstanceNode< ); } - return `${parent.contextReference()}/${definition.nodeName}`; + return `${parent.contextReference()}/${definition.qualifiedName.getPrefixedName()}`; }; // EvaluationContext: node-specific diff --git a/packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts b/packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts index b2f291e04..2c1000860 100644 --- a/packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts +++ b/packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts @@ -1,10 +1,12 @@ import type { ReactiveScope } from '../../lib/reactivity/scope.ts'; import type { BindComputationExpression } from '../../parse/expression/BindComputationExpression.ts'; +import type { AnyBindPreloadDefinition } from '../../parse/model/BindPreloadDefinition.ts'; import type { EvaluationContext } from './EvaluationContext.ts'; export type DecodeInstanceValue = (value: string) => string; interface InstanceValueContextDefinitionBind { + readonly preload: AnyBindPreloadDefinition | null; readonly calculate: BindComputationExpression<'calculate'> | null; readonly readonly: BindComputationExpression<'readonly'>; } diff --git a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts index d224eb2b9..6b4b3aae5 100644 --- a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts +++ b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts @@ -1,4 +1,5 @@ import type { SubmissionState } from '../../../client/submission/SubmissionState.ts'; +import type { QualifiedName } from '../../../lib/names/QualifiedName.ts'; import type { EscapedXMLText } from '../../../lib/xml-serialization.ts'; import type { ClientReactiveSubmittableChildNode, @@ -13,7 +14,7 @@ interface ClientReactiveSubmittableLeafNodeCurrentState { export type SerializedSubmissionValue = string; interface ClientReactiveSubmittableLeafNodeDefinition { - readonly nodeName: string; + readonly qualifiedName: QualifiedName; } export interface ClientReactiveSubmittableLeafNode { diff --git a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts index aa64e5a74..b4a18f49a 100644 --- a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts +++ b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts @@ -1,4 +1,5 @@ import type { SubmissionState } from '../../../client/submission/SubmissionState.ts'; +import type { QualifiedName } from '../../../lib/names/QualifiedName.ts'; export interface ClientReactiveSubmittableChildNode { readonly submissionState: SubmissionState; @@ -12,7 +13,7 @@ interface ClientReactiveSubmittableParentNodeCurrentState< } export interface ClientReactiveSubmittableParentNodeDefinition { - readonly nodeName: string; + readonly qualifiedName: QualifiedName; } export interface ClientReactiveSubmittableParentNode< diff --git a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableValueNode.ts b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableValueNode.ts index 9fb504a96..c9f847e57 100644 --- a/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableValueNode.ts +++ b/packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableValueNode.ts @@ -1,4 +1,5 @@ import type { SubmissionState } from '../../../client/submission/SubmissionState.ts'; +import type { QualifiedName } from '../../../lib/names/QualifiedName.ts'; import type { ClientReactiveSubmittableChildNode, ClientReactiveSubmittableParentNode, @@ -12,7 +13,7 @@ interface ClientReactiveSubmittableValueNodeCurrentState { export type SerializedSubmissionValue = string; interface ClientReactiveSubmittableValueNodeDefinition { - readonly nodeName: string; + readonly qualifiedName: QualifiedName; } export interface ClientReactiveSubmittableValueNode { diff --git a/packages/xforms-engine/src/integration/xpath/adapter/names.ts b/packages/xforms-engine/src/integration/xpath/adapter/names.ts index 0e2e5f91a..3162408f5 100644 --- a/packages/xforms-engine/src/integration/xpath/adapter/names.ts +++ b/packages/xforms-engine/src/integration/xpath/adapter/names.ts @@ -15,49 +15,54 @@ import { XHTML_NAMESPACE_URI, } from '@getodk/common/constants/xmlns.ts'; import { XPathFunctionalityNotSupportedError } from '../../../error/XPathFunctionalityNotSupportedError.ts'; -import type { InstanceNode } from '../../../instance/abstract/InstanceNode.ts'; -import type { StaticNode } from '../static-dom/StaticNode.ts'; +import type { AnyStaticNode } from '../static-dom/StaticNode.ts'; import type { EngineXPathNode } from './kind.ts'; -import type { getNamespaceDeclarations } from './traversal.ts'; export const getEngineXPathNodeNamespaceURI = (node: EngineXPathNode): string | null => { switch (node.nodeType) { case 'primary-instance': + case 'static-document': case 'static-text': + case 'repeat-range:controlled': + case 'repeat-range:uncontrolled': return null; case 'static-attribute': case 'static-element': - return node.namespaceURI; + return node.qualifiedName.namespaceURI?.href ?? null; default: - return XFORMS_NAMESPACE_URI; + return node.definition.qualifiedName.namespaceURI?.href ?? null; } }; -/** - * @todo currently, neither {@link InstanceNode} nor {@link StaticNode} account - * for prefixes in qualified names. This was already a general enough gap that - * it makes sense to defer to a broader solution as it becomes a priority - * (likely prompted by a bug report about unexpected behavior of the XPath - * `name` function). - */ export const getEngineXPathNodeQualifiedName = (node: EngineXPathNode): string => { - return getEngineXPathNodeLocalName(node); + switch (node.nodeType) { + case 'static-attribute': + case 'static-element': + return node.qualifiedName.getPrefixedName(); + + case 'static-document': + case 'static-text': + return ''; + + default: + return node.definition.qualifiedName.getPrefixedName(); + } }; export const getEngineXPathNodeLocalName = (node: EngineXPathNode): string => { switch (node.nodeType) { case 'static-attribute': case 'static-element': - return node.localName; + return node.qualifiedName.localName; case 'static-document': case 'static-text': return ''; default: - return node.definition.nodeName; + return node.definition.qualifiedName.localName; } }; @@ -65,9 +70,23 @@ export const getEngineProcessingInstructionName = XPathFunctionalityNotSupportedError.createStubImplementation('processing-instruction'); /** - * @todo @see {@link getNamespaceDeclarations} + * @todo in most cases we should not have **custom** namespace resolution from a + * static node (e.g. external secondary instance, itext translation) context. + * The main exception to that would be _XML external secondary instances_, which + * of course can declare arbitrary namespaces on any arbitrary subtree, just + * like a form definition's XML. In all other cases, we'd want to resolve a + * prefix here from the _primary instance_ context. However, we don't (yet) have + * access to the primary instance context from a static node. So we currently + * fall back to the previous (incomplete/potentially wrong) default mapping. + * + * Note that this is relatively safe for the general case, and only potentially + * wrong for: + * + * 1. Forms authored as XML, with arbitrary/non-default namespace declarations + * 2. XML external secondary instances, also with arbitrary/non-default + * namespace declarations */ -export const resolveEngineXPathNodeNamespaceURI = (_: EngineXPathNode, prefix: string | null) => { +const resolveNamespaceURIFromStaticNodeContext = (_: AnyStaticNode, prefix: string | null) => { switch (prefix) { case null: return XFORMS_NAMESPACE_URI; @@ -97,3 +116,33 @@ export const resolveEngineXPathNodeNamespaceURI = (_: EngineXPathNode, prefix: s return null; } }; + +export const resolveEngineXPathNodeNamespaceURI = ( + node: EngineXPathNode, + prefix: string | null +): string | null => { + switch (node.nodeType) { + case 'static-attribute': + case 'static-document': + case 'static-element': + case 'static-text': + return resolveNamespaceURIFromStaticNodeContext(node, prefix); + + case 'group': + case 'input': + case 'model-value': + case 'note': + case 'primary-instance': + case 'range': + case 'rank': + case 'repeat-instance': + case 'repeat-range:controlled': + case 'repeat-range:uncontrolled': + case 'root': + case 'select': + case 'subtree': + case 'trigger': + case 'upload': + return node.definition.namespaceDeclarations.get(prefix)?.declaredURI?.href ?? null; + } +}; diff --git a/packages/xforms-engine/src/integration/xpath/adapter/traversal.ts b/packages/xforms-engine/src/integration/xpath/adapter/traversal.ts index 30f8b8942..f7171cae9 100644 --- a/packages/xforms-engine/src/integration/xpath/adapter/traversal.ts +++ b/packages/xforms-engine/src/integration/xpath/adapter/traversal.ts @@ -27,16 +27,17 @@ export const getEngineXPathAttributes = (node: EngineXPathNode): Iterable => []; diff --git a/packages/xforms-engine/src/integration/xpath/static-dom/StaticAttribute.ts b/packages/xforms-engine/src/integration/xpath/static-dom/StaticAttribute.ts index 78f89fff9..7d25f2703 100644 --- a/packages/xforms-engine/src/integration/xpath/static-dom/StaticAttribute.ts +++ b/packages/xforms-engine/src/integration/xpath/static-dom/StaticAttribute.ts @@ -1,28 +1,36 @@ import { XPathNodeKindKey } from '@getodk/xpath'; +import { QualifiedName } from '../../../lib/names/QualifiedName.ts'; import type { XFormsXPathAttribute } from '../adapter/XFormsXPathNode.ts'; +import type { StaticDocument } from './StaticDocument.ts'; import type { StaticElement } from './StaticElement.ts'; -import type { StaticNamedNodeOptions } from './StaticNamedNode.ts'; -import { StaticNamedNode } from './StaticNamedNode.ts'; +import { StaticNode } from './StaticNode.ts'; -interface StaticAttributeOptions extends StaticNamedNodeOptions { +interface StaticAttributeOptions { + readonly namespaceURI: string | null; + readonly prefix?: string | null; + readonly localName: string; readonly value: string; } -export class StaticAttribute extends StaticNamedNode<'attribute'> implements XFormsXPathAttribute { +export class StaticAttribute extends StaticNode<'attribute'> implements XFormsXPathAttribute { readonly [XPathNodeKindKey] = 'attribute'; readonly nodeType = 'static-attribute'; + readonly rootDocument: StaticDocument; readonly root: StaticElement; + readonly qualifiedName: QualifiedName; readonly attributes = [] as const; readonly children = null; - override readonly value: string; + readonly value: string; constructor( - override readonly parent: StaticElement, + readonly parent: StaticElement, options: StaticAttributeOptions ) { - super(parent, options); + super(); + this.rootDocument = parent.rootDocument; this.root = parent.root; + this.qualifiedName = new QualifiedName(options); this.value = options.value; } diff --git a/packages/xforms-engine/src/integration/xpath/static-dom/StaticDocument.ts b/packages/xforms-engine/src/integration/xpath/static-dom/StaticDocument.ts index bd1aeaf81..74b8bad34 100644 --- a/packages/xforms-engine/src/integration/xpath/static-dom/StaticDocument.ts +++ b/packages/xforms-engine/src/integration/xpath/static-dom/StaticDocument.ts @@ -15,7 +15,6 @@ export abstract class StaticDocument readonly StaticAttribute[]; export type StaticElementChildNodesFactory = (element: StaticElement) => readonly StaticChildNode[]; -export interface StaticElementOptions extends StaticNamedNodeOptions {} +export interface StaticElementOptions { + readonly namespaceURI: string | null; + readonly prefix?: string | null; + readonly localName: string; +} type StaticElementKnownAttributeValue< T extends StaticElement, @@ -34,7 +39,7 @@ const assertStaticElementKnownAttributeValue: AssertStaticElementKnownAttributeV }; export class StaticElement - extends StaticNamedNode<'element'> + extends StaticNode<'element'> implements XFormsXPathElement { readonly [XFORMS_LOCAL_NAME]?: string; @@ -42,25 +47,33 @@ export class StaticElement readonly [XPathNodeKindKey] = 'element'; readonly nodeType = 'static-element'; + readonly rootDocument: StaticDocument; readonly root: StaticElement; + readonly qualifiedName: QualifiedName; readonly attributes: readonly StaticAttribute[]; readonly children: readonly StaticChildNode[]; + readonly value = null; constructor( - parent: Parent, + readonly parent: Parent, attributesFactory: StaticElementAttributesFactory, childNodesFactory: StaticElementChildNodesFactory, options: StaticElementOptions ) { - super(parent, options); + super(); + + const { rootDocument } = parent; + + this.rootDocument = rootDocument; // Account for the fact that we may be constructing the document root! - if (parent === this.rootDocument) { + if (parent === rootDocument) { this.root = this; } else { this.root = parent.root; } + this.qualifiedName = new QualifiedName(options); this.attributes = attributesFactory(this); this.children = childNodesFactory(this); } @@ -73,7 +86,7 @@ export class StaticElement protected getAttributeNode(localName: string): StaticAttribute | null { return ( this.attributes.find((attribute) => { - return attribute.localName === localName; + return attribute.qualifiedName.localName === localName; }) ?? null ); } diff --git a/packages/xforms-engine/src/integration/xpath/static-dom/StaticNamedNode.ts b/packages/xforms-engine/src/integration/xpath/static-dom/StaticNamedNode.ts deleted file mode 100644 index cbb760444..000000000 --- a/packages/xforms-engine/src/integration/xpath/static-dom/StaticNamedNode.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { XFORMS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; -import type { XFormsXPathNamedNode, XPathNamedNodeKind } from '../adapter/XFormsXPathNode.ts'; -import type { StaticDocument } from './StaticDocument.ts'; -import type { StaticParentNode } from './StaticNode.ts'; -import { StaticNode } from './StaticNode.ts'; - -export interface StaticNamedNodeOptions { - readonly namespaceURI: string | null; - readonly localName: string; - readonly value?: string; -} - -export abstract class StaticNamedNode - extends StaticNode - implements XFormsXPathNamedNode -{ - readonly rootDocument: StaticDocument; - readonly isXFormsNamespace: boolean; - readonly namespaceURI: string | null; - readonly localName: string; - readonly value: string | null; - - protected constructor( - readonly parent: StaticParentNode, - options: StaticNamedNodeOptions - ) { - super(); - - this.rootDocument = parent.rootDocument; - - const { namespaceURI, localName, value = null } = options; - - this.namespaceURI = namespaceURI; - this.localName = localName; - this.value = value; - - if (namespaceURI === XFORMS_NAMESPACE_URI) { - this.isXFormsNamespace = true; - } else if (parent == null || parent.isXFormsNamespace) { - this.isXFormsNamespace = namespaceURI == null; - } else { - this.isXFormsNamespace = false; - } - } -} diff --git a/packages/xforms-engine/src/lib/client-reactivity/submission/createLeafNodeSubmissionState.ts b/packages/xforms-engine/src/lib/client-reactivity/submission/createLeafNodeSubmissionState.ts index 13c1cba34..df3660168 100644 --- a/packages/xforms-engine/src/lib/client-reactivity/submission/createLeafNodeSubmissionState.ts +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/createLeafNodeSubmissionState.ts @@ -14,7 +14,7 @@ export const createLeafNodeSubmissionState = ( const value = node.encodeValue(node.currentState.value); const xmlValue = escapeXMLText(value); - return serializeLeafElementXML(node.definition.nodeName, xmlValue); + return serializeLeafElementXML(node.definition.qualifiedName, xmlValue); }, }; }; diff --git a/packages/xforms-engine/src/lib/client-reactivity/submission/createParentNodeSubmissionState.ts b/packages/xforms-engine/src/lib/client-reactivity/submission/createParentNodeSubmissionState.ts index 249f4f208..067eb735c 100644 --- a/packages/xforms-engine/src/lib/client-reactivity/submission/createParentNodeSubmissionState.ts +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/createParentNodeSubmissionState.ts @@ -16,7 +16,7 @@ export const createParentNodeSubmissionState = ( return child.submissionState.submissionXML; }); - return serializeParentElementXML(node.definition.nodeName, serializedChildren); + return serializeParentElementXML(node.definition.qualifiedName, serializedChildren); }, }; }; diff --git a/packages/xforms-engine/src/lib/client-reactivity/submission/createRootSubmissionState.ts b/packages/xforms-engine/src/lib/client-reactivity/submission/createRootSubmissionState.ts index c20a9d246..2f230b728 100644 --- a/packages/xforms-engine/src/lib/client-reactivity/submission/createRootSubmissionState.ts +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/createRootSubmissionState.ts @@ -10,9 +10,9 @@ export const createRootSubmissionState = (node: Root): SubmissionState => { return child.submissionState.submissionXML; }); - return serializeParentElementXML(node.definition.nodeName, serializedChildren, { + return serializeParentElementXML(node.definition.qualifiedName, serializedChildren, { namespaceDeclarations, - attributes, + attributes: Array.from(attributes.values()), }); }, }; diff --git a/packages/xforms-engine/src/lib/client-reactivity/submission/createValueNodeSubmissionState.ts b/packages/xforms-engine/src/lib/client-reactivity/submission/createValueNodeSubmissionState.ts index 9cfdd954d..0a335351c 100644 --- a/packages/xforms-engine/src/lib/client-reactivity/submission/createValueNodeSubmissionState.ts +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/createValueNodeSubmissionState.ts @@ -5,7 +5,7 @@ import { escapeXMLText, serializeLeafElementXML } from '../../xml-serialization. export const createValueNodeSubmissionState = ( node: ClientReactiveSubmittableValueNode ): SubmissionState => { - const { nodeName } = node.definition; + const { qualifiedName } = node.definition; return { get submissionXML() { @@ -15,7 +15,7 @@ export const createValueNodeSubmissionState = ( const xmlValue = escapeXMLText(node.currentState.instanceValue); - return serializeLeafElementXML(nodeName, xmlValue); + return serializeLeafElementXML(qualifiedName, xmlValue); }, }; }; diff --git a/packages/xforms-engine/src/lib/names/NamespaceDeclaration.ts b/packages/xforms-engine/src/lib/names/NamespaceDeclaration.ts new file mode 100644 index 000000000..319d8510d --- /dev/null +++ b/packages/xforms-engine/src/lib/names/NamespaceDeclaration.ts @@ -0,0 +1,106 @@ +import { XMLNS_NAMESPACE_URI, XMLNS_PREFIX } from '@getodk/common/constants/xmlns.ts'; +import { escapeXMLText } from '../xml-serialization.ts'; +import type { NamespaceDeclarationMap } from './NamespaceDeclarationMap.ts'; +import type { NamespaceURI } from './QualifiedName.ts'; +import { QualifiedName } from './QualifiedName.ts'; + +interface NamespaceDeclarationXMLSerializationOptions { + readonly omitDefaultNamespace?: boolean; +} + +export interface NamespaceDeclarationOptions { + readonly declaredPrefix: string | null; + readonly declaredURI: NamespaceURI; +} + +/** + * Provides a generalized representation of an XML namespace declaration, which + * can be used for: + * + * - Resolution of a declared namespace URI, by its declared prefix + * - Resolution of a declared namespace prefix associated with its namespace URI + * - Scoped resolution of same in an arbitrary DOM-like tree of nodes (or + * representations thereof) + * - Serialization of the namespace declaration as an XML representation, as + * part of broader XML serialization logic from an arbitrary DOM-like tree of + * nodes (or representations thereof) + * + * @see {@link NamespaceDeclarationMap} for details on scoped usage + */ +export class NamespaceDeclaration { + private readonly serializedXML: string; + + /** + * A namespace is declared as either: + * + * - a "default" namespace (for which no prefix is declared, in which case + * this value will be `null`) + * + * - a namespace prefix (for which the prefix can be used to reference the + * declared namespace, in which case this value will be a `string`) + */ + readonly declaredPrefix: string | null; + + /** + * A namespace is declared for a {@link NamespaceURI}, i.e. either a + * {@link URL} or `null`, where `null` corresponds to the "null namespace" + * (i.e. `xmlns=""` or `xmlns:prefix=""`, in serialized XML). + */ + readonly declaredURI: NamespaceURI; + + constructor(options: NamespaceDeclarationOptions) { + const { declaredPrefix, declaredURI } = options; + + /** + * Represents the {@link QualifiedName} **of the {@link NamespaceDeclaration} itself, used only for consistent XML serialization logic. + */ + let qualifiedName: QualifiedName; + + switch (declaredPrefix) { + // Declaring a "null prefix" is equivalent to the following XML syntax: + // `xmlns="..."` + case null: + qualifiedName = new QualifiedName({ + namespaceURI: XMLNS_NAMESPACE_URI, + prefix: null, + localName: XMLNS_PREFIX, + }); + + break; + + // Declaring a non-null prefix is equivalent to the following XML syntax: + // `xmlns:$declaredPrefix="..." + default: + qualifiedName = new QualifiedName({ + namespaceURI: XMLNS_NAMESPACE_URI, + prefix: XMLNS_PREFIX, + localName: declaredPrefix, + }); + break; + } + + this.declaredPrefix = declaredPrefix; + this.declaredURI = declaredURI; + + const serializedName = qualifiedName.getPrefixedName(); + const serializedValue = escapeXMLText(declaredURI?.href ?? ''); + + this.serializedXML = ` ${serializedName}="${serializedValue}"`; + } + + declaresNamespaceURI(namespaceURI: NamespaceURI) { + if (namespaceURI == null) { + return this.declaredURI === null; + } + + return this.declaredURI?.href === namespaceURI.href; + } + + serializeNamespaceDeclarationXML(options?: NamespaceDeclarationXMLSerializationOptions): string { + if (options?.omitDefaultNamespace && this.declaredPrefix == null) { + return ''; + } + + return this.serializedXML; + } +} diff --git a/packages/xforms-engine/src/lib/names/NamespaceDeclarationMap.ts b/packages/xforms-engine/src/lib/names/NamespaceDeclarationMap.ts new file mode 100644 index 000000000..0852cbe6a --- /dev/null +++ b/packages/xforms-engine/src/lib/names/NamespaceDeclarationMap.ts @@ -0,0 +1,228 @@ +import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts'; +import { NamespaceDeclaration } from './NamespaceDeclaration.ts'; +import type { NamespaceURI, QualifiedName } from './QualifiedName.ts'; + +export interface NamedNodeDefinition { + readonly qualifiedName: QualifiedName; +} + +type NamedNodeDefinitionMap = ReadonlyMap; + +export interface NamedSubtreeDefinition extends NamedNodeDefinition { + readonly namespaceDeclarations: NamespaceDeclarationMap; + readonly parent: NamedSubtreeDefinition | null; + readonly attributes?: NamedNodeDefinitionMap; +} + +/** + * @todo This is a bit of a code style experiment! Responsive to + * {@link https://github.com/getodk/web-forms/issues/296 | How should we represent enumerated types?}. + * Observations to be considered for that issue... + * + * This more or less works as one would expect, with one really irritating + * downside: unlike a **TypeScript `enum`**, code completions (in VSCode, but + * I'd expect the same for any TypeScript language server/LSP implementation) + * automatically suggest the bare string values rather than the equivalent + * syntax referencing the enumerations as defined here. Without care, it would + * be fairly trivial to lose consistency between the source "enum" and consuming + * code which we presume to be an exhaustive check (such as the `switch` + * statement operating on it below). This is somewhat mitigated for now by + * habitual use of {@link UnreachableError} (and would be better mitigated by a + * lint rule to enforce exhaustiveness checks over similar enumerations). But + * there is an obvious _stylistic mismatch_ between how an editor treats "thing + * shaped like `enum` but not semantically an `enum`", whereas there's no such + * mismatch in how it treats a plain "union of strings". If nothing else, that + * mismatch would tend to exacerbate exhaustiveness drift as an enumeration + * evolves. + */ +const DECLARE_NAMESPACE_RESULTS = { + SUCCESS: 'SUCCESS', + HOISTED: 'HOISTED', + DEFERRED: 'DEFERRED', + REDUNDANT: 'REDUNDANT', + CONFLICT: 'CONFLICT', +} as const; + +type DeclareNamespaceResultEnum = typeof DECLARE_NAMESPACE_RESULTS; + +type DeclareNamespaceResult = DeclareNamespaceResultEnum[keyof DeclareNamespaceResultEnum]; + +export class NamespaceDeclarationMap extends Map { + constructor(readonly subtree: NamedSubtreeDefinition) { + super(); + + this.declareNamespace(subtree); + + const { attributes } = subtree; + + if (attributes != null) { + for (const attribute of attributes.values()) { + this.declareNamespace(attribute); + } + } + } + + /** + * For any {@link definition | named node definition}, we can _infer_ a + * namespace declaration (rather than parsing it directly, which is error + * prone depending on parsing context) from that definition's + * {@link QualifiedName.namespaceURI} and {@link QualifiedName.prefix} (if the + * latter is defined). + * + * If a namespace declaration can be inferred, we "declare" (set, in + * {@link Map} semantics) it in **EITHER**: + * + * - An ancestor {@link NamedSubtreeDefinition | named subtree definition}'s + * {@link NamespaceDeclarationMap}: if such an ancestor exists and has no + * conflicting declaration for the same prefix; **OR** + * - This {@link NamespaceDeclarationMap}, if no suitable ancestor exists + * + * This can be described as "hoisting" the declaration to the uppermost node + * (or definitional representation of same) where it would be valid to declare + * the namespace for its prefix. + * + * In the following example, note that this logic applies for arbitrary tree + * structures satisfying the {@link NamedNodeDefinition} and + * {@link NamedSubtreeDefinition} interfaces. XML syntax is used to provide a + * concise explanation, but it should not be inferred that this is operating + * directly on an XML value (or any platform-native DOM structure of the + * same). + * + * @example Given an input tree like: + * + * ```xml + * + * + * + * + * ``` + * + * The namespace declarations will be assigned as if they'd been declared + * like: + * + * ```xml + * + * + * + * + * + * + * ``` + * + * **IMPORTANT:** this behavior may seem overly complicated! It should be + * noted that the behavior: + * + * 1. ... is conceptually similar to behavior observable in a web standard + * WHAT Working Group DOM (as in browser DOM, XML DOM) implementation. + * There, serializing any subtree element will produce namespace + * declarations on the root element for any namespaces _referenced within + * its subtree but declared on an ancestor_. Note that in this case, the + * hierarchical behavior is inverted, but it demonstrates the same + * effective namespace scoping semantics. + * + * 2. ... vastly simplifies our ability to produce a compact XML + * representation from any arbitrary tree representation of its nodes. + * Hoisting namespace declarations to their uppermost scope, and + * deduplicating recursively up the ancestor tree, ensures that we only + * declare a given namespace once as it is referenced. + * + * @todo While this design is intended to help with producing compact + * serialized XML, at time of writing there is still an aspect which is + * unaddressed in the serialization logic: we assume namespace declarations + * are referenced if they've been parsed. This logic doesn't hold for nodes + * which are ultimately omitted from serialization, which would occur for + * non-relevant nodes, and repeat ranges with zero repeat instances (or any of + * their descendants). A future iteration of this same behavior could produce + * XML which is theoretically more compact, by performing the same declaration + * hoisting logic _dynamically at call time_ rather than at parse time. + */ + declareNamespace(definition: NamedNodeDefinition): DeclareNamespaceResult { + const { prefix, namespaceURI } = definition.qualifiedName; + + if (typeof prefix === 'symbol') { + return DECLARE_NAMESPACE_RESULTS.DEFERRED; + } + + const parentNamespaceDeclarations = this.subtree.parent?.namespaceDeclarations; + + if (parentNamespaceDeclarations != null) { + const ancestorResult = parentNamespaceDeclarations.declareNamespace(definition); + + switch (ancestorResult) { + case DECLARE_NAMESPACE_RESULTS.CONFLICT: + break; + + case DECLARE_NAMESPACE_RESULTS.DEFERRED: + return ancestorResult; + + case DECLARE_NAMESPACE_RESULTS.HOISTED: + return ancestorResult; + + case DECLARE_NAMESPACE_RESULTS.SUCCESS: + case DECLARE_NAMESPACE_RESULTS.REDUNDANT: + return DECLARE_NAMESPACE_RESULTS.HOISTED; + + default: + throw new UnreachableError(ancestorResult); + } + } + + const currentDeclaration = this.get(prefix); + + if (currentDeclaration == null) { + this.set( + prefix, + new NamespaceDeclaration({ + declaredPrefix: prefix, + declaredURI: namespaceURI, + }) + ); + + return DECLARE_NAMESPACE_RESULTS.SUCCESS; + } + + if (currentDeclaration.declaresNamespaceURI(namespaceURI)) { + return DECLARE_NAMESPACE_RESULTS.REDUNDANT; + } + + return DECLARE_NAMESPACE_RESULTS.CONFLICT; + } + + /** + * Given a {@link namespaceURI}, resolves a declared prefix (which may be + * `null`) for the {@link subtree} context **or any of its ancestors**. This + * is an important semantic detail: + * + * - Namespace declarations on a given subtree are effective for all of its + * descendants _until another declaration for the same prefix or namespace + * URI is encountered_ + * - We "hoist" namespace declarations up to the uppermost {@link subtree}'s + * {@link NamespaceDeclarationMap} during parsing (as described in more + * detail on {@link declareNamespace}). + */ + lookupPrefix(namespaceURI: NamespaceURI): string | null { + const namespace = String(namespaceURI); + + // Note: this is a dynamic lookup on the _very unlikely_ chance that a + // lookup occurs while parsing is still in progress. It's expected that we + // collect all namespace declarations by the time parsing is complete, at + // which point we could theoretically collect a companion map where the + // namespace URI is used as a key. This has been deferred for now, because + // we'd need: + // + // 1. To know _in this class_ when parsing is complete (which seems like a + // huge excess of mixed responsibilities!) + // 2. To resolve the "object-as-value-as-map-key" problem, which has also + // been deferred. + for (const namespaceDeclaration of this.values()) { + if (String(namespaceDeclaration.declaredURI) === namespace) { + return namespaceDeclaration.declaredPrefix; + } + } + + return this.subtree.parent?.namespaceDeclarations.lookupPrefix(namespaceURI) ?? null; + } +} diff --git a/packages/xforms-engine/src/lib/names/NamespaceURL.ts b/packages/xforms-engine/src/lib/names/NamespaceURL.ts new file mode 100644 index 000000000..0961fad87 --- /dev/null +++ b/packages/xforms-engine/src/lib/names/NamespaceURL.ts @@ -0,0 +1,44 @@ +import type { NamespaceDeclaration } from './NamespaceDeclaration.ts'; + +/** + * Convenience wrapper to represent an XML namespace URI as a {@link URL}. This + * representation is used/responsible for: + * + * - normalized logic for XML semantics around special namespace URI values, in + * particular for consistent handling of the "null namespace" (input for such + * is accepted as either an empty string or `null`) + * - validation of input: a non-"null namespace" value will be rejected if it is + * not a valid URI string + * - type-level distinction between a namespace URI and a + * {@link NamespaceDeclaration.declaredPrefix | namespace declaration's prefix}, + * as an aide to avoid using one in place of the other as e.g. a positional + * argument + * + * @todo Test the finer distinctions between "URL" and "URI"! + * + * @todo Probably not a huge deal in the scheme of things, but this is almost + * entirely pure overhead at runtime! The "validation" use case is kind of a + * stretch, and may well be wrong. The type-level distinction from a namespace + * prefix, however, has proved useful **quite a few times** during iteration of + * this change. If we can actually measure an impact, it might be worth instead + * considering "branded types" for the type-level distinct (in which case we + * could use a factory function to handle both the branding and special XML + * semantics). + */ +export class NamespaceURL extends URL { + static from(namespaceURI: NamespaceURL | string | null): NamespaceURL | null { + if (namespaceURI == null || namespaceURI === '') { + return null; + } + + return new this(String(namespaceURI)); + } + + override readonly href: string; + + private constructor(href: string) { + super(href); + + this.href = href; + } +} diff --git a/packages/xforms-engine/src/lib/names/QualifiedName.ts b/packages/xforms-engine/src/lib/names/QualifiedName.ts new file mode 100644 index 000000000..8f2dd2b02 --- /dev/null +++ b/packages/xforms-engine/src/lib/names/QualifiedName.ts @@ -0,0 +1,170 @@ +import type { XPathDOMAdapter } from '@getodk/xpath'; +import { NamespaceURL } from './NamespaceURL.ts'; + +export type NamespaceURI = NamespaceURL | null; +export type QualifiedNamePrefix = string | null; + +export interface NamespaceQualifiedNameSource { + readonly namespaceURI: NamespaceURI | string; + readonly localName: string; + + /** + * Note that this property is intentionally optional as one of the + * {@link QualifiedNameSource | QualifiedName source input}, and its absence + * is treated differently from an explicitly assigned `null` value. + * + * @see {@link SourcePrefixUnspecified}, {@link DeferredPrefix} + */ + readonly prefix?: QualifiedNamePrefix; +} + +const SOURCE_PREFIX_UNSPECIFIED = Symbol('SOURCE_PREFIX_UNSPECIFIED'); + +/** + * May be used as a placeholder for a {@link QualifiedName.prefix}, where the + * actual prefix may not be known at definition time. + * + * Example: parsing non-XML sources into an XML-like tree, e.g. for XPath + * evaluation; in which case, we may not need to resolve a prefix for the name + * until such a node is serialized as XML, if it ever is. + */ +type SourcePrefixUnspecified = typeof SOURCE_PREFIX_UNSPECIFIED; + +/** + * Represents a {@link QualifiedName.prefix} whose resolution may be deferred, + * e.g. until all requisite parsing is complete and/or until XML serialization + * requires use of a prefix to represent the corresponding + * {@link QualifiedName.namespaceURI}. + */ +// prettier-ignore +type DeferredPrefix = + // eslint-disable-next-line @typescript-eslint/sort-type-constituents + | string + | null + | SourcePrefixUnspecified; + +interface DeferredPrefixedQualifiedNameSource { + readonly namespaceURI: NamespaceURI | string; + readonly prefix: DeferredPrefix; + readonly localName: string; +} + +// prettier-ignore +export type QualifiedNameSource = + // eslint-disable-next-line @typescript-eslint/sort-type-constituents + | NamespaceQualifiedNameSource + | DeferredPrefixedQualifiedNameSource; + +interface PrefixResolutionOptions { + lookupPrefix(namespaceURI: NamespaceURI | string): string | null; +} + +/** + * @todo This is in the `lib` directory because it's a cross-cutting concern, + * applicable to: + * + * - Parsing XML into a useful runtime data model (usage which motivated initial + * development of this class) + * - Serializing XML from a runtime data model (also motivated initial dev) + * - `@getodk/xpath` (internal) e.g. references to + * {@link https://www.w3.org/TR/REC-xml-names/#NT-QName | QName} in various + * parts of XPath expression _syntax_, as well as various parts of the package + * interpreting those parts of syntax + * - `@getodk/xpath` (cross-package), e.g. in aspects of the + * {@link XPathDOMAdapter} APIs, and implementations thereof + * - A zillion potential optimizations, e.g. where names are useful in a lookup + * table (or used in conjunction with other information to construct keys for + * same) + * + * @todo As a cross-cutting concern, there are subtle but important differences + * between certain XPath and XML semantics around expressions of a "null" + * {@link prefix}. E.g. in the expression `/foo`, **technically** the `foo` Step + * should select child elements _in the null namespace_, whereas in most other + * cases a null prefix (when explicitly assigned `null`, rather than + * {@link DeferredPrefix | deferred for later resolution}) is expected to + * correspond _to the default namespace_ (whatever that is in the context of the + * {@link QualifiedName | qualified-named thing}). + * + * @todo As a mechanism for many optimizations, an evolution of this class would + * be **BY FAR** most useful if it can be treated as a _value type_, despite + * challenges using non-primitives as such in a JS runtime. To be clear: it + * would be most useful if every instance of {@link QualifiedName} having the + * same property values (or in some cases, the same combined + * {@link namespaceURI}/{@link localName} or combined + * {@link prefix}/{@link localName}) would also have _reference equality_ with + * other instances having the same property values (or pertinent subset + * thereof). Making a somewhat obvious point explicit: this would be + * particularly useful in cases where a lookup table is implemented as a native + * {@link Map}, where using {@link QualifiedName} as a key would break + * expectations (and probably quite a lot of functionality!) if 2+ equivalent + * keys mapped to different values. + * + * @todo Where we would want to treat instances as a value type, it would be + * useful to look at prior art for representation of the same data as a string. + * One frame of reference worth looking at is + * {@link https://www.w3.org/TR/xpath-30/#prod-xpath30-URIQualifiedName | XPath 3.0's URIQualifiedName} + * (but note that this syntax is mutually exclusive with the prefixed `QName`). + */ +export class QualifiedName implements DeferredPrefixedQualifiedNameSource { + private readonly defaultPrefixResolutionOptions: PrefixResolutionOptions; + + readonly namespaceURI: NamespaceURI; + + /** + * @see {@link SourcePrefixUnspecified}, {@link DeferredPrefix} + */ + readonly prefix: DeferredPrefix; + + readonly localName: string; + + constructor(source: QualifiedNameSource) { + const { localName } = source; + + let prefix = source.prefix; + + if (typeof prefix === 'undefined') { + prefix = SOURCE_PREFIX_UNSPECIFIED; + } + + const namespaceURI = NamespaceURL.from(source.namespaceURI); + + this.namespaceURI = namespaceURI; + this.prefix = prefix; + this.localName = localName; + + this.defaultPrefixResolutionOptions = { + lookupPrefix: () => { + if (prefix === SOURCE_PREFIX_UNSPECIFIED) { + throw new Error(`Failed to resolve prefix for namespace URI: ${String(namespaceURI)}`); + } + + return prefix; + }, + }; + } + + /** + * @todo at time of writing, it's not expected we will actually supply + * {@link options} in calls to this method! Current calls are from definitions + * whose prefixes are known at parse time (i.e. they are prefixed in the + * source XML from which they're parsed). + * + * The intent of accepting the options here now is to leave a fairly large + * breadcrumb, for any use case where we might want to serialize XML from + * artificially constructed DOM-like trees (e.g. `StaticNode` implementations + * defined by parsing non-XML external secondary instances such as CSV and + * GeoJSON). AFAIK this doesn't correspond to any known feature in the "like + * Collect" scope, but it could have implications for inspecting form details + * in e.g. "debug/form design/dev mode" scenarios. + */ + getPrefixedName(options: PrefixResolutionOptions = this.defaultPrefixResolutionOptions): string { + const { namespaceURI, localName } = this; + const prefix = options.lookupPrefix(namespaceURI); + + if (prefix == null) { + return localName; + } + + return `${prefix}:${localName}`; + } +} diff --git a/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts b/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts index 9986e506a..90d21a6f7 100644 --- a/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts +++ b/packages/xforms-engine/src/lib/reactivity/createInstanceValueState.ts @@ -8,6 +8,13 @@ import type { SimpleAtomicState, SimpleAtomicStateSetter } from './types.ts'; type InitialValueSource = 'FORM_DEFAULT' | 'PRIMARY_INSTANCE'; +/** + * @todo {@link InitialValueSource} naming leaves a lot to be desired. As described in {@link InstanceValueStateOptions.initialValueSource}, this check (for now) will effectively answer the question: "are we **NOT** editing instance state (e.g. a submission)?". This answer, in turn, determines whether to {@link setPreloadUIDValue} + */ +const isInstanceFirstLoad = (valueSource?: InitialValueSource) => { + return valueSource === 'FORM_DEFAULT'; +}; + export interface InstanceValueStateOptions { /** * Specifies the source of a {@link createInstanceValueState} signal's initial @@ -106,10 +113,47 @@ const guardDownstreamReadonlyWrites = ( return [getValue, setValue]; }; +/** + * Per {@link https://getodk.github.io/xforms-spec/#preload-attributes:~:text=concatenation%20of%20%E2%80%98uuid%3A%E2%80%99%20and%20uuid()} + */ +const PRELOAD_UID_EXPRESSION = 'concat("uuid:", uuid())'; + +/** + * @todo This is a temporary one-off, until we support the full range of + * {@link https://getodk.github.io/xforms-spec/#preload-attributes | preloads}. + * + * @todo ALSO, IMPORTANTLY(!): the **call site** for this function is + * semantically where we would expect to trigger a + * {@link https://getodk.github.io/xforms-spec/#event:odk-instance-first-load | odk-instance-first-load event}, + * _and compute_ preloads semantically associated with that event. + */ +const setPreloadUIDValue = ( + context: InstanceValueContext, + valueState: RelevantValueState, + options: InstanceValueStateOptions +): void => { + const { preload } = context.definition.bind; + + if (preload?.type !== 'uid' || !isInstanceFirstLoad(options?.initialValueSource)) { + return; + } + + const preloadUIDValue = context.evaluator.evaluateString(PRELOAD_UID_EXPRESSION, { + contextNode: context.contextNode, + }); + + const [, setValue] = valueState; + + setValue(preloadUIDValue); +}; + /** * Defines a reactive effect which writes the result of `calculate` bind * computations to the provided value setter, on initialization and any * subsequent reactive update. + * + * @see {@link setPreloadUIDValue} for important details about spec ordering of + * events and computations. */ const createCalculation = ( context: InstanceValueContext, @@ -153,6 +197,12 @@ export const createInstanceValueState = ( const initialValue = getInitialValue(context, options); const baseValueState = createSignal(initialValue); const relevantValueState = createRelevantValueState(context, baseValueState); + + /** + * @see {@link setPreloadUIDValue} for important details about spec ordering of events and computations. + */ + setPreloadUIDValue(context, relevantValueState, options); + const { calculate } = context.definition.bind; if (calculate != null) { diff --git a/packages/xforms-engine/src/lib/xml-serialization.ts b/packages/xforms-engine/src/lib/xml-serialization.ts index 2da91748a..4460724e1 100644 --- a/packages/xforms-engine/src/lib/xml-serialization.ts +++ b/packages/xforms-engine/src/lib/xml-serialization.ts @@ -1,3 +1,6 @@ +import type { NamespaceDeclarationMap } from './names/NamespaceDeclarationMap.ts'; +import type { QualifiedName } from './names/QualifiedName.ts'; + declare const ESCAPED_XML_TEXT_BRAND: unique symbol; export type EscapedXMLText = string & { readonly [ESCAPED_XML_TEXT_BRAND]: true }; @@ -75,36 +78,65 @@ export const escapeXMLText = ( : (out as EscapedXMLText); }; -interface SerializableNamespaceDeclaration { - serializeNamespaceDeclarationXML(): string; -} - interface SerializableElementAttribute { serializeAttributeXML(): string; } interface ElementXMLSerializationOptions { - readonly namespaceDeclarations?: readonly SerializableNamespaceDeclaration[]; + readonly namespaceDeclarations?: NamespaceDeclarationMap; readonly attributes?: readonly SerializableElementAttribute[]; } +const serializeElementNamespaceDeclarationXML = ( + namespaceDeclarations?: NamespaceDeclarationMap +): string => { + if (namespaceDeclarations == null) { + return ''; + } + + return Array.from(namespaceDeclarations.values()) + .map((namespaceDeclaration) => { + return namespaceDeclaration.serializeNamespaceDeclarationXML({ + omitDefaultNamespace: true, + }); + }) + .join(''); +}; + +const serializeElementAttributeXML = ( + attributes?: readonly SerializableElementAttribute[] +): string => { + if (attributes == null) { + return ''; + } + + return attributes + .map((attribute) => { + return attribute.serializeAttributeXML(); + }) + .join(''); +}; + const serializeElementXML = ( - nodeName: string, + qualifiedName: QualifiedName, children: string, options: ElementXMLSerializationOptions = {} ): string => { - const namespaceDeclarations = + // See JSDoc for the `getPrefixedName` method. If we find we do actually need + // custom element (subtree) prefix resolution, we'd uncomment the argument + // below. (Either way, at time of writing the affected tests pass where + // expected when the option is passed. It's omitted on the presumption that it + // would be redundant, since the nodes being serialized are already resolved + // with the same set of namespace declarations which would affect them.) + // + // prettier-ignore + const nodeName = qualifiedName.getPrefixedName( + // options.namespaceDeclarations + ); + const namespaceDeclarations = serializeElementNamespaceDeclarationXML( options.namespaceDeclarations - ?.map((namespaceDeclaration) => { - return namespaceDeclaration.serializeNamespaceDeclarationXML(); - }) - .join('') ?? ''; - const attributes = - options.attributes - ?.map((attribute) => { - return attribute.serializeAttributeXML(); - }) - .join('') ?? ''; + ); + const attributes = serializeElementAttributeXML(options.attributes); const prefix = `<${nodeName}${namespaceDeclarations}${attributes}`; if (children === '') { @@ -115,17 +147,17 @@ const serializeElementXML = ( }; export const serializeParentElementXML = ( - nodeName: string, + qualifiedName: QualifiedName, serializedChildren: readonly string[], options?: ElementXMLSerializationOptions ): string => { - return serializeElementXML(nodeName, serializedChildren.join(''), options); + return serializeElementXML(qualifiedName, serializedChildren.join(''), options); }; export const serializeLeafElementXML = ( - nodeName: string, + qualifiedName: QualifiedName, xmlValue: EscapedXMLText, options?: ElementXMLSerializationOptions ): string => { - return serializeElementXML(nodeName, xmlValue.normalize(), options); + return serializeElementXML(qualifiedName, xmlValue.normalize(), options); }; diff --git a/packages/xforms-engine/src/parse/XFormDOM.ts b/packages/xforms-engine/src/parse/XFormDOM.ts index 7d61a8bda..c1e92dbc8 100644 --- a/packages/xforms-engine/src/parse/XFormDOM.ts +++ b/packages/xforms-engine/src/parse/XFormDOM.ts @@ -1,4 +1,9 @@ -import { XFORMS_NAMESPACE_URI, XMLNS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import { + JAVAROSA_NAMESPACE_URI, + OPENROSA_XFORMS_NAMESPACE_URI, + XFORMS_NAMESPACE_URI, + XMLNS_NAMESPACE_URI, +} from '@getodk/common/constants/xmlns.ts'; import type { KnownAttributeLocalNamedElement, LocalNamedElement, @@ -7,6 +12,118 @@ import { DefaultEvaluator } from '@getodk/xpath'; interface DOMBindElement extends KnownAttributeLocalNamedElement<'bind', 'nodeset'> {} +const getMetaElement = (primaryInstanceRoot: Element): Element | null => { + for (const child of primaryInstanceRoot.children) { + const { localName, namespaceURI } = child; + + if ( + (namespaceURI === OPENROSA_XFORMS_NAMESPACE_URI || namespaceURI === XFORMS_NAMESPACE_URI) && + localName === 'meta' + ) { + return child; + } + } + + return null; +}; + +const getMetaChildElement = (meta: Element | null, localName: string): Element | null => { + if (meta == null) { + return null; + } + + const { namespaceURI } = meta; + + for (const child of meta.children) { + if (child.localName === localName && child.namespaceURI === namespaceURI) { + return child; + } + } + + return null; +}; + +const getQualifiedName = ( + contextNode: Node, + namespaceURI: string | null, + localName: string +): string => { + const prefix = contextNode.lookupPrefix(namespaceURI); + + if (prefix == null) { + return localName; + } + + return `${prefix}:${localName}`; +}; + +const createNamespacedChildElement = ( + parent: Element, + namespaceURI: string | null, + localName: string +): Element => { + const qualifiedName = getQualifiedName(parent, namespaceURI, localName); + const child = parent.ownerDocument.createElementNS(namespaceURI, qualifiedName); + + parent.append(child); + + return child; +}; + +const setNamespacedAttributeValue = ( + element: Element, + namespaceURI: string | null, + localName: string, + value: string +) => { + const qualifiedName = getQualifiedName(element, namespaceURI, localName); + + element.setAttributeNS(namespaceURI, qualifiedName, value); +}; + +const createDefaultInstanceIDBinding = ( + model: Element, + primaryInstanceRoot: Element, + meta: Element, + instanceID: Element +): DOMBindElement => { + const bind = createNamespacedChildElement(model, model.namespaceURI, 'bind'); + const nodeset = `/${primaryInstanceRoot.nodeName}/${meta.nodeName}/${instanceID.nodeName}`; + + bind.setAttribute('nodeset', nodeset); + setNamespacedAttributeValue(bind, JAVAROSA_NAMESPACE_URI, 'preload', 'uid'); + + return bind as DOMBindElement; +}; + +const normalizeDefaultMetaBindings = ( + model: Element, + primaryInstanceRoot: Element, + binds: readonly DOMBindElement[] +): readonly DOMBindElement[] => { + let meta = getMetaElement(primaryInstanceRoot); + let instanceID = getMetaChildElement(meta, 'instanceID'); + + if (meta == null) { + meta = createNamespacedChildElement(primaryInstanceRoot, OPENROSA_XFORMS_NAMESPACE_URI, 'meta'); + } + + if (instanceID == null) { + instanceID = createNamespacedChildElement(meta, meta.namespaceURI, 'instanceID'); + + const instanceIDBinding = createDefaultInstanceIDBinding( + model, + primaryInstanceRoot, + meta, + instanceID + ); + + return [...binds, instanceIDBinding]; + } + + return binds; +}; + export interface DOMItextTranslationElement extends KnownAttributeLocalNamedElement<'translation', 'lang'> {} @@ -238,23 +355,6 @@ export class XFormDOM { const html = evaluator.evaluateNonNullElement('/h:html', { contextNode: xformDocument, }); - - let body = evaluator.evaluateNonNullElement('./h:body', { - contextNode: html, - }); - let normalizedXML: string; - - if (options.isNormalized) { - normalizedXML = sourceXML; - } else { - body = normalizeXFormBody(body); - - // TODO: if we keep doing normalization this way long term (or using the DOM - // for parsing long term, for that matter!), we should measure this. And we - // should measure against XMLSerializer while we're at it! - normalizedXML = xformDocument.documentElement.outerHTML; - } - const head = evaluator.evaluateNonNullElement('./h:head', { contextNode: html, }); @@ -264,9 +364,12 @@ export class XFormDOM { const model = evaluator.evaluateNonNullElement('./xf:model', { contextNode: head, }); - const binds = evaluator.evaluateNodes('./xf:bind[@nodeset]', { - contextNode: model, - }); + let binds: readonly DOMBindElement[] = evaluator.evaluateNodes( + './xf:bind[@nodeset]', + { + contextNode: model, + } + ); const instances = evaluator.evaluateNodes('./xf:instance', { contextNode: model, @@ -292,6 +395,23 @@ export class XFormDOM { } ); + let body = evaluator.evaluateNonNullElement('./h:body', { + contextNode: html, + }); + let normalizedXML: string; + + if (options.isNormalized) { + normalizedXML = sourceXML; + } else { + body = normalizeXFormBody(body); + binds = normalizeDefaultMetaBindings(model, primaryInstanceRoot, binds); + + // TODO: if we keep doing normalization this way long term (or using the DOM + // for parsing long term, for that matter!), we should measure this. And we + // should measure against XMLSerializer while we're at it! + normalizedXML = xformDocument.documentElement.outerHTML; + } + this.normalizedXML = normalizedXML; this.xformDocument = xformDocument; this.html = html; diff --git a/packages/xforms-engine/src/parse/XFormDefinition.ts b/packages/xforms-engine/src/parse/XFormDefinition.ts index 478510e9b..8db500bfc 100644 --- a/packages/xforms-engine/src/parse/XFormDefinition.ts +++ b/packages/xforms-engine/src/parse/XFormDefinition.ts @@ -24,10 +24,7 @@ export class XFormDefinition { this.xformDocument = xformDocument; this.id = id; this.title = title.textContent ?? ''; - - // TODO: highly unlikely primary instance root will need a namespace prefix - // but noting it just in case there is such weird usage... - this.rootReference = `/${primaryInstanceRoot.localName}`; + this.rootReference = `/${primaryInstanceRoot.nodeName}`; this.body = new BodyDefinition(this); this.model = new ModelDefinition(this); diff --git a/packages/xforms-engine/src/parse/model/BindDefinition.ts b/packages/xforms-engine/src/parse/model/BindDefinition.ts index 7f2a203b0..593e7c3a0 100644 --- a/packages/xforms-engine/src/parse/model/BindDefinition.ts +++ b/packages/xforms-engine/src/parse/model/BindDefinition.ts @@ -4,6 +4,7 @@ import { BindComputationExpression } from '../expression/BindComputationExpressi import { MessageDefinition } from '../text/MessageDefinition.ts'; import type { XFormDefinition } from '../XFormDefinition.ts'; import type { BindElement } from './BindElement.ts'; +import { BindPreloadDefinition, type AnyBindPreloadDefinition } from './BindPreloadDefinition.ts'; import type { BindType } from './BindTypeDefinition.ts'; import { BindTypeDefinition } from './BindTypeDefinition.ts'; import type { ModelDefinition } from './ModelDefinition.ts'; @@ -12,6 +13,8 @@ export class BindDefinition extends DependencyCon readonly type: BindTypeDefinition; readonly parentNodeset: string | null; + readonly preload: AnyBindPreloadDefinition | null; + readonly calculate: BindComputationExpression<'calculate'> | null; readonly readonly: BindComputationExpression<'readonly'>; readonly relevant: BindComputationExpression<'relevant'>; @@ -82,6 +85,7 @@ export class BindDefinition extends DependencyCon const parentNodeset = nodeset.replace(/\/[^/]+$/, ''); this.parentNodeset = parentNodeset.length > 1 ? parentNodeset : null; + this.preload = BindPreloadDefinition.from(bindElement); this.calculate = BindComputationExpression.forComputation(this, 'calculate'); this.readonly = BindComputationExpression.forComputation(this, 'readonly'); this.relevant = BindComputationExpression.forComputation(this, 'relevant'); diff --git a/packages/xforms-engine/src/parse/model/BindPreloadDefinition.ts b/packages/xforms-engine/src/parse/model/BindPreloadDefinition.ts new file mode 100644 index 000000000..23d32e193 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/BindPreloadDefinition.ts @@ -0,0 +1,138 @@ +import { JAVAROSA_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import { getPropertyKeys } from '@getodk/common/lib/objects/structure.ts'; +import { UnknownPreloadAttributeValueNotice } from '../../error/UnknownPreloadAttributeValueNotice.ts'; +import type { BindElement } from './BindElement.ts'; + +const preloadParametersByType = { + uid: [null], + date: ['today'], + timestamp: ['start', 'end'], + property: ['deviceid', 'email', 'username', 'phonenumber'], +} as const; + +const preloadParameterTypes = getPropertyKeys(preloadParametersByType); + +type PreloadParametersByType = typeof preloadParametersByType; + +type PreloadType = keyof PreloadParametersByType; + +type AssertPreloadType = (type: string) => asserts type is PreloadType; + +const assertPreloadType: AssertPreloadType = (type) => { + if (!preloadParameterTypes.includes(type as PreloadType)) { + throw new UnknownPreloadAttributeValueNotice('jr:preload', preloadParameterTypes, type); + } +}; + +const getPreloadType = (bindElement: BindElement): PreloadType | null => { + const type = bindElement.getAttributeNS(JAVAROSA_NAMESPACE_URI, 'preload'); + + if (type == null) { + return null; + } + + assertPreloadType(type); + + return type; +}; + +type PreloadParameter = PreloadParametersByType[Type][number]; + +type AssertPreloadParameter = ( + type: Type, + parameter: string | null +) => asserts parameter is PreloadParameter; + +const assertPreloadParameter: AssertPreloadParameter = ( + type: Type, + parameter: string | null +) => { + const parameters: ReadonlyArray> = preloadParametersByType[type]; + + if (!parameters.includes(parameter as PreloadParameter)) { + throw new UnknownPreloadAttributeValueNotice('jr:preloadParams', parameters, parameter); + } +}; + +const getPreloadParameter = ( + bindElement: BindElement, + type: Type +): PreloadParameter => { + const parameter = bindElement.getAttributeNS(JAVAROSA_NAMESPACE_URI, 'preloadParams'); + + assertPreloadParameter(type, parameter); + + return parameter; +}; + +interface PreloadInput { + readonly type: Type; + readonly parameter: PreloadParameter; +} + +type AnyPreloadInput = { + [Type in PreloadType]: PreloadInput; +}[PreloadType]; + +const getPreloadInput = (bindElement: BindElement): AnyPreloadInput | null => { + const type = getPreloadType(bindElement); + + if (type == null) { + return null; + } + + type Type = typeof type; + + const parameter: PreloadParameter = getPreloadParameter(bindElement, type); + + return { + type, + parameter, + } satisfies PreloadInput as AnyPreloadInput; +}; + +/** + * Parsed representation of + * {@link https://getodk.github.io/xforms-spec/#preload-attributes | Preload Attributes}. + * If specified on a + * {@link https://getodk.github.io/xforms-spec/#bindings | binding}, this will + * be parsed to define: + * + * - {@link type}, a `jr:preload` + * - {@link parameter}, an associated `jr:preloadParams` value + * + * @todo It would probably make sense for the _definition_ to also convey: + * + * 1. Which {@link https://getodk.github.io/xforms-spec/#events | event} the + * preload is semantically associated with (noting that the spec may be a tad + * overzealous about this association). + * + * 2. The constant XPath expression (or other computation?) expressed by the + * combined {@link type} and {@link parameter}. + */ +export class BindPreloadDefinition implements PreloadInput { + static from(bindElement: BindElement): AnyBindPreloadDefinition | null { + const input = getPreloadInput(bindElement); + + if (input == null) { + return null; + } + + return new this(input) satisfies BindPreloadDefinition as AnyBindPreloadDefinition; + } + + readonly type: Type; + readonly parameter: PreloadParameter; + + private constructor(input: PreloadInput) { + this.type = input.type; + this.parameter = input.parameter; + } +} + +// prettier-ignore +export type AnyBindPreloadDefinition = + // eslint-disable-next-line @typescript-eslint/sort-type-constituents + | BindPreloadDefinition<'uid'> + | BindPreloadDefinition<'timestamp'> + | BindPreloadDefinition<'property'>; diff --git a/packages/xforms-engine/src/parse/model/ItextTranslation/ItextTranslationRootDefinition.ts b/packages/xforms-engine/src/parse/model/ItextTranslation/ItextTranslationRootDefinition.ts index b6228ce17..d9ea10d4c 100644 --- a/packages/xforms-engine/src/parse/model/ItextTranslation/ItextTranslationRootDefinition.ts +++ b/packages/xforms-engine/src/parse/model/ItextTranslation/ItextTranslationRootDefinition.ts @@ -1,6 +1,7 @@ import type { XFormsItextTranslationElement } from '@getodk/xpath'; import { XFORMS_KNOWN_ATTRIBUTE, XFORMS_LOCAL_NAME } from '@getodk/xpath'; import { StaticElement } from '../../../integration/xpath/static-dom/StaticElement.ts'; +import type { ItextTranslationDefinition } from './ItextTranslationDefinition.ts'; // prettier-ignore type ItextTranslationRootKnownAttributeValue = @@ -21,7 +22,7 @@ const assertItextTranslationRootKnownAttributeValue: AssertItextTranslationRootK }; export class ItextTranslationRootDefinition - extends StaticElement + extends StaticElement implements XFormsItextTranslationElement { override readonly [XFORMS_LOCAL_NAME] = 'translation'; diff --git a/packages/xforms-engine/src/parse/model/LeafNodeDefinition.ts b/packages/xforms-engine/src/parse/model/LeafNodeDefinition.ts index e8fc408cf..e6d9e4774 100644 --- a/packages/xforms-engine/src/parse/model/LeafNodeDefinition.ts +++ b/packages/xforms-engine/src/parse/model/LeafNodeDefinition.ts @@ -1,17 +1,23 @@ import type { ValueType } from '../../client/ValueType.ts'; +import { + NamespaceDeclarationMap, + type NamedSubtreeDefinition, +} from '../../lib/names/NamespaceDeclarationMap.ts'; +import { QualifiedName } from '../../lib/names/QualifiedName.ts'; import type { AnyBodyElementDefinition, ControlElementDefinition } from '../body/BodyDefinition.ts'; import type { BindDefinition } from './BindDefinition.ts'; import { DescendentNodeDefinition } from './DescendentNodeDefinition.ts'; import type { ParentNodeDefinition } from './NodeDefinition.ts'; -export class LeafNodeDefinition extends DescendentNodeDefinition< - 'leaf-node', - ControlElementDefinition | null -> { +export class LeafNodeDefinition + extends DescendentNodeDefinition<'leaf-node', ControlElementDefinition | null> + implements NamedSubtreeDefinition +{ readonly type = 'leaf-node'; readonly valueType: V; - readonly nodeName: string; + readonly namespaceDeclarations: NamespaceDeclarationMap; + readonly qualifiedName: QualifiedName; readonly children = null; readonly instances = null; readonly defaultValue: string; @@ -29,7 +35,8 @@ export class LeafNodeDefinition extends Descend super(parent, bind, bodyElement); this.valueType = bind.type.resolved satisfies ValueType as V; - this.nodeName = node.localName; + this.qualifiedName = new QualifiedName(node); + this.namespaceDeclarations = new NamespaceDeclarationMap(this); this.defaultValue = node.textContent ?? ''; } diff --git a/packages/xforms-engine/src/parse/model/NodeDefinition.ts b/packages/xforms-engine/src/parse/model/NodeDefinition.ts index d70a16b5c..2b8de4b3d 100644 --- a/packages/xforms-engine/src/parse/model/NodeDefinition.ts +++ b/packages/xforms-engine/src/parse/model/NodeDefinition.ts @@ -1,3 +1,8 @@ +import type { + NamedSubtreeDefinition, + NamespaceDeclarationMap, +} from '../../lib/names/NamespaceDeclarationMap.ts'; +import type { QualifiedName } from '../../lib/names/QualifiedName.ts'; import type { AnyBodyElementDefinition } from '../body/BodyDefinition.ts'; import type { RepeatElementDefinition } from '../body/RepeatElementDefinition.ts'; import type { BindDefinition } from './BindDefinition.ts'; @@ -83,9 +88,12 @@ export type ChildNodeInstanceDefinition = | SubtreeDefinition | LeafNodeDefinition; -export abstract class NodeDefinition { +export abstract class NodeDefinition + implements NamedSubtreeDefinition +{ abstract readonly type: Type; - abstract readonly nodeName: string; + abstract readonly namespaceDeclarations: NamespaceDeclarationMap; + abstract readonly qualifiedName: QualifiedName; abstract readonly bodyElement: AnyBodyElementDefinition | RepeatElementDefinition | null; abstract readonly isTranslated: boolean; abstract readonly root: RootDefinition; diff --git a/packages/xforms-engine/src/parse/model/RepeatInstanceDefinition.ts b/packages/xforms-engine/src/parse/model/RepeatInstanceDefinition.ts index 4931ec361..fb4374320 100644 --- a/packages/xforms-engine/src/parse/model/RepeatInstanceDefinition.ts +++ b/packages/xforms-engine/src/parse/model/RepeatInstanceDefinition.ts @@ -1,3 +1,5 @@ +import { NamespaceDeclarationMap } from '../../lib/names/NamespaceDeclarationMap.ts'; +import { QualifiedName } from '../../lib/names/QualifiedName.ts'; import { RepeatElementDefinition } from '../body/RepeatElementDefinition.ts'; import { DescendentNodeDefinition } from './DescendentNodeDefinition.ts'; import type { ChildNodeDefinition } from './NodeDefinition.ts'; @@ -9,7 +11,8 @@ export class RepeatInstanceDefinition extends DescendentNodeDefinition< > { readonly type = 'repeat-instance'; - readonly nodeName: string; + readonly namespaceDeclarations: NamespaceDeclarationMap; + readonly qualifiedName: QualifiedName; readonly children: readonly ChildNodeDefinition[]; readonly instances = null; readonly defaultValue = null; @@ -22,7 +25,8 @@ export class RepeatInstanceDefinition extends DescendentNodeDefinition< super(parent, bind, bodyElement); - this.nodeName = range.nodeName; + this.qualifiedName = new QualifiedName(node); + this.namespaceDeclarations = new NamespaceDeclarationMap(this); this.children = root.buildSubtree(this); } diff --git a/packages/xforms-engine/src/parse/model/RepeatRangeDefinition.ts b/packages/xforms-engine/src/parse/model/RepeatRangeDefinition.ts index 323e4fa7f..b7dcd73ea 100644 --- a/packages/xforms-engine/src/parse/model/RepeatRangeDefinition.ts +++ b/packages/xforms-engine/src/parse/model/RepeatRangeDefinition.ts @@ -1,3 +1,5 @@ +import { NamespaceDeclarationMap } from '../../lib/names/NamespaceDeclarationMap.ts'; +import { QualifiedName } from '../../lib/names/QualifiedName.ts'; import type { RepeatElementDefinition } from '../body/RepeatElementDefinition.ts'; import { RepeatCountControlExpression } from '../expression/RepeatCountControlExpression.ts'; import type { BindDefinition } from './BindDefinition.ts'; @@ -54,7 +56,8 @@ export class RepeatRangeDefinition extends DescendentNodeDefinition< readonly count: RepeatCountControlExpression | null; readonly node = null; - readonly nodeName: string; + readonly namespaceDeclarations: NamespaceDeclarationMap; + readonly qualifiedName: QualifiedName; readonly defaultValue = null; private constructor( @@ -68,7 +71,8 @@ export class RepeatRangeDefinition extends DescendentNodeDefinition< const { template, instanceNodes } = RepeatTemplateDefinition.parseModelNodes(this, modelNodes); this.template = template; - this.nodeName = template.nodeName; + this.qualifiedName = template.qualifiedName; + this.namespaceDeclarations = new NamespaceDeclarationMap(this); this.count = RepeatCountControlExpression.from(bodyElement, instanceNodes.length); assertRepeatRangeDefinitionUnion(this); diff --git a/packages/xforms-engine/src/parse/model/RepeatTemplateDefinition.ts b/packages/xforms-engine/src/parse/model/RepeatTemplateDefinition.ts index 23a7228f2..41e9d1f19 100644 --- a/packages/xforms-engine/src/parse/model/RepeatTemplateDefinition.ts +++ b/packages/xforms-engine/src/parse/model/RepeatTemplateDefinition.ts @@ -1,4 +1,6 @@ import { JAVAROSA_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import { NamespaceDeclarationMap } from '../../lib/names/NamespaceDeclarationMap.ts'; +import { QualifiedName } from '../../lib/names/QualifiedName.ts'; import type { RepeatElementDefinition } from '../body/RepeatElementDefinition.ts'; import { BindDefinition } from './BindDefinition.ts'; import { DescendentNodeDefinition } from './DescendentNodeDefinition.ts'; @@ -115,7 +117,8 @@ export class RepeatTemplateDefinition extends DescendentNodeDefinition< readonly type = 'repeat-template'; readonly node: Element; - readonly nodeName: string; + readonly namespaceDeclarations: NamespaceDeclarationMap; + readonly qualifiedName: QualifiedName; readonly children: readonly ChildNodeDefinition[]; readonly instances = null; readonly defaultValue = null; @@ -133,7 +136,8 @@ export class RepeatTemplateDefinition extends DescendentNodeDefinition< node.removeAttributeNS(JAVAROSA_NAMESPACE_URI, 'template'); this.node = node; - this.nodeName = node.localName; + this.qualifiedName = new QualifiedName(node); + this.namespaceDeclarations = new NamespaceDeclarationMap(this); this.children = root.buildSubtree(this); } diff --git a/packages/xforms-engine/src/parse/model/RootAttributeDefinition.ts b/packages/xforms-engine/src/parse/model/RootAttributeDefinition.ts index 5a07d0f3c..d818ea3b1 100644 --- a/packages/xforms-engine/src/parse/model/RootAttributeDefinition.ts +++ b/packages/xforms-engine/src/parse/model/RootAttributeDefinition.ts @@ -1,5 +1,8 @@ import { XMLNS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import type { NamedNodeDefinition } from '../../lib/names/NamespaceDeclarationMap.ts'; +import { QualifiedName } from '../../lib/names/QualifiedName.ts'; import { escapeXMLText } from '../../lib/xml-serialization.ts'; +import type { RootDefinition } from './RootDefinition.ts'; interface RootAttributeSource { readonly namespaceURI: string | null; @@ -13,29 +16,19 @@ interface RootAttributeSource { * @todo This class is named and typed to emphasize its intentionally narrow * usage and purpose. It **intentionally** avoids addressing the much broader * set of concerns around modeling attributes in primary instance/submissions. - * - * @todo This class technically does double duty, as it will also capture an - * explicit namespace declaration (if {@link RootAttributeSource} is one). - * This matches the DOM semantics from which we currently parse, but differs - * from XML/XPath semantics (where a "namespace declaration" node is distinct - * from an attribute node, despite having similar serialized syntax). */ -export class RootAttributeDefinition { +export class RootAttributeDefinition implements NamedNodeDefinition { private readonly serializedXML: string; - readonly namespaceURI: string | null; - readonly nodeName: string; - readonly prefix: string | null; - readonly localName: string; + readonly parent: RootDefinition; + readonly qualifiedName: QualifiedName; readonly value: string; - constructor(source: RootAttributeSource) { + constructor(root: RootDefinition, source: RootAttributeSource) { const { namespaceURI, nodeName, value } = source; - this.namespaceURI = source.namespaceURI; - this.nodeName = nodeName; - this.prefix = source.prefix; - this.localName = source.localName; + this.parent = root; + this.qualifiedName = new QualifiedName(source); this.value = value; // We serialize namespace declarations separately diff --git a/packages/xforms-engine/src/parse/model/RootAttributeMap.ts b/packages/xforms-engine/src/parse/model/RootAttributeMap.ts new file mode 100644 index 000000000..6832e9274 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/RootAttributeMap.ts @@ -0,0 +1,44 @@ +import { XMLNS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import type { QualifiedName } from '../../lib/names/QualifiedName.ts'; +import { RootAttributeDefinition } from './RootAttributeDefinition.ts'; +import type { RootDefinition } from './RootDefinition.ts'; + +const isNonNamespaceDeclarationDOMAttr = (domAttr: Attr) => { + return domAttr.namespaceURI !== XMLNS_NAMESPACE_URI; +}; + +/** + * @todo This can be trivially expanded to a narrowly general case when we + * prioritize work to + * {@link https://github.com/getodk/web-forms/issues/285 | support attributes} + * (as modeled form nodes on par with elements). It's been deferred here to + * avoid expanding scope of an already fairly large yak shave. + * + * @todo There's a **much more expansive** general case just waiting for a good + * opportuntity to prioritize it. E.g. a `NamedNodeMap`, where T is any + * generalized concept of a named node. This expansive generalization would have + * a ton of value in a variety of known performance optimization + * targets/solutions (i.e. optimizing the most redundant, suboptimal, frequently + * performed aspects of any typical XPath expression in a typical XForm). + * + * @see {@link QualifiedName} for more detail. + */ +export class RootAttributeMap extends Map { + static from(root: RootDefinition, domRootSourceElement: Element) { + const domAttrs = Array.from(domRootSourceElement.attributes); + const nonNamespaceDeclarationDOMAttrs = domAttrs.filter(isNonNamespaceDeclarationDOMAttr); + const attributes = nonNamespaceDeclarationDOMAttrs.map((source) => { + return new RootAttributeDefinition(root, source); + }); + + return new this(attributes); + } + + private constructor(attributes: readonly RootAttributeDefinition[]) { + super( + attributes.map((attribute) => { + return [attribute.qualifiedName, attribute]; + }) + ); + } +} diff --git a/packages/xforms-engine/src/parse/model/RootDefinition.ts b/packages/xforms-engine/src/parse/model/RootDefinition.ts index 1c710de13..a1cdb72af 100644 --- a/packages/xforms-engine/src/parse/model/RootDefinition.ts +++ b/packages/xforms-engine/src/parse/model/RootDefinition.ts @@ -1,3 +1,5 @@ +import { NamespaceDeclarationMap } from '../../lib/names/NamespaceDeclarationMap.ts'; +import { QualifiedName } from '../../lib/names/QualifiedName.ts'; import type { BodyClassList } from '../body/BodyDefinition.ts'; import type { XFormDefinition } from '../XFormDefinition.ts'; import type { FormSubmissionDefinition } from './FormSubmissionDefinition.ts'; @@ -8,19 +10,17 @@ import { NodeDefinition } from './NodeDefinition.ts'; import { NoteNodeDefinition } from './NoteNodeDefinition.ts'; import { RangeNodeDefinition } from './RangeNodeDefinition.ts'; import { RepeatRangeDefinition } from './RepeatRangeDefinition.ts'; -import { RootAttributeDefinition } from './RootAttributeDefinition.ts'; -import type { RootNamespaceDeclaration } from './RootNamespaceDeclaration.ts'; -import { RootNamespaceDeclarations } from './RootNamespaceDeclarations.ts'; +import { RootAttributeMap } from './RootAttributeMap.ts'; import { SubtreeDefinition } from './SubtreeDefinition.ts'; export class RootDefinition extends NodeDefinition<'root'> { readonly type = 'root'; - readonly nodeName: string; + readonly qualifiedName: QualifiedName; readonly bodyElement = null; readonly root = this; readonly parent = null; - readonly namespaceDeclarations: readonly RootNamespaceDeclaration[]; - readonly attributes: readonly RootAttributeDefinition[]; + readonly namespaceDeclarations: NamespaceDeclarationMap; + readonly attributes: RootAttributeMap; readonly children: readonly ChildNodeDefinition[]; readonly instances = null; readonly node: Element; @@ -34,20 +34,14 @@ export class RootDefinition extends NodeDefinition<'root'> { readonly submission: FormSubmissionDefinition, readonly classes: BodyClassList ) { + const { primaryInstanceRoot } = form.xformDOM; + const qualifiedName = new QualifiedName(primaryInstanceRoot); + const nodeName = qualifiedName.getPrefixedName(); + // TODO: theoretically the pertinent step in the bind's `nodeset` *could* be // namespaced. It also may make more sense to determine the root nodeset // earlier (i.e. in the appropriate definition class). - // - // TODO: while it's unlikely a form actually defines a for the root, - // if it did, bind nodesets are not yet normalized, so `/root` may currently - // be defined as `/ root` (or even `/ *` or any other valid expression - // resolving to the root). - const { primaryInstanceRoot } = form.xformDOM; - const { localName: rootNodeName } = primaryInstanceRoot; - - const nodeName = rootNodeName; - - const nodeset = `/${rootNodeName}`; + const nodeset = `/${nodeName}`; const bind = model.binds.get(nodeset); if (bind == null) { @@ -56,17 +50,10 @@ export class RootDefinition extends NodeDefinition<'root'> { super(bind); - this.nodeName = nodeName; + this.qualifiedName = qualifiedName; this.node = primaryInstanceRoot; - - const attributes = Array.from(primaryInstanceRoot.attributes).map((attr) => { - return new RootAttributeDefinition(attr); - }); - const namespaceDeclarationMap = new RootNamespaceDeclarations(primaryInstanceRoot, attributes); - - this.attributes = attributes; - this.namespaceDeclarations = Array.from(namespaceDeclarationMap.values()); - + this.attributes = RootAttributeMap.from(this, primaryInstanceRoot); + this.namespaceDeclarations = new NamespaceDeclarationMap(this); this.children = this.buildSubtree(this); } @@ -80,13 +67,13 @@ export class RootDefinition extends NodeDefinition<'root'> { const childrenByName = new Map(); for (const child of node.children) { - const { localName } = child; + const { nodeName } = child; - let elements = childrenByName.get(localName); + let elements = childrenByName.get(nodeName); if (elements == null) { elements = [child]; - childrenByName.set(localName, elements); + childrenByName.set(nodeName, elements); } else { // TODO: check if previous element exists, was it previous element // sibling. Highly likely this should otherwise fail! @@ -94,8 +81,8 @@ export class RootDefinition extends NodeDefinition<'root'> { } } - return Array.from(childrenByName).map(([localName, children]) => { - const nodeset = `${parentNodeset}/${localName}`; + return Array.from(childrenByName).map(([nodeName, children]) => { + const nodeset = `${parentNodeset}/${nodeName}`; const bind = binds.getOrCreateBindDefinition(nodeset); const bodyElement = body.getBodyElement(nodeset); const [firstChild, ...restChildren] = children; diff --git a/packages/xforms-engine/src/parse/model/RootNamespaceDeclaration.ts b/packages/xforms-engine/src/parse/model/RootNamespaceDeclaration.ts deleted file mode 100644 index b4803c437..000000000 --- a/packages/xforms-engine/src/parse/model/RootNamespaceDeclaration.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { XMLNS_PREFIX } from '@getodk/common/constants/xmlns.ts'; -import { escapeXMLText } from '../../lib/xml-serialization.ts'; - -export class RootNamespaceDeclaration { - private readonly serializedXML: string; - - constructor( - readonly prefix: string | null, - readonly namespaceURI: string | null - ) { - if (prefix == null) { - // We intentionally omit the default namespace declaration, which is - // consistent with both Collect and Enketo. Including it may technically - // be more "correct", but consistency ensures we don't introduce subtle - // problems in any namespace-aware usage downstream. - this.serializedXML = ''; - } else { - const name = `${XMLNS_PREFIX}:${prefix}`; - const value = escapeXMLText(namespaceURI ?? '', true); - - this.serializedXML = ` ${name}="${value}"`; - } - } - - serializeNamespaceDeclarationXML(): string { - return this.serializedXML; - } -} diff --git a/packages/xforms-engine/src/parse/model/RootNamespaceDeclarations.ts b/packages/xforms-engine/src/parse/model/RootNamespaceDeclarations.ts deleted file mode 100644 index 82e54cffc..000000000 --- a/packages/xforms-engine/src/parse/model/RootNamespaceDeclarations.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { XMLNS_NAMESPACE_URI, XMLNS_PREFIX } from '@getodk/common/constants/xmlns.ts'; -import type { RootAttributeDefinition } from './RootAttributeDefinition.ts'; -import { RootNamespaceDeclaration } from './RootNamespaceDeclaration.ts'; - -export class RootNamespaceDeclarations extends Map { - constructor(sourceElement: Element, attributes: readonly RootAttributeDefinition[]) { - const { prefix: elementPrefix, namespaceURI: elementNamespaceURI } = sourceElement; - - super([[elementPrefix, new RootNamespaceDeclaration(elementPrefix, elementNamespaceURI)]]); - - this.set( - sourceElement.prefix, - new RootNamespaceDeclaration(sourceElement.prefix, sourceElement.namespaceURI) - ); - - for (const attribute of attributes) { - const { namespaceURI, nodeName, prefix, localName, value } = attribute; - - // Attribute **IS** a namespace declaration. See commentary on - // `RootAttributeDefinition`. - if (namespaceURI === XMLNS_NAMESPACE_URI) { - // If the nodeName is `xmlns`, the attribute is a **DEFAULT** - // namespace declaration (also known as the "null namespace"). In - // which case, the _declared prefix_ is `null`. - if (nodeName === XMLNS_PREFIX) { - this.set(null, new RootNamespaceDeclaration(null, value)); - } - // Otherwise, the declared prefix is the attribute node's _local name_, - // e.g. `xmlns:orx` is a declaration for the namespace prefix `orx`. - else { - this.set(null, new RootNamespaceDeclaration(localName, value)); - } - } else if (!this.has(prefix)) { - this.set(prefix, new RootNamespaceDeclaration(prefix, namespaceURI)); - } - } - } -} diff --git a/packages/xforms-engine/src/parse/model/SubtreeDefinition.ts b/packages/xforms-engine/src/parse/model/SubtreeDefinition.ts index 900aa7c9a..5d7425a02 100644 --- a/packages/xforms-engine/src/parse/model/SubtreeDefinition.ts +++ b/packages/xforms-engine/src/parse/model/SubtreeDefinition.ts @@ -1,3 +1,5 @@ +import { NamespaceDeclarationMap } from '../../lib/names/NamespaceDeclarationMap.ts'; +import { QualifiedName } from '../../lib/names/QualifiedName.ts'; import type { AnyBodyElementDefinition, AnyGroupElementDefinition, @@ -12,7 +14,8 @@ export class SubtreeDefinition extends DescendentNodeDefinition< > { readonly type = 'subtree'; - readonly nodeName: string; + readonly namespaceDeclarations: NamespaceDeclarationMap; + readonly qualifiedName: QualifiedName; readonly children: readonly ChildNodeDefinition[]; readonly instances = null; readonly defaultValue = null; @@ -34,7 +37,8 @@ export class SubtreeDefinition extends DescendentNodeDefinition< const { root } = parent; - this.nodeName = node.localName; + this.qualifiedName = new QualifiedName(node); + this.namespaceDeclarations = new NamespaceDeclarationMap(this); this.children = root.buildSubtree(this); } diff --git a/packages/xforms-engine/src/parse/shared/parseStaticDocumentFromDOMSubtree.ts b/packages/xforms-engine/src/parse/shared/parseStaticDocumentFromDOMSubtree.ts index f79b697ea..73f600d74 100644 --- a/packages/xforms-engine/src/parse/shared/parseStaticDocumentFromDOMSubtree.ts +++ b/packages/xforms-engine/src/parse/shared/parseStaticDocumentFromDOMSubtree.ts @@ -8,9 +8,9 @@ import type { import type { StaticElementAttributesFactory, StaticElementChildNodesFactory, + StaticElementOptions, } from '../../integration/xpath/static-dom/StaticElement.ts'; import { StaticElement } from '../../integration/xpath/static-dom/StaticElement.ts'; -import type { StaticNamedNodeOptions } from '../../integration/xpath/static-dom/StaticNamedNode.ts'; import type { StaticParentNode } from '../../integration/xpath/static-dom/StaticNode.ts'; import { StaticText } from '../../integration/xpath/static-dom/StaticText.ts'; @@ -115,12 +115,12 @@ export type StaticElementConstructor< parent: Parent, attributesFactory: StaticElementAttributesFactory, childNodesFactory: StaticElementChildNodesFactory, - options: StaticNamedNodeOptions + options: StaticElementOptions ): T; }; const parseStaticElementFromDOMElement = < - T extends StaticElement, + T extends StaticElement, Parent extends StaticParentNode = StaticParentNode, >( parent: Parent, diff --git a/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts b/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts index 480d502bb..d19ff5372 100644 --- a/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts +++ b/packages/xforms-engine/test/parse/model/ModelDefinition.test.ts @@ -35,12 +35,16 @@ describe('ModelDefinition', () => { `root id="model-definition"`, t('first-question'), t('second-question'), - t('third-question') + t('third-question'), + // prettier-ignore + t('orx:meta', + t('orx:instanceID')) ) ), bind('/root/first-question').type('string'), bind('/root/second-question').type('string'), - bind('/root/third-question').type('string') + bind('/root/third-question').type('string'), + bind('/root/orx:meta/orx:instanceID').preload('uid') ) ), // prettier-ignore @@ -96,6 +100,21 @@ describe('ModelDefinition', () => { }, children: null, }, + { + type: 'subtree', + bind: { + nodeset: '/root/orx:meta', + }, + children: [ + { + type: 'leaf-node', + bind: { + nodeset: '/root/orx:meta/orx:instanceID', + }, + children: null, + }, + ], + }, ], }); });