Class: Dommy::HTMLCollection

Inherits:
Object
  • Object
show all
Includes:
Bridge::Methods, Enumerable
Defined in:
lib/dommy/html_collection.rb

Overview

HTMLCollection — live, ordered set of Element nodes. Distinct from NodeList in two ways:

- Always element-only (Node types other than Element are skipped)
- Supports `namedItem(name)` lookup by `id` or `name` attribute

Live behavior: pass an evaluator block (called &compute) that returns the current element list on every access. Each query re-evaluates, so mutations to the parent tree are reflected immediately.

Intentionally NOT a subclass of Array; spec semantics demand Array.isArray(html_collection) === false in real browsers, and mirroring that here helps tests written against MDN behavior.

Constant Summary collapse

HTML_NAMESPACE =

Shared getElementsByTagNameNS(namespace, localName) — a live collection of descendants of root matching the (namespace, localName) filter, where "*" matches any. An empty-string namespace means the null namespace.

"http://www.w3.org/1999/xhtml"

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Bridge::Methods

included

Constructor Details

#initialize(&compute) ⇒ HTMLCollection

Returns a new instance of HTMLCollection.



21
22
23
# File 'lib/dommy/html_collection.rb', line 21

def initialize(&compute)
  @compute = compute
end

Class Method Details

.ascii_downcase(str) ⇒ Object

ASCII-only lowercase (A-Z -> a-z), leaving non-ASCII code points intact, per the spec's "converted to ASCII lowercase".



74
75
76
# File 'lib/dommy/html_collection.rb', line 74

def self.ascii_downcase(str)
  str.gsub(/[A-Z]/) { |c| (c.ord + 32).chr }
end

.elements_by_tag_name(root, document, qualified_name) ⇒ Object

WHATWG getElementsByTagName(qualifiedName) — a live collection filtered by qualified name. "*" matches any. In an HTML document, HTML-namespace elements match case-insensitively (ASCII), while other-namespace elements (and everything in a non-HTML document) match case-sensitively.



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
# File 'lib/dommy/html_collection.rb', line 34

def self.elements_by_tag_name(root, document, qualified_name)
  qn = qualified_name.to_s
  html_doc = document.respond_to?(:html_document?) ? document.html_document? : true
  qn_lower = ascii_downcase(qn)
  new do
    root.css("*").filter_map do |node|
      el = document.wrap_node(node)
      next nil unless el
      next el if qn == "*"

      el_qn = qualified_name_of(el)
      el_ns = el.respond_to?(:namespace_uri) ? el.namespace_uri : nil
      # For an HTML-namespace element in an HTML document, only the QUERY is
      # ASCII-lowercased — the element's own qualified name is compared as-is
      # (so an uppercase-localName HTML element, e.g. createElementNS(html,
      # "I"), never matches "i" or "I").
      match =
        if html_doc && el_ns == HTML_NAMESPACE
          el_qn == qn_lower
        else
          el_qn == qn
        end
      match ? el : nil
    end
  end
end

.elements_by_tag_name_ns(root, document, namespace, local_name) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/dommy/html_collection.rb', line 78

def self.elements_by_tag_name_ns(root, document, namespace, local_name)
  ns = namespace.to_s
  ns_filter = ns == "*" ? :any : (ns.empty? ? nil : ns)
  local = local_name.to_s
  local_filter = local == "*" ? :any : local
  new do
    # Match on the element's LOCAL NAME (case-sensitive, exact) and
    # namespace — NOT a CSS type selector, which is case-insensitive in an
    # HTML document and keys off the qualified name (so it misses a
    # prefixed `test:body` and wrongly matches `BODY` for `body`).
    root.css("*").filter_map do |node|
      el = document.wrap_node(node)
      next nil unless el

      el_ns = el.respond_to?(:namespace_uri) ? el.namespace_uri : nil
      next nil unless ns_filter == :any || el_ns == ns_filter

      el_local = el.respond_to?(:local_name) ? el.local_name : nil
      next nil unless local_filter == :any || el_local == local_filter

      el
    end
  end
end

.qualified_name_of(el) ⇒ Object

The element's qualified name (prefix:localName, or just localName). The backend node name can't be trusted — the HTML parser lowercases it — so rebuild it from the case-preserving local name and prefix.



64
65
66
67
68
69
70
# File 'lib/dommy/html_collection.rb', line 64

def self.qualified_name_of(el)
  local = el.respond_to?(:local_name) ? el.local_name.to_s : ""
  prefix = el.respond_to?(:__js_get__) ? el.__js_get__("prefix") : nil
  prefix = nil if prefix.nil? || prefix.to_s.empty? ||
                  (defined?(Bridge::UNDEFINED) && prefix.equal?(Bridge::UNDEFINED))
  prefix ? "#{prefix}:#{local}" : local
end

