dommy 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
@@ -24,5 +24,114 @@ module Dommy
24
24
  end
25
25
 
26
26
  CSS_PSEUDO_HANDLERS = CSSPseudoHandlers.new
27
+
28
+ # Adds `:scope` support. Nokogiri compiles `:scope` into a custom XPath
29
+ # function `nokogiri:scope(.)`, calling it as `scope(node_set)`; a scoped
30
+ # query (`el.querySelector(":scope > p")`) resolves it to the context
31
+ # element, so only that element matches. One instance per query — it carries
32
+ # the context node.
33
+ class ScopedCSSPseudoHandlers < CSSPseudoHandlers
34
+ def initialize(scope_node)
35
+ @scope_node = scope_node
36
+ end
37
+
38
+ def scope(list)
39
+ list.find_all { |node| node.pointer_id == @scope_node.pointer_id }
40
+ end
41
+ end
42
+
43
+ def self.scoped_pseudo_handlers(scope_node)
44
+ ScopedCSSPseudoHandlers.new(scope_node)
45
+ end
46
+
47
+ # The complete set of CSS pseudo-classes (+ the four legacy single-colon
48
+ # pseudo-elements). A `:identifier` outside this set is an unknown selector
49
+ # token → SyntaxError, whereas a known-but-unimplemented one (`:hover`) is a
50
+ # valid selector that simply matches nothing.
51
+ KNOWN_PSEUDOS = %w[
52
+ active any-link autofill blank checked current default defined disabled empty
53
+ enabled first first-child first-of-type focus focus-visible focus-within
54
+ fullscreen future has host hover in-range indeterminate invalid is lang
55
+ last-child last-of-type left link local-link modal not nth-child nth-col
56
+ nth-last-child nth-last-col nth-last-of-type nth-of-type only-child
57
+ only-of-type optional out-of-range past placeholder-shown playing paused
58
+ read-only read-write required right root scope target target-within
59
+ user-invalid user-valid valid visited where dir
60
+ before after first-line first-letter
61
+ ].to_set.freeze
62
+
63
+ # Validate a non-null CSS selector for `querySelector`/`matches`/`closest`,
64
+ # raising SyntaxError for syntactically invalid selectors. Delegates to the
65
+ # full grammar parser (SelectorParser), which catches everything the old
66
+ # heuristic did (empty string, leading combinator, unknown pseudo-class) plus
67
+ # the rest of the Selectors grammar (`[*=v]`, `..x`, `div % p`, unknown
68
+ # pseudo-elements, undeclared namespaces, …) that Nokogiri silently accepts.
69
+ def self.validate_selector!(selector)
70
+ SelectorParser.validate!(selector.to_s)
71
+ end
72
+
73
+ # Coerce the JS argument of a query method (querySelector/All) per WebIDL: the
74
+ # selector is a *non-nullable* DOMString, so JS `null` → "null" and
75
+ # `undefined` → "undefined" (which then match `<null>` / `<undefined>` typed
76
+ # elements rather than returning nothing), while a missing argument is a
77
+ # TypeError. Used at every JS dispatch site so the behaviour is uniform.
78
+ def self.css_query_arg!(args)
79
+ raise ::Dommy::Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?
80
+
81
+ value = args[0]
82
+ return "null" if value.nil?
83
+ return "undefined" if defined?(::Dommy::Bridge::UNDEFINED) && value.equal?(::Dommy::Bridge::UNDEFINED)
84
+
85
+ value
86
+ end
87
+
88
+ # Nokogiri's CSS→XPath compiler chokes on an escaped colon INSIDE an
89
+ # attribute selector (`[xlink\:href]`, a namespaced/SVG attribute → "Invalid
90
+ # predicate"), though it handles escaped colons in class/id selectors fine
91
+ # (`.md\:flex`, `#a\:b` — Tailwind). Those attribute selectors target
92
+ # XML-namespaced attributes the HTML backend doesn't model, so drop just the
93
+ # comma-clauses that use them; the rest of the selector list is preserved.
94
+ # (Real frameworks hit this constantly — Turbo's click handler matches
95
+ # `a[href], a[xlink\:href]` on every click.) Returns a backend-safe selector;
96
+ # if every clause was unsupported, returns one that compiles but never
97
+ # matches.
98
+ ATTR_ESCAPED_COLON = /\[[^\]]*\\:[^\]]*\]/
99
+ def self.backend_safe_selector(selector)
100
+ # First drop clauses whose subject is a pseudo-element (`::before`,
101
+ # `:first-line`) — they match no element, and the backend can't compile
102
+ # `::`. Then drop the escaped-colon attribute clauses below.
103
+ s = SelectorParser.matchable_selector(selector.to_s)
104
+ return s unless s.include?('\\') && s.match?(ATTR_ESCAPED_COLON)
105
+
106
+ kept = split_selector_list(s).reject { |clause| clause.match?(ATTR_ESCAPED_COLON) }
107
+ kept.empty? ? ":not(*)" : kept.join(", ")
108
+ end
109
+
110
+ # Split a selector list on top-level commas only (commas inside [...], (...),
111
+ # or quotes are part of a single complex selector and must not split it).
112
+ def self.split_selector_list(selector)
113
+ clauses = []
114
+ depth = 0
115
+ quote = nil
116
+ current = +""
117
+ selector.each_char do |ch|
118
+ if quote
119
+ quote = nil if ch == quote
120
+ elsif ch == '"' || ch == "'"
121
+ quote = ch
122
+ elsif ch == "[" || ch == "("
123
+ depth += 1
124
+ elsif ch == "]" || ch == ")"
125
+ depth -= 1 if depth.positive?
126
+ elsif ch == "," && depth.zero?
127
+ clauses << current.strip
128
+ current = +""
129
+ next
130
+ end
131
+ current << ch
132
+ end
133
+ clauses << current.strip
134
+ clauses.reject(&:empty?)
135
+ end
27
136
  end
