Module: Dommy::Internal::XmlSerialization

Defined in:
lib/dommy/internal/xml_serialization.rb

Overview

WHATWG "XML serialization" algorithm (DOM Parsing & Serialization), run over Dommy node wrappers so it is backend-agnostic. Produces namespace-correct XML — default-namespace inheritance/reset, dropping redundant/inconsistent xmlns, generating ns1/ns2 prefixes — which a backend's own to_xml does not. Namespace declarations are read via Backend.namespace_definitions (libxml2 models them as namespace nodes, not attributes) and presented to the algorithm as the xmlns/xmlns:* attributes the spec expects.

Defined Under Namespace

Classes: Attr

Constant Summary collapse

XML_NS =
"http://www.w3.org/XML/1998/namespace"
XMLNS_NS =
"http://www.w3.org/2000/xmlns/"
HTML_NS =
"http://www.w3.org/1999/xhtml"
VOID_ELEMENTS =

HTML void elements: when empty and in the HTML namespace they self-close with a trailing space in XML serialization (<br />).

%w[
  area base basefont bgsound br col embed frame hr img input keygen link
  meta param source track wbr
].freeze

Class Method Summary collapse

Class Method Details

.already_emitted_prefix?(_attr) ⇒ Boolean

An xmlns:foo declaration is re-emitted by serialize_attributes unless the element start tag already wrote it (when it adopted that prefix). We keep it simple: the spec drops a redundant declaration whose (prefix, value) is already in the local prefixes map, which record_namespace_information set.

Returns:

  • (Boolean)


203
204
205
# File 'lib/dommy/internal/xml_serialization.rb', line 203

def already_emitted_prefix?(_attr)
  false
end

.attr_struct(attr) ⇒ Object



294
295
296
297
298
299
300
301
# File 'lib/dommy/internal/xml_serialization.rb', line 294

def attr_struct(attr)
  Attr.new(
    presence(attr.respond_to?(:namespace_uri) ? attr.namespace_uri : nil),
    presence(attr.respond_to?(:prefix) ? attr.prefix : nil),
    attr.respond_to?(:local_name) ? attr.local_name : attr.name,
    attr.respond_to?(:value) ? attr.value.to_s : ""
  )
end

.backend_namespace_attrs(node) ⇒ Object



279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/dommy/internal/xml_serialization.rb', line 279

def backend_namespace_attrs(node)
  backend = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
  return [] unless backend

  Backend.namespace_definitions(backend).map do |defn|
    pfx = defn.respond_to?(:prefix) ? defn.prefix : defn.first
    href = defn.respond_to?(:href) ? defn.href : defn.last
    if pfx.nil? || pfx.to_s.empty?
      Attr.new(XMLNS_NS, nil, "xmlns", href.to_s)
    else
      Attr.new(XMLNS_NS, "xmlns", pfx.to_s, href.to_s)
    end
  end
end

.child_nodes(node) ⇒ Object



260
261
262
263
264
# File 'lib/dommy/internal/xml_serialization.rb', line 260

def child_nodes(node)
  return node.child_nodes.to_a if node.respond_to?(:child_nodes)

  []
end

.copy_map(map) ⇒ Object



317
318
319
# File 'lib/dommy/internal/xml_serialization.rb', line 317

def copy_map(map)
  map.each_with_object({}) { |(k, v), out| out[k] = v.dup }
end

.created_namespace(node) ⇒ Object

The explicit createElementNS [namespace, prefix, local] when the backend (lexbor) didn't retain it, else nil.



256
257
258
# File 'lib/dommy/internal/xml_serialization.rb', line 256

def created_namespace(node)
  node.respond_to?(:__internal_created_namespace__) ? node.__internal_created_namespace__ : nil
end

.element_attributes(node) ⇒ Object

Regular attributes plus xmlns / xmlns:* declarations synthesized from the backend's namespace definitions (which libxml2 keeps off the attribute list). xmlns declarations come first so they populate the prefix map.



273
274
275
276
277
# File 'lib/dommy/internal/xml_serialization.rb', line 273

def element_attributes(node)
  ns_attrs = backend_namespace_attrs(node)
  regular = node.respond_to?(:attributes) ? node.attributes.to_a.map { |a| attr_struct(a) } : []
  ns_attrs + regular
end

.element_namespace(node) ⇒ Object

The element's TRUE namespace from the backend (nil when none) — not the wrapper's #namespace_uri, which defaults to the HTML namespace for HTML documents and isn't right for an XML serialization.



225
226
227
228
229
230
231
232
233
234
# File 'lib/dommy/internal/xml_serialization.rb', line 225

def element_namespace(node)
  created = created_namespace(node)
  return presence(created[0]) if created

  # The backend's raw namespace — the HTML namespace for an HTML element
  # (which an XML serialization DOES declare as xmlns="…xhtml"), or the
  # parsed namespace for an XML element.
  backend = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
  presence(backend.respond_to?(:namespace_uri) ? backend.namespace_uri : nil)
end

.element_prefix(node) ⇒ Object



236
237
238
239
240
241
242
# File 'lib/dommy/internal/xml_serialization.rb', line 236

