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,
+ },
+ ],
+ },
],
});
});