Module: Dommy::Internal

Defined in:
lib/dommy/internal/idna.rb,
lib/dommy/internal/css/calc.rb,
lib/dommy/internal/punycode.rb,
lib/dommy/internal/aria_role.rb,
lib/dommy/internal/css/color.rb,
lib/dommy/internal/idna_data.rb,
lib/dommy/internal/aria_state.rb,
lib/dommy/internal/child_node.rb,
lib/dommy/internal/cookie_jar.rb,
lib/dommy/internal/css/parser.rb,
lib/dommy/internal/namespaces.rb,
lib/dommy/internal/url_parser.rb,
lib/dommy/internal/css/cascade.rb,
lib/dommy/internal/ipv4_parser.rb,
lib/dommy/internal/parent_node.rb,
lib/dommy/internal/css/counters.rb,
lib/dommy/internal/css/supports.rb,
lib/dommy/internal/dom_matching.rb,
lib/dommy/internal/selector_ast.rb,
lib/dommy/internal/aria_snapshot.rb,
lib/dommy/internal/css_rule_text.rb,
lib/dommy/internal/node_equality.rb,
lib/dommy/internal/css/rule_index.rb,
lib/dommy/internal/node_traversal.rb,
lib/dommy/internal/selector_index.rb,
lib/dommy/internal/accessible_name.rb,
lib/dommy/internal/css/media_query.rb,
lib/dommy/internal/selector_parser.rb,
lib/dommy/internal/element_matching.rb,
lib/dommy/internal/global_functions.rb,
lib/dommy/internal/observer_manager.rb,
lib/dommy/internal/observer_matcher.rb,
lib/dommy/internal/scope_resolution.rb,
lib/dommy/internal/selector_matcher.rb,
lib/dommy/internal/css/ua_stylesheet.rb,
lib/dommy/internal/xml_serialization.rb,
lib/dommy/internal/accessibility_tree.rb,
lib/dommy/internal/node_wrapper_cache.rb,
lib/dommy/internal/css_pseudo_handlers.rb,
lib/dommy/internal/observable_callback.rb,
lib/dommy/internal/mutation_coordinator.rb,
lib/dommy/internal/reflected_attributes.rb,
lib/dommy/internal/shadow_root_registry.rb,
lib/dommy/internal/css/custom_properties.rb,
lib/dommy/internal/css/property_registry.rb,
lib/dommy/internal/range_text_serializer.rb,
lib/dommy/internal/accessible_description.rb,
lib/dommy/internal/template_content_registry.rb,
lib/dommy/internal/css/computed_style_declaration.rb

Defined Under Namespace

Modules: AccessibilityTree, AccessibleDescription, AccessibleName, AriaRole, AriaSnapshot, AriaState, CSS, CSSRuleText, ChildNode, DomMatching, ElementMatching, GlobalFunctions, IDNA, IDNAData, Ipv4Parser, Namespaces, NodeEquality, NodeTraversal, ObservableCallback, ObserverMatcher, ParentNode, Punycode, ReflectedAttributes, ScopeResolution, SelectorAST, SelectorMatcher, SelectorParser, UrlParser, XmlSerialization Classes: CookieJar, MutationCoordinator, NodeWrapperCache, ObserverManager, RangeTextSerializer, SelectorIndex, ShadowRootRegistry, TemplateContentRegistry

Constant Summary collapse

KNOWN_PSEUDOS =

The complete set of CSS pseudo-classes (+ the four legacy single-colon pseudo-elements). A :identifier outside this set is an unknown selector token → SyntaxError, whereas a known-but-unimplemented one (:hover) is a valid selector that simply matches nothing.

%w[
  active any-link autofill blank checked current default defined disabled empty
  enabled first first-child first-of-type focus focus-visible focus-within
  fullscreen future has host hover in-range indeterminate invalid is lang
  last-child last-of-type left link local-link modal not nth-child nth-col
  nth-last-child nth-last-col nth-last-of-type nth-of-type only-child
  only-of-type optional out-of-range past placeholder-shown playing paused
  read-only read-write required right root scope target target-within
  user-invalid user-valid valid visited where dir
  before after first-line first-letter
].to_set.freeze
ATTR_ESCAPED_COLON =

Nokogiri's CSS→XPath compiler chokes on an escaped colon INSIDE an attribute selector ([xlink\:href], a namespaced/SVG attribute → "Invalid predicate"), though it handles escaped colons in class/id selectors fine (.md\:flex, #a\:b — Tailwind). Those attribute selectors target XML-namespaced attributes the HTML backend doesn't model, so drop just the comma-clauses that use them; the rest of the selector list is preserved. (Real frameworks hit this constantly — Turbo's click handler matches a[href], a[xlink\:href] on every click.) Returns a backend-safe selector; if every clause was unsupported, returns one that compiles but never matches.

/\[[^\]]*\\:[^\]]*\]/
LANG_PSEUDO =