Instance Method Details

#[](key) ⇒ Object

[] supports both integer index (coll[0], coll[-1]) and string name (coll["myId"]). Negative indices are interpreted Ruby-style (offset from the end), even though the spec's item(i) is positive-only.



146
147
148
149
150
151
152
153
154
155
# File 'lib/dommy/html_collection.rb', line 146

def [](key)
  case key
  when Integer
    to_a[key]
  when /\A-?\d+\z/
    to_a[key.to_i]
  else
    named_item(key)
  end
end

#__js_call__(method, args) ⇒ Object



218
219
220
221
222
223
224
225
# File 'lib/dommy/html_collection.rb', line 218

def __js_call__(method, args)
  case method
  when "item"
    item(args[0])
  when "namedItem"
    named_item(args[0])
  end
end

#__js_get__(key) ⇒ Object



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/dommy/html_collection.rb', line 173

def __js_get__(key)
  case key
  when "length"
    length
  when Integer
    item(key)
  else
    s = key.to_s
    if s.match?(/\A\d+\z/) && s.to_i < 4_294_967_295 && s == s.to_i.to_s
      # A valid array index is the CANONICAL decimal of 0 ≤ n < 2^32-1 (no
      # leading zeros: "03" is NOT an index, it is a named key). A pure
      # indexed lookup — out of range yields nil (→ undefined), no named
      # fallback.
      item(s.to_i)
    else
      # Non-array-index strings (negative, ≥ 2^32-1, or names) use the named
      # getter; a miss is JS `undefined` (and `"x" in coll` false).
      named_item(s) || (s == "length" ? length : Bridge::ABSENT)
    end
  end
end

#__js_named_props__Object

WebIDL "supported property names" for HTMLCollection: in tree order, each element contributes its non-empty id, then (if it is in the HTML namespace) its non-empty name — ignoring duplicates.



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/dommy/html_collection.rb', line 198

def __js_named_props__
  names = []
  to_a.each do |el|
    next unless el.respond_to?(:__dommy_backend_node__)

    node = el.__dommy_backend_node__
    id = node["id"].to_s
    names << id if !id.empty? && !names.include?(id)

    name = node["name"].to_s
    next if name.empty? || names.include?(name)

    html_ns = !el.respond_to?(:namespace_uri) || el.namespace_uri == "http://www.w3.org/1999/xhtml"
    names << name if html_ns
  end
  names
end

#each(&blk) ⇒ Object



165
166
167
# File 'lib/dommy/html_collection.rb', line 165

def each(&blk)
  to_a.each(&blk)
end

#empty?Boolean

Returns:

  • (Boolean)


109
110
111
# File 'lib/dommy/html_collection.rb', line 109

def empty?
  to_a.empty?
end

#first(n = nil) ⇒ Object



157
158
159
# File 'lib/dommy/html_collection.rb', line 157

def first(n = nil)
  n.nil? ? to_a.first : to_a.first(n)
end

#item(index) ⇒ Object



113
114
115
116
117
# File 'lib/dommy/html_collection.rb', line 113

def item(index)
  # `index` is a WebIDL unsigned long, so it wraps modulo 2^32 (e.g. item(2^32)
  # is item(0)); Ruby's modulo also normalizes negatives to that range.
  to_a[index.to_i % 4_294_967_296]
end

#last(n = nil) ⇒ Object



161
162
163
# File 'lib/dommy/html_collection.rb', line 161

def last(n = nil)
  n.nil? ? to_a.last : to_a.last(n)
end

#lengthObject Also known as: size



103
104
105
# File 'lib/dommy/html_collection.rb', line 103

def length
  to_a.length
end

#named_item(name) ⇒ Object

namedItem(name) returns the first element whose id or name attribute equals name. Returns nil if no match.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/dommy/html_collection.rb', line 121

def named_item(name)
  # A numeric argument (`namedItem(2147483648)`) crosses from JS as a Float
  # for values past int32; format it as an integer string so it matches an
  # `id`/`name` attribute like "2147483648" (not "2147483648.0").
  key = (name.is_a?(Float) && name.finite? && name == name.to_i) ? name.to_i.to_s : name.to_s
  return nil if key.empty?

  to_a.find do |el|
    next false unless el.respond_to?(:__dommy_backend_node__)

    node = el.__dommy_backend_node__
    # `id` matches any element; `name` matches only HTML-namespace elements
    # (WebIDL supported property names), so a null-namespace element's
    # `name` attribute isn't a supported name.
    next true if node["id"].to_s == key

    html_ns = !el.respond_to?(:namespace_uri) || el.namespace_uri == "http://www.w3.org/1999/xhtml"
    html_ns && node["name"].to_s == key
  end
end

#to_aObject



169
170
171
# File 'lib/dommy/html_collection.rb', line 169

def to_a
  @compute.call
end