From b576ec4bd587baef7b6922cf0d5c84d7c23af4de Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Tue, 21 Jan 2025 15:23:36 -0800 Subject: [PATCH] engine (fix): include namespace declarations in submission XML --- .changeset/healthy-jobs-look.md | 7 ++ .../ComparableXMLSerialization.ts | 74 +++++++++++++------ packages/scenario/test/submission.test.ts | 54 +++++++------- .../submission/createRootSubmissionState.ts | 4 +- .../src/lib/xml-serialization.ts | 47 ++++++------ .../parse/model/RootAttributeDefinition.ts | 46 +++++++++--- .../src/parse/model/RootDefinition.ts | 11 ++- .../parse/model/RootNamespaceDeclaration.ts | 29 ++++++++ .../parse/model/RootNamespaceDeclarations.ts | 38 ++++++++++ 9 files changed, 230 insertions(+), 80 deletions(-) create mode 100644 .changeset/healthy-jobs-look.md create mode 100644 packages/xforms-engine/src/parse/model/RootNamespaceDeclaration.ts create mode 100644 packages/xforms-engine/src/parse/model/RootNamespaceDeclarations.ts diff --git a/.changeset/healthy-jobs-look.md b/.changeset/healthy-jobs-look.md new file mode 100644 index 000000000..631ab7dc3 --- /dev/null +++ b/.changeset/healthy-jobs-look.md @@ -0,0 +1,7 @@ +--- +'@getodk/xforms-engine': patch +'@getodk/web-forms': patch +'@getodk/scenario': patch +--- + +Fix: include namespace declarations in submission XML diff --git a/packages/scenario/src/serialization/ComparableXMLSerialization.ts b/packages/scenario/src/serialization/ComparableXMLSerialization.ts index e61719ad7..56906fe47 100644 --- a/packages/scenario/src/serialization/ComparableXMLSerialization.ts +++ b/packages/scenario/src/serialization/ComparableXMLSerialization.ts @@ -1,4 +1,8 @@ -import { XFORMS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import { + XFORMS_NAMESPACE_URI, + XMLNS_NAMESPACE_URI, + XMLNS_PREFIX, +} from '@getodk/common/constants/xmlns.ts'; import { InspectableComparisonError } from '@getodk/common/test/assertions/helpers.ts'; import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts'; import { ComparableAssertableValue } from '../comparable/ComparableAssertableValue.ts'; @@ -8,38 +12,53 @@ class ComparableXMLQualifiedName { constructor( readonly namespaceURI: string | null, + readonly nodeName: string, readonly localName: string ) { - this.sortKey = JSON.stringify({ namespaceURI, localName }); + let namespaceDeclarationType: string; + + if (namespaceURI === XMLNS_NAMESPACE_URI) { + if (nodeName === XMLNS_PREFIX) { + namespaceDeclarationType = 'default'; + } else { + namespaceDeclarationType = 'non-default'; + } + } else { + namespaceDeclarationType = 'none'; + } + + this.sortKey = JSON.stringify({ + namespaceDeclarationType, + namespaceURI, + localName, + }); } - /** - * @todo prefix re-serialization - */ toString(): string { - const { namespaceURI } = this; + const { namespaceURI, nodeName } = this; if (namespaceURI == null || namespaceURI === XFORMS_NAMESPACE_URI) { return this.localName; } - return this.sortKey; + return nodeName; } } class ComparableXMLAttribute { static from(attr: Attr): ComparableXMLAttribute { - return new this(attr.namespaceURI, attr.localName, attr.value); + return new this(attr.namespaceURI, attr.nodeName, attr.localName, attr.value); } readonly qualifiedName: ComparableXMLQualifiedName; private constructor( namespaceURI: string | null, + nodeName: string, localName: string, readonly value: string ) { - this.qualifiedName = new ComparableXMLQualifiedName(namespaceURI, localName); + this.qualifiedName = new ComparableXMLQualifiedName(namespaceURI, nodeName, localName); } /** @@ -59,17 +78,23 @@ const comparableXMLElementAttributes = (element: Element): readonly ComparableXM return ComparableXMLAttribute.from(attr); }); - return attributes.sort(({ qualifiedName: a }, { qualifiedName: b }) => { - if (a > b) { - return 1; + return attributes.sort( + ( + // prettier-ignore + { qualifiedName: { sortKey: a } }, + { qualifiedName: { sortKey: b } } + ) => { + if (a > b) { + return 1; + } + + if (b > a) { + return -1; + } + + return 0; } - - if (b > a) { - return -1; - } - - return 0; - }); + ); }; const isElement = (node: ChildNode): node is Element => { @@ -118,18 +143,25 @@ class ComparableXMLElement { const attributes = comparableXMLElementAttributes(element); const children = comparableXMLElementChildren(element); - return new this(element.namespaceURI, element.localName, attributes, children); + return new this( + element.namespaceURI, + element.nodeName, + element.localName, + attributes, + children + ); } readonly qualifiedName: ComparableXMLQualifiedName; private constructor( namespaceURI: string | null, + nodeName: string, localName: string, readonly attributes: readonly ComparableXMLAttribute[], readonly children: readonly ComparableXMLElementChild[] ) { - this.qualifiedName = new ComparableXMLQualifiedName(namespaceURI, localName); + this.qualifiedName = new ComparableXMLQualifiedName(namespaceURI, nodeName, localName); } toString(): string { diff --git a/packages/scenario/test/submission.test.ts b/packages/scenario/test/submission.test.ts index 8f27d2768..996ce426f 100644 --- a/packages/scenario/test/submission.test.ts +++ b/packages/scenario/test/submission.test.ts @@ -1,3 +1,7 @@ +import { + OPENROSA_XFORMS_NAMESPACE_URI, + XFORMS_NAMESPACE_URI, +} from '@getodk/common/constants/xmlns.ts'; import { bind, body, @@ -141,7 +145,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t('data id="xml-serialization-basic-default-values"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="xml-serialization-basic-default-values"`, t('grp', t('inp', defaults.inp ?? ''), t('sel1', defaults.sel1 ?? ''), @@ -186,7 +190,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t(`data id="${formId}" version="${version}"`, + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="${formId}" version="${version}"`, t('inp', 'val'), t('meta', t('instanceID', DEFAULT_INSTANCE_ID))).asXml() @@ -222,7 +226,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t(`data id="${formId}" orx:version="${version}"`, + t(`data xmlns="${XFORMS_NAMESPACE_URI}" xmlns:orx="${OPENROSA_XFORMS_NAMESPACE_URI}" id="${formId}" orx:version="${version}"`, t('inp', 'val'), t('meta', t('instanceID', DEFAULT_INSTANCE_ID))).asXml() @@ -258,7 +262,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t(`data id="${formId}" orx:version="${version}"`, + t(`data xmlns="${XFORMS_NAMESPACE_URI}" xmlns:orx="${OPENROSA_XFORMS_NAMESPACE_URI}" id="${formId}" orx:version="${version}"`, t('inp', 'val'), t('meta', t('instanceID', DEFAULT_INSTANCE_ID))).asXml() @@ -312,7 +316,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t('data id="unicode-normalization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="unicode-normalization"`, t('rep', t('inp', composed)), t('meta', @@ -327,7 +331,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t('data id="unicode-normalization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="unicode-normalization"`, t('rep', t('inp', composed)), t('meta', @@ -365,7 +369,7 @@ describe('Form submission', () => { it('does not serialize an element for a repeat range', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t('data id="xml-serialization-repeats"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="xml-serialization-repeats"`, t('meta', t('instanceID', DEFAULT_INSTANCE_ID))).asXml() ); @@ -379,7 +383,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t('data id="xml-serialization-repeats"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="xml-serialization-repeats"`, t('rep', t('inp', 'a')), t('rep', @@ -392,7 +396,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t('data id="xml-serialization-repeats"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="xml-serialization-repeats"`, t('rep', t('inp', 'b')), t('meta', @@ -443,7 +447,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t('data id="xml-serialization-relevance"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="xml-serialization-relevance"`, t('grp-rel', '1'), t('inp-rel', '0'), t('grp'), @@ -457,7 +461,7 @@ describe('Form submission', () => { expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore - t('data id="xml-serialization-relevance"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="xml-serialization-relevance"`, t('grp-rel', '0'), t('inp-rel', '1'), t('meta', @@ -510,7 +514,7 @@ describe('Form submission', () => { // Default serialization before any state change expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp')), @@ -525,7 +529,7 @@ describe('Form submission', () => { // After first value change expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp', `${i}`)), @@ -545,7 +549,7 @@ describe('Form submission', () => { // Default serialization before any state change expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp')), @@ -558,7 +562,7 @@ describe('Form submission', () => { // First repeat instance added expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp')), @@ -573,7 +577,7 @@ describe('Form submission', () => { // Second repeat instance added expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp')), @@ -592,7 +596,7 @@ describe('Form submission', () => { // Each of the above values set expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp', 'rep 1 inp')), @@ -609,7 +613,7 @@ describe('Form submission', () => { // Last repeat instance removed expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp', 'rep 1 inp')), @@ -624,7 +628,7 @@ describe('Form submission', () => { // First repeat instance removed expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp', 'rep 2 inp')), @@ -637,7 +641,7 @@ describe('Form submission', () => { // All repeat instances removed expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('meta', t('instanceID', DEFAULT_INSTANCE_ID))).asXml() @@ -660,7 +664,7 @@ describe('Form submission', () => { // Current serialization before any relevance change expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel'), t('rep', t('inp', 'rep 1 inp')), @@ -677,7 +681,7 @@ describe('Form submission', () => { // Non-relevant /data/rep[position() != '1']/inp omitted expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel', '1'), t('rep', t('inp', 'rep 1 inp')), @@ -692,7 +696,7 @@ describe('Form submission', () => { // Non-relevant /data/rep[position() != '3']/inp omitted expect(serialized).toBe( // prettier-ignore - t('data id="reactive-xml-serialization"', + t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="reactive-xml-serialization"`, t('rep-inp-rel', '3'), t('rep'), t('rep'), @@ -842,7 +846,7 @@ describe('Form submission', () => { expect(scenario.getValidationOutcome().outcome).toBe(ANSWER_OK); // prettier-ignore - validSubmissionXML = t('data id="prepare-for-submission"', + validSubmissionXML = t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="prepare-for-submission"`, t('rep', t('inp', 'rep 1 inp')), t('rep', @@ -879,7 +883,7 @@ describe('Form submission', () => { expect(scenario.getValidationOutcome().outcome).toBe(ANSWER_REQUIRED_BUT_EMPTY); // prettier-ignore - invalidSubmissionXML = t('data id="prepare-for-submission"', + invalidSubmissionXML = t(`data xmlns="${XFORMS_NAMESPACE_URI}" id="prepare-for-submission"`, t('rep', t('inp', 'rep 1 inp')), t('rep', 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 02b6394e5..c20a9d246 100644 --- a/packages/xforms-engine/src/lib/client-reactivity/submission/createRootSubmissionState.ts +++ b/packages/xforms-engine/src/lib/client-reactivity/submission/createRootSubmissionState.ts @@ -5,12 +5,14 @@ import { serializeParentElementXML } from '../../xml-serialization.ts'; export const createRootSubmissionState = (node: Root): SubmissionState => { return { get submissionXML() { + const { namespaceDeclarations, attributes } = node.definition; const serializedChildren = node.currentState.children.map((child) => { return child.submissionState.submissionXML; }); return serializeParentElementXML(node.definition.nodeName, serializedChildren, { - attributes: node.definition.attributes, + namespaceDeclarations, + attributes, }); }, }; diff --git a/packages/xforms-engine/src/lib/xml-serialization.ts b/packages/xforms-engine/src/lib/xml-serialization.ts index 08157319f..2da91748a 100644 --- a/packages/xforms-engine/src/lib/xml-serialization.ts +++ b/packages/xforms-engine/src/lib/xml-serialization.ts @@ -75,44 +75,51 @@ export const escapeXMLText = ( : (out as EscapedXMLText); }; -interface SerializableElementAttribute { - readonly nodeName: string; - readonly value: string; +interface SerializableNamespaceDeclaration { + serializeNamespaceDeclarationXML(): string; } -const serializeAttributeXML = (attribute: SerializableElementAttribute) => { - return ` ${attribute.nodeName}="${escapeXMLText(attribute.value, true)}"`; -}; +interface SerializableElementAttribute { + serializeAttributeXML(): string; +} -interface SerializeElementXMLOptions { - readonly attributes: readonly SerializableElementAttribute[]; +interface ElementXMLSerializationOptions { + readonly namespaceDeclarations?: readonly SerializableNamespaceDeclaration[]; + readonly attributes?: readonly SerializableElementAttribute[]; } const serializeElementXML = ( nodeName: string, children: string, - options: SerializeElementXMLOptions + options: ElementXMLSerializationOptions = {} ): string => { - const attributes = options.attributes.map(serializeAttributeXML).join(''); + const namespaceDeclarations = + options.namespaceDeclarations + ?.map((namespaceDeclaration) => { + return namespaceDeclaration.serializeNamespaceDeclarationXML(); + }) + .join('') ?? ''; + const attributes = + options.attributes + ?.map((attribute) => { + return attribute.serializeAttributeXML(); + }) + .join('') ?? ''; + const prefix = `<${nodeName}${namespaceDeclarations}${attributes}`; if (children === '') { - return `<${nodeName}${attributes}/>`; + return `${prefix}/>`; } - // TODO: attributes - return `<${nodeName}${attributes}>${children}`; + return `${prefix}>${children}`; }; -export type ElementXMLSerializationOptions = Partial; - export const serializeParentElementXML = ( nodeName: string, serializedChildren: readonly string[], options?: ElementXMLSerializationOptions ): string => { - return serializeElementXML(nodeName, serializedChildren.join(''), { - attributes: options?.attributes ?? [], - }); + return serializeElementXML(nodeName, serializedChildren.join(''), options); }; export const serializeLeafElementXML = ( @@ -120,7 +127,5 @@ export const serializeLeafElementXML = ( xmlValue: EscapedXMLText, options?: ElementXMLSerializationOptions ): string => { - return serializeElementXML(nodeName, xmlValue.normalize(), { - attributes: options?.attributes ?? [], - }); + return serializeElementXML(nodeName, xmlValue.normalize(), options); }; diff --git a/packages/xforms-engine/src/parse/model/RootAttributeDefinition.ts b/packages/xforms-engine/src/parse/model/RootAttributeDefinition.ts index ee50ef67b..5a07d0f3c 100644 --- a/packages/xforms-engine/src/parse/model/RootAttributeDefinition.ts +++ b/packages/xforms-engine/src/parse/model/RootAttributeDefinition.ts @@ -1,6 +1,11 @@ +import { XMLNS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts'; +import { escapeXMLText } from '../../lib/xml-serialization.ts'; + interface RootAttributeSource { - /** @see {@link RootAttributeDefinition.nodeName} */ + readonly namespaceURI: string | null; readonly nodeName: string; + readonly prefix: string | null; + readonly localName: string; readonly value: string; } @@ -8,21 +13,40 @@ 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 { - /** - * Note: this parameter/property is named and typed to emphasize the fact - * that its source is the **prefixed name** of the attribute (e.g. as a - * reference to {@link Attr.nodeName}, with the same semantics), as parsed - * from a form definition. At time of writing, we have decided that the - * safest way to handle such attributes is to preserve their namespace - * details **as authored**. - */ + private readonly serializedXML: string; + + readonly namespaceURI: string | null; readonly nodeName: string; + readonly prefix: string | null; + readonly localName: string; readonly value: string; constructor(source: RootAttributeSource) { - this.nodeName = source.nodeName; - this.value = source.value; + const { namespaceURI, nodeName, value } = source; + + this.namespaceURI = source.namespaceURI; + this.nodeName = nodeName; + this.prefix = source.prefix; + this.localName = source.localName; + this.value = value; + + // We serialize namespace declarations separately + if (namespaceURI === XMLNS_NAMESPACE_URI) { + this.serializedXML = ''; + } else { + this.serializedXML = ` ${nodeName}="${escapeXMLText(value, true)}"`; + } + } + + serializeAttributeXML(): string { + return this.serializedXML; } } diff --git a/packages/xforms-engine/src/parse/model/RootDefinition.ts b/packages/xforms-engine/src/parse/model/RootDefinition.ts index 9916b48ee..1c710de13 100644 --- a/packages/xforms-engine/src/parse/model/RootDefinition.ts +++ b/packages/xforms-engine/src/parse/model/RootDefinition.ts @@ -9,6 +9,8 @@ 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 { SubtreeDefinition } from './SubtreeDefinition.ts'; export class RootDefinition extends NodeDefinition<'root'> { @@ -17,6 +19,7 @@ export class RootDefinition extends NodeDefinition<'root'> { readonly bodyElement = null; readonly root = this; readonly parent = null; + readonly namespaceDeclarations: readonly RootNamespaceDeclaration[]; readonly attributes: readonly RootAttributeDefinition[]; readonly children: readonly ChildNodeDefinition[]; readonly instances = null; @@ -55,9 +58,15 @@ export class RootDefinition extends NodeDefinition<'root'> { this.nodeName = nodeName; this.node = primaryInstanceRoot; - this.attributes = Array.from(primaryInstanceRoot.attributes).map((attr) => { + + 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.children = this.buildSubtree(this); } diff --git a/packages/xforms-engine/src/parse/model/RootNamespaceDeclaration.ts b/packages/xforms-engine/src/parse/model/RootNamespaceDeclaration.ts new file mode 100644 index 000000000..c5759ea53 --- /dev/null +++ b/packages/xforms-engine/src/parse/model/RootNamespaceDeclaration.ts @@ -0,0 +1,29 @@ +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 + ) { + let serializedName: string; + + if (prefix == null) { + serializedName = XMLNS_PREFIX; + } else { + serializedName = `${XMLNS_PREFIX}:${prefix}`; + } + + const serializedValue = escapeXMLText(namespaceURI ?? '', true); + + this.serializedXML = ` ${serializedName}="${serializedValue}"`; + + this.prefix = prefix; + } + + 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 new file mode 100644 index 000000000..82e54cffc --- /dev/null +++ b/packages/xforms-engine/src/parse/model/RootNamespaceDeclarations.ts @@ -0,0 +1,38 @@ +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)); + } + } + } +}