dommy 0.8.0 → 0.9.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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Internal
|
|
7
|
+
module CSS
|
|
8
|
+
# The minimal UA stylesheet (HTML Standard, Rendering section): the
|
|
9
|
+
# non-rendering / hidden defaults that visibility detection depends on,
|
|
10
|
+
# display type defaults, and details/dialog behavior. Kept deliberately
|
|
11
|
+
# small — anything an author sheet usually overrides anyway is omitted.
|
|
12
|
+
#
|
|
13
|
+
# Selectors must stay within what both DOM backends can match
|
|
14
|
+
# (so no case-insensitive attribute flags, no state pseudo-classes).
|
|
15
|
+
module UAStylesheet
|
|
16
|
+
TEXT = <<~CSS
|
|
17
|
+
[hidden] { display: none }
|
|
18
|
+
area, base, basefont, datalist, head, link, meta, noembed,
|
|
19
|
+
noframes, param, rp, script, style, template, title { display: none }
|
|
20
|
+
input[type="hidden"] { display: none }
|
|
21
|
+
dialog:not([open]) { display: none }
|
|
22
|
+
details:not([open]) > *:not(summary) { display: none }
|
|
23
|
+
|
|
24
|
+
html, body, address, article, aside, blockquote, details, dialog,
|
|
25
|
+
dd, div, dl, dt, fieldset, figcaption, figure, footer, form,
|
|
26
|
+
h1, h2, h3, h4, h5, h6, header, hgroup, hr, legend, main, nav, ol, p,
|
|
27
|
+
pre, section, summary, ul { display: block }
|
|
28
|
+
li { display: list-item }
|
|
29
|
+
table { display: table }
|
|
30
|
+
caption { display: table-caption }
|
|
31
|
+
colgroup { display: table-column-group }
|
|
32
|
+
col { display: table-column }
|
|
33
|
+
thead { display: table-header-group }
|
|
34
|
+
tbody { display: table-row-group }
|
|
35
|
+
tfoot { display: table-footer-group }
|
|
36
|
+
tr { display: table-row }
|
|
37
|
+
td, th { display: table-cell }
|
|
38
|
+
|
|
39
|
+
b, strong { font-weight: 700 }
|
|
40
|
+
i, em, cite, var, dfn { font-style: italic }
|
|
41
|
+
pre, code, kbd, samp { font-family: monospace }
|
|
42
|
+
center { text-align: center }
|
|
43
|
+
CSS
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def rules
|
|
48
|
+
@rules ||= Parser.parse(TEXT).freeze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -2,47 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Dommy
|
|
4
4
|
module Internal
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
class CSSPseudoHandlers < BasicObject
|
|
11
|
-
include ::Kernel
|
|
12
|
-
|
|
13
|
-
def disabled(list)
|
|
14
|
-
list.find_all { |node| node.has_attribute?("disabled") }
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def enabled(list)
|
|
18
|
-
list.find_all { |node| !node.has_attribute?("disabled") }
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def checked(list)
|
|
22
|
-
list.find_all { |node| node.has_attribute?("checked") }
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
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
|
|
5
|
+
# CSS selector validation and sanitization shared by all backends. The
|
|
6
|
+
# custom pseudo-class *evaluation* (`:disabled`/`:enabled`/`:checked`/
|
|
7
|
+
# `:scope`) is backend-specific and lives in each backend adapter (see
|
|
8
|
+
# `Backend.select_all`); what stays here is the backend-independent grammar
|
|
9
|
+
# checking and the backend-safe rewriting that precedes every query.
|
|
46
10
|
|
|
47
11
|
# The complete set of CSS pseudo-classes (+ the four legacy single-colon
|
|
48
12
|
# pseudo-elements). A `:identifier` outside this set is an unknown selector
|
|
@@ -99,14 +63,291 @@ module Dommy
|
|
|
99
63
|
def self.backend_safe_selector(selector)
|
|
100
64
|
# First drop clauses whose subject is a pseudo-element (`::before`,
|
|
101
65
|
# `:first-line`) — they match no element, and the backend can't compile
|
|
102
|
-
# `::`. Then
|
|
66
|
+
# `::`. Then normalise two valid-but-backend-unfriendly forms before the
|
|
67
|
+
# escaped-colon handling: a trailing unclosed `[`/`(` (CSS closes these at
|
|
68
|
+
# EOF) and namespace prefixes (`*|attr`, `|el` — in an HTML document every
|
|
69
|
+
# node/attribute is in the null namespace, so the prefix is matched away).
|
|
103
70
|
s = SelectorParser.matchable_selector(selector.to_s)
|
|
71
|
+
s = strip_namespace_prefixes(close_open_brackets(s))
|
|
72
|
+
# `:visited` never matches without browsing history (which Dommy doesn't
|
|
73
|
+
# model), so reduce it to a never-match — and, as a bonus, drop the
|
|
74
|
+
# dependency on a backend that can't compile `:visited` (lexbor) while
|
|
75
|
+
# `:link` (an unvisited link) is matched normally.
|
|
76
|
+
s = s.gsub(/:visited(?![\w-])/, ":not(*)") if s.include?(":visited")
|
|
77
|
+
# Neither backend's selector engine implements `:lang()` (lexbor registers
|
|
78
|
+
# it but its parse handler is a deliberate fail-stub). Strip it for the
|
|
79
|
+
# backend; the caller post-filters matches with #lang_match? — see the
|
|
80
|
+
# query methods.
|
|
81
|
+
s = s.gsub(LANG_PSEUDO, "") if s =~ /:lang\(/i
|
|
82
|
+
# `:target` (the element referenced by the document's URL fragment) is also
|
|
83
|
+
# unsupported by the backends; strip it and post-filter by id. A bare
|
|
84
|
+
# `:target` collapses to the universal selector.
|
|
85
|
+
if s =~ /:target(?![\w-])/
|
|
86
|
+
s = s.gsub(/:target(?![\w-])/, "")
|
|
87
|
+
s = "*" if s.strip.empty?
|
|
88
|
+
end
|
|
89
|
+
# Both backends treat `:enabled` / `:disabled` as always-true (matching
|
|
90
|
+
# every element, not just disableable form controls), so strip them and
|
|
91
|
+
# post-filter with #enableable?/#form_control_disabled? — see the query
|
|
92
|
+
# methods. A standalone occurrence (`#x :enabled`) becomes the universal
|
|
93
|
+
# selector so the combinator keeps a subject.
|
|
94
|
+
if s =~ ENABLED_DISABLED_PSEUDO
|
|
95
|
+
s = s.gsub(/(^|[\s>+~,(])\s*:(?:enabled|disabled)(?![\w-])/) { "#{Regexp.last_match(1)}*" }
|
|
96
|
+
s = s.gsub(ENABLED_DISABLED_PSEUDO, "")
|
|
97
|
+
s = "*" if s.strip.empty?
|
|
98
|
+
end
|
|
99
|
+
# Legacy backend-safe rewriting for callers that still delegate to a
|
|
100
|
+
# backend selector engine. The primary DOM query and cascade paths use
|
|
101
|
+
# SelectorMatcher and do not strip state pseudo-classes.
|
|
102
|
+
if s =~ STATE_PSEUDO
|
|
103
|
+
s = broaden_state_pseudo_functions(s)
|
|
104
|
+
s = s.gsub(/(^|[\s>+~,(])\s*:(?:hover|focus-within|focus-visible|focus|checked)(?![\w-])/) { "#{Regexp.last_match(1)}*" }
|
|
105
|
+
s = s.gsub(STATE_PSEUDO, "")
|
|
106
|
+
s = "*" if s.strip.empty?
|
|
107
|
+
end
|
|
104
108
|
return s unless s.include?('\\') && s.match?(ATTR_ESCAPED_COLON)
|
|
105
109
|
|
|
106
110
|
kept = split_selector_list(s).reject { |clause| clause.match?(ATTR_ESCAPED_COLON) }
|
|
107
111
|
kept.empty? ? ":not(*)" : kept.join(", ")
|
|
108
112
|
end
|
|
109
113
|
|
|
114
|
+
# The argument of a `:lang(X)` pseudo-class, when the selector has exactly
|
|
115
|
+
# one distinct one (the common `#x:lang(en)` shape); nil when there is none.
|
|
116
|
+
# Multiple distinct languages can't be recovered from the result set alone,
|
|
117
|
+
# so those fall back to the stripped backend selector (which over-matches).
|
|
118
|
+
LANG_PSEUDO = /:lang\(\s*("?)([^)"']*)\1\s*\)/i
|
|
119
|
+
def self.lang_pseudo_value(selector)
|
|
120
|
+
langs = selector.to_s.scan(LANG_PSEUDO).map { |m| m[1].strip.downcase }.reject(&:empty?).uniq
|
|
121
|
+
langs.size == 1 ? langs.first : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `:lang(x)` matching (BCP47 extended filtering): an element's content
|
|
125
|
+
# language is the value of the nearest `lang` attribute on it or an
|
|
126
|
+
# ancestor, and `:lang(x)` matches when that language equals `x` or begins
|
|
127
|
+
# with `x` + "-" (case-insensitively). No `lang` in the chain → no match.
|
|
128
|
+
def self.lang_match?(backend_node, lang)
|
|
129
|
+
actual = nearest_lang(backend_node)
|
|
130
|
+
return false unless actual
|
|
131
|
+
|
|
132
|
+
a = actual.downcase
|
|
133
|
+
a == lang || a.start_with?("#{lang}-")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The disableable form-control elements `:enabled` / `:disabled` apply to.
|
|
137
|
+
ENABLEABLE_ELEMENTS = %w[button input select textarea optgroup option fieldset].freeze
|
|
138
|
+
ENABLED_DISABLED_PSEUDO = /:(?:enabled|disabled)(?![\w-])/
|
|
139
|
+
|
|
140
|
+
# State pseudo-classes evaluated by post-filter against DOM state
|
|
141
|
+
# (longest alternatives first so :focus doesn't shadow :focus-within).
|
|
142
|
+
STATE_PSEUDO = /:(?:hover|focus-within|focus-visible|focus|checked)(?![\w-])/
|
|
143
|
+
|
|
144
|
+
# Run a backend selector evaluation with the shared error policy:
|
|
145
|
+
# - an "Unregistered function" means a valid pseudo the backend compiled
|
|
146
|
+
# but can't evaluate (`:active`, `:invalid`, …) → degrade to no match
|
|
147
|
+
# (returns []),
|
|
148
|
+
# - a backend syntax complaint becomes a DOMException::SyntaxError,
|
|
149
|
+
# - anything else propagates.
|
|
150
|
+
def self.with_selector_errors(selector)
|
|
151
|
+
yield
|
|
152
|
+
rescue ::StandardError => e
|
|
153
|
+
return [] if e.message.include?("Unregistered function")
|
|
154
|
+
|
|
155
|
+
if (defined?(::Nokogiri::CSS::SyntaxError) && e.is_a?(::Nokogiri::CSS::SyntaxError)) || e.message.include?("unexpected")
|
|
156
|
+
raise DOMException::SyntaxError, "'#{selector}' is not a valid selector."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
raise
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `:hover` matches the hovered element and all its ancestors; same shape
|
|
163
|
+
# for `:focus-within` against the focused element.
|
|
164
|
+
def self.self_or_ancestor_of?(backend_node, target, document)
|
|
165
|
+
element = document.wrap_node(backend_node)
|
|
166
|
+
return false unless element
|
|
167
|
+
|
|
168
|
+
element == target || (element.respond_to?(:contains?) && element.contains?(target))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# `:checked`'s checkedness/selectedness is live state, not the attribute:
|
|
172
|
+
# checkbox/radio inputs match on the checked property (which defaults to
|
|
173
|
+
# the attribute), <option> on selectedness.
|
|
174
|
+
def self.checked_state?(element)
|
|
175
|
+
return false unless element
|
|
176
|
+
|
|
177
|
+
case element.tag_name
|
|
178
|
+
when "INPUT"
|
|
179
|
+
%w[checkbox radio].include?(element.respond_to?(:type) ? element.type.to_s : "") &&
|
|
180
|
+
element.respond_to?(:checked) && !!element.checked
|
|
181
|
+
when "OPTION"
|
|
182
|
+
element.respond_to?(:selected) && !!element.selected
|
|
183
|
+
else
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# `:enabled` / `:disabled` apply only to disableable form controls — not to
|
|
189
|
+
# links or any other element, which the backends wrongly match.
|
|
190
|
+
def self.enableable?(backend_node)
|
|
191
|
+
ENABLEABLE_ELEMENTS.include?(backend_node.name.to_s.downcase)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A form control is disabled when it carries the `disabled` attribute, or — for
|
|
195
|
+
# an <option> — its containing <optgroup> is disabled. (The disabled-<fieldset>
|
|
196
|
+
# descendant propagation is not modeled.)
|
|
197
|
+
def self.form_control_disabled?(backend_node)
|
|
198
|
+
return true unless backend_node["disabled"].nil?
|
|
199
|
+
|
|
200
|
+
if backend_node.name.to_s.downcase == "option"
|
|
201
|
+
parent = backend_node.respond_to?(:parent) ? backend_node.parent : nil
|
|
202
|
+
return true if parent.respond_to?(:name) &&
|
|
203
|
+
parent.name.to_s.downcase == "optgroup" && !parent["disabled"].nil?
|
|
204
|
+
end
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The id referenced by the document's URL fragment (`:target`), or nil when
|
|
209
|
+
# there is no fragment.
|
|
210
|
+
def self.target_id(document)
|
|
211
|
+
view = document.respond_to?(:default_view) ? document.default_view : nil
|
|
212
|
+
loc = view.respond_to?(:location) ? view.location : nil if view
|
|
213
|
+
hash = loc&.__js_get__("hash").to_s
|
|
214
|
+
hash.start_with?("#") && hash.length > 1 ? hash[1..] : nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def self.nearest_lang(backend_node)
|
|
218
|
+
node = backend_node
|
|
219
|
+
while node
|
|
220
|
+
if node.respond_to?(:element?) && node.element?
|
|
221
|
+
v = node["lang"]
|
|
222
|
+
return v if v && !v.to_s.empty?
|
|
223
|
+
end
|
|
224
|
+
node = node.respond_to?(:parent) ? node.parent : nil
|
|
225
|
+
end
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
STATE_FUNCTIONS = %w[not is where has].freeze
|
|
230
|
+
|
|
231
|
+
# If a functional pseudo-class contains a state pseudo, dropping only the
|
|
232
|
+
# state token changes logic (`:not(:hover)` -> `:not(*)`). Drop the whole
|
|
233
|
+
# function for candidate collection instead; the structural filter above
|
|
234
|
+
# restores exact semantics.
|
|
235
|
+
def self.broaden_state_pseudo_functions(selector)
|
|
236
|
+
s = selector.to_s
|
|
237
|
+
out = +""
|
|
238
|
+
i = 0
|
|
239
|
+
while i < s.length
|
|
240
|
+
fn = STATE_FUNCTIONS.find do |name|
|
|
241
|
+
token = s[i, name.length + 2]
|
|
242
|
+
token && token.casecmp?(":#{name}(")
|
|
243
|
+
end
|
|
244
|
+
if fn
|
|
245
|
+
close = matching_paren_index(s, i + fn.length + 1)
|
|
246
|
+
if close && s[i..close] =~ STATE_PSEUDO
|
|
247
|
+
i = close + 1
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
out << s[i]
|
|
252
|
+
i += 1
|
|
253
|
+
end
|
|
254
|
+
out
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def self.matching_paren_index(string, open_index)
|
|
258
|
+
depth = 0
|
|
259
|
+
quote = nil
|
|
260
|
+
esc = false
|
|
261
|
+
i = open_index
|
|
262
|
+
while i < string.length
|
|
263
|
+
ch = string[i]
|
|
264
|
+
if esc
|
|
265
|
+
esc = false
|
|
266
|
+
elsif ch == "\\"
|
|
267
|
+
esc = true
|
|
268
|
+
elsif quote
|
|
269
|
+
quote = nil if ch == quote
|
|
270
|
+
elsif ch == '"' || ch == "'"
|
|
271
|
+
quote = ch
|
|
272
|
+
elsif ch == "("
|
|
273
|
+
depth += 1
|
|
274
|
+
elsif ch == ")"
|
|
275
|
+
depth -= 1
|
|
276
|
+
return i if depth.zero?
|
|
277
|
+
end
|
|
278
|
+
i += 1
|
|
279
|
+
end
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Append the closers for any `[`/`(` left open outside a string — CSS
|
|
284
|
+
# tokenizing implicitly closes them at EOF, so `[align="center"` is the valid
|
|
285
|
+
# `[align="center"]`, but the backend selector engines reject the unclosed
|
|
286
|
+
# form. String contents and escapes are skipped.
|
|
287
|
+
def self.close_open_brackets(selector)
|
|
288
|
+
sq = 0
|
|
289
|
+
pr = 0
|
|
290
|
+
quote = nil
|
|
291
|
+
esc = false
|
|
292
|
+
selector.each_char do |ch|
|
|
293
|
+
if esc
|
|
294
|
+
esc = false
|
|
295
|
+
elsif ch == "\\"
|
|
296
|
+
esc = true
|
|
297
|
+
elsif quote
|
|
298
|
+
quote = nil if ch == quote
|
|
299
|
+
elsif ch == '"' || ch == "'"
|
|
300
|
+
quote = ch
|
|
301
|
+
elsif ch == "["
|
|
302
|
+
sq += 1
|
|
303
|
+
elsif ch == "]"
|
|
304
|
+
sq -= 1 if sq.positive?
|
|
305
|
+
elsif ch == "("
|
|
306
|
+
pr += 1
|
|
307
|
+
elsif ch == ")"
|
|
308
|
+
pr -= 1 if pr.positive?
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
selector + ("]" * sq) + (")" * pr)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Drop CSS namespace prefixes (`*|`, `|`) that precede a type or attribute
|
|
315
|
+
# name. An HTML document has only the HTML / null namespaces, so `*|x`
|
|
316
|
+
# (any namespace) and `|x` (no namespace) both reduce to `x`. Leaves the
|
|
317
|
+
# `|=` attribute matcher and the `||` column combinator intact, and never
|
|
318
|
+
# touches a `|` inside a string.
|
|
319
|
+
def self.strip_namespace_prefixes(selector)
|
|
320
|
+
out = +""
|
|
321
|
+
quote = nil
|
|
322
|
+
esc = false
|
|
323
|
+
chars = selector.chars
|
|
324
|
+
i = 0
|
|
325
|
+
while i < chars.length
|
|
326
|
+
ch = chars[i]
|
|
327
|
+
if esc
|
|
328
|
+
out << ch
|
|
329
|
+
esc = false
|
|
330
|
+
elsif ch == "\\"
|
|
331
|
+
out << ch
|
|
332
|
+
esc = true
|
|
333
|
+
elsif quote
|
|
334
|
+
out << ch
|
|
335
|
+
quote = nil if ch == quote
|
|
336
|
+
elsif ch == '"' || ch == "'"
|
|
337
|
+
quote = ch
|
|
338
|
+
out << ch
|
|
339
|
+
elsif ch == "*" && chars[i + 1] == "|" && chars[i + 2] != "=" && chars[i + 2] != "|"
|
|
340
|
+
i += 1 # skip the `*`; the `|` is handled next iteration
|
|
341
|
+
elsif ch == "|" && chars[i + 1] != "=" && chars[i + 1] != "|" && out[-1] != "|"
|
|
342
|
+
# bare namespace separator — drop it (not `|=`, not `||`)
|
|
343
|
+
else
|
|
344
|
+
out << ch
|
|
345
|
+
end
|
|
346
|
+
i += 1
|
|
347
|
+
end
|
|
348
|
+
out
|
|
349
|
+
end
|
|
350
|
+
|
|
110
351
|
# Split a selector list on top-level commas only (commas inside [...], (...),
|
|
111
352
|
# or quotes are part of a single complex selector and must not split it).
|
|
112
353
|
def self.split_selector_list(selector)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
# Splits raw stylesheet text into individual rule slices and pulls a
|
|
6
|
+
# single rule apart into its prelude (selector / at-rule condition) and
|
|
7
|
+
# body — enough to back the CSSOM CSSRule accessors (selectorText, style,
|
|
8
|
+
# nested cssRules) without re-tokenizing the whole grammar. Each slice is
|
|
9
|
+
# kept verbatim so CSSRule#cssText round-trips the source until mutated.
|
|
10
|
+
#
|
|
11
|
+
# This is deliberately a lightweight scanner (it tracks brace depth,
|
|
12
|
+
# string literals and `/* */` comments); the cascade's correctness still
|
|
13
|
+
# comes from lexbor. It exists only to give JS-visible CSSOM introspection.
|
|
14
|
+
module CSSRuleText
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Top-level rule slices in source order, each stripped of surrounding
|
|
18
|
+
# whitespace. Block rules (`sel { ... }`, `@media ... { ... }`) span to
|
|
19
|
+
# their matching `}`; statement at-rules (`@import ...;`) span to `;`.
|
|
20
|
+
#
|
|
21
|
+
# Scans over the BINARY bytes, not the UTF-8 characters: every byte we
|
|
22
|
+
# branch on ({ } " ' / * ; \\ and ASCII whitespace) is 7-bit, and a UTF-8
|
|
23
|
+
# multibyte sequence is wholly bytes >= 0x80 — none of which collide with
|
|
24
|
+
# those, so the structure is read identically while `bytes[i]` stays O(1)
|
|
25
|
+
# (UTF-8 `String#[i]` is O(i), making the whole scan O(n^2) — the dominant
|
|
26
|
+
# cost on a large stylesheet). Slice boundaries always land on a 7-bit byte
|
|
27
|
+
# (a delimiter) or a UTF-8 lead byte (the first non-space of a prelude), so
|
|
28
|
+
# each byteslice is a whole-character substring; re-tag it UTF-8 on the way
|
|
29
|
+
# out so the verbatim rule text round-trips unchanged.
|
|
30
|
+
def split_rules(text)
|
|
31
|
+
bytes = text.to_s.b
|
|
32
|
+
rules = []
|
|
33
|
+
start = nil
|
|
34
|
+
depth = 0
|
|
35
|
+
string = nil
|
|
36
|
+
i = 0
|
|
37
|
+
len = bytes.length
|
|
38
|
+
|
|
39
|
+
while i < len
|
|
40
|
+
ch = bytes[i]
|
|
41
|
+
|
|
42
|
+
if string
|
|
43
|
+
if ch == "\\"
|
|
44
|
+
i += 2
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
string = nil if ch == string
|
|
48
|
+
i += 1
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if ch == "/" && bytes[i + 1] == "*"
|
|
53
|
+
close = bytes.index("*/", i + 2)
|
|
54
|
+
i = (close || len - 2) + 2
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
case ch
|
|
59
|
+
when '"', "'"
|
|
60
|
+
start ||= i
|
|
61
|
+
string = ch
|
|
62
|
+
when "{"
|
|
63
|
+
start ||= i
|
|
64
|
+
depth += 1
|
|
65
|
+
when "}"
|
|
66
|
+
depth -= 1 if depth.positive?
|
|
67
|
+
if depth.zero? && start
|
|
68
|
+
rules << bytes[start..i]
|
|
69
|
+
start = nil
|
|
70
|
+
end
|
|
71
|
+
when ";"
|
|
72
|
+
if depth.zero? && start
|
|
73
|
+
rules << bytes[start..i]
|
|
74
|
+
start = nil
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
start ||= i unless ch.match?(/\s/)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
i += 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
rules << bytes[start..] if start
|
|
84
|
+
rules.map { |slice| slice.force_encoding(Encoding::UTF_8).strip }.reject(&:empty?)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# [prelude, body] for one rule slice. `body` is the text between the
|
|
88
|
+
# outermost braces, or nil for a braceless statement at-rule. `prelude`
|
|
89
|
+
# is the selector list (style rule) or the at-rule keyword + condition.
|
|
90
|
+
#
|
|
91
|
+
# Walks the BINARY bytes for the same reason as #split_rules: the brace
|
|
92
|
+
# scan is O(1) per byte instead of O(i) per UTF-8 character. Brace indices
|
|
93
|
+
# land on the 7-bit `{`/`}`, so every byteslice is a whole-character
|
|
94
|
+
# substring; re-tag the pieces UTF-8.
|
|
95
|
+
def split_rule(text)
|
|
96
|
+
bytes = text.to_s.b
|
|
97
|
+
brace = top_level_brace(bytes)
|
|
98
|
+
return [bytes.force_encoding(Encoding::UTF_8).sub(/;\s*\z/, "").strip, nil] unless brace
|
|
99
|
+
|
|
100
|
+
prelude = bytes[0...brace].force_encoding(Encoding::UTF_8).strip
|
|
101
|
+
close = matching_brace(bytes, brace)
|
|
102
|
+
body = bytes[(brace + 1)...(close || bytes.length)].to_s.force_encoding(Encoding::UTF_8)
|
|
103
|
+
[prelude, body]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The at-rule keyword (lowercased, without `@`) of a prelude, or nil for
|
|
107
|
+
# a plain style rule.
|
|
108
|
+
def at_keyword(prelude)
|
|
109
|
+
match = prelude.to_s.match(/\A@(-?[a-z][a-z-]*)/i)
|
|
110
|
+
match && match[1].downcase
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Index of the first `{` at brace depth 0, skipping strings and comments.
|
|
114
|
+
def top_level_brace(text)
|
|
115
|
+
scan_braces(text, 0) { |ch, i| return i if ch == "{" }
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Index of the `}` matching the `{` at `open`, skipping nested braces,
|
|
120
|
+
# strings and comments. nil when unterminated.
|
|
121
|
+
def matching_brace(text, open)
|
|
122
|
+
depth = 0
|
|
123
|
+
scan_braces(text, open) do |ch, i|
|
|
124
|
+
depth += 1 if ch == "{"
|
|
125
|
+
if ch == "}"
|
|
126
|
+
depth -= 1
|
|
127
|
+
return i if depth.zero?
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Walk `text` from `from`, yielding [char, index] for each `{`/`}` that
|
|
134
|
+
# lies outside string literals and `/* */` comments.
|
|
135
|
+
def scan_braces(text, from)
|
|
136
|
+
string = nil
|
|
137
|
+
i = from
|
|
138
|
+
len = text.length
|
|
139
|
+
while i < len
|
|
140
|
+
ch = text[i]
|
|
141
|
+
if string
|
|
142
|
+
if ch == "\\"
|
|
143
|
+
i += 2
|
|
144
|
+
next
|
|
145
|
+
end
|
|
146
|
+
string = nil if ch == string
|
|
147
|
+
elsif ch == "/" && text[i + 1] == "*"
|
|
148
|
+
i = (text.index("*/", i + 2) || len - 2) + 2
|
|
149
|
+
next
|
|
150
|
+
elsif ch == '"' || ch == "'"
|
|
151
|
+
string = ch
|
|
152
|
+
elsif ch == "{" || ch == "}"
|
|
153
|
+
yield ch, i
|
|
154
|
+
end
|
|
155
|
+
i += 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -83,13 +83,13 @@ module Dommy
|
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
86
|
+
# Visibility check. Fast HTML-level signals first (`hidden` attribute,
|
|
87
|
+
# `<input type=hidden>`, non-rendering ancestors, inline
|
|
88
|
+
# `display:none` / `visibility:hidden`); when the document carries
|
|
89
|
+
# author CSS and the makiri-backed parser is available, stylesheet-
|
|
90
|
+
# driven `display:none` / `visibility:hidden` (e.g. via a class) is
|
|
91
|
+
# detected through the computed style as well. No layout: geometry-
|
|
92
|
+
# dependent invisibility stays out of scope.
|
|
93
93
|
def visible?(element)
|
|
94
94
|
return true unless element.respond_to?(:__dommy_backend_node__)
|
|
95
95
|
|
|
@@ -101,9 +101,40 @@ module Dommy
|
|
|
101
101
|
return false if node_invisible_self?(ancestor)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
css_visible?(element)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# CSS-aware extension of visible?, consulted only when the document
|
|
108
|
+
# has author CSS (Cascade.author_css? keeps sheetless documents on the
|
|
109
|
+
# fast path). `display: none` on the element or any ancestor hides;
|
|
110
|
+
# computed `visibility: hidden/collapse` (inherited, overridable by a
|
|
111
|
+
# descendant's `visibility: visible`) hides.
|
|
112
|
+
def css_visible?(element)
|
|
113
|
+
document = element.respond_to?(:owner_document) ? element.owner_document : nil
|
|
114
|
+
return true unless document && CSS::Cascade.author_css?(document)
|
|
115
|
+
|
|
116
|
+
return false if %w[hidden collapse].include?(CSS::Cascade.computed_style(element)["visibility"])
|
|
117
|
+
|
|
118
|
+
current = element
|
|
119
|
+
while current
|
|
120
|
+
styles = CSS::Cascade.computed_style(current)
|
|
121
|
+
return false if styles["display"] == "none"
|
|
122
|
+
# Zero effective opacity is invisible (Selenium's displayed
|
|
123
|
+
# algorithm); a zero anywhere in the chain zeroes the product.
|
|
124
|
+
return false if opacity_zero?(styles["opacity"])
|
|
125
|
+
current = current.respond_to?(:parent_element) ? current.parent_element : nil
|
|
126
|
+
end
|
|
127
|
+
|
|
104
128
|
true
|
|
105
129
|
end
|
|
106
130
|
|
|
131
|
+
def opacity_zero?(value)
|
|
132
|
+
text = value.to_s.strip
|
|
133
|
+
return false unless text.match?(/\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)%?\z/)
|
|
134
|
+
|
|
135
|
+
text.to_f <= 0
|
|
136
|
+
end
|
|
137
|
+
|
|
107
138
|
# Filter elements by Capybara-style :visible option.
|
|
108
139
|
# @param elements [Array]
|
|
109
140
|
# @param visible [:visible, :all, :hidden, true, false, nil]
|
|
@@ -128,14 +159,54 @@ module Dommy
|
|
|
128
159
|
return true if node.respond_to?(:name) && node.name == "input" && node["type"] == "hidden"
|
|
129
160
|
|
|
130
161
|
style = node["style"].to_s
|
|
131
|
-
style.match?(/display\s*:\s*none/i) ||
|
|
162
|
+
style.match?(/display\s*:\s*none/i) ||
|
|
163
|
+
style.match?(/visibility\s*:\s*hidden/i) ||
|
|
164
|
+
# Inline zero opacity hides too — keeps the fast path consistent
|
|
165
|
+
# with the CSS-aware check on sheetless documents.
|
|
166
|
+
style.match?(INLINE_ZERO_OPACITY)
|
|
132
167
|
end
|
|
133
168
|
|
|
169
|
+
# opacity: 0 / 0.0 / .0 / 0% (and nothing else) as an inline value.
|
|
170
|
+
INLINE_ZERO_OPACITY = /opacity\s*:\s*\+?(?:0+(?:\.0*)?|\.0+)%?\s*(?:[;!]|\z)/i
|
|
171
|
+
|
|
134
172
|
def non_rendering_tag?(node)
|
|
135
173
|
node.respond_to?(:name) && %w[head script style template].include?(node.name)
|
|
136
174
|
end
|
|
137
175
|
|
|
138
|
-
|
|
176
|
+
# Tags whose text never renders, so it must not count as page text.
|
|
177
|
+
NON_RENDERED_TEXT_TAGS = %w[head script style template noscript].freeze
|
|
178
|
+
|
|
179
|
+
# Visible text of a node's subtree: like `text_content`, but excluding
|
|
180
|
+
# subtrees that never render (script/style/head/template/noscript).
|
|
181
|
+
# Mirrors what a browser exposes to `has_text?` / text filters, so JSON
|
|
182
|
+
# embedded in a `<script data-page>` (Inertia/Turbo) or inline CSS is not
|
|
183
|
+
# mistaken for visible page text.
|
|
184
|
+
def rendered_text(node)
|
|
185
|
+
return "" if node.nil?
|
|
186
|
+
|
|
187
|
+
out = +""
|
|
188
|
+
append_rendered_text(node, out)
|
|
189
|
+
out
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def append_rendered_text(node, out)
|
|
193
|
+
return unless node.respond_to?(:child_nodes)
|
|
194
|
+
|
|
195
|
+
node.child_nodes.each do |child|
|
|
196
|
+
# Element wrappers don't expose node_type as a Ruby method (it lives
|
|
197
|
+
# on the JS bridge), so branch on class: recurse into rendered
|
|
198
|
+
# elements, take character data verbatim, skip comments.
|
|
199
|
+
if child.is_a?(Dommy::Element)
|
|
200
|
+
next if NON_RENDERED_TEXT_TAGS.include?(child.local_name.to_s.downcase)
|
|
201
|
+
|
|
202
|
+
append_rendered_text(child, out)
|
|
203
|
+
elsif child.respond_to?(:node_type) && child.node_type == Dommy::Node::TEXT_NODE
|
|
204
|
+
out << child.text_content.to_s
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private_class_method :node_invisible_self?, :non_rendering_tag?, :append_rendered_text
|
|
139
210
|
end
|
|
140
211
|
end
|
|
141
212
|
end
|