28
137
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+
6
+ module Dommy
7
+ module Internal
8
+ # Stateless global functions exposed on the JS global (Window) that don't
9
+ # depend on any window state. Kept here so Window doesn't carry the cgi/erb
10
+ # dependency just for URI component encoding.
11
+ module GlobalFunctions
12
+ module_function
13
+
14
+ # JS `encodeURIComponent`: percent-encode everything except
15
+ # `A-Za-z0-9 - _ . ! ~ * ' ( )`. `ERB::Util.url_encode` matches this,
16
+ # unlike `CGI.escape` (which uses `+` for space).
17
+ def encode_uri_component(value)
18
+ ERB::Util.url_encode(value.to_s)
19
+ end
20
+
21
+ def decode_uri_component(value)
22
+ CGI.unescape(value.to_s)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -87,14 +87,21 @@ module Dommy
87
87
  end
88
88
 
89
89
  # `domain` → ASCII-only form. Returns nil for nil input.
90
- def self.to_ascii(domain)
90
+ #
91
+ # `check_hyphens` / `verify_dns_length` default to the strict
92
+ # RFC 5891 profile. The WHATWG URL "domain to ASCII" operation
93
+ # instead runs UTS #46 with CheckHyphens=false and
94
+ # VerifyDnsLength=false (so leading/trailing/3-4 hyphens, empty
95
+ # labels, and over-long labels/domains are all permitted); the
96
+ # URL parser passes both as false.
97
+ def self.to_ascii(domain, check_hyphens: true, verify_dns_length: true)
91
98
  return domain if domain.nil?
92
99
 
93
100
  mapped = uts46_map(domain.to_s)
94
101
  normalized = mapped.unicode_normalize(:nfc)
95
102
  labels = normalized.split(".", -1)
96
103
 
97
- validate_no_empty_intermediate(labels)
104
+ validate_no_empty_intermediate(labels) if verify_dns_length
98
105
  bidi_domain = labels.any? { |l| bidi_label?(l) }
99
106
 
100
107
  encoded = labels.map do |label|
@@ -111,13 +118,15 @@ module Dommy
111
118
  validate_a_label_roundtrip(label, decoded)
112
119
  end
113
120
 
114
- validate_label(decoded, bidi_domain: bidi_domain)
121
+ validate_label(decoded, bidi_domain: bidi_domain, check_hyphens: check_hyphens)
115
122
  encode_label(decoded)
116
123
  end
117
124
 
118
- encoded.each { |label| validate_a_label_form(label) }
125
+ if verify_dns_length
126
+ encoded.each { |label| validate_a_label_form(label) }
127
+ end
119
128
  result = encoded.join(".")
120
- validate_total_length(result)
129
+ validate_total_length(result) if verify_dns_length
121
130
  result
122
131
  end
123
132
 
@@ -165,10 +174,10 @@ module Dommy
165
174
 
166
175
  # --- Step 5: validate per-label ---------------------------------
167
176
 
