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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. 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
- # Custom Nokogiri pseudo-class handlers so CSS selectors like
6
- # `:disabled` / `:enabled` / `:checked` work in query_selector(_all).
7
- # Nokogiri calls the method named after the pseudo-class with the current
8
- # node list and expects the filtered list back. Receives raw Nokogiri
9
- # nodes (not Dommy wrappers).
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 drop the escaped-colon attribute clauses below.
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
- # Best-effort visibility check using HTML-level signals only.
87
- # Does NOT evaluate CSS stylesheets — `display: none` via class
88
- # is NOT detected. See README for details and workarounds.
89
- #
90
- # Detects: `hidden` attribute, `<input type=hidden>`, non-rendering
91
- # ancestors (head/script/style/template), inline `display:none` /
92
- # `visibility:hidden` on element or any ancestor.
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) || style.match?(/visibility\s*:\s*hidden/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
- private_class_method :node_invisible_self?, :non_rendering_tag?
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