Class: Canon::Html::DataModel

Inherits:
DataModel
  • Object
show all
Defined in:
lib/canon/html/data_model.rb

Overview

Builds XPath data model from HTML HTML-specific parsing with lowercase element/attribute names, whitespace-sensitive element handling, and fragment parsing

Class Method Summary collapse

Class Method Details

.build_from_nokogiri(nokogiri_doc) ⇒ Object

Build XPath data model from a Nokogiri HTML document or fragment — a TreeBuilder walk like the XML extractors, with the HTML whitespace policy and xmlns-free attributes.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/canon/html/data_model.rb', line 82

def self.build_from_nokogiri(nokogiri_doc)
  builder = Canon::Xml::TreeBuilder::DEFAULT
  root = Canon::Xml::Nodes::RootNode.new
  skip_types = defined?(Nokogiri) ? [Nokogiri::XML::DTD] : []

  if nokogiri_doc.is_a?(Nokogiri::XML::Document) && nokogiri_doc.root
    root.add_child(walk(builder, nokogiri_doc.root))
    builder.add_document_children(root, nokogiri_doc.children,
                                  nokogiri_doc.root, skip_types) do |child|
      walk(builder, child)
    end
  else
    builder.add_document_children(root, nokogiri_doc.children,
                                  nil, skip_types) do |child|
      walk(builder, child)
    end
  end

  root
end

.from_html(html_string, version: :html4) ⇒ Canon::Xml::Nodes::RootNode

Build XPath data model from HTML string

Parameters:

  • html_string (String)

    HTML content to parse

  • version (Symbol) (defaults to: :html4)

    HTML version (:html4 or :html5)

Returns:



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/canon/html/data_model.rb', line 16

def self.from_html(html_string, version: :html4)
  # Detect if this is a full document (has <html> tag) or fragment
  # Full documents should use document parser to preserve structure
  # Fragments should use fragment parser to avoid adding implicit wrappers
  is_full_document = html_string.match?(%r{<html[\s>]}i)

  # Parse with Nokogiri using appropriate parser
  doc = if is_full_document
          # CRITICAL FIX: For full HTML documents, parse as document first
          # and extract the body element. This avoids Nokogiri::HTML.fragment()
          # incorrectly moving head elements (like meta) to the body.
          # Parse as full document to get proper structure
          full_doc = if version == :html5
                       Nokogiri::HTML5(html_string)
                     else
                       Nokogiri::HTML4(html_string)
                     end
          # Extract body element and create fragment from it
          body = full_doc.at_css("body")
          if body
            # Create a fragment and copy body children to it
            # This preserves the body structure without head elements
            frag = if version == :html5
                     Nokogiri::HTML5::DocumentFragment.new(full_doc)
                   else
                     Nokogiri::HTML4::DocumentFragment.new(full_doc)
                   end
            body.children.each do |child|
              frag.add_child(child.dup)
            end
            frag
          elsif version == :html5
            # No body found, fall back to fragment parsing
            Nokogiri::HTML5.fragment(html_string)
          else
            Nokogiri::HTML4.fragment(html_string)
          end
        elsif version == :html5
          # Fragment - use fragment parser to avoid implicit wrappers
          Nokogiri::HTML5.fragment(html_string)
        else
          Nokogiri::HTML4.fragment(html_string)
        end

  # HTML doesn't have strict namespace requirements like XML,
  # so skip the relative namespace URI check

  # Convert to XPath data model (reuse XML infrastructure)
  build_from_nokogiri(doc)
end

.parse(html_string, version: :html4) ⇒ Object

Alias for compatibility



68
69
70
# File 'lib/canon/html/data_model.rb', line 68

def self.parse(html_string, version: :html4)
  from_html(html_string, version: version)
end

.serialize(node) ⇒ Object

Serialize HTML node to string



73
74
75
76
77
# File 'lib/canon/html/data_model.rb', line 73

def self.serialize(node)
  # HTML nodes use the same serialization as XML
  # Delegate to XML serialization implementation
  Canon::Xml::DataModel.serialize(node)
end

.walk(builder, node, inherited_namespaces: nil) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/canon/html/data_model.rb', line 103

def self.walk(builder, node, inherited_namespaces: nil)
  case node
  when Nokogiri::XML::Element
    scope = builder.merge_namespace_scope(
      inherited_namespaces,
      node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
    )
    element = builder.element(
      name: node.name,
      prefix: node.namespace&.prefix,
      namespace_uri: node.namespace&.href,
      # HTML attributes are namespace-free; xmlns declarations are
      # not reported as attributes.
      attributes: node.attribute_nodes.filter_map do |attr|
        next if attr.name.start_with?("xmlns")

        [attr.name, attr.value, nil, nil]
      end,
      namespace_scope: scope,
    )
    node.children.each do |child|
      built = walk(builder, child, inherited_namespaces: scope)
      element.add_child(built) if built
    end
    element
  when Nokogiri::XML::Text
    builder.text(
      node.content,
      keep: Canon::Xml::WhitespacePolicy.keep_html_text?(
        node.content,
        parent_name: node.parent.is_a?(Nokogiri::XML::Element) ? node.parent.name : nil,
        inline_significant:
          Canon::Comparison::WhitespaceSensitivity.inline_whitespace_significant?(node),
      ),
    )
  when Nokogiri::XML::Comment
    builder.comment(node.content)
  when Nokogiri::XML::ProcessingInstruction
    builder.processing_instruction(node.name, node.content)
  end
end