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
:identifieroutside 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 matchesa[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/:disabledapply 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
- .backend_safe_selector(selector) ⇒ Object
-
.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(*)). -
.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), -
.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.. -
.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" andundefined→ "undefined" (which then match<null>/<undefined>typed elements rather than returning nothing), while a missing argument is a TypeError. -
.enableable?(backend_node) ⇒ Boolean
:enabled/:disabledapply only to disableable form controls — not to links or any other element, which the backends wrongly match. -
.form_control_disabled?(backend_node) ⇒ Boolean
A form control is disabled when it carries the
disabledattribute, or — for an -
.lang_match?(backend_node, lang) ⇒ Boolean
:lang(x)matching (BCP47 extended filtering): an element's content language is the value of the nearestlangattribute on it or an ancestor, and:lang(x)matches when that language equalsxor begins withx+ "-" (case-insensitively). - .lang_pseudo_value(selector) ⇒ Object
- .matching_paren_index(string, open_index) ⇒ Object
- .nearest_lang(backend_node) ⇒ Object
-
.self_or_ancestor_of?(backend_node, target, document) ⇒ Boolean
:hovermatches the hovered element and all its ancestors; same shape for:focus-withinagainst the focused element. -
.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).
-
.strip_namespace_prefixes(selector) ⇒ Object
Drop CSS namespace prefixes (
*|,|) that precede a type or attribute name. -
.target_id(document) ⇒ Object
The id referenced by the document's URL fragment (
:target), or nil when there is no fragment. -
.validate_selector!(selector) ⇒ Object
Validate a non-null CSS selector for
querySelector/matches/closest, raising SyntaxError for syntactically invalid selectors. -
.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.
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),
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.
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
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.
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.
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..include?("Unregistered function") if (defined?(::Nokogiri::CSS::SyntaxError) && e.is_a?(::Nokogiri::CSS::SyntaxError)) || e..include?("unexpected") raise DOMException::SyntaxError, "'#{selector}' is not a valid selector." end raise end |