168
- def self.validate_label(label, bidi_domain:)
177
+ def self.validate_label(label, bidi_domain:, check_hyphens: true)
169
178
  return if label.empty?
170
179
 
171
- validate_hyphens(label)
180
+ validate_hyphens(label) if check_hyphens
172
181
  validate_no_leading_combining_mark(label)
173
182
  check_contextj(label)
174
183
  check_contexto(label)
@@ -63,15 +63,30 @@ module Dommy
63
63
  end
64
64
 
65
65
  def self.parse_number(str)
66
- return 0 if str == "0"
66
+ return nil if str.empty?
67
67
 
68
- if str.match?(/\A0[xX][0-9a-fA-F]+\z/)
69
- str[2..].to_i(16)
70
- elsif str.match?(/\A0[0-7]+\z/)
71
- str.to_i(8)
72
- elsif str.match?(/\A[1-9][0-9]*\z/)
73
- str.to_i
68
+ radix = 10
69
+ digits = str
70
+ if str.length >= 2 && (str[0, 2] == "0x" || str[0, 2] == "0X")
71
+ radix = 16
72
+ digits = str[2..]
73
+ elsif str.length >= 2 && str[0] == "0"
74
+ radix = 8
75
+ digits = str[1..]
74
76
  end
77
+
78
+ # A bare radix prefix (`0x`, `0`) denotes the number 0.
79
+ return 0 if digits.empty?
80
+
81
+ valid =
82
+ case radix
83
+ when 16 then digits.match?(/\A[0-9a-fA-F]+\z/)
84
+ when 8 then digits.match?(/\A[0-7]+\z/)
85
+ else digits.match?(/\A[0-9]+\z/)
86
+ end
87
+ return nil unless valid
88
+
89
+ digits.to_i(radix)
75
90
  end
76
91
  end
77
92
  end
@@ -109,7 +109,13 @@ module Dommy
109
109
  previous_sibling: prev_w,
110
110
  next_sibling: next_w
111
111
  )
112
+ # Only observers whose matching registration requested childList get the
113
+ # record (an `attributes`/`characterData`-only observer must not — e.g.
114
+ # `observe(t, {childList: false, attributes: true})`).
112
115
  @observer_manager.observers_matching(target).each do |observer|
116
+ entry = observer.find_matching_entry(target)
117
+ next unless entry && entry[:child_list]
118
+
113
119
  observer.enqueue(record)
114
120
  end
115
121
 
@@ -117,11 +123,13 @@ module Dommy
117
123
  end
118
124
 
119
125
  # Fire MutationObserver attribute records
120
- def notify_attribute_mutation(target_node:, attribute_name:, old_value:)
126
+ def notify_attribute_mutation(target_node:, attribute_name:, old_value:, namespace: nil)
121
127
  target = @document.wrap_node(target_node)
122
128
  return nil unless target
123
129
 
124
- attr = attribute_name.to_s.downcase
130
+ # A namespaced attribute keeps its local name as-is; a plain HTML
131
+ # attribute is lower-cased.
132
+ attr = namespace ? attribute_name.to_s : attribute_name.to_s.downcase
125
133
  new_value = target_node[attr]
126
134
 
127
135
  # Custom Element attributeChangedCallback (synchronous)
@@ -139,6 +147,7 @@ module Dommy
139
147
  type: "attributes",
140
148
  target: target,
141
149
  attribute_name: attr,
150
+ attribute_namespace: namespace,
142
151
  old_value: entry[:attribute_old_value] ? old_value : nil
143
152
  )