def element_prefix(node)
  created = created_namespace(node)
  return presence(created[1]) if created

  backend = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
  presence(backend.respond_to?(:prefix) ? backend.prefix : nil)
end

.empty_element_close(ns, node, qualified) ⇒ Object

WHATWG XML serialization of an empty element: an HTML-namespace void element self-closes with a space (<br />); any other HTML-namespace element gets an explicit end tag (<div></div>); a non-HTML element self-closes (<foo/>).



117
118
119
120
121
# File 'lib/dommy/internal/xml_serialization.rb', line 117

def empty_element_close(ns, node, qualified)
  return "/>" unless ns == HTML_NS

  VOID_ELEMENTS.include?(local_name(node).downcase) ? " />" : "></#{qualified}>"
end

.escape_attr(str) ⇒ Object



332
333
334
335
336
337
338
339
340
341
# File 'lib/dommy/internal/xml_serialization.rb', line 332

def escape_attr(str)
  str.to_s
     .gsub("&", "&amp;")
     .gsub('"', "&quot;")
     .gsub("<", "&lt;")
     .gsub(">", "&gt;")
     .gsub("\t", "&#9;")
     .gsub("\n", "&#10;")
     .gsub("\r", "&#13;")
end

.escape_text(str) ⇒ Object



328
329
330
# File 'lib/dommy/internal/xml_serialization.rb', line 328

def escape_text(str)
  str.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
end

.generate_prefix(map, ns, prefix_index) ⇒ Object



159
160
161
162
163
164
# File 'lib/dommy/internal/xml_serialization.rb', line 159

def generate_prefix(map, ns, prefix_index)
  generated = "ns#{prefix_index[0]}"
  prefix_index[0] += 1
  (map[ns] ||= []) << generated
  generated
end

.local_name(node) ⇒ Object

