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,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ module CSS
6
+ # A Media Queries Level 4 subset evaluator for `matchMedia` and
7
+ # friends. Queries are matched against an Environment snapshot
8
+ # rather than a real device.
9
+ #
10
+ # Supported: media types (all/screen true, anything else false),
11
+ # `not` / `only` prefixes, `and` chains, comma-separated OR lists,
12
+ # `not` before a single condition, `(feature: value)` and
13
+ # `(feature)` boolean contexts, and the range syntax for
14
+ # width/height (`(width >= 600px)`, `(400px <= width <= 800px)`).
15
+ #
16
+ # Deliberately given up: the `or` combinator, nested parentheses
17
+ # (`(not (width))`, general `<media-condition>` grouping), calc(),
18
+ # and most discrete features beyond the ones listed in
19
+ # FEATURE_VALUES below. Any query we cannot parse — including one
20
+ # using `or` — evaluates to false, mirroring the spec's "unknown
21
+ # query becomes `not all`" rule; we never raise.
22
+ module MediaQuery
23
+ module_function
24
+
25
+ Environment = Struct.new(
26
+ :viewport_width, :viewport_height, :device_pixel_ratio,
27
+ :prefers_color_scheme, :prefers_reduced_motion, :hover, :pointer,
28
+ keyword_init: true
29
+ ) do
30
+ def self.default
31
+ new(viewport_width: 1280, viewport_height: 720, device_pixel_ratio: 1.0,
32
+ prefers_color_scheme: "light", prefers_reduced_motion: "no-preference",
33
+ hover: "hover", pointer: "fine")
34
+ end
35
+ end
36
+
37
+ DEFAULT = Environment.default.freeze
38
+
39
+ # Valid values for the discrete features we support; anything
40
+ # else is treated as unparseable (query becomes false).
41
+ FEATURE_VALUES = {
42
+ "prefers-color-scheme" => %w[light dark],
43
+ "prefers-reduced-motion" => %w[no-preference reduce],
44
+ "hover" => %w[none hover],
45
+ "any-hover" => %w[none hover],
46
+ "pointer" => %w[none coarse fine],
47
+ "any-pointer" => %w[none coarse fine]
48
+ }.freeze
49
+
50
+ # Evaluates a media query list. Comma-separated queries OR
51
+ # together: the list matches if any single query matches. The
52
+ # empty string is the empty query list, which matches (this is
53
+ # what `matchMedia("")` does in browsers).
54
+ def match?(text, environment = DEFAULT)
55
+ text = text.to_s.strip
56
+ return true if text.empty?
57
+
58
+ # Commas cannot appear inside our supported conditions, so a
59
+ # plain split is safe.
60
+ text.split(",", -1).any? { |query| query_match?(query.strip, environment) }
61
+ end
62
+
63
+ # --- single query ---------------------------------------------
64
+
65
+ def query_match?(query, env)
66
+ tokens = tokenize(query)
67
+ return false if tokens.nil? || tokens.empty?
68
+
69
+ i = 0
70
+ negate_query = false
71
+ if tokens[0] == "not"
72
+ negate_query = true
73
+ i = 1
74
+ elsif tokens[0] == "only"
75
+ # `only` is only valid immediately before a media type
76
+ # (`only screen`); `only (condition)` is a parse error.
77
+ return false unless tokens[1].is_a?(String)
78
+
79
+ i = 1
80
+ end
81
+ return false if i >= tokens.size
82
+
83
+ # First segment: a media type word, or a leading condition.
84
+ first = tokens[i]
85
+ if first.is_a?(String)
86
+ return false if %w[and not only or].include?(first)
87
+
88
+ result = first == "all" || first == "screen"
89
+ i += 1
90
+ else
91
+ result = evaluate_condition(first[1], env)
92
+ return false if result.nil?
93
+
94
+ i += 1
95
+ end
96
+
97
+ # Remaining segments: `and [not] ( ... )` repeated. Anything
98
+ # else (notably `or`) makes the whole query unparseable.
99
+ while i < tokens.size
100
+ return false unless tokens[i] == "and"
101
+
102
+ i += 1
103
+ negate_condition = false
104
+ if tokens[i] == "not"
105
+ negate_condition = true
106
+ i += 1
107
+ end
108
+ token = tokens[i]
109
+ return false unless token.is_a?(Array)
110
+
111
+ value = evaluate_condition(token[1], env)
112
+ return false if value.nil?
113
+
114
+ value = !value if negate_condition
115
+ result &&= value
116
+ i += 1
117
+ end
118
+
119
+ negate_query ? !result : result
120
+ end
121
+
122
+ # Splits a query into lower-cased keyword strings and
123
+ # `[:cond, inner]` pairs for parenthesized conditions. Returns
124
+ # nil when the query contains anything else (stray characters,
125
+ # nested or unbalanced parentheses).
126
+ def tokenize(query)
127
+ tokens = []
128
+ rest = query
129
+ until (rest = rest.lstrip).empty?
130
+ if rest.start_with?("(")
131
+ close = rest.index(")")
132
+ return nil if close.nil?
133
+
134
+ inner = rest[1...close]
135
+ return nil if inner.include?("(") # nested parens unsupported
136
+
137
+ tokens << [:cond, inner.strip.downcase]
138
+ rest = rest[(close + 1)..]
139
+ elsif (match = rest.match(/\A[a-z][a-z0-9-]*/i))
140
+ tokens << match[0].downcase
141
+ rest = rest[match[0].length..]
142
+ return nil unless rest.empty? || rest.start_with?("(") || rest.match?(/\A\s/)
143
+ else
144
+ return nil
145
+ end
146
+ end
147
+ tokens
148
+ end
149
+
150
+ # --- conditions ------------------------------------------------
151
+
152
+ # `inner` is the lower-cased text between parentheses. Returns
153
+ # true/false, or nil when the condition cannot be interpreted.
154
+ def evaluate_condition(inner, env)
155
+ return nil if inner.empty?
156
+
157
+ if inner.match?(/[<>]/) || (inner.include?("=") && !inner.include?(":"))
158
+ evaluate_range(inner, env)
159
+ elsif inner.include?(":")
160
+ name, _, value = inner.partition(":")
161
+ evaluate_feature(name.strip, value.strip, env)
162
+ else
163
+ evaluate_boolean_feature(inner, env)
164
+ end
165
+ end
166
+
167
+ def evaluate_feature(name, value, env)
168
+ case name
169
+ when "width", "min-width", "max-width"
170
+ px = parse_length(value)
171
+ px && compare_with_prefix(name, env.viewport_width, px)
172
+ when "height", "min-height", "max-height"
173
+ px = parse_length(value)
174
+ px && compare_with_prefix(name, env.viewport_height, px)
175
+ when "orientation"
176
+ case value
177
+ when "portrait" then env.viewport_height >= env.viewport_width
178
+ when "landscape" then env.viewport_width > env.viewport_height
179
+ end
180
+ when "aspect-ratio", "min-aspect-ratio", "max-aspect-ratio"
181
+ evaluate_aspect_ratio(name, value, env)
182
+ when "prefers-color-scheme"
183
+ FEATURE_VALUES[name].include?(value) ? env.prefers_color_scheme == value : nil
184
+ when "prefers-reduced-motion"
185
+ FEATURE_VALUES[name].include?(value) ? env.prefers_reduced_motion == value : nil
186
+ when "hover", "any-hover"
187
+ FEATURE_VALUES[name].include?(value) ? env.hover == value : nil
188
+ when "pointer", "any-pointer"
189
+ FEATURE_VALUES[name].include?(value) ? env.pointer == value : nil
190
+ when "resolution", "min-resolution", "max-resolution"
191
+ dppx = parse_resolution(value)
192
+ dppx && compare_with_prefix(name, env.device_pixel_ratio, dppx)
193
+ end
194
+ end
195
+
196
+ def evaluate_boolean_feature(name, env)
197
+ case name
198
+ when "width" then env.viewport_width > 0
199
+ when "height" then env.viewport_height > 0
200
+ when "hover", "any-hover" then env.hover != "none"
201
+ when "pointer", "any-pointer" then env.pointer != "none"
202
+ end
203
+ end
204
+
205
+ # `(aspect-ratio: a/b)` — compare width/height against a/b
206
+ # without floating point: width * b <=> a * height.
207
+ def evaluate_aspect_ratio(name, value, env)
208
+ match = value.match(%r{\A(\d+)\s*/\s*(\d+)\z})
209
+ return nil unless match
210
+
211
+ a = Integer(match[1], 10)
212
+ b = Integer(match[2], 10)
213
+ return nil if a.zero? || b.zero?
214
+
215
+ compare_with_prefix(name, env.viewport_width * b, a * env.viewport_height)
216
+ end
217
+
218
+ # Range syntax, single (`width >= 600px`, `600px <= width`) or
219
+ # double (`400px <= width <= 800px`). width/height only.
220
+ def evaluate_range(inner, env)
221
+ parts = inner.split(/(<=|>=|<|>|=)/).map(&:strip)
222
+ case parts.size
223
+ when 3
224
+ left, op, right = parts
225
+ if (actual = range_feature_value(left, env))
226
+ value = parse_length(right)
227
+ value && compare(actual, op, value)
228
+ elsif (actual = range_feature_value(right, env))
229
+ value = parse_length(left)
230
+ value && compare(value, op, actual)
231
+ end
232
+ when 5
233
+ low, op1, name, op2, high = parts
234
+ actual = range_feature_value(name, env)
235
+ return nil unless actual
236
+ return nil unless (%w[< <=].include?(op1) && %w[< <=].include?(op2)) ||
237
+ (%w[> >=].include?(op1) && %w[> >=].include?(op2))
238
+
239
+ low_px = parse_length(low)
240
+ high_px = parse_length(high)
241
+ return nil unless low_px && high_px
242
+
243
+ compare(low_px, op1, actual) && compare(actual, op2, high_px)
244
+ end
245
+ end
246
+
247
+ def range_feature_value(name, env)
248
+ case name
249
+ when "width" then env.viewport_width
250
+ when "height" then env.viewport_height
251
+ end
252
+ end
253
+
254
+ def compare(left, op, right)
255
+ case op
256
+ when "<" then left < right
257
+ when "<=" then left <= right
258
+ when ">" then left > right
259
+ when ">=" then left >= right
260
+ when "=" then left == right
261
+ end
262
+ end
263
+
264
+ # min- prefix means "at least", max- means "at most", bare name
265
+ # means exact equality.
266
+ def compare_with_prefix(name, actual, target)
267
+ if name.start_with?("min-")
268
+ actual >= target
269
+ elsif name.start_with?("max-")
270
+ actual <= target
271
+ else
272
+ actual == target
273
+ end
274
+ end
275
+
276
+ # --- value parsing ---------------------------------------------
277
+
278
+ # Lengths in px; em/rem are converted at the canonical 16px.
279
+ # A bare 0 (no unit) is a valid length; any other unitless
280
+ # number is not.
281
+ def parse_length(value)
282
+ return 0.0 if value.match?(/\A0+(?:\.0+)?\z/)
283
+
284
+ match = value.match(/\A(\d+(?:\.\d+)?)(px|em|rem)\z/)
285
+ return nil unless match
286
+
287
+ number = Float(match[1])
288
+ match[2] == "px" ? number : number * 16
289
+ end
290
+
291
+ # Resolutions normalized to dppx; dpi divides by 96.
292
+ def parse_resolution(value)
293
+ match = value.match(/\A(\d+(?:\.\d+)?)(x|dppx|dpi)\z/)
294
+ return nil unless match
295
+
296
+ number = Float(match[1])
297
+ match[2] == "dpi" ? number / 96 : number
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../selector_parser"
4
+
5
+ module Dommy
6
+ module Internal
7
+ module CSS
8
+ # The abstraction seam over the CSS stylesheet parser. The only
9
+ # implementation is makiri's lexbor binding
10
+ # (Makiri::Lexbor::CSS.parse_stylesheet) — see css-cascade.md: the
11
+ # tokenizer, error recovery, and specificity all come from lexbor;
12
+ # this module just normalizes its plain-Hash output into structs the
13
+ # cascade layer consumes. CSS parsing is independent of the DOM
14
+ # backend, so this works on the Nokogiri backend too as long as the
15
+ # makiri gem is installed.
16
+ module Parser
17
+ # One comma branch of a selector list. `specificity` is the
18
+ # Selectors L4 [A, B, C] triple computed from Dommy's AST.
19
+ Selector = Struct.new(:text, :ast, :specificity)
20
+
21
+ Declaration = Struct.new(:name, :value, :important)
22
+
23
+ StyleRule = Struct.new(:selectors, :declarations) do
24
+ def grouping? = false
25
+ end
26
+
27
+ # Grouping at-rules (CSS conditional rules): their block contributes
28
+ # its rules to the cascade only when the rule is "active" (RuleIndex
29
+ # decides — @media query matches, @supports condition holds, @layer
30
+ # always). The block is flattened in source order; @layer ordering and
31
+ # @supports/@media precedence beyond source order are out of scope.
32
+ MediaRule = Struct.new(:condition, :rules) do
33
+ def grouping? = true
34
+ end
35
+
36
+ # @layer name { ... } — a named (or anonymous, name nil) cascade layer.
37
+ # Its rules always participate; their precedence is decided by layer
38
+ # order (RuleIndex/Cascade), which sits between origin and specificity.
39
+ LayerRule = Struct.new(:name, :rules) do
40
+ def grouping? = true
41
+ end
42
+
43
+ # @layer a, b, c; (statement form) or an empty @layer block — declares
44
+ # layer names (and thereby their order) without contributing rules.
45
+ # `names` entries are layer names; nil means an anonymous layer.
46
+ LayerStatement = Struct.new(:names) do
47
+ def grouping? = false
48
+ end
49
+
50
+ # @supports (cond) { ... } — its rules participate when `condition`
51
+ # evaluates true (Supports.match?).
52
+ SupportsRule = Struct.new(:condition, :rules) do
53
+ def grouping? = true
54
+ end
55
+
56
+ # @scope (start) to (end) { ... } — `start`/`end` are selector-list texts
57
+ # (either may be nil: a missing `start` scopes to the document element,
58
+ # a missing `end` has no lower boundary). RuleIndex resolves the scoping
59
+ # roots and limits and scopes the block's rules to in-scope elements.
60
+ ScopeRule = Struct.new(:start, :end, :rules) do
61
+ def grouping? = true
62
+ end
63
+
64
+ # @import url(...) [media] — the referenced sheet's rules are spliced
65
+ # in at this position (RuleIndex resolves the URL through the host).
66
+ # `media` (may be "") gates them like @media.
67
+ ImportRule = Struct.new(:url, :media) do
68
+ def grouping? = false
69
+ end
70
+
71
+ # Raised when CSS features are used without the makiri gem.
72
+ class Unavailable < StandardError
73
+ def initialize(msg = nil)
74
+ super(msg || "Dommy's CSS support requires the 'makiri' gem (its bundled " \
75
+ "lexbor provides the CSS parser). Add `gem \"makiri\"` to use " \
76
+ "stylesheet-aware computed styles.")
77
+ end
78
+ end
79
+
80
+ module_function
81
+
82
+ def available?
83
+ return @available unless @available.nil?
84
+
85
+ @available = begin
86
+ require "makiri"
87
+ !!defined?(::Makiri::Lexbor::CSS)
88
+ rescue LoadError
89
+ false
90
+ end
91
+ end
92
+
93
+ # Parse a stylesheet into an Array of StyleRule / MediaRule / LayerRule
94
+ # / SupportsRule in source order (other at-rules are ignored). Lexbor's
95
+ # css-syntax-3 error recovery applies; selectors and at-rules it can't
96
+ # parse are surfaced for Dommy to re-validate, not silently dropped.
97
+ def parse(text)
98
+ raise Unavailable unless available?
99
+
100
+ rules = ::Makiri::Lexbor::CSS.parse_stylesheet(text.to_s)
101
+ normalize_rules(rules, collect_namespaces(rules))
102
+ end
103
+
104
+ def normalize_rules(rules, namespaces = {})
105
+ rules.filter_map do |rule|
106
+ case rule[:type]
107
+ when :style
108
+ build_style_rule(normalize_selectors(rule[:selectors], namespaces), rule[:declarations])
109
+ when :bad_style
110
+ # lexbor couldn't parse this selector list (most often a
111
+ # pseudo-element like ::before, which its parser predates).
112
+ # Re-validate the raw prelude with Dommy's Selectors L4 parser:
113
+ # pseudo-element rules survive, genuinely invalid ones drop
114
+ # (normalize_selectors returns []).
115
+ build_style_rule(normalize_selectors([{text: rule[:selector_text]}], namespaces), rule[:declarations])
116
+ when :at_rule
117
+ normalize_at_rule(rule, namespaces)
118
+ end
119
+ end
120
+ end
121
+
122
+ # The stylesheet's @namespace prefix => URI map (with :default for the
123
+ # prefixless default namespace), so `svg|rect` resolves when the sheet
124
+ # declared `@namespace svg url(...)`. @namespace is top-level only.
125
+ def collect_namespaces(rules)
126
+ rules.each_with_object({}) do |rule, namespaces|
127
+ next unless rule[:type] == :at_rule && rule[:name].to_s.casecmp("namespace").zero?
128
+
129
+ prefix, uri = parse_namespace(rule[:prelude])
130
+ namespaces[prefix.nil? ? :default : prefix] = uri if uri
131
+ end
132
+ end
133
+
134
+ # @namespace prelude: `[prefix] (url(uri) | "uri")`. Returns
135
+ # [prefix_or_nil, uri_or_nil].
136
+ def parse_namespace(prelude)
137
+ rest = prelude.to_s.strip
138
+ prefix = nil
139
+ if (match = rest.match(/\A([A-Za-z_][\w-]*)\s+/))
140
+ prefix = match[1]
141
+ rest = rest[match.end(0)..]
142
+ end
143
+
144
+ uri =
145
+ if (match = rest.match(/\Aurl\(\s*(.*?)\s*\)\s*\z/i))
146
+ match[1]
147
+ elsif (match = rest.match(/\A(?:"([^"]*)"|'([^']*)')\s*\z/))
148
+ match[1] || match[2]
149
+ end
150
+ [prefix, uri&.gsub(/\A["']|["']\z/, "")&.strip]
151
+ end
152
+
153
+ # @media / @layer / @supports become grouping rules the cascade
154
+ # understands; every other at-rule (@font-face, @keyframes, @import,
155
+ # @page, ...) is ignored for now (returning nil drops it from the
156
+ # rule stream, but lexbor still parsed it, so nothing crashes).
157
+ def normalize_at_rule(rule, namespaces = {})
158
+ case rule[:name].to_s.downcase
159
+ when "media"
160
+ MediaRule.new(rule[:prelude], normalize_rules(rule[:rules], namespaces))
161
+ when "layer"
162
+ normalize_layer(rule, namespaces)
163
+ when "supports"
164
+ SupportsRule.new(rule[:prelude], normalize_rules(rule[:rules], namespaces))
165
+ when "scope"
166
+ scope_start, scope_end = parse_scope_prelude(rule[:prelude])
167
+ ScopeRule.new(scope_start, scope_end, normalize_rules(rule[:rules], namespaces))
168
+ when "import"
169
+ parse_import(rule[:prelude])
170
+ end
171
+ end
172
+
173
+ # @layer comes in two shapes (lexbor reports both as an at_rule):
174
+ # block form `@layer name { rules }` — one named/anonymous layer with
175
+ # content (an empty block carries no rules, like a statement);
176
+ # statement form `@layer a, b, c;` — declares several layers' order
177
+ # without content.
178
+ # An empty `rules` means the statement form (or empty block): it only
179
+ # fixes layer order. A nil name (empty prelude) is an anonymous layer.
180
+ def normalize_layer(rule, namespaces)
181
+ names = split_layer_names(rule[:prelude])
182
+ rules = rule[:rules] || []
183
+ if rules.empty?
184
+ LayerStatement.new(names.empty? ? [nil] : names)
185
+ else
186
+ LayerRule.new(names.first, normalize_rules(rules, namespaces))
187
+ end
188
+ end
189
+
190
+ # The comma-separated layer names of an @layer prelude ("" → [], so an
191
+ # anonymous layer yields no names).
192
+ def split_layer_names(prelude)
193
+ prelude.to_s.split(",").map(&:strip).reject(&:empty?)
194
+ end
195
+
196
+ # @scope prelude: `(<start>) [to (<end>)]`. Returns [start_or_nil,
197
+ # end_or_nil] as selector-list texts (an empty prelude — bare @scope —
198
+ # yields [nil, nil]).
199
+ def parse_scope_prelude(prelude)
200
+ text = prelude.to_s.strip
201
+ return [nil, nil] if text.empty?
202
+
203
+ match = text.match(/\A\(\s*(.*?)\s*\)\s*(?:to\s*\(\s*(.*?)\s*\)\s*)?\z/m)
204
+ return [nil, nil] unless match
205
+
206
+ [presence(match[1]), presence(match[2])]
207
+ end
208
+
209
+ def presence(string)
210
+ string.nil? || string.empty? ? nil : string
211
+ end
212
+
213
+ # @import prelude: `url(x)` / `"x"` / `'x'`, optionally followed by a
214
+ # media query. Returns nil (the rule is dropped) when no URL is found.
215
+ def parse_import(prelude)
216
+ prelude = prelude.to_s.strip
217
+ if (match = prelude.match(/\Aurl\(\s*(.*?)\s*\)/i))
218
+ url = match[1]
219
+ elsif (match = prelude.match(/\A(?:"([^"]*)"|'([^']*)')/))
220
+ url = match[1] || match[2]
221
+ else
222
+ return nil
223
+ end
224
+
225
+ url = url.to_s.gsub(/\A["']|["']\z/, "").strip
226
+ return nil if url.empty?
227
+
228
+ ImportRule.new(url, prelude[match.end(0)..].to_s.strip)
229
+ end
230
+
231
+ def build_style_rule(selectors, declarations)
232
+ return nil if selectors.empty?
233
+
234
+ StyleRule.new(selectors, declarations.map { |d| Declaration.new(d[:name], d[:value], d[:important]) })
235
+ end
236
+
237
+ def normalize_selectors(selectors, namespaces = {})
238
+ selectors.map do |selector|
239
+ ast = Internal::SelectorParser.parse!(selector[:text], namespaces: namespaces)
240
+ Selector.new(selector[:text], ast, ast.specificity.to_a)
241
+ end
242
+ rescue DOMException::SyntaxError
243
+ []
244
+ end
245
+
246
+ # Parse a bare declaration block (no selector / braces) into
247
+ # Declarations. Used by the CSSOM RuleStyleDeclaration to read a rule's
248
+ # `style`; the cascade reaches declarations through parse/normalize_rules.
249
+ def parse_declarations(text)
250
+ text.split(";").filter_map do |chunk|
251
+ name, value = chunk.split(":", 2)
252
+ next unless name && value
253
+
254
+ name = name.strip.downcase
255
+ value = value.strip
256
+ next if name.empty? || value.empty?
257
+
258
+ important = !value.sub!(/\s*!important\s*\z/i, "").nil?
259
+ Declaration.new(name, value.strip, important)
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end