144
153
  )
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # Namespace constants and the WHATWG DOM "validate and extract" algorithm,
6
+ # shared by createAttributeNS / setAttributeNS / createElementNS.
7
+ module Namespaces
8
+ HTML = "http://www.w3.org/1999/xhtml"
9
+ SVG = "http://www.w3.org/2000/svg"
10
+ MATHML = "http://www.w3.org/1998/Math/MathML"
11
+ XML = "http://www.w3.org/XML/1998/namespace"
12
+ XLINK = "http://www.w3.org/1999/xlink"
13
+ XMLNS = "http://www.w3.org/2000/xmlns/"
14
+
15
+ # XML Name / QName productions, matching the canonical
16
+ # `xml-name-validator` package (what WHATWG DOM "validate" relies on).
17
+ # Built from the XML 1.0 NameStartChar / NameChar Unicode ranges; an
18
+ # NCName excludes ":", a QName is one optional `prefix:` + local NCName.
19
+ NC_START = "A-Za-z_\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D" \
20
+ "\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF" \
21
+ "\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\u{10000}-\\u{EFFFF}"
22
+ NC_EXTRA = "\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040"
23
+ NCSTART = "[#{NC_START}]"
24
+ NCCHAR = "[#{NC_START}#{NC_EXTRA}]"
25
+
26
+ # The full Name production (NameStartChar additionally includes ":").
27
+ NAME = Regexp.new("\\A[:#{NC_START}][:#{NC_START}#{NC_EXTRA}]*\\z")
28
+ # PrefixedName | UnprefixedName.
29
+ QNAME = Regexp.new(
30
+ "(?:\\A#{NCSTART}#{NCCHAR}*:#{NCSTART}#{NCCHAR}*\\z)|(?:\\A#{NCSTART}#{NCCHAR}*\\z)"
31
+ )
32
+
33
+ module_function
34
+
35
+ # https://dom.spec.whatwg.org/#validate-and-extract
36
+ # Returns [namespace_or_nil, prefix_or_nil, local_name]. Raises
37
+ # DOMException (InvalidCharacterError / NamespaceError) on bad input.
38
+ def validate_and_extract(namespace, qualified_name)
39
+ ns = namespace.to_s
40
+ ns = nil if ns.empty?
41
+ qname = qualified_name.to_s
42
+
43
+ unless qname.match?(QNAME)
44
+ raise DOMException::InvalidCharacterError, "invalid qualified name: #{qname.inspect}"
45
+ end
46
+
47
+ prefix = nil
48
+ local = qname
49
+ if qname.include?(":")
50
+ prefix, local = qname.split(":", 2)
51
+ end
52
+
53
+ if prefix && ns.nil?
54
+ raise DOMException::NamespaceError, "prefix #{prefix.inspect} with null namespace"
55
+ end
56
+ if prefix == "xml" && ns != XML
57
+ raise DOMException::NamespaceError, "prefix 'xml' must use the XML namespace"
58
+ end
59
+ if (qname == "xmlns" || prefix == "xmlns") && ns != XMLNS
60
+ raise DOMException::NamespaceError, "'xmlns' must use the XMLNS namespace"
61
+ end
62
+ if ns == XMLNS && qname != "xmlns" && prefix != "xmlns"
63
+ raise DOMException::NamespaceError, "the XMLNS namespace requires the 'xmlns' name/prefix"
64
+ end
65
+
66
+ [ns, prefix, local]
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # WHATWG DOM "equals" / Node.isEqualNode (https://dom.spec.whatwg.org/#concept-node-equals).
6
+ # Operates on Dommy node wrappers through their bridge-facing accessors
7
+ # (__js_get__ for the per-type data, #child_nodes / #attributes for the
8
+ # structure), so it works uniformly across the heterogeneous node classes.
9
+ module NodeEquality
10
+ module_function
11
+
12
+ def equal?(a, b)
13
+ return false if b.nil?
14
+
15
+ type = prop(a, "nodeType")
16
+ return false unless type == prop(b, "nodeType")
17
+ return false unless data_equal?(a, b, type)
18
+
19
+ kids_a = children(a)
20
+ kids_b = children(b)
21
+ return false unless kids_a.length == kids_b.length
22
+
23
+ kids_a.each_index { |i| return false unless equal?(kids_a[i], kids_b[i]) }
24
+ true
25
+ end
26
+
27
+ # Per-type "these two nodes have equal own properties" (children compared
28
+ # separately by #equal?).
29
+ def data_equal?(a, b, type)
30
+ case type
31
+ when Node::ELEMENT_NODE
32
+ prop(a, "namespaceURI") == prop(b, "namespaceURI") &&
33
+ prop(a, "prefix") == prop(b, "prefix") &&
34
+ prop(a, "localName") == prop(b, "localName") &&
35
+ attributes_equal?(a, b)
36
+ when Node::DOCUMENT_TYPE_NODE
37
+ prop(a, "nodeName") == prop(b, "nodeName") &&
38
+ prop(a, "publicId") == prop(b, "publicId") &&
39
+ prop(a, "systemId") == prop(b, "systemId")
40
+ when Node::PROCESSING_INSTRUCTION_NODE
41
+ prop(a, "target") == prop(b, "target") && prop(a, "data") == prop(b, "data")
42
+ when Node::TEXT_NODE, Node::CDATA_SECTION_NODE, Node::COMMENT_NODE
43
+ prop(a, "data") == prop(b, "data")
44
+ when Node::ATTRIBUTE_NODE
45
+ prop(a, "namespaceURI") == prop(b, "namespaceURI") &&
46
+ prop(a, "localName") == prop(b, "localName") &&
47
+ prop(a, "value") == prop(b, "value")
48
+ else
49
+ # Document / DocumentFragment carry no own properties to compare.
50
+ true
51
+ end
52
+ end
53
+
54
+ # Equal attribute lists, order-independent (each attribute of A must have
55
+ # a (namespace, localName, value) match in B, and the counts must match).
56
+ def attributes_equal?(a, b)
57
+ attrs_a = attribute_descriptors(a)
58
+ attrs_b = attribute_descriptors(b)
59
+ return false unless attrs_a.length == attrs_b.length
60
+
61
+ attrs_a.all? { |x| attrs_b.include?(x) }
62
+ end
63
+
64
+ def attribute_descriptors(node)
65
+ return [] unless node.respond_to?(:attributes)
66
+
67
+ map = node.attributes
68
+ (0...map.length).map do |i|
69
+ attr = map.item(i)
70
+ [attr.namespace_uri, attr.local_name, attr.value]
71
+ end
72
+ end
73
+
74
+ def children(node)
75
+ return [] unless node.respond_to?(:child_nodes)
76
+
77
+ list = node.child_nodes
78
+ list.respond_to?(:to_a) ? list.to_a : (0...list.length).map { |i| list.item(i) }
79
+ end
80
+
81
+ def prop(node, key)
82
+ node.__js_get__(key)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -6,8 +6,6 @@ module Dommy
6
6
  # Ensures that wrap_node(nokogiri_node) always returns the same Ruby object.