The backend node name is the local part, case-preserved (the wrapper's #local_name lower-cases for HTML).



246
247
248
249
250
251
252
# File 'lib/dommy/internal/xml_serialization.rb', line 246

def local_name(node)
  created = created_namespace(node)
  return created[2] if created && presence(created[2])

  backend = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
  backend ? backend.name.split(":", 2).last : node.__js_get__("nodeName")
end

.node_type(node) ⇒ Object

---- node data access (backend-agnostic, via Dommy wrappers) ----



209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/dommy/internal/xml_serialization.rb', line 209

def node_type(node)
  return 1  if node.is_a?(Dommy::Element)
  return 4  if defined?(Dommy::CDATASectionNode) && node.is_a?(Dommy::CDATASectionNode)
  return 3  if node.is_a?(Dommy::TextNode)
  return 8  if node.is_a?(Dommy::CommentNode)
  return 7  if node.is_a?(Dommy::ProcessingInstructionNode)
  return 10 if node.is_a?(Dommy::DocumentType)
  return 11 if node.is_a?(Dommy::Fragment)
  return 9  if node.is_a?(Dommy::Document)

  0
end

.presence(value) ⇒ Object



321
322
323
324
325
326
# File 'lib/dommy/internal/xml_serialization.rb', line 321

def presence(value)
  return nil if value.nil?

  s = value.to_s
  s.empty? ? nil : s
end

.record_namespace_information(attrs, map, local_prefixes) ⇒ Object

https://w3c.github.io/DOM-Parsing/#recording-the-namespace Updates map/local_prefixes from the element's xmlns declarations and returns the default-namespace value declared on the element (or nil).



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/dommy/internal/xml_serialization.rb', line 126

def record_namespace_information(attrs, map, local_prefixes)
  default_ns = nil
  attrs.each do |attr|
    next unless attr.namespace == XMLNS_NS

    if attr.prefix.nil?
      # xmlns="..." — a default namespace declaration.
      default_ns = attr.value
      next
    end

    prefix_def = attr.local_name
    ns_def = attr.value
    next if ns_def == XML_NS
    # An already-recorded (prefix → namespace) pairing is redundant.
    next if (map[ns_def] || []).include?(prefix_def)

    (map[ns_def] ||= []) << prefix_def
    local_prefixes[prefix_def] = ns_def
  end
  default_ns
end

.retrieve_preferred_prefix(map, ns, preferred) ⇒ Object



150
151
152
153
154
155
156
# File 'lib/dommy/internal/xml_serialization.rb', line 150

def retrieve_preferred_prefix(map, ns, preferred)
  candidates = map[ns.to_s] || map[ns]
  return nil if candidates.nil? || candidates.empty?
  return preferred if preferred && candidates.include?(preferred)

  candidates.last
end

.serialize(node) ⇒ Object



29
30
31
32
# File 'lib/dommy/internal/xml_serialization.rb', line 29

def serialize(node)
  prefix_index = [1]
  serialize_node(node, nil, { XML_NS => ["xml"] }, prefix_index)
end

.serialize_attributes(attrs, map, prefix_index, local_prefixes, ignore_ns_def) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/dommy/internal/xml_serialization.rb', line 167

def serialize_attributes(attrs, map, prefix_index, local_prefixes, ignore_ns_def)
  result = +""
  attrs.each do |attr|
    ns = presence(attr.namespace)
    prefix = nil

    if ns
      if ns == XMLNS_NS
        # A default-namespace declaration already emitted by the element.
        next if attr.prefix.nil? && ignore_ns_def
        # A prefixed declaration that the element already wrote out.
        next if attr.prefix && local_prefixes[attr.local_name] == attr.value && already_emitted_prefix?(attr)
        prefix = attr.prefix # "xmlns" for xmlns:foo, nil for xmlns
      elsif ns == XML_NS
        prefix = "xml"
      else
        candidate = retrieve_preferred_prefix(map, ns, presence(attr.prefix))
        if candidate.nil?
          candidate = generate_prefix(map, ns, prefix_index)
          result << %( xmlns:#{candidate}="#{escape_attr(ns)}")
        end
        prefix = candidate
      end
    end

    result << " "
    result << "#{prefix}:" if prefix
    result << %(#{attr.local_name}="#{escape_attr(attr.value)}")
  end
  result
end

.serialize_children(node, context_ns, map, prefix_index) ⇒ Object



47
48
49
# File 'lib/dommy/internal/xml_serialization.rb', line 47

def serialize_children(node, context_ns, map, prefix_index)
  child_nodes(node).map { |c| serialize_node(c, context_ns, map, prefix_index) }.join
end

.serialize_doctype(node) ⇒ Object



303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/dommy/internal/xml_serialization.rb', line 303

def serialize_doctype(node)
  name = node.respond_to?(:name) ? node.name : node.__js_get__("name")
  public_id = node.__js_get__("publicId").to_s
  system_id = node.__js_get__("systemId").to_s
  out = +"<!DOCTYPE #{name}"
  if !public_id.empty?
    out << %( PUBLIC "#{public_id}")
    out << %( "#{system_id}") unless system_id.empty?
  elsif !system_id.empty?
    out << %( SYSTEM "#{system_id}")
  end
  out << ">"
end

.serialize_element(node, context_ns, map, prefix_index) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/dommy/internal/xml_serialization.rb', line 52

def serialize_element(node, context_ns, map, prefix_index)
  map = copy_map(map)
  local_prefixes = {}
  attrs = element_attributes(node)
  local_default_ns = record_namespace_information(attrs, map, local_prefixes)
  inherited_ns = context_ns
  ns = presence(element_namespace(node))
  ignore_ns_def = false
  markup = +"<"
  qualified = nil

  if inherited_ns == ns
    ignore_ns_def = true unless local_default_ns.nil?
    qualified = (ns == XML_NS ? "xml:" : "") + local_name(node)
    markup << qualified
  else
    prefix = presence(element_prefix(node))
    candidate = retrieve_preferred_prefix(map, ns, prefix)

    if prefix == "xmlns"
      candidate = "xmlns"
    end

    if candidate && candidate != "xmlns"
      qualified = "#{candidate}:#{local_name(node)}"
      if local_default_ns && local_default_ns != XML_NS
        inherited_ns = local_default_ns.empty? ? nil : local_default_ns
      end
      markup << qualified
    elsif prefix
      prefix = generate_prefix(map, ns, prefix_index) if local_prefixes.key?(prefix)
      (map[ns] ||= []) << prefix
      qualified = "#{prefix}:#{local_name(node)}"
      markup << qualified << %( xmlns:#{prefix}="#{escape_attr(ns)}")
      inherited_ns = (local_default_ns.nil? || local_default_ns.empty? ? nil : local_default_ns) unless local_default_ns.nil?
    elsif local_default_ns.nil? || local_default_ns != ns.to_s
      ignore_ns_def = true
      qualified = local_name(node)
      inherited_ns = ns
      markup << qualified << %( xmlns="#{escape_attr(ns.to_s)}")
    else
      qualified = local_name(node)
      inherited_ns = ns
      markup << qualified
    end
  end

  markup << serialize_attributes(attrs, map, prefix_index, local_prefixes, ignore_ns_def)

  children = child_nodes(node)
  if children.empty?
    markup << empty_element_close(ns, node, qualified)
    return markup
  end

  markup << ">"
  children.each { |c| markup << serialize_node(c, inherited_ns, map, prefix_index) }
  markup << "</#{qualified}>"
  markup
end

.serialize_node(node, context_ns, map, prefix_index) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/dommy/internal/xml_serialization.rb', line 34

def serialize_node(node, context_ns, map, prefix_index)
  case node_type(node)
  when 1     then serialize_element(node, context_ns, map, prefix_index)
  when 3     then escape_text(string_data(node))
  when 4     then "<![CDATA[#{string_data(node)}]]>"
  when 7     then "<?#{node.target} #{string_data(node)}?>"
  when 8     then "<!--#{string_data(node)}-->"
  when 9, 11 then serialize_children(node, context_ns, map, prefix_index)
  when 10    then serialize_doctype(node)
  else ""
  end
end

.string_data(node) ⇒ Object



266
267
268
# File 'lib/dommy/internal/xml_serialization.rb', line 266

def string_data(node)
  node.respond_to?(:data) ? node.data.to_s : node.__js_get__("data").to_s
end