This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git


The following commit(s) were added to refs/heads/master by this push:
     new cecd7eefcb Fix consumer POM serialization of prefixed XML attributes 
(fixes #11760)
cecd7eefcb is described below

commit cecd7eefcb14bd10dde22720fe5f9dee12608548
Author: Guillaume Nodet <[email protected]>
AuthorDate: Tue May 19 17:15:59 2026 +0200

    Fix consumer POM serialization of prefixed XML attributes (fixes #11760)
    
    During consumer POM transformation, namespace declarations like xmlns:mvn on
    <project> are lost (not part of the Maven model), but prefixed attributes 
like
    mvn:combine.children on XmlNode configuration trees survive, producing 
invalid
    XML with undeclared namespace prefixes.
    
    Fix by adding namespace context tracking to XmlNode: the parser now 
accumulates
    namespace declarations and propagates them to child nodes. The StAX and XPP3
    writers resolve prefixed attributes against local declarations first, then 
the
    inherited namespace context, auto-declaring namespaces as needed. Orphaned
    prefixes (no declaration, no context) are stripped as a last resort to 
ensure
    valid XML output.
    
    Changes:
    - Add XmlNode.namespaces() returning inherited prefix-to-URI bindings
    - Accumulate and propagate namespace context during parsing in 
DefaultXmlService
    - Fix writer-stax.vm and writer.vm to resolve and declare namespaces 
properly
    - Preserve dominant node's namespace context during merge
    - Add 28 tests covering parsing, writing, merging, and consumer POM 
simulation
---
 .../java/org/apache/maven/api/xml/XmlNode.java     |  38 +-
 .../maven/internal/xml/DefaultXmlService.java      | 141 +++-
 .../apache/maven/internal/xml/XmlNodeImplTest.java | 799 +++++++++++++++++++++
 src/mdo/writer-stax.vm                             |  59 +-
 src/mdo/writer.vm                                  |  54 +-
 5 files changed, 1050 insertions(+), 41 deletions(-)

diff --git 
a/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java 
b/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java
index a78357a091..a9634585d5 100644
--- a/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java
+++ b/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java
@@ -159,6 +159,24 @@ public interface XmlNode {
     @Nullable
     String attribute(@Nonnull String name);
 
+    /**
+     * Returns the namespace context for this node — a map of namespace prefix 
to URI
+     * for all namespace bindings in scope, including those declared on this 
element
+     * and those inherited from ancestor elements.
+     * <p>
+     * This is used by the write side to properly resolve prefixed attributes.
+     * For example, if an attribute {@code mvn:combine.children} exists on a 
child element
+     * but {@code xmlns:mvn} was declared on the root element, this map will 
contain
+     * the {@code mvn → http://maven.apache.org/POM/4.0.0} binding.
+     *
+     * @return map of namespace prefix to URI, never {@code null}
+     * @since 4.1.0
+     */
+    @Nonnull
+    default Map<String, String> namespaces() {
+        return Map.of();
+    }
+
     /**
      * Returns an immutable list of all child nodes.
      *
@@ -358,6 +376,7 @@ class Builder {
         private String namespaceUri;
         private String prefix;
         private Map<String, String> attributes;
+        private Map<String, String> namespaces;
         private List<XmlNode> children;
         private Object inputLocation;
 
@@ -421,6 +440,21 @@ public Builder attributes(Map<String, String> attributes) {
             return this;
         }
 
+        /**
+         * Sets the namespace context for this node.
+         * <p>
+         * This map contains all namespace prefix to URI bindings in scope,
+         * including inherited ones from ancestor elements.
+         *
+         * @param namespaces the map of namespace prefix to URI
+         * @return this builder instance
+         * @since 4.1.0
+         */
+        public Builder namespaces(Map<String, String> namespaces) {
+            this.namespaces = namespaces;
+            return this;
+        }
+
         /**
          * Sets the child nodes of the XML node.
          * <p>
@@ -454,7 +488,7 @@ public Builder inputLocation(Object inputLocation) {
          * @throws NullPointerException if name has not been set
          */
         public XmlNode build() {
-            return new Impl(prefix, namespaceUri, name, value, attributes, 
children, inputLocation);
+            return new Impl(prefix, namespaceUri, name, value, attributes, 
namespaces, children, inputLocation);
         }
 
         private record Impl(
@@ -463,6 +497,7 @@ private record Impl(
                 @Nonnull String name,
                 String value,
                 @Nonnull Map<String, String> attributes,
+                @Nonnull Map<String, String> namespaces,
                 @Nonnull List<XmlNode> children,
                 Object inputLocation)
                 implements XmlNode, Serializable {
@@ -473,6 +508,7 @@ private record Impl(
                 namespaceUri = namespaceUri == null ? "" : namespaceUri;
                 name = Objects.requireNonNull(name);
                 attributes = ImmutableCollections.copy(attributes);
+                namespaces = ImmutableCollections.copy(namespaces);
                 children = ImmutableCollections.copy(children);
             }
 
diff --git 
a/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java
 
b/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java
index 3516960d76..085bf46806 100644
--- 
a/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java
+++ 
b/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java
@@ -30,6 +30,7 @@
 import java.io.Writer;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -68,18 +69,23 @@ public XmlNode doRead(Reader reader, @Nullable 
XmlService.InputLocationBuilder l
     @Override
     public XmlNode doRead(XMLStreamReader parser, @Nullable 
XmlService.InputLocationBuilder locationBuilder)
             throws XMLStreamException {
-        return doBuild(parser, DEFAULT_TRIM, locationBuilder);
+        return doBuild(parser, DEFAULT_TRIM, locationBuilder, new HashMap<>());
     }
 
-    private XmlNode doBuild(XMLStreamReader parser, boolean trim, 
InputLocationBuilder locationBuilder)
+    private XmlNode doBuild(
+            XMLStreamReader parser,
+            boolean trim,
+            InputLocationBuilder locationBuilder,
+            Map<String, String> parentNamespaces)
             throws XMLStreamException {
         boolean spacePreserve = false;
-        String lPrefix = null;
-        String lNamespaceUri = null;
-        String lName = null;
-        String lValue = null;
+        String elementPrefix = null;
+        String elementNamespaceUri = null;
+        String elementName = null;
+        String elementValue = null;
         Object location = null;
         Map<String, String> attrs = null;
+        Map<String, String> nsContext = null;
         List<XmlNode> children = null;
         int eventType = parser.getEventType();
         int lastStartTag = -1;
@@ -87,54 +93,67 @@ private XmlNode doBuild(XMLStreamReader parser, boolean 
trim, InputLocationBuild
             if (eventType == XMLStreamReader.START_ELEMENT) {
                 lastStartTag = parser.getLocation().getLineNumber() * 1000
                         + parser.getLocation().getColumnNumber();
-                if (lName == null) {
+                // The first START_ELEMENT we encounter is "this" element;
+                // subsequent START_ELEMENTs are children, handled in the else 
branch.
+                if (elementName == null) {
                     int namespacesSize = parser.getNamespaceCount();
-                    lPrefix = parser.getPrefix();
-                    lNamespaceUri = parser.getNamespaceURI();
-                    lName = parser.getLocalName();
+                    elementPrefix = parser.getPrefix();
+                    elementNamespaceUri = parser.getNamespaceURI();
+                    elementName = parser.getLocalName();
                     location = locationBuilder != null ? 
locationBuilder.toInputLocation(parser) : null;
+                    // Build the namespace context: start with inherited, add 
local declarations.
+                    // The default namespace (empty prefix) is excluded 
because per the XML namespace
+                    // spec (Section 6.2), default namespace declarations do 
NOT apply to attributes.
+                    nsContext = new HashMap<>(parentNamespaces);
                     int attributesSize = parser.getAttributeCount();
                     if (attributesSize > 0 || namespacesSize > 0) {
                         attrs = new HashMap<>();
                         for (int i = 0; i < namespacesSize; i++) {
                             String nsPrefix = parser.getNamespacePrefix(i);
                             String nsUri = parser.getNamespaceURI(i);
-                            attrs.put(nsPrefix != null && !nsPrefix.isEmpty() 
? "xmlns:" + nsPrefix : "xmlns", nsUri);
+                            if (nsPrefix != null && !nsPrefix.isEmpty()) {
+                                nsContext.put(nsPrefix, nsUri);
+                                attrs.put("xmlns:" + nsPrefix, nsUri);
+                            } else {
+                                attrs.put("xmlns", nsUri);
+                            }
                         }
                         for (int i = 0; i < attributesSize; i++) {
-                            String aName = parser.getAttributeLocalName(i);
-                            String aValue = parser.getAttributeValue(i);
-                            String aPrefix = parser.getAttributePrefix(i);
-                            if (aPrefix != null && !aPrefix.isEmpty()) {
-                                aName = aPrefix + ":" + aName;
+                            String attrName = parser.getAttributeLocalName(i);
+                            String attrValue = parser.getAttributeValue(i);
+                            String attrPrefix = parser.getAttributePrefix(i);
+                            if (attrPrefix != null && !attrPrefix.isEmpty()) {
+                                attrName = attrPrefix + ":" + attrName;
                             }
-                            attrs.put(aName, aValue);
-                            spacePreserve = spacePreserve || 
("xml:space".equals(aName) && "preserve".equals(aValue));
+                            attrs.put(attrName, attrValue);
+                            spacePreserve =
+                                    spacePreserve || 
("xml:space".equals(attrName) && "preserve".equals(attrValue));
                         }
                     }
                 } else {
                     if (children == null) {
                         children = new ArrayList<>();
                     }
-                    XmlNode child = doBuild(parser, trim, locationBuilder);
+                    XmlNode child = doBuild(parser, trim, locationBuilder, 
nsContext);
                     children.add(child);
                 }
             } else if (eventType == XMLStreamReader.CHARACTERS || eventType == 
XMLStreamReader.CDATA) {
                 String text = parser.getText();
-                lValue = lValue != null ? lValue + text : text;
+                elementValue = elementValue != null ? elementValue + text : 
text;
             } else if (eventType == XMLStreamReader.END_ELEMENT) {
                 boolean emptyTag = lastStartTag
                         == parser.getLocation().getLineNumber() * 1000
                                 + parser.getLocation().getColumnNumber();
-                if (lValue != null && trim && !spacePreserve) {
-                    lValue = lValue.trim();
+                if (elementValue != null && trim && !spacePreserve) {
+                    elementValue = elementValue.trim();
                 }
                 return XmlNode.newBuilder()
-                        .prefix(lPrefix)
-                        .namespaceUri(lNamespaceUri)
-                        .name(lName)
-                        .value(children == null ? (lValue != null ? lValue : 
emptyTag ? null : "") : null)
+                        .prefix(elementPrefix)
+                        .namespaceUri(elementNamespaceUri)
+                        .name(elementName)
+                        .value(children == null ? (elementValue != null ? 
elementValue : emptyTag ? null : "") : null)
                         .attributes(attrs)
+                        .namespaces(nsContext)
                         .children(children)
                         .inputLocation(location)
                         .build();
@@ -162,9 +181,7 @@ public void doWrite(XmlNode node, Writer writer) throws 
IOException {
     private void writeNode(XMLStreamWriter xmlWriter, XmlNode node) throws 
XMLStreamException {
         xmlWriter.writeStartElement(node.prefix(), node.name(), 
node.namespaceUri());
 
-        for (Map.Entry<String, String> attr : node.attributes().entrySet()) {
-            xmlWriter.writeAttribute(attr.getKey(), attr.getValue());
-        }
+        writeAttributes(xmlWriter, node.attributes(), node.namespaces());
 
         for (XmlNode child : node.children()) {
             writeNode(xmlWriter, child);
@@ -178,6 +195,71 @@ private void writeNode(XMLStreamWriter xmlWriter, XmlNode 
node) throws XMLStream
         xmlWriter.writeEndElement();
     }
 
+    /**
+     * Writes XmlNode attributes, properly handling namespace declarations
+     * ({@code xmlns:prefix}) and prefixed attributes ({@code 
prefix:localName}).
+     * The namespace context is used to resolve prefixes when the {@code 
xmlns:}
+     * declaration is not present in the attribute map (e.g., it was declared 
on
+     * an ancestor element).
+     *
+     * @param xmlWriter the StAX writer
+     * @param attributes the attribute map (may contain xmlns: entries)
+     * @param namespaces the namespace context (prefix → URI) for resolving 
prefixed attributes
+     */
+    private static void writeAttributes(
+            XMLStreamWriter xmlWriter, Map<String, String> attributes, 
Map<String, String> namespaces)
+            throws XMLStreamException {
+        // Collect which namespace prefixes need to be declared on this 
element:
+        // start with those explicitly in attributes (xmlns:prefix), then add
+        // any prefixes used by attributes that are resolved from the 
namespace context
+        Set<String> declaredPrefixes = new HashSet<>();
+        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
+            String key = attribute.getKey();
+            if ("xmlns".equals(key)) {
+                xmlWriter.writeDefaultNamespace(attribute.getValue());
+            } else if (key.startsWith("xmlns:")) {
+                String prefix = key.substring(6);
+                xmlWriter.writeNamespace(prefix, attribute.getValue());
+                declaredPrefixes.add(prefix);
+            }
+        }
+        // Write prefixed attributes, declaring their namespace if needed
+        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
+            String key = attribute.getKey();
+            String value = attribute.getValue();
+            if ("xmlns".equals(key) || key.startsWith("xmlns:")) {
+                continue; // already written above
+            } else if (key.startsWith("xml:")) {
+                // The xml: prefix is predefined and bound to the XML 
namespace.
+                // It must not be declared, but attributes like xml:space 
still need
+                // to be written using the proper namespace URI.
+                
xmlWriter.writeAttribute("http://www.w3.org/XML/1998/namespace";, 
key.substring(4), value);
+            } else if (key.contains(":")) {
+                int colon = key.indexOf(':');
+                String prefix = key.substring(0, colon);
+                String localName = key.substring(colon + 1);
+                // Look up namespace URI: first from local xmlns: 
declarations, then from context
+                String nsUri = attributes.get("xmlns:" + prefix);
+                if (nsUri == null) {
+                    nsUri = namespaces.get(prefix);
+                }
+                if (nsUri != null) {
+                    // Declare the namespace if not already declared on this 
element
+                    if (declaredPrefixes.add(prefix)) {
+                        xmlWriter.writeNamespace(prefix, nsUri);
+                    }
+                    xmlWriter.writeAttribute(prefix, nsUri, localName, value);
+                } else {
+                    // No namespace declaration found for this prefix; write 
as unprefixed
+                    // to produce valid XML
+                    xmlWriter.writeAttribute(localName, value);
+                }
+            } else {
+                xmlWriter.writeAttribute(key, value);
+            }
+        }
+    }
+
     /**
      * Merges one DOM into another, given a specific algorithm and possible 
override points for that algorithm.<p>
      * The algorithm is as follows:
@@ -368,6 +450,7 @@ public XmlNode doMerge(XmlNode dominant, XmlNode recessive, 
Boolean childMergeOv
                             .name(dominant.name())
                             .value(value != null ? value : dominant.value())
                             .attributes(attrs)
+                            .namespaces(dominant.namespaces())
                             .children(children)
                             .inputLocation(location)
                             .build();
diff --git 
a/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java
 
b/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java
index e980517194..4a50e0e6ca 100644
--- 
a/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java
+++ 
b/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java
@@ -24,6 +24,7 @@
 import java.io.IOException;
 import java.io.Reader;
 import java.io.StringReader;
+import java.io.StringWriter;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -36,10 +37,13 @@
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class XmlNodeImplTest {
 
@@ -715,6 +719,801 @@ public Object toInputLocation(XMLStreamReader parser) {
         }
     }
 
+    // 
========================================================================================
+    // Namespace context - Parsing tests
+    // 
========================================================================================
+
+    @Test
+    void testParseNamespaceContextSinglePrefixOnRoot() throws Exception {
+        String xml = """
+                <root xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <child/>
+                </root>
+                """;
+        XmlNode node = toXmlNode(xml);
+        assertEquals("http://maven.apache.org/POM/4.0.0";, 
node.namespaces().get("mvn"));
+    }
+
+    @Test
+    void testParseNamespaceContextMultiplePrefixes() throws Exception {
+        String xml = """
+                <root xmlns:mvn="http://maven.apache.org/POM/4.0.0";
+                      xmlns:custom="http://example.com/custom";
+                      xmlns:other="http://example.com/other";>
+                    <child/>
+                </root>
+                """;
+        XmlNode node = toXmlNode(xml);
+        assertEquals(3, node.namespaces().size());
+        assertEquals("http://maven.apache.org/POM/4.0.0";, 
node.namespaces().get("mvn"));
+        assertEquals("http://example.com/custom";, 
node.namespaces().get("custom"));
+        assertEquals("http://example.com/other";, 
node.namespaces().get("other"));
+    }
+
+    @Test
+    void testParseNamespaceContextInheritedByChild() throws Exception {
+        String xml = """
+                <root xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <child mvn:combine.children="append"/>
+                </root>
+                """;
+        XmlNode node = toXmlNode(xml);
+        XmlNode child = node.child("child");
+        assertNotNull(child);
+        // Child inherits parent's namespace context
+        assertEquals("http://maven.apache.org/POM/4.0.0";, 
child.namespaces().get("mvn"));
+        // Child does NOT have xmlns:mvn in its own attributes
+        assertNull(child.attribute("xmlns:mvn"));
+    }
+
+    @Test
+    void testParseNamespaceContextInheritedAcrossThreeLevels() throws 
Exception {
+        String xml = """
+                <root xmlns:a="http://example.com/a";>
+                    <level1 xmlns:b="http://example.com/b";>
+                        <level2 a:x="1" b:y="2">
+                            <leaf/>
+                        </level2>
+                    </level1>
+                </root>
+                """;
+        XmlNode root = toXmlNode(xml);
+        XmlNode level1 = root.child("level1");
+        XmlNode level2 = level1.child("level2");
+        XmlNode leaf = level2.child("leaf");
+
+        // root has only "a"
+        assertEquals("http://example.com/a";, root.namespaces().get("a"));
+        assertNull(root.namespaces().get("b"));
+
+        // level1 has both "a" (inherited) and "b" (own)
+        assertEquals("http://example.com/a";, level1.namespaces().get("a"));
+        assertEquals("http://example.com/b";, level1.namespaces().get("b"));
+
+        // level2 inherits both
+        assertEquals("http://example.com/a";, level2.namespaces().get("a"));
+        assertEquals("http://example.com/b";, level2.namespaces().get("b"));
+
+        // leaf also inherits both
+        assertEquals("http://example.com/a";, leaf.namespaces().get("a"));
+        assertEquals("http://example.com/b";, leaf.namespaces().get("b"));
+    }
+
+    @Test
+    void testParseDefaultNamespaceNotInNamespacesMap() throws Exception {
+        String xml = """
+                <root xmlns="http://maven.apache.org/POM/4.0.0";>
+                    <child/>
+                </root>
+                """;
+        XmlNode node = toXmlNode(xml);
+        // Default namespace (no prefix) should NOT be in the namespaces map
+        // since namespaces() tracks prefix→URI bindings for resolving 
prefixed attributes
+        assertNull(node.namespaces().get(""));
+        assertNull(node.namespaces().get("xmlns"));
+        // The default namespace is stored as an attribute instead
+        assertEquals("http://maven.apache.org/POM/4.0.0";, 
node.attribute("xmlns"));
+    }
+
+    @Test
+    void testParseNamespaceContextChildOverridesPrefix() throws Exception {
+        String xml = """
+                <root xmlns:ns="http://example.com/original";>
+                    <child xmlns:ns="http://example.com/overridden"; 
ns:attr="val">
+                        <grandchild ns:attr2="val2"/>
+                    </child>
+                </root>
+                """;
+        XmlNode root = toXmlNode(xml);
+        XmlNode child = root.child("child");
+        XmlNode grandchild = child.child("grandchild");
+
+        // Root has original binding
+        assertEquals("http://example.com/original";, 
root.namespaces().get("ns"));
+        // Child overrides
+        assertEquals("http://example.com/overridden";, 
child.namespaces().get("ns"));
+        // Grandchild inherits the overridden version
+        assertEquals("http://example.com/overridden";, 
grandchild.namespaces().get("ns"));
+    }
+
+    @Test
+    void testParseNoNamespaceDeclarationsProducesEmptyMap() throws Exception {
+        String xml = "<root><child attr=\"value\"/></root>";
+        XmlNode root = toXmlNode(xml);
+        assertTrue(root.namespaces().isEmpty());
+        XmlNode child = root.child("child");
+        assertNotNull(child);
+        assertTrue(child.namespaces().isEmpty());
+    }
+
+    @Test
+    void testParseNamespacesMapIsImmutable() throws Exception {
+        String xml = """
+                <root xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <child/>
+                </root>
+                """;
+        XmlNode node = toXmlNode(xml);
+        assertThrows(
+                UnsupportedOperationException.class, () -> 
node.namespaces().put("foo", "bar"));
+    }
+
+    // 
========================================================================================
+    // Namespace context - Writing tests
+    // 
========================================================================================
+
+    @Test
+    void testWriteWithNamespaceDeclarationsAndPrefixedAttributes() throws 
Exception {
+        String xml = """
+                <project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <compilerArgs mvn:combine.children="append">
+                        <arg>-Xlint:deprecation</arg>
+                    </compilerArgs>
+                </project>
+                """;
+
+        XmlNode node = toXmlNode(xml);
+        assertEquals("http://maven.apache.org/POM/4.0.0";, 
node.attribute("xmlns:mvn"));
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        XmlNode reRead = toXmlNode(output);
+        assertNotNull(reRead);
+    }
+
+    @Test
+    void testWriteStripsOrphanedPrefixOnAttributes() throws Exception {
+        XmlNode node = XmlNode.newBuilder()
+                .name("compilerArgs")
+                .attributes(Map.of("mvn:combine.children", "append"))
+                .children(List.of(XmlNode.newBuilder()
+                        .name("arg")
+                        .value("-Xlint:deprecation")
+                        .build()))
+                .build();
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        assertFalse(output.contains("mvn:combine"), "Output should not contain 
orphaned mvn: prefix");
+        assertTrue(output.contains("combine.children=\"append\""), "Attribute 
should be written unprefixed");
+
+        XmlNode reRead = toXmlNode(output);
+        assertNotNull(reRead);
+        assertEquals("append", reRead.attribute("combine.children"));
+    }
+
+    @Test
+    void testWriteForeignNamespaceAttributeRoundTrip() throws Exception {
+        XmlNode node = XmlNode.newBuilder()
+                .name("compilerArgs")
+                .attributes(Map.of(
+                        "xmlns:custom", "http://example.com/custom";,
+                        "custom:myattr", "value"))
+                .children(List.of(XmlNode.newBuilder()
+                        .name("arg")
+                        .value("-Xlint:deprecation")
+                        .build()))
+                .build();
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        XmlNode reRead = toXmlNode(output);
+        assertNotNull(reRead);
+        assertEquals("value", reRead.attribute("custom:myattr"));
+        assertEquals("http://example.com/custom";, 
reRead.attribute("xmlns:custom"));
+    }
+
+    @Test
+    void testWritePreservesPrefixFromInheritedNamespaceContext() throws 
Exception {
+        String xml = """
+                <project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:custom="http://example.com/custom";>
+                    <compilerArgs custom:myattr="value">
+                        <arg>-Xlint:deprecation</arg>
+                    </compilerArgs>
+                </project>
+                """;
+
+        XmlNode node = toXmlNode(xml);
+        XmlNode compilerArgs = node.child("compilerArgs");
+        assertNotNull(compilerArgs);
+        assertEquals("value", compilerArgs.attribute("custom:myattr"));
+        assertNull(compilerArgs.attribute("xmlns:custom"), "xmlns:custom 
should be on parent, not child");
+        assertEquals("http://example.com/custom";, 
compilerArgs.namespaces().get("custom"));
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(compilerArgs, writer);
+        String output = writer.toString();
+
+        XmlNode reRead = toXmlNode(output);
+        assertNotNull(reRead);
+        assertEquals("value", reRead.attribute("custom:myattr"));
+    }
+
+    @Test
+    void testWriteStripsOrphanedPrefixWithoutNamespaceContext() throws 
Exception {
+        XmlNode node = XmlNode.newBuilder()
+                .name("compilerArgs")
+                .attributes(Map.of("mvn:combine.children", "append"))
+                .children(List.of(XmlNode.newBuilder()
+                        .name("arg")
+                        .value("-Xlint:deprecation")
+                        .build()))
+                .build();
+
+        assertTrue(node.namespaces().isEmpty(), "No namespace context");
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        assertFalse(output.contains("mvn:combine"), "Output should not contain 
orphaned mvn: prefix");
+        assertTrue(output.contains("combine.children=\"append\""), "Attribute 
should be written unprefixed");
+
+        XmlNode reRead = toXmlNode(output);
+        assertNotNull(reRead);
+        assertEquals("append", reRead.attribute("combine.children"));
+    }
+
+    @Test
+    void testWriteMultiplePrefixedAttributesFromDifferentNamespaces() throws 
Exception {
+        String xml = """
+                <root xmlns:a="http://example.com/a"; 
xmlns:b="http://example.com/b";>
+                    <child a:x="1" b:y="2"/>
+                </root>
+                """;
+        XmlNode root = toXmlNode(xml);
+        XmlNode child = root.child("child");
+        assertNotNull(child);
+
+        // Write only the child (which has prefixed attrs but no local xmlns:)
+        StringWriter writer = new StringWriter();
+        XmlService.write(child, writer);
+        String output = writer.toString();
+
+        // Both namespace declarations should be auto-declared
+        assertTrue(output.contains("xmlns:a="), "Should auto-declare xmlns:a");
+        assertTrue(output.contains("xmlns:b="), "Should auto-declare xmlns:b");
+
+        // Round-trip should preserve attributes
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("1", reRead.attribute("a:x"));
+        assertEquals("2", reRead.attribute("b:y"));
+    }
+
+    @Test
+    void testWriteLocalXmlnsOverridesNamespaceContext() throws Exception {
+        // Build a node where the local attribute has xmlns:ns with one URI
+        // but the namespace context has a different URI for the same prefix.
+        // The local declaration should win.
+        XmlNode node = XmlNode.newBuilder()
+                .name("elem")
+                .attributes(Map.of(
+                        "xmlns:ns", "http://example.com/local";,
+                        "ns:attr", "value"))
+                .namespaces(Map.of("ns", "http://example.com/context";))
+                .build();
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        // The local xmlns:ns should be used, not the one from context
+        assertTrue(output.contains("http://example.com/local";), "Local xmlns: 
should take precedence");
+
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("value", reRead.attribute("ns:attr"));
+        assertEquals("http://example.com/local";, reRead.attribute("xmlns:ns"));
+    }
+
+    @Test
+    void testWriteXmlSpaceAttributeRoundTrip() throws Exception {
+        String xml = """
+                <root xml:space="preserve">  content with spaces  </root>
+                """;
+        XmlNode node = toXmlNode(xml);
+        assertEquals("preserve", node.attribute("xml:space"));
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        // xml: prefix should be handled without explicit declaration
+        assertFalse(output.contains("xmlns:xml"), "xml: prefix must not be 
declared");
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("preserve", reRead.attribute("xml:space"));
+        assertEquals("  content with spaces  ", reRead.value());
+    }
+
+    @Test
+    void testWriteUnprefixedAttributeUnchanged() throws Exception {
+        XmlNode node = XmlNode.newBuilder()
+                .name("elem")
+                .attributes(Map.of("simple", "value", "another", "val2"))
+                .build();
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("value", reRead.attribute("simple"));
+        assertEquals("val2", reRead.attribute("another"));
+    }
+
+    @Test
+    void testWriteNamespaceNotDeclaredTwice() throws Exception {
+        // When xmlns:mvn is both in attributes AND namespace context,
+        // it should only be declared once
+        XmlNode node = XmlNode.newBuilder()
+                .name("elem")
+                .attributes(Map.of(
+                        "xmlns:mvn", "http://maven.apache.org/POM/4.0.0";,
+                        "mvn:combine.children", "append"))
+                .namespaces(Map.of("mvn", "http://maven.apache.org/POM/4.0.0";))
+                .build();
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        // Count occurrences of xmlns:mvn - should be exactly 1
+        int count = 0;
+        int idx = 0;
+        while ((idx = output.indexOf("xmlns:mvn", idx)) != -1) {
+            count++;
+            idx += "xmlns:mvn".length();
+        }
+        assertEquals(1, count, "xmlns:mvn should be declared exactly once");
+
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("append", reRead.attribute("mvn:combine.children"));
+    }
+
+    @Test
+    void testWriteChildInheritsContextAndWritesStandalone() throws Exception {
+        // Parse a 3-level structure, then write the grandchild standalone
+        String xml = """
+                <root xmlns:a="http://example.com/a";>
+                    <mid xmlns:b="http://example.com/b";>
+                        <leaf a:x="1" b:y="2" plain="3"/>
+                    </mid>
+                </root>
+                """;
+        XmlNode root = toXmlNode(xml);
+        XmlNode leaf = root.child("mid").child("leaf");
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(leaf, writer);
+        String output = writer.toString();
+
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("1", reRead.attribute("a:x"));
+        assertEquals("2", reRead.attribute("b:y"));
+        assertEquals("3", reRead.attribute("plain"));
+    }
+
+    // 
========================================================================================
+    // Namespace context - Merge tests
+    // 
========================================================================================
+
+    @Test
+    void testMergePreservesDominantNamespaces() throws Exception {
+        String dominant = """
+                <root xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <child mvn:combine.children="append">
+                        <item>dom</item>
+                    </child>
+                </root>
+                """;
+        String recessive = """
+                <root>
+                    <child>
+                        <item>rec</item>
+                    </child>
+                </root>
+                """;
+        XmlNode merged = XmlService.merge(toXmlNode(dominant), 
toXmlNode(recessive));
+
+        // The merged root should keep dominant's namespace context
+        assertEquals("http://maven.apache.org/POM/4.0.0";, 
merged.namespaces().get("mvn"));
+
+        // The merged child should also have the namespace context
+        XmlNode child = merged.child("child");
+        assertNotNull(child);
+        assertEquals("http://maven.apache.org/POM/4.0.0";, 
child.namespaces().get("mvn"));
+    }
+
+    @Test
+    void testMergeCombineChildrenAppendPreservesNamespaces() throws Exception {
+        String dominant = """
+                <root xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <items combine.children="append">
+                        <item>a</item>
+                    </items>
+                </root>
+                """;
+        String recessive = """
+                <root xmlns="http://maven.apache.org/POM/4.0.0";>
+                    <items>
+                        <item>b</item>
+                    </items>
+                </root>
+                """;
+        XmlNode merged = XmlService.merge(toXmlNode(dominant), 
toXmlNode(recessive));
+        XmlNode items = merged.child("items");
+
+        assertEquals(2, items.children().size(), "append should merge 
children");
+        // Namespace context should be preserved on the merged element
+        assertEquals("http://maven.apache.org/POM/4.0.0";, 
items.namespaces().get("mvn"));
+    }
+
+    @Test
+    void testMergeCombineSelfOverridePreservesNamespaces() throws Exception {
+        String dominant = """
+                <root xmlns:ns="http://example.com/ns";>
+                    <child combine.self="override" ns:attr="dominant">
+                        <item>dom</item>
+                    </child>
+                </root>
+                """;
+        String recessive = """
+                <root>
+                    <child>
+                        <item>rec1</item>
+                        <item>rec2</item>
+                    </child>
+                </root>
+                """;
+        XmlNode merged = XmlService.merge(toXmlNode(dominant), 
toXmlNode(recessive));
+        XmlNode child = merged.child("child");
+
+        // override means dominant completely replaces recessive
+        assertEquals(1, child.children().size());
+        assertEquals("dom", child.children().get(0).value());
+        // Namespace context preserved
+        assertEquals("http://example.com/ns";, child.namespaces().get("ns"));
+    }
+
+    @Test
+    void testMergedNodeWriteProducesValidXml() throws Exception {
+        String dominant = """
+                <root xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <child mvn:combine.children="append">
+                        <item>a</item>
+                    </child>
+                </root>
+                """;
+        String recessive = """
+                <root>
+                    <child>
+                        <item>b</item>
+                    </child>
+                </root>
+                """;
+        XmlNode merged = XmlService.merge(toXmlNode(dominant), 
toXmlNode(recessive));
+
+        // Write the merged child alone - it should produce valid XML
+        // because it has the namespace context from the dominant
+        XmlNode child = merged.child("child");
+        StringWriter writer = new StringWriter();
+        XmlService.write(child, writer);
+        String output = writer.toString();
+
+        // mvn:combine.children should be preserved with namespace declaration
+        assertTrue(output.contains("mvn:combine.children"), "Prefix should be 
preserved from context");
+        assertTrue(output.contains("xmlns:mvn="), "Namespace should be 
auto-declared");
+
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("append", reRead.attribute("mvn:combine.children"));
+    }
+
+    // 
========================================================================================
+    // Namespace context - Builder tests
+    // 
========================================================================================
+
+    @Test
+    void testBuilderWithExplicitNamespaces() throws Exception {
+        XmlNode node = XmlNode.newBuilder()
+                .name("elem")
+                .attributes(Map.of("ns:attr", "value"))
+                .namespaces(Map.of("ns", "http://example.com/ns";))
+                .build();
+
+        assertEquals("http://example.com/ns";, node.namespaces().get("ns"));
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(node, writer);
+        String output = writer.toString();
+
+        assertTrue(output.contains("xmlns:ns="), "Namespace should be 
auto-declared from builder context");
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("value", reRead.attribute("ns:attr"));
+    }
+
+    @Test
+    void testBuilderWithNullNamespacesDefaultsToEmpty() {
+        XmlNode node = XmlNode.newBuilder().name("elem").build();
+        assertNotNull(node.namespaces());
+        assertTrue(node.namespaces().isEmpty());
+    }
+
+    @Test
+    void testBuilderNamespacesAreImmutable() {
+        Map<String, String> mutableNs = new HashMap<>(Map.of("ns", 
"http://example.com";));
+        XmlNode node = 
XmlNode.newBuilder().name("elem").namespaces(mutableNs).build();
+
+        // Mutating the original map should not affect the node
+        mutableNs.put("other", "http://other.com";);
+        assertNull(node.namespaces().get("other"));
+
+        // The namespaces map itself should be immutable
+        assertThrows(
+                UnsupportedOperationException.class, () -> 
node.namespaces().put("foo", "bar"));
+    }
+
+    @Test
+    void testDefaultNamespacesMethodReturnsEmptyMap() {
+        // XmlNode built with newInstance (which doesn't set namespaces)
+        // should return empty map from the default namespaces() method
+        XmlNode node = XmlNode.newInstance("test");
+        assertNotNull(node.namespaces());
+        assertTrue(node.namespaces().isEmpty());
+    }
+
+    // 
========================================================================================
+    // Namespace context - Round-trip fidelity tests
+    // 
========================================================================================
+
+    @Test
+    void testRoundTripPreservesNamespaceContext() throws Exception {
+        String xml = """
+                <root xmlns:a="http://example.com/a"; 
xmlns:b="http://example.com/b";>
+                    <child a:x="1" b:y="2"/>
+                </root>
+                """;
+        XmlNode original = toXmlNode(xml);
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(original, writer);
+        XmlNode reRead = toXmlNode(writer.toString());
+
+        // Root namespace context should be preserved
+        assertEquals(original.namespaces().get("a"), 
reRead.namespaces().get("a"));
+        assertEquals(original.namespaces().get("b"), 
reRead.namespaces().get("b"));
+
+        // Child namespace context should be preserved
+        XmlNode origChild = original.child("child");
+        XmlNode reReadChild = reRead.child("child");
+        assertEquals(origChild.namespaces().get("a"), 
reReadChild.namespaces().get("a"));
+        assertEquals(origChild.namespaces().get("b"), 
reReadChild.namespaces().get("b"));
+    }
+
+    @Test
+    void testRoundTripDeepNestedStructure() throws Exception {
+        String xml = """
+                <root xmlns:ns="http://example.com/ns";>
+                    <level1>
+                        <level2>
+                            <level3 ns:deep="value">text</level3>
+                        </level2>
+                    </level1>
+                </root>
+                """;
+        XmlNode original = toXmlNode(xml);
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(original, writer);
+        XmlNode reRead = toXmlNode(writer.toString());
+
+        XmlNode level3 = 
reRead.child("level1").child("level2").child("level3");
+        assertEquals("value", level3.attribute("ns:deep"));
+        assertEquals("text", level3.value());
+        assertEquals("http://example.com/ns";, level3.namespaces().get("ns"));
+    }
+
+    @Test
+    void testRoundTripWithOverriddenNamespace() throws Exception {
+        String xml = """
+                <root xmlns:ns="http://example.com/v1";>
+                    <child xmlns:ns="http://example.com/v2"; ns:attr="val"/>
+                </root>
+                """;
+        XmlNode original = toXmlNode(xml);
+        XmlNode child = original.child("child");
+        assertEquals("http://example.com/v2";, child.namespaces().get("ns"));
+
+        // Write and re-read just the child
+        StringWriter writer = new StringWriter();
+        XmlService.write(child, writer);
+        XmlNode reRead = toXmlNode(writer.toString());
+
+        assertEquals("val", reRead.attribute("ns:attr"));
+        assertEquals("http://example.com/v2";, reRead.namespaces().get("ns"));
+    }
+
+    // 
========================================================================================
+    // Namespace context - Consumer POM simulation tests
+    // 
========================================================================================
+
+    @Test
+    void testConsumerPomScenarioPrefixFromContext() throws Exception {
+        // Simulate: parse a full POM with xmlns:mvn on project, 
mvn:combine.children on child
+        String xml = """
+                <project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <configuration>
+                                    <compilerArgs 
mvn:combine.children="append">
+                                        <arg>-Xlint</arg>
+                                    </compilerArgs>
+                                </configuration>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+        XmlNode project = toXmlNode(xml);
+        XmlNode compilerArgs = project.child("build")
+                .child("plugins")
+                .child("plugin")
+                .child("configuration")
+                .child("compilerArgs");
+        assertNotNull(compilerArgs);
+        assertEquals("append", compilerArgs.attribute("mvn:combine.children"));
+        assertEquals(
+                "http://maven.apache.org/POM/4.0.0";, 
compilerArgs.namespaces().get("mvn"));
+
+        // Simulate consumer POM: write only the configuration subtree
+        XmlNode config = 
project.child("build").child("plugins").child("plugin").child("configuration");
+        StringWriter writer = new StringWriter();
+        XmlService.write(config, writer);
+        String output = writer.toString();
+
+        // Should produce valid XML with auto-declared xmlns:mvn
+        XmlNode reRead = toXmlNode(output);
+        XmlNode reReadArgs = reRead.child("compilerArgs");
+        assertEquals("append", reReadArgs.attribute("mvn:combine.children"));
+    }
+
+    @Test
+    void testConsumerPomScenarioNoContextFallback() throws Exception {
+        // Simulate: programmatically-built XmlNode without namespace context
+        // (as might happen if someone builds configuration in code)
+        XmlNode config = XmlNode.newBuilder()
+                .name("configuration")
+                .children(List.of(XmlNode.newBuilder()
+                        .name("compilerArgs")
+                        .attributes(Map.of("mvn:combine.children", "append"))
+                        .children(List.of(
+                                
XmlNode.newBuilder().name("arg").value("-Xlint").build()))
+                        .build()))
+                .build();
+
+        StringWriter writer = new StringWriter();
+        XmlService.write(config, writer);
+        String output = writer.toString();
+
+        // Without namespace context, prefix should be stripped
+        assertFalse(output.contains("mvn:"), "No mvn: prefix without context");
+        XmlNode reRead = toXmlNode(output);
+        assertEquals("append", 
reRead.child("compilerArgs").attribute("combine.children"));
+    }
+
+    // 
========================================================================================
+    // Namespace context - Merge directive interaction tests
+    // 
========================================================================================
+
+    @Test
+    void testPrefixedCombineChildrenDoesNotMerge() throws Exception {
+        String dominant = """
+                <project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <compilerArgs mvn:combine.children="append">
+                        <arg>-Xlint:deprecation</arg>
+                    </compilerArgs>
+                </project>
+                """;
+
+        String recessive = """
+                <project xmlns="http://maven.apache.org/POM/4.0.0";>
+                    <compilerArgs>
+                        <arg>-Xlint:unchecked</arg>
+                    </compilerArgs>
+                </project>
+                """;
+
+        XmlNode dominantNode = toXmlNode(dominant);
+        XmlNode recessiveNode = toXmlNode(recessive);
+        XmlNode merged = XmlService.merge(dominantNode, recessiveNode);
+
+        XmlNode compilerArgs = merged.child("compilerArgs");
+        assertNotNull(compilerArgs);
+        assertEquals(
+                1,
+                compilerArgs.children().size(),
+                "mvn:combine.children should not trigger append; only 
unprefixed combine.children works");
+    }
+
+    @Test
+    void testUnprefixedCombineChildrenStillWorks() throws Exception {
+        String dominant = """
+                <project xmlns="http://maven.apache.org/POM/4.0.0";>
+                    <compilerArgs combine.children="append">
+                        <arg>-Xlint:deprecation</arg>
+                    </compilerArgs>
+                </project>
+                """;
+
+        String recessive = """
+                <project xmlns="http://maven.apache.org/POM/4.0.0";>
+                    <compilerArgs>
+                        <arg>-Xlint:unchecked</arg>
+                    </compilerArgs>
+                </project>
+                """;
+
+        XmlNode dominantNode = toXmlNode(dominant);
+        XmlNode recessiveNode = toXmlNode(recessive);
+        XmlNode merged = XmlService.merge(dominantNode, recessiveNode);
+
+        XmlNode compilerArgs = merged.child("compilerArgs");
+        assertNotNull(compilerArgs);
+        assertEquals(2, compilerArgs.children().size(), "Unprefixed 
combine.children=append should work");
+    }
+
+    @Test
+    void testPrefixedCombineSelfDoesNotOverride() throws Exception {
+        String dominant = """
+                <root xmlns:mvn="http://maven.apache.org/POM/4.0.0";>
+                    <child mvn:combine.self="override">
+                        <item>dom</item>
+                    </child>
+                </root>
+                """;
+        String recessive = """
+                <root>
+                    <child>
+                        <item>rec</item>
+                        <extra>bonus</extra>
+                    </child>
+                </root>
+                """;
+        XmlNode merged = XmlService.merge(toXmlNode(dominant), 
toXmlNode(recessive));
+        XmlNode child = merged.child("child");
+
+        // mvn:combine.self should NOT trigger override (only unprefixed 
combine.self works)
+        // Default merge behavior merges children by name
+        assertEquals("dom", child.child("item").value());
+        // The "extra" child from recessive should survive since combine.self 
wasn't triggered
+        assertNotNull(child.child("extra"), "Recessive children should survive 
since mvn:combine.self is ignored");
+    }
+
     public static Xpp3Dom build(Reader reader) throws XmlPullParserException, 
IOException {
         try (Reader closeMe = reader) {
             return new Xpp3Dom(XmlNodeBuilder.build(reader, true, null));
diff --git a/src/mdo/writer-stax.vm b/src/mdo/writer-stax.vm
index 9f12f7fc4e..fb2aaf7bd3 100644
--- a/src/mdo/writer-stax.vm
+++ b/src/mdo/writer-stax.vm
@@ -366,14 +366,7 @@ public class ${className} {
     private void writeDom(XmlNode dom, XMLStreamWriter serializer) throws 
IOException, XMLStreamException {
         if (dom != null) {
             serializer.writeStartElement(namespace, dom.name());
-            for (Map.Entry<String, String> attr : dom.attributes().entrySet()) 
{
-                if (attr.getKey().startsWith("xml:")) {
-                    
serializer.writeAttribute("http://www.w3.org/XML/1998/namespace";,
-                    attr.getKey().substring(4), attr.getValue());
-                } else {
-                    serializer.writeAttribute(attr.getKey(), attr.getValue());
-                }
-            }
+            writeXmlNodeAttributes(serializer, dom.attributes(), 
dom.namespaces());
             for (XmlNode child : dom.children()) {
                 writeDom(child, serializer);
             }
@@ -410,6 +403,56 @@ public class ${className} {
             serializer.writeAttribute(attrName, value);
         }
     }
+
+    /**
+     * Writes XmlNode attributes, properly handling namespace declarations
+     * ({@code xmlns:prefix}) and prefixed attributes ({@code 
prefix:localName}).
+     * The namespace context is used to resolve prefixes when the {@code 
xmlns:}
+     * declaration is not present in the attribute map.
+     */
+    private static void writeXmlNodeAttributes(XMLStreamWriter serializer, 
Map<String, String> attributes, Map<String, String> namespaces) throws 
XMLStreamException {
+        // Collect which namespace prefixes need to be declared on this element
+        Set<String> declaredPrefixes = new HashSet<>();
+        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
+            String key = attribute.getKey();
+            if ("xmlns".equals(key)) {
+                serializer.writeDefaultNamespace(attribute.getValue());
+            } else if (key.startsWith("xmlns:")) {
+                String prefix = key.substring(6);
+                serializer.writeNamespace(prefix, attribute.getValue());
+                declaredPrefixes.add(prefix);
+            }
+        }
+        // Write prefixed attributes, declaring their namespace if needed
+        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
+            String key = attribute.getKey();
+            String value = attribute.getValue();
+            if ("xmlns".equals(key) || key.startsWith("xmlns:")) {
+                continue; // already written above
+            } else if (key.startsWith("xml:")) {
+                serializer.writeAttribute(
+                        "http://www.w3.org/XML/1998/namespace";, 
key.substring(4), value);
+            } else if (key.contains(":")) {
+                int colon = key.indexOf(':');
+                String prefix = key.substring(0, colon);
+                String localName = key.substring(colon + 1);
+                String nsUri = attributes.get("xmlns:" + prefix);
+                if (nsUri == null) {
+                    nsUri = namespaces.get(prefix);
+                }
+                if (nsUri != null) {
+                    if (declaredPrefixes.add(prefix)) {
+                        serializer.writeNamespace(prefix, nsUri);
+                    }
+                    serializer.writeAttribute(prefix, nsUri, localName, value);
+                } else {
+                    serializer.writeAttribute(localName, value);
+                }
+            } else {
+                serializer.writeAttribute(key, value);
+            }
+        }
+    }
 #if ( $locationTracking )
 
     /**
diff --git a/src/mdo/writer.vm b/src/mdo/writer.vm
index 3795700a2d..6a63c6e3d4 100644
--- a/src/mdo/writer.vm
+++ b/src/mdo/writer.vm
@@ -252,9 +252,7 @@ public class ${className} {
     private void writeDom(XmlNode dom, XmlSerializer serializer) throws 
IOException {
         if (dom != null) {
             serializer.startTag(NAMESPACE, dom.getName());
-            for (Map.Entry<String, String> attr : 
dom.getAttributes().entrySet()) {
-                serializer.attribute(NAMESPACE, attr.getKey(), 
attr.getValue());
-            }
+            writeXmlNodeAttributes(serializer, dom.getAttributes(), 
dom.namespaces());
             for (XmlNode child : dom.getChildren()) {
                 writeDom(child, serializer);
             }
@@ -266,6 +264,56 @@ public class ${className} {
         }
     }
 
+    /**
+     * Writes XmlNode attributes, properly handling namespace declarations
+     * ({@code xmlns:prefix}) and prefixed attributes ({@code 
prefix:localName}).
+     * The namespace context is used to resolve prefixes when the {@code 
xmlns:}
+     * declaration is not present in the attribute map.
+     */
+    private static void writeXmlNodeAttributes(XmlSerializer serializer, 
Map<String, String> attributes, Map<String, String> namespaces) throws 
IOException {
+        // Collect which namespace prefixes need to be declared on this element
+        Set<String> declaredPrefixes = new HashSet<>();
+        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
+            String key = attribute.getKey();
+            if ("xmlns".equals(key)) {
+                serializer.setPrefix("", attribute.getValue());
+            } else if (key.startsWith("xmlns:")) {
+                String prefix = key.substring(6);
+                serializer.setPrefix(prefix, attribute.getValue());
+                declaredPrefixes.add(prefix);
+            }
+        }
+        // Write prefixed attributes, declaring their namespace if needed
+        for (Map.Entry<String, String> attribute : attributes.entrySet()) {
+            String key = attribute.getKey();
+            String value = attribute.getValue();
+            if ("xmlns".equals(key) || key.startsWith("xmlns:")) {
+                continue; // already handled above
+            } else if (key.startsWith("xml:")) {
+                serializer.attribute("http://www.w3.org/XML/1998/namespace";, 
key.substring(4), value);
+            } else if (key.contains(":")) {
+                int colon = key.indexOf(':');
+                String prefix = key.substring(0, colon);
+                String localName = key.substring(colon + 1);
+                String nsUri = attributes.get("xmlns:" + prefix);
+                if (nsUri == null) {
+                    nsUri = namespaces.get(prefix);
+                }
+                if (nsUri != null) {
+                    if (declaredPrefixes.add(prefix)) {
+                        serializer.setPrefix(prefix, nsUri);
+                    }
+                    serializer.attribute(nsUri, localName, value);
+                } else {
+                    // No namespace declaration for this prefix; write as 
unprefixed
+                    serializer.attribute(NAMESPACE, localName, value);
+                }
+            } else {
+                serializer.attribute(NAMESPACE, key, value);
+            }
+        }
+    }
+
     private void writeTag(String tagName, String defaultValue, String value, 
XmlSerializer serializer) throws IOException {
         if (value != null && !Objects.equals(defaultValue, value)) {
             serializer.startTag(NAMESPACE, 
tagName).text(value).endTag(NAMESPACE, tagName);

Reply via email to