7
7
  # Separates identity/caching management from Document's public DOM API.
8
8
  class NodeWrapperCache
9
- NAME_RE = /\A[a-z][\w.\-]*\z/i
10
-
11
9
  def initialize(document)
12
10
  @document = document
13
11
  @wrappers = {}
@@ -29,17 +27,36 @@ module Dommy
29
27
  # Factory methods
30
28
 
31
29
  def create_element(name)
32
- str = name.to_s
30
+ str = domstring(name)
33
31
  raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
34
- raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(NAME_RE)
32
+ raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(Namespaces::NAME)
33
+
34
+ # WHATWG createElement: lowercase (ASCII) the name only in an HTML
35
+ # document; the namespace is the HTML namespace for HTML/XHTML documents
36
+ # and null for a non-XHTML XML document. Record the metadata so the
37
+ # element's localName/tagName/namespaceURI getters report it faithfully
38
+ # (in particular case preservation for XML/XHTML).
39
+ if @document.html_document?
40
+ local = str.downcase(:ascii)
41
+ namespace = Element::HTML_NAMESPACE
42
+ else
43
+ local = str
44
+ namespace = @document.content_type == "application/xhtml+xml" ? Element::HTML_NAMESPACE : nil
45
+ end
35
46
 
36
- wrap_node(Backend.create_element(str.downcase, @document.nokogiri_doc))
47
+ wrapper = wrap_node(Backend.create_element(local, @document.nokogiri_doc))
48
+ wrapper.__internal_set_namespace__(namespace, nil, local, local)
49
+ wrapper
37
50
  end
38
51
 
39
52
  def create_text_node(text)
40
53
  wrap_node(Backend.create_text(text.to_s, @document.nokogiri_doc))
41
54
  end
42
55
 
56
+ def create_cdata_section(text)
57
+ wrap_node(Backend.create_cdata(text.to_s, @document.nokogiri_doc))
58
+ end
59
+
43
60
  def create_comment(text)
44
61
  wrap_node(Backend.create_comment(text.to_s, @document.nokogiri_doc))
45
62
  end
@@ -49,46 +66,52 @@ module Dommy
49
66
  end
50
67
 
51
68
  def create_attribute(name)
52
- str = name.to_s
69
+ str = domstring(name)
53
70
  raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
54
- raise DOMException::InvalidCharacterError, "invalid attribute name: #{str.inspect}" unless str.match?(NAME_RE)
71
+ raise DOMException::InvalidCharacterError, "invalid attribute name: #{str.inspect}" unless str.match?(Namespaces::NAME)
55
72
 
56
73
  Attr.new(str)
57
74
  end
58
75
 
