fix(js): make setAttributeNS/getAttributeNS/removeAttributeNS namespace-aware

The three NS methods ignored the namespace: setAttributeNS stored the qualified
name against the non-namespaced store and getAttributeNS/removeAttributeNS
looked up by local name only, so a namespaced attribute was not addressable by
(namespace, localName) — getAttributeNS(xlink, 'href') returned null.

Record the namespace on the attribute and add namespace-aware DOM ops that match
by (namespace, localName), where the local name is the attribute name after any
prefix (so both setAttributeNS-created 'xlink:href' and parser-created 'href'
resolve). The non-namespaced getAttribute/serialization paths are untouched, and
getAttributeNS(null, name) still reads plain attributes.

Fixes #503
This commit is contained in:
Andrey Mnatsakanov committed 2026-07-26 13:29:43 +02:00
1 parent 8078cc3070
commit 6314ecbf88
4 files changed
+114 -3

No files matched your search

+53
View File
@@ -32,6 +32,16 @@ pub struct Attribute {
pub value: String,
}
/// The local-name portion of an attribute name, i.e. the part after any prefix.
/// `"xlink:href"` -> `"href"`, `"href"` -> `"href"`. Used for namespace-aware
/// attribute lookup, which keys on `(namespace, localName)`.
fn attr_local_suffix(name: &str) -> &str {
match name.rfind(':') {
Some(i) => &name[i + 1..],
None => name,
}
}
#[derive(Clone, Debug)]
pub enum NodeData {
Document,
@@ -126,6 +136,49 @@ impl Node {
}
}
// Read a namespaced attribute by (namespace, localName). The stored local
// name may be qualified (e.g. "xlink:href" for setAttributeNS-created attrs)
// or a bare local (e.g. "href" for parser-created ones), so compare against
// the part after any prefix.
pub fn get_attribute_ns(&self, ns: &str, local: &str) -> Option<&str> {
self.attrs()?.iter().find_map(|a| {
if a.name.ns.as_ref() == ns && attr_local_suffix(a.name.local.as_ref()) == local {
Some(a.value.as_str())
} else {
None
}
})
}
// Set a namespaced attribute. `qualified` is the qualified name
// (e.g. "xlink:href"); it is stored as the local name with the namespace
// recorded, matching how the non-namespaced path stores names, so
// getAttribute(qualified) and serialization keep working unchanged.
pub fn set_attribute_ns(&mut self, ns: &str, qualified: &str, value: String) {
if let NodeData::Element { attrs, .. } = &mut self.data {
let local = attr_local_suffix(qualified);
if let Some(attr) = attrs
.iter_mut()
.find(|a| a.name.ns.as_ref() == ns && attr_local_suffix(a.name.local.as_ref()) == local)
{
attr.value = value;
} else {
attrs.push(Attribute {
name: QualName::new(None, Namespace::from(ns), LocalName::from(qualified)),
value,
});
}
}
}
pub fn remove_attribute_ns(&mut self, ns: &str, local: &str) {
if let NodeData::Element { attrs, .. } = &mut self.data {
attrs.retain(|a| {
!(a.name.ns.as_ref() == ns && attr_local_suffix(a.name.local.as_ref()) == local)
});
}
}
pub fn text_content_of_text_node(&self) -> Option<&str> {
match &self.data {
NodeData::Text { contents } => Some(contents),