The argument of a :lang(X) pseudo-class, when the selector has exactly one distinct one (the common #x:lang(en) shape); nil when there is none. Multiple distinct languages can't be recovered from the result set alone, so those fall back to the stripped backend selector (which over-matches).

/:lang\(\s*("?)([^)"']*)\1\s*\)/i
ENABLEABLE_ELEMENTS =

The disableable form-control elements :enabled / :disabled apply to.

%w[button input select textarea optgroup option fieldset].freeze
ENABLED_DISABLED_PSEUDO =
/:(?:enabled|disabled)(?![\w-])/
STATE_PSEUDO =

State pseudo-classes evaluated by post-filter against DOM state (longest alternatives first so :focus doesn't shadow :focus-within).

/:(?:hover|focus-within|focus-visible|focus|checked)(?![\w-])/
STATE_FUNCTIONS =
%w[not is where has].freeze

Class Method Summary collapse

Class Method Details

.backend_safe_selector(selector) ⇒ Object



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
112
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 63

def self.backend_safe_selector(selector)
  # First drop clauses whose subject is a pseudo-element (`::before`,
  # `:first-line`) — they match no element, and the backend can't compile
  # `::`. Then normalise two valid-but-backend-unfriendly forms before the
  # escaped-colon handling: a trailing unclosed `[`/`(` (CSS closes these at
  # EOF) and namespace prefixes (`*|attr`, `|el` — in an HTML document every
  # node/attribute is in the null namespace, so the prefix is matched away).
  s = SelectorParser.matchable_selector(selector.to_s)
  s = strip_namespace_prefixes(close_open_brackets(s))
  # `:visited` never matches without browsing history (which Dommy doesn't
  # model), so reduce it to a never-match — and, as a bonus, drop the
  # dependency on a backend that can't compile `:visited` (lexbor) while
  # `:link` (an unvisited link) is matched normally.
  s = s.gsub(/:visited(?![\w-])/, ":not(*)") if s.include?(":visited")
  # Neither backend's selector engine implements `:lang()` (lexbor registers
  # it but its parse handler is a deliberate fail-stub). Strip it for the
  # backend; the caller post-filters matches with #lang_match? — see the
  # query methods.
  s = s.gsub(LANG_PSEUDO, "") if s =~ /:lang\(/i
  # `:target` (the element referenced by the document's URL fragment) is also
  # unsupported by the backends; strip it and post-filter by id. A bare
  # `:target` collapses to the universal selector.
  if s =~ /:target(?![\w-])/
    s = s.gsub(/:target(?![\w-])/, "")
    s = "*" if s.strip.empty?
  end
  # Both backends treat `:enabled` / `:disabled` as always-true (matching
  # every element, not just disableable form controls), so strip them and
  # post-filter with #enableable?/#form_control_disabled? — see the query
  # methods. A standalone occurrence (`#x :enabled`) becomes the universal
  # selector so the combinator keeps a subject.
  if s =~ ENABLED_DISABLED_PSEUDO
    s = s.gsub(/(^|[\s>+~,(])\s*:(?:enabled|disabled)(?![\w-])/) { "#{Regexp.last_match(1)}*" }
    s = s.gsub(ENABLED_DISABLED_PSEUDO, "")
    s = "*" if s.strip.empty?
  end
  # Legacy backend-safe rewriting for callers that still delegate to a
  # backend selector engine. The primary DOM query and cascade paths use
  # SelectorMatcher and do not strip state pseudo-classes.
  if s =~ STATE_PSEUDO
    s = broaden_state_pseudo_functions(s)
    s = s.gsub(/(^|[\s>+~,(])\s*:(?:hover|focus-within|focus-visible|focus|checked)(?![\w-])/) { "#{Regexp.last_match(1)}*" }
    s = s.gsub(STATE_PSEUDO, "")
    s = "*" if s.strip.empty?
  end
  return s unless s.include?('\\') && s.match?(ATTR_ESCAPED_COLON)

  kept = split_selector_list(s).reject { |clause| clause.match?(ATTR_ESCAPED_COLON) }
  kept.empty? ? ":not(*)" : kept.join(", ")
end

.broaden_state_pseudo_functions(selector) ⇒ Object

If a functional pseudo-class contains a state pseudo, dropping only the state token changes logic (:not(:hover) -> :not(*)). Drop the whole function for candidate collection instead; the structural filter above restores exact semantics.



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 235

def self.broaden_state_pseudo_functions(selector)
  s = selector.to_s
  out = +""
  i = 0
  while i < s.length
    fn = STATE_FUNCTIONS.find do |name|
      token = s[i, name.length + 2]
      token && token.casecmp?(":#{name}(")
    end
    if fn
      close = matching_paren_index(s, i + fn.length + 1)
      if close && s[i..close] =~ STATE_PSEUDO
        i = close + 1
        next
      end
    end
    out << s[i]
    i += 1
  end
  out
end

.checked_state?(element) ⇒ Boolean

:checked's checkedness/selectedness is live state, not the attribute: checkbox/radio inputs match on the checked property (which defaults to the attribute),

Returns:

  • (Boolean)


174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 174

def self.checked_state?(element)
  return false unless element

  case element.tag_name
  when "INPUT"
    %w[checkbox radio].include?(element.respond_to?(:type) ? element.type.to_s : "") &&
      element.respond_to?(:checked) && !!element.checked
  when "OPTION"
    element.respond_to?(:selected) && !!element.selected
  else
    false
  end
end

.close_open_brackets(selector) ⇒ Object

Append the closers for any [/( left open outside a string — CSS tokenizing implicitly closes them at EOF, so [align="center" is the valid [align="center"], but the backend selector engines reject the unclosed form. String contents and escapes are skipped.



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 287

def self.close_open_brackets(selector)
  sq = 0
  pr = 0
  quote = nil
  esc = false
  selector.each_char do |ch|
    if esc
      esc = false
    elsif ch == "\\"
      esc = true
    elsif quote
      quote = nil if ch == quote
    elsif ch == '"' || ch == "'"
      quote = ch
    elsif ch == "["
      sq += 1
    elsif ch == "]"
      sq -= 1 if sq.positive?
    elsif ch == "("
      pr += 1
    elsif ch == ")"
      pr -= 1 if pr.positive?
    end
  end
  selector + ("]" * sq) + (")" * pr)
end

.css_query_arg!(args) ⇒ Object

Coerce the JS argument of a query method (querySelector/All) per WebIDL: the selector is a non-nullable DOMString, so JS null → "null" and undefined → "undefined" (which then match <null> / <undefined> typed elements rather than returning nothing), while a missing argument is a TypeError. Used at every JS dispatch site so the behaviour is uniform.



42
43
44
45
46
47
48
49
50
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 42

def self.css_query_arg!(args)
  raise ::Dommy::Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?

  value = args[0]
  return "null" if value.nil?
  return "undefined" if defined?(::Dommy::Bridge::UNDEFINED) && value.equal?(::Dommy::Bridge::UNDEFINED)

  value
end

.enableable?(backend_node) ⇒ Boolean

:enabled / :disabled apply only to disableable form controls — not to links or any other element, which the backends wrongly match.

Returns:

  • (Boolean)


190
191
192
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 190

def self.enableable?(backend_node)
  ENABLEABLE_ELEMENTS.include?(backend_node.name.to_s.downcase)
end

.form_control_disabled?(backend_node) ⇒ Boolean

A form control is disabled when it carries the disabled attribute, or — for an is disabled. (The disabled-

descendant propagation is not modeled.)

Returns:

  • (Boolean)


197
198
199
200
201
202
203
204
205
206
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 197

def self.form_control_disabled?(backend_node)
  return true unless backend_node["disabled"].nil?

  if backend_node.name.to_s.downcase == "option"
    parent = backend_node.respond_to?(:parent) ? backend_node.parent : nil
    return true if parent.respond_to?(:name) &&
                   parent.name.to_s.downcase == "optgroup" && !parent["disabled"].nil?
  end
  false
end

.lang_match?(backend_node, lang) ⇒ Boolean

:lang(x) matching (BCP47 extended filtering): an element's content language is the value of the nearest lang attribute on it or an ancestor, and :lang(x) matches when that language equals x or begins with x + "-" (case-insensitively). No lang in the chain → no match.

Returns:

  • (Boolean)


128
129
130
131
132
133
134
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 128

def self.lang_match?(backend_node, lang)
  actual = nearest_lang(backend_node)
  return false unless actual

  a = actual.downcase
  a == lang || a.start_with?("#{lang}-")
end

.lang_pseudo_value(selector) ⇒ Object



119
120
121
122
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 119

def self.lang_pseudo_value(selector)
  langs = selector.to_s.scan(LANG_PSEUDO).map { |m| m[1].strip.downcase }.reject(&:empty?).uniq
  langs.size == 1 ? langs.first : nil
end

.matching_paren_index(string, open_index) ⇒ Object



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 257

def self.matching_paren_index(string, open_index)
  depth = 0
  quote = nil
  esc = false
  i = open_index
  while i < string.length
    ch = string[i]
    if esc
      esc = false
    elsif ch == "\\"
      esc = true
    elsif quote
      quote = nil if ch == quote
    elsif ch == '"' || ch == "'"
      quote = ch
    elsif ch == "("
      depth += 1
    elsif ch == ")"
      depth -= 1
      return i if depth.zero?
    end
    i += 1
  end
  nil
end

.nearest_lang(backend_node) ⇒ Object



217
218
219
220
221
222
223
224
225
226
227
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 217

def self.nearest_lang(backend_node)
  node = backend_node
  while node
    if node.respond_to?(:element?) && node.element?
      v = node["lang"]
      return v if v && !v.to_s.empty?
    end
    node = node.respond_to?(:parent) ? node.parent : nil
  end
  nil
end

.self_or_ancestor_of?(backend_node, target, document) ⇒ Boolean

:hover matches the hovered element and all its ancestors; same shape for :focus-within against the focused element.

Returns:

  • (Boolean)


164
165
166
167
168
169
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 164

def self.self_or_ancestor_of?(backend_node, target, document)
  element = document.wrap_node(backend_node)
  return false unless element

  element == target || (element.respond_to?(:contains?) && element.contains?(target))
end

.split_selector_list(selector) ⇒ Object

Split a selector list on top-level commas only (commas inside [...], (...), or quotes are part of a single complex selector and must not split it).



353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 353

def self.split_selector_list(selector)
  clauses = []
  depth = 0
  quote = nil
  current = +""
  selector.each_char do |ch|
    if quote
      quote = nil if ch == quote
    elsif ch == '"' || ch == "'"
      quote = ch
    elsif ch == "[" || ch == "("
      depth += 1
    elsif ch == "]" || ch == ")"
      depth -= 1 if depth.positive?
    elsif ch == "," && depth.zero?
      clauses << current.strip
      current = +""
      next
    end
    current << ch
  end
  clauses << current.strip
  clauses.reject(&:empty?)
end

.strip_namespace_prefixes(selector) ⇒ Object

Drop CSS namespace prefixes (*|, |) that precede a type or attribute name. An HTML document has only the HTML / null namespaces, so *|x (any namespace) and |x (no namespace) both reduce to x. Leaves the |= attribute matcher and the || column combinator intact, and never touches a | inside a string.



319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 319

def self.strip_namespace_prefixes(selector)
  out = +""
  quote = nil
  esc = false
  chars = selector.chars
  i = 0
  while i < chars.length
    ch = chars[i]
    if esc
      out << ch
      esc = false
    elsif ch == "\\"
      out << ch
      esc = true
    elsif quote
      out << ch
      quote = nil if ch == quote
    elsif ch == '"' || ch == "'"
      quote = ch
      out << ch
    elsif ch == "*" && chars[i + 1] == "|" && chars[i + 2] != "=" && chars[i + 2] != "|"
      i += 1 # skip the `*`; the `|` is handled next iteration
    elsif ch == "|" && chars[i + 1] != "=" && chars[i + 1] != "|" && out[-1] != "|"
      # bare namespace separator — drop it (not `|=`, not `||`)
    else
      out << ch
    end
    i += 1
  end
  out
end

.target_id(document) ⇒ Object

The id referenced by the document's URL fragment (:target), or nil when there is no fragment.



210
211
212
213
214
215
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 210

def self.target_id(document)
  view = document.respond_to?(:default_view) ? document.default_view : nil
  loc = view.respond_to?(:location) ? view.location : nil if view
  hash = loc&.__js_get__("hash").to_s
  hash.start_with?("#") && hash.length > 1 ? hash[1..] : nil
end

.validate_selector!(selector) ⇒ Object

Validate a non-null CSS selector for querySelector/matches/closest, raising SyntaxError for syntactically invalid selectors. Delegates to the full grammar parser (SelectorParser), which catches everything the old heuristic did (empty string, leading combinator, unknown pseudo-class) plus the rest of the Selectors grammar ([*=v], ..x, div % p, unknown pseudo-elements, undeclared namespaces, …) that Nokogiri silently accepts.



33
34
35
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 33

def self.validate_selector!(selector)
  SelectorParser.validate!(selector.to_s)
end

.with_selector_errors(selector) ⇒ Object

Run a backend selector evaluation with the shared error policy:

  • an "Unregistered function" means a valid pseudo the backend compiled but can't evaluate (:active, :invalid, …) → degrade to no match (returns []),
  • a backend syntax complaint becomes a DOMException::SyntaxError,
  • anything else propagates.


150
151
152
153
154
155
156
157
158
159
160
# File 'lib/dommy/internal/css_pseudo_handlers.rb', line 150

def self.with_selector_errors(selector)
  yield
rescue ::StandardError => e
  return [] if e.message.include?("Unregistered function")

  if (defined?(::Nokogiri::CSS::SyntaxError) && e.is_a?(::Nokogiri::CSS::SyntaxError)) || e.message.include?("unexpected")
    raise DOMException::SyntaxError, "'#{selector}' is not a valid selector."
  end

  raise
end