59
- def create_attribute_ns(_namespace_uri, qualified_name)
60
- str = qualified_name.to_s
61
- raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
62
- raise DOMException::InvalidCharacterError, "invalid qualified name: #{str.inspect}" unless str.match?(NAME_RE)
63
-
64
- Attr.new(str)
76
+ def create_attribute_ns(namespace_uri, qualified_name)
77
+ namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
78
+ qualified_name = domstring(qualified_name)
79
+ ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name)
80
+ Attr.new(qualified_name, namespace_uri: ns, prefix: prefix, local_name: local)
65
81
  end
66
82
 
67
83
  def create_element_ns(namespace_uri, qualified_name)
68
- str = qualified_name.to_s
69
- raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
70
- raise DOMException::InvalidCharacterError, "invalid qualified name: #{str.inspect}" unless str.match?(NAME_RE)
71
-
72
- el = Backend.create_element(str, @document.nokogiri_doc)
73
- if namespace_uri && !namespace_uri.to_s.empty?
74
- Backend.add_namespace_definition(el, nil, namespace_uri.to_s)
75
- end
76
-
77
- wrap(el)
84
+ # WHATWG "validate and extract": QName-validate the qualifiedName
85
+ # (InvalidCharacterError) and apply the prefix/namespace rules
86
+ # (NamespaceError), then build the element with its prefix bound.
87
+ # namespace is nullable (undefined → null); qualifiedName is a plain
88
+ # DOMString (undefined → "undefined", null → "null").
89
+ namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
90
+ qualified_name = domstring(qualified_name)
91
+ ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name)
92
+
93
+ el = Backend.create_element(qualified_name, @document.nokogiri_doc)
94
+ Backend.add_namespace_definition(el, prefix, ns) if ns
95
+
96
+ wrapper = wrap(el)
97
+ wrapper.__internal_set_namespace__(ns, prefix, local, qualified_name)
98
+ wrapper
78
99
  end
79
100
 
80
101
  # Query methods
81
102
 
82
103
  def query_selector(selector)
83
- return nil if selector.nil? || selector.to_s.empty?
104
+ return nil if selector.nil?
105
+ Internal.validate_selector!(selector)
84
106
 
85
- wrap(@document.nokogiri_doc.at_css(selector.to_s, CSS_PSEUDO_HANDLERS))
107
+ wrap(@document.nokogiri_doc.at_css(Internal.backend_safe_selector(selector.to_s), CSS_PSEUDO_HANDLERS))
86
108
  end
87
109
 
88
110
  def query_selector_all(selector)
89
- return NodeList.new if selector.nil? || selector.to_s.empty?
111
+ return NodeList.new if selector.nil?
112
+ Internal.validate_selector!(selector)
90
113
 
91
- NodeList.new(@document.nokogiri_doc.css(selector.to_s, CSS_PSEUDO_HANDLERS).map { |node| wrap(node) }.compact)
114
+ NodeList.new(@document.nokogiri_doc.css(Internal.backend_safe_selector(selector.to_s), CSS_PSEUDO_HANDLERS).map { |node| wrap(node) }.compact)
92
115
  end
93
116
 
94
117
  def get_element_by_id(id)
@@ -144,6 +167,15 @@ module Dommy
144
167
 
145
168
  private
146
169
 
170
+ # WebIDL DOMString coercion for a name/qualifiedName argument: JS
171
+ # `undefined` → "undefined", JS `null` (Ruby nil) → "null", else #to_s.
172
+ def domstring(value)
173
+ return "undefined" if value.equal?(Bridge::UNDEFINED)
174
+ return "null" if value.nil?
175
+
176
+ value.to_s
177
+ end
178
+
147
179
  def wrap_node(node)
148
180
  wrap(node)
149
181
  end
@@ -152,6 +184,9 @@ module Dommy
152
184
  case node
153
185
  when Backend.element_class
154
186
  build_element_wrapper(node)
187
+ when ->(n) { Backend.cdata_class && n.is_a?(Backend.cdata_class) }
188
+ # CDATA is a Text subtype in the backend, so match it before text_class.
189
+ CDATASectionNode.new(@document, node)
155
190
  when Backend.text_class
156
191
  TextNode.new(@document, node)
157
192
  when Backend.comment_class
@@ -14,11 +14,7 @@ module Dommy
14
14
  private
15
15
 
16
16
  def invoke_callback(entries)
17
- if @callback.respond_to?(:__js_call__)
18
- @callback.__js_call__("call", [entries, self])
19
- elsif @callback.respond_to?(:call)
20
- @callback.call(entries, self)
21
- end
17
+ CallableInvoker.invoke(@callback, entries, self)
22
18
  end
23
19
  end
24
20
  end