diff --git a/ext/nokolexbor/libxml/tree.h b/ext/nokolexbor/libxml/tree.h index bd61fe0..8573257 100644 --- a/ext/nokolexbor/libxml/tree.h +++ b/ext/nokolexbor/libxml/tree.h @@ -24,7 +24,7 @@ extern "C" { static size_t tmp_len; #define NODE_NAME(node) lxb_dom_node_name_qualified((lxb_dom_node_t *)(node), &tmp_len) -#define NODE_NS_HREF(node) ((node)->prefix ? lxb_ns_by_id((node)->owner_document->ns, (node)->ns, &tmp_len) : NULL) +#define NODE_NS_HREF(node) (((node)->ns != 0 && (node)->ns != LXB_NS_HTML) ? lxb_ns_by_id((node)->owner_document->ns, (node)->ns, &tmp_len) : NULL) #define NODE_NS_PREFIX(node) lxb_ns_by_id((node)->owner_document->prefix, (node)->prefix, &tmp_len) /* diff --git a/ext/nokolexbor/nl_node.c b/ext/nokolexbor/nl_node.c index 28c7727..ee32848 100644 --- a/ext/nokolexbor/nl_node.c +++ b/ext/nokolexbor/nl_node.c @@ -1,6 +1,7 @@ #include "nokolexbor.h" #include "config.h" #include "libxml/tree.h" +#include #ifdef HAVE_PTHREAD_H #include @@ -1202,6 +1203,70 @@ nl_node_path(VALUE self) return ret; } +/** + * Get the namespace of this Node + * + * @return [String] The namespace of this Node + */ +static VALUE +nl_node_namespace_href(VALUE self) +{ + lxb_dom_node_t *node = nl_rb_node_unwrap(self); + /* ns == 0 means no namespace; LXB_NS_HTML is the implicit HTML namespace + * which we do not expose (matches Nokogiri::HTML behaviour) */ + if (node->ns == 0 || node->ns == LXB_NS_HTML) return Qnil; + size_t len; + const lxb_char_t *href = lxb_ns_by_id(node->owner_document->ns, node->ns, &len); + if (href == NULL || href[0] == '\0') return Qnil; + return rb_utf8_str_new((const char *)href, len); +} + +static VALUE +nl_node_namespace_prefix(VALUE self) +{ + lxb_dom_node_t *node = nl_rb_node_unwrap(self); + if (node->prefix == 0) return Qnil; + const lxb_char_t *prefix = NODE_NS_PREFIX(node); + if (prefix == NULL || prefix[0] == '\0') return Qnil; + return rb_utf8_str_new((const char *)prefix, tmp_len); +} + +static VALUE +nl_node_namespace_definitions(VALUE self) +{ + lxb_dom_node_t *node = nl_rb_node_unwrap(self); + VALUE ary = rb_ary_new(); + + if (node->type != LXB_DOM_NODE_TYPE_ELEMENT) return ary; + + VALUE rb_doc = nl_rb_document_get(self); + VALUE cNamespace = rb_const_get(mNokolexbor, rb_intern("Namespace")); + + lxb_dom_attr_t *attr = lxb_dom_element_first_attribute(lxb_dom_interface_element(node)); + while (attr != NULL) { + size_t name_len; + const lxb_char_t *name = lxb_dom_attr_qualified_name(attr, &name_len); + if (name_len >= 5 && strncmp((const char *)name, "xmlns", 5) == 0) { + size_t val_len; + const lxb_char_t *val = lxb_dom_attr_value(attr, &val_len); + VALUE rb_href = val ? rb_utf8_str_new((const char *)val, val_len) : rb_str_new("", 0); + VALUE rb_prefix; + if (name_len == 5) { + rb_prefix = Qnil; + } else if (name[5] == ':') { + rb_prefix = rb_utf8_str_new((const char *)name + 6, name_len - 6); + } else { + attr = attr->next; + continue; + } + VALUE ns = rb_funcall(cNamespace, rb_intern("new"), 3, rb_doc, rb_prefix, rb_href); + rb_ary_push(ary, ns); + } + attr = attr->next; + } + return ary; +} + static void free_css_parser(void *data) { @@ -1270,6 +1335,9 @@ void Init_nl_node(void) rb_define_method(cNokolexborNode, "inspect", nl_node_inspect, -1); rb_define_method(cNokolexborNode, "source_location", nl_node_source_location, 0); rb_define_method(cNokolexborNode, "path", nl_node_path, 0); + rb_define_method(cNokolexborNode, "namespace_href", nl_node_namespace_href, 0); + rb_define_method(cNokolexborNode, "namespace_prefix", nl_node_namespace_prefix, 0); + rb_define_method(cNokolexborNode, "namespace_definitions", nl_node_namespace_definitions, 0); rb_define_alias(cNokolexborNode, "attr", "[]"); rb_define_alias(cNokolexborNode, "get_attribute", "[]"); diff --git a/lib/nokolexbor.rb b/lib/nokolexbor.rb index b10abda..580100f 100644 --- a/lib/nokolexbor.rb +++ b/lib/nokolexbor.rb @@ -22,6 +22,7 @@ end require 'nokolexbor/version' +require 'nokolexbor/namespace' require 'nokolexbor/node' require 'nokolexbor/document' require 'nokolexbor/node_set' diff --git a/lib/nokolexbor/document.rb b/lib/nokolexbor/document.rb index d136b00..b29382d 100644 --- a/lib/nokolexbor/document.rb +++ b/lib/nokolexbor/document.rb @@ -72,6 +72,13 @@ def document self end + # Get the namespaces in scope on the root element. + # + # @return [Hash{String => String}] + def namespaces + root&.namespaces || {} + end + # Get the meta tag encoding for this document. If there is no meta tag, nil is returned. # # @return [String] diff --git a/lib/nokolexbor/namespace.rb b/lib/nokolexbor/namespace.rb new file mode 100644 index 0000000..34644a3 --- /dev/null +++ b/lib/nokolexbor/namespace.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Nokolexbor + class Namespace + attr_reader :prefix, :href, :document + + def initialize(document, prefix, href) + @document = document + @prefix = prefix + @href = href + end + + def inspect + if prefix + "#<#{self.class.name} prefix=#{prefix.inspect} href=#{href.inspect}>" + else + "#<#{self.class.name} href=#{href.inspect}>" + end + end + end +end diff --git a/lib/nokolexbor/node.rb b/lib/nokolexbor/node.rb index c3d57f5..3035284 100644 --- a/lib/nokolexbor/node.rb +++ b/lib/nokolexbor/node.rb @@ -58,6 +58,52 @@ def document? is_a?(Nokolexbor::Document) end + # Get the {Namespace} of this node, or +nil+ if there is no namespace. + # + # @return [Namespace, nil] + def namespace + href = namespace_href + return nil if href.nil? + Nokolexbor::Namespace.new(document, namespace_prefix, href) + end + + # Get the namespaces in scope for this node — those defined on the node + # itself and its ancestors. + # + # @return [Array] + def namespace_scopes + result = {} + node = self + while node && !node.document? + node.namespace_definitions.each do |ns| + result[ns.prefix] ||= ns + end + node = node.respond_to?(:parent) ? node.parent : nil + end + result.values + end + + # Get the namespaces in scope for this node as a Hash. + # + # @return [Hash{String => String}] e.g. {"xmlns:foo" => "http://example.com"} + def namespaces + namespace_scopes.each_with_object({}) do |ns, hash| + key = ns.prefix ? "xmlns:#{ns.prefix}" : "xmlns" + hash[key] = ns.href + end + end + + # Add a namespace definition to this node. + # + # @param prefix [String, nil] The namespace prefix, or +nil+ for the default namespace. + # @param href [String] The namespace URI. + # @return [Namespace] + def add_namespace_definition(prefix, href) + attr_name = prefix ? "xmlns:#{prefix}" : "xmlns" + set_attr(attr_name, href) + Nokolexbor::Namespace.new(document, prefix, href) + end + # Get the path to this node as a CSS expression def css_path path.split(%r{/}).filter_map do |part| @@ -764,8 +810,7 @@ def extract_params(params) end ns, binds = hashes.reverse - # ns ||= (document.root&.namespaces || {}) - ns ||= {} + ns ||= (document.root&.namespaces || {}) [params, handler, ns, binds] end diff --git a/spec/node_spec.rb b/spec/node_spec.rb index ce4f292..0de87ea 100644 --- a/spec/node_spec.rb +++ b/spec/node_spec.rb @@ -1053,4 +1053,137 @@ _(doc.at_css('.a').path).must_equal '/html/body/div/span/a[1]' _(doc.at_css('.b').path).must_equal '/html/body/div/span/a[2]' end + + describe 'namespace' do + it 'returns nil for plain HTML elements' do + doc = Nokolexbor::HTML('
') + _(doc.at_css('div').namespace).must_be_nil + end + + it 'returns Namespace for SVG elements' do + doc = Nokolexbor::HTML('') + svg = doc.at_css('svg') + ns = svg.namespace + _(ns).must_be_instance_of Nokolexbor::Namespace + _(ns.href).must_equal 'http://www.w3.org/2000/svg' + _(ns.prefix).must_be_nil + end + + it 'SVG children inherit SVG namespace' do + doc = Nokolexbor::HTML('') + circle = doc.at_css('circle') + _(circle.namespace_href).must_equal 'http://www.w3.org/2000/svg' + end + + it 'returns Namespace for MathML elements' do + doc = Nokolexbor::HTML('x') + math = doc.at_css('math') + ns = math.namespace + _(ns).must_be_instance_of Nokolexbor::Namespace + _(ns.href).must_equal 'http://www.w3.org/1998/Math/MathML' + end + end + + describe 'namespace_definitions' do + it 'returns empty array for node without xmlns attributes' do + doc = Nokolexbor::HTML('
') + _(doc.at_css('div').namespace_definitions).must_equal [] + end + + it 'returns Namespace objects for xmlns:prefix attributes' do + doc = Nokolexbor::HTML('
') + defs = doc.at_css('div').namespace_definitions + _(defs.size).must_equal 1 + _(defs.first).must_be_instance_of Nokolexbor::Namespace + _(defs.first.prefix).must_equal 'foo' + _(defs.first.href).must_equal 'http://example.com/foo' + end + + it 'returns Namespace with nil prefix for default xmlns attribute' do + doc = Nokolexbor::HTML('
') + defs = doc.at_css('div').namespace_definitions + _(defs.size).must_equal 1 + _(defs.first.prefix).must_be_nil + _(defs.first.href).must_equal 'http://example.com/default' + end + + it 'returns empty array for non-element nodes' do + doc = Nokolexbor::HTML('
text
') + _(doc.at_css('div').children.first.namespace_definitions).must_equal [] + end + end + + describe 'namespaces' do + it 'returns empty hash for plain node' do + doc = Nokolexbor::HTML('
') + _(doc.at_css('div').namespaces).must_equal({}) + end + + it 'returns hash with xmlns: keys for prefixed namespaces' do + doc = Nokolexbor::HTML('
') + _(doc.at_css('div').namespaces).must_equal('xmlns:foo' => 'http://example.com/foo') + end + + it 'returns xmlns key for default namespace' do + doc = Nokolexbor::HTML('
') + _(doc.at_css('div').namespaces).must_equal('xmlns' => 'http://example.com/default') + end + + it 'includes ancestor namespaces' do + doc = Nokolexbor::HTML('
') + ns = doc.at_css('span').namespaces + _(ns['xmlns:foo']).must_equal 'http://example.com/foo' + _(ns['xmlns:bar']).must_equal 'http://example.com/bar' + end + + it 'child namespace does not override ancestor namespace with same prefix' do + doc = Nokolexbor::HTML('
') + ns = doc.at_css('span').namespaces + _(ns['xmlns:foo']).must_equal 'http://child.com' + end + end + + describe 'add_namespace_definition' do + it 'adds xmlns attribute and returns Namespace object' do + doc = Nokolexbor::HTML('
') + node = doc.at_css('div') + ns = node.add_namespace_definition('foo', 'http://example.com/foo') + _(ns).must_be_instance_of Nokolexbor::Namespace + _(ns.prefix).must_equal 'foo' + _(ns.href).must_equal 'http://example.com/foo' + _(node['xmlns:foo']).must_equal 'http://example.com/foo' + end + + it 'adds default namespace with nil prefix' do + doc = Nokolexbor::HTML('
') + node = doc.at_css('div') + ns = node.add_namespace_definition(nil, 'http://example.com/default') + _(ns.prefix).must_be_nil + _(node['xmlns']).must_equal 'http://example.com/default' + end + end + + describe 'Namespace class' do + it 'has prefix, href and document' do + doc = Nokolexbor::HTML('
') + ns = Nokolexbor::Namespace.new(doc, 'foo', 'http://example.com') + _(ns.prefix).must_equal 'foo' + _(ns.href).must_equal 'http://example.com' + _(ns.document).must_equal doc + end + + it 'inspect includes prefix and href' do + doc = Nokolexbor::HTML('
') + ns = Nokolexbor::Namespace.new(doc, 'foo', 'http://example.com') + _(ns.inspect).must_include 'prefix="foo"' + _(ns.inspect).must_include 'href="http://example.com"' + end + + it 'inspect without prefix' do + doc = Nokolexbor::HTML('
') + ns = Nokolexbor::Namespace.new(doc, nil, 'http://example.com') + _(ns.inspect).wont_include 'prefix' + _(ns.inspect).must_include 'href="http://example.com"' + end + end end \ No newline at end of file