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,512 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "color"
4
+ require_relative "calc"
5
+
6
+ module Dommy
7
+ module Internal
8
+ module CSS
9
+ # The curated property table driving the cascade: which properties
10
+ # inherit, their initial values, computed-value transforms, and
11
+ # shorthand expansion. Properties outside the table degrade gracefully:
12
+ # their cascaded value is returned as-is (no inheritance, no
13
+ # normalization) rather than raising.
14
+ module PropertyRegistry
15
+ Property = Struct.new(:inherited, :initial, keyword_init: true)
16
+
17
+ PROPERTIES = {
18
+ "display" => Property.new(inherited: false, initial: "inline"),
19
+ "visibility" => Property.new(inherited: true, initial: "visible"),
20
+ "opacity" => Property.new(inherited: false, initial: "1"),
21
+ "color" => Property.new(inherited: true, initial: "rgb(0, 0, 0)"),
22
+ "background-color" => Property.new(inherited: false, initial: "rgba(0, 0, 0, 0)"),
23
+ "font-size" => Property.new(inherited: true, initial: "16px"),
24
+ "font-family" => Property.new(inherited: true, initial: "sans-serif"),
25
+ "font-weight" => Property.new(inherited: true, initial: "400"),
26
+ "font-style" => Property.new(inherited: true, initial: "normal"),
27
+ "line-height" => Property.new(inherited: true, initial: "normal"),
28
+ "text-align" => Property.new(inherited: true, initial: "start"),
29
+ "text-decoration-line" => Property.new(inherited: false, initial: "none"),
30
+ "white-space" => Property.new(inherited: true, initial: "normal"),
31
+ "letter-spacing" => Property.new(inherited: true, initial: "normal"),
32
+ "word-spacing" => Property.new(inherited: true, initial: "normal"),
33
+ "cursor" => Property.new(inherited: true, initial: "auto"),
34
+ "direction" => Property.new(inherited: true, initial: "ltr"),
35
+ "text-transform" => Property.new(inherited: true, initial: "none"),
36
+ "text-indent" => Property.new(inherited: true, initial: "0px"),
37
+ "font-variant" => Property.new(inherited: true, initial: "normal"),
38
+ "list-style-type" => Property.new(inherited: true, initial: "disc"),
39
+ "list-style-position" => Property.new(inherited: true, initial: "outside"),
40
+ "list-style-image" => Property.new(inherited: true, initial: "none"),
41
+ "tab-size" => Property.new(inherited: true, initial: "8"),
42
+ "z-index" => Property.new(inherited: false, initial: "auto"),
43
+ "position" => Property.new(inherited: false, initial: "static"),
44
+ # Color longhands of the border/outline/text-decoration shorthands:
45
+ # registered so they normalize to rgb() and resolve currentColor (the
46
+ # initial of each). Width/style longhands stay unregistered (passed
47
+ # through as specified — Dommy applies no style-dependent used-value
48
+ # rule like "width is 0 when style is none").
49
+ "border-top-color" => Property.new(inherited: false, initial: "currentColor"),
50
+ "border-right-color" => Property.new(inherited: false, initial: "currentColor"),
51
+ "border-bottom-color" => Property.new(inherited: false, initial: "currentColor"),
52
+ "border-left-color" => Property.new(inherited: false, initial: "currentColor"),
53
+ "outline-color" => Property.new(inherited: false, initial: "currentColor"),
54
+ "text-decoration-color" => Property.new(inherited: false, initial: "currentColor"),
55
+ "text-decoration-style" => Property.new(inherited: false, initial: "solid"),
56
+ "flex-grow" => Property.new(inherited: false, initial: "0"),
57
+ "flex-shrink" => Property.new(inherited: false, initial: "1"),
58
+ "flex-basis" => Property.new(inherited: false, initial: "auto"),
59
+ "flex-direction" => Property.new(inherited: false, initial: "row"),
60
+ "flex-wrap" => Property.new(inherited: false, initial: "nowrap"),
61
+ }.freeze
62
+
63
+ COLOR_PROPERTIES = %w[
64
+ color background-color
65
+ border-top-color border-right-color border-bottom-color border-left-color
66
+ outline-color text-decoration-color
67
+ ].freeze
68
+
69
+ FONT_WEIGHT_KEYWORDS = {"normal" => "400", "bold" => "700"}.freeze
70
+
71
+ # text-decoration sub-property keyword sets (the shorthand classifies
72
+ # each token as a line, a style, or — failing both — a color).
73
+ TEXT_DECORATION_LINES = %w[none underline overline line-through blink].freeze
74
+ TEXT_DECORATION_STYLES = %w[solid double dotted dashed wavy].freeze
75
+
76
+ BORDER_SIDES = %w[top right bottom left].freeze
77
+ BORDER_STYLE_KEYWORDS = %w[none hidden dotted dashed solid double groove ridge inset outset].freeze
78
+ BORDER_WIDTH_KEYWORDS = %w[thin medium thick].freeze
79
+
80
+ BOX_SHORTHANDS = {
81
+ "margin" => "margin-%s",
82
+ "padding" => "padding-%s",
83
+ "inset" => "%s",
84
+ }.freeze
85
+
86
+ # Per-side longhands of the `border` family, in (width, style, color)
87
+ # order across the four sides — the targets a CSS-wide keyword on
88
+ # `border` expands to.
89
+ BORDER_LONGHANDS = BORDER_SIDES.flat_map do |side|
90
+ %w[width style color].map { |kind| "border-#{side}-#{kind}" }
91
+ end.freeze
92
+
93
+ # The longhands each curated shorthand covers — what a CSS-wide
94
+ # keyword (or an invalid var() substitution) applies to when it
95
+ # appears as the shorthand's whole value.
96
+ EXPANSION_TARGETS = {
97
+ "margin" => %w[margin-top margin-right margin-bottom margin-left],
98
+ "padding" => %w[padding-top padding-right padding-bottom padding-left],
99
+ "inset" => %w[top right bottom left],
100
+ "overflow" => %w[overflow-x overflow-y],
101
+ "gap" => %w[row-gap column-gap],
102
+ "background" => %w[background-color],
103
+ "font" => %w[font-style font-weight font-size line-height font-family],
104
+ "text-decoration" => %w[text-decoration-line text-decoration-style text-decoration-color],
105
+ "border" => BORDER_LONGHANDS,
106
+ "border-width" => %w[border-top-width border-right-width border-bottom-width border-left-width],
107
+ "border-style" => %w[border-top-style border-right-style border-bottom-style border-left-style],
108
+ "border-color" => %w[border-top-color border-right-color border-bottom-color border-left-color],
109
+ "border-top" => %w[border-top-width border-top-style border-top-color],
110
+ "border-right" => %w[border-right-width border-right-style border-right-color],
111
+ "border-bottom" => %w[border-bottom-width border-bottom-style border-bottom-color],
112
+ "border-left" => %w[border-left-width border-left-style border-left-color],
113
+ "outline" => %w[outline-width outline-style outline-color],
114
+ "flex" => %w[flex-grow flex-shrink flex-basis],
115
+ "flex-flow" => %w[flex-direction flex-wrap],
116
+ "list-style" => %w[list-style-type list-style-position list-style-image],
117
+ "place-content" => %w[align-content justify-content],
118
+ "place-items" => %w[align-items justify-items],
119
+ "place-self" => %w[align-self justify-self],
120
+ }.freeze
121
+
122
+ module_function
123
+
124
+ def known?(name) = PROPERTIES.key?(name)
125
+
126
+ def expansion_targets(name) = EXPANSION_TARGETS.fetch(name, [name])
127
+
128
+ # Custom properties inherit by default (css-variables-1 §2); their
129
+ # initial value is the guaranteed-invalid value (nil here).
130
+ def inherited?(name) = name.start_with?("--") || PROPERTIES[name]&.inherited || false
131
+
132
+ def initial(name) = PROPERTIES[name]&.initial
133
+
134
+ # Expand a (possibly shorthand) declaration into [[name, value], ...]
135
+ # longhand pairs. Cascade correctness is the point: a later shorthand
136
+ # must reset the longhands it covers (e.g. `background: url(x)` resets
137
+ # background-color to its initial). Shorthands outside this curated
138
+ # set pass through unexpanded (documented divergence).
139
+ def expand(name, value)
140
+ value = value.to_s.strip
141
+ case name
142
+ when "margin", "padding", "inset"
143
+ expand_box(BOX_SHORTHANDS[name], value) || [[name, value]]
144
+ when "overflow"
145
+ expand_overflow(value)
146
+ when "gap"
147
+ expand_gap(value)
148
+ when "background"
149
+ [["background-color", Color.extract(value) || initial("background-color")]]
150
+ when "font"
151
+ expand_font(value) || [[name, value]]
152
+ when "text-decoration"
153
+ expand_text_decoration(value)
154
+ when "border"
155
+ expand_border_all(value)
156
+ when "border-top", "border-right", "border-bottom", "border-left"
157
+ expand_border_side(name.delete_prefix("border-"), value)
158
+ when "border-width", "border-style", "border-color"
159
+ expand_border_box(name.delete_prefix("border-"), value) || [[name, value]]
160
+ when "outline"
161
+ expand_outline(value)
162
+ when "flex"
163
+ expand_flex(value)
164
+ when "flex-flow"
165
+ expand_flex_flow(value)
166
+ when "list-style"
167
+ expand_list_style(value)
168
+ when "place-content", "place-items", "place-self"
169
+ expand_place(name.delete_prefix("place-"), value)
170
+ else
171
+ [[name, value]]
172
+ end
173
+ end
174
+
175
+ # px-per-unit for the absolute length units (CSS Values 4: 96px per
176
+ # inch). These need no layout, so a computed value can carry them as px.
177
+ ABSOLUTE_UNIT_PX = {
178
+ "px" => 1.0,
179
+ "pt" => 96.0 / 72,
180
+ "pc" => 16.0,
181
+ "in" => 96.0,
182
+ "cm" => 96.0 / 2.54,
183
+ "mm" => 96.0 / 25.4,
184
+ # 1Q = 1/40 of a centimetre (CSS Values 4 §5.2).
185
+ "q" => 96.0 / 2.54 / 40,
186
+ }.freeze
187
+
188
+ LENGTH_PATTERN = /\A(-?\d+(?:\.\d+)?)(px|em|rem|pt|pc|in|cm|mm|q|vw|vh|vmin|vmax)\z/i
189
+
190
+ # The computed-value transform for one property. `font_size` /
191
+ # `root_font_size` are the element's / root's computed font-size in px;
192
+ # `viewport_width` / `viewport_height` the viewport in px. Together they
193
+ # let a bare length resolve to px without layout (em/rem/absolute/vw/vh).
194
+ def computed_value(name, value, font_size:, root_font_size:, viewport_width: nil, viewport_height: nil)
195
+ return Color.normalize(value) if COLOR_PROPERTIES.include?(name)
196
+ return FONT_WEIGHT_KEYWORDS.fetch(value.downcase, value) if name == "font-weight"
197
+ return clamp_opacity(value) if name == "opacity"
198
+ # A <percentage> line-height computes to px against the element's own
199
+ # font-size (CSS2 §10.8.1), inherited as that length. A unitless
200
+ # <number> is kept as the number — its whole point is to inherit
201
+ # unitless and scale to each descendant's font-size.
202
+ if name == "line-height" && (pct = percentage_of(value, font_size))
203
+ return pct
204
+ end
205
+
206
+ ctx = {font_size: font_size, root_font_size: root_font_size,
207
+ viewport_width: viewport_width, viewport_height: viewport_height}
208
+ evaluate_calc(value, **ctx) || resolve_length(value, **ctx) || value
209
+ end
210
+
211
+ # opacity computes to a number clamped to [0, 1]; a percentage maps to
212
+ # that fraction (css-color-4 §13). Unparseable values pass through.
213
+ def clamp_opacity(value)
214
+ text = value.to_s.strip
215
+ number =
216
+ if text.end_with?("%")
217
+ Float(text[0..-2]) / 100.0
218
+ else
219
+ Float(text)
220
+ end
221
+ format_number(number.clamp(0.0, 1.0))
222
+ rescue ArgumentError, TypeError
223
+ value
224
+ end
225
+
226
+ # px for a "<number>%" value against `base` px, or nil when not a
227
+ # percentage (or no base available).
228
+ def percentage_of(value, base)
229
+ return nil unless base
230
+
231
+ match = value.to_s.strip.match(/\A(-?\d+(?:\.\d+)?)%\z/)
232
+ match && format_px(match[1].to_f / 100.0 * base)
233
+ end
234
+
235
+ # Resolve a bare "<number><unit>" length to a px string, or nil when it
236
+ # isn't a single resolvable length. See resolve_length_px for the units.
237
+ def resolve_length(value, **ctx)
238
+ px = resolve_length_px(value, **ctx)
239
+ px && format_px(px)
240
+ end
241
+
242
+ # The px magnitude (Float) of a single "<number><unit>" length, or nil
243
+ # when the value isn't such a length or its base is unavailable. Handles
244
+ # font-relative (em/rem), absolute (px/pt/pc/in/cm/mm/Q) and viewport
245
+ # (vw/vh/vmin/vmax) units — everything resolvable without layout.
246
+ def resolve_length_px(value, font_size:, root_font_size:, viewport_width: nil, viewport_height: nil)
247
+ match = value.to_s.match(LENGTH_PATTERN)
248
+ return nil unless match
249
+
250
+ number = match[1].to_f
251
+ case match[2].downcase
252
+ when "em" then font_size && number * font_size
253
+ when "rem" then root_font_size && number * root_font_size
254
+ when "vw" then viewport_width && number * viewport_width / 100.0
255
+ when "vh" then viewport_height && number * viewport_height / 100.0
256
+ when "vmin" then viewport_width && viewport_height && number * [viewport_width, viewport_height].min / 100.0
257
+ when "vmax" then viewport_width && viewport_height && number * [viewport_width, viewport_height].max / 100.0
258
+ else number * ABSOLUTE_UNIT_PX[match[2].downcase]
259
+ end
260
+ end
261
+
262
+ def format_px(number)
263
+ rounded = number.round(3)
264
+ rounded == rounded.to_i ? "#{rounded.to_i}px" : "#{rounded}px"
265
+ end
266
+
267
+ # -- shorthand expansions ------------------------------------------
268
+
269
+ # CSS box expansion: 1 value -> all sides, 2 -> v/h, 3 -> t/h/b, 4 ->
270
+ # t/r/b/l. Returns nil for token counts outside 1..4 (or values with
271
+ # nested whitespace we can't split safely).
272
+ def expand_box(pattern, value)
273
+ tokens = value.split(/\s+/)
274
+ return nil unless (1..4).cover?(tokens.size) && tokens.none? { |t| t.include?("(") }
275
+
276
+ top, right, bottom, left =
277
+ case tokens.size
278
+ when 1 then [tokens[0]] * 4
279
+ when 2 then [tokens[0], tokens[1], tokens[0], tokens[1]]
280
+ when 3 then [tokens[0], tokens[1], tokens[2], tokens[1]]
281
+ else tokens
282
+ end
283
+ %w[top right bottom left].zip([top, right, bottom, left]).map do |side, v|
284
+ [format(pattern, side), v]
285
+ end
286
+ end
287
+
288
+ def expand_overflow(value)
289
+ x, y = value.split(/\s+/)
290
+ [["overflow-x", x], ["overflow-y", y || x]]
291
+ end
292
+
293
+ def expand_gap(value)
294
+ row, column = value.split(/\s+/)
295
+ [["row-gap", row], ["column-gap", column || row]]
296
+ end
297
+
298
+ # Minimal `font` shorthand: [style] [weight] <size>[/<line-height>]
299
+ # <family...>. Omitted sub-properties reset to their initial value
300
+ # (shorthand semantics). Returns nil when no size token is found
301
+ # (e.g. system font keywords) so the caller passes it through.
302
+ def expand_font(value)
303
+ tokens = value.split(/\s+/)
304
+ size_index = tokens.index { |t| t.match?(%r{\A\d+(?:\.\d+)?(px|em|rem|%)(/|\z)}i) }
305
+ return nil unless size_index
306
+
307
+ style = "normal"
308
+ weight = "400"
309
+ tokens[0...size_index].each do |token|
310
+ case token.downcase
311
+ when "italic", "oblique" then style = token.downcase
312
+ when "bold", /\A[1-9]00\z/ then weight = FONT_WEIGHT_KEYWORDS.fetch(token.downcase, token)
313
+ end
314
+ end
315
+
316
+ size, line_height = tokens[size_index].split("/", 2)
317
+ family = tokens[(size_index + 1)..].join(" ")
318
+
319
+ pairs = [
320
+ ["font-style", style],
321
+ ["font-weight", weight],
322
+ ["font-size", size],
323
+ ["line-height", line_height || "normal"],
324
+ ]
325
+ pairs << ["font-family", family] unless family.empty?
326
+ pairs
327
+ end
328
+
329
+ def expand_text_decoration(value)
330
+ lines = []
331
+ style = nil
332
+ color = nil
333
+ split_tokens(value).each do |token|
334
+ low = token.downcase
335
+ if TEXT_DECORATION_LINES.include?(low)
336
+ lines << token
337
+ elsif TEXT_DECORATION_STYLES.include?(low)
338
+ style = token
339
+ else
340
+ color ||= token
341
+ end
342
+ end
343
+ [["text-decoration-line", lines.empty? ? "none" : lines.join(" ")],
344
+ ["text-decoration-style", style || "solid"],
345
+ ["text-decoration-color", color || "currentColor"]]
346
+ end
347
+
348
+ # Whitespace split that keeps parenthesized groups intact, so a color
349
+ # `rgb(0, 0, 0)` or an `url(...)` stays one token.
350
+ def split_tokens(value)
351
+ tokens = []
352
+ current = +""
353
+ depth = 0
354
+ value.each_char do |char|
355
+ if char == "("
356
+ depth += 1
357
+ current << char
358
+ elsif char == ")"
359
+ depth -= 1 if depth.positive?
360
+ current << char
361
+ elsif char.match?(/\s/) && depth.zero?
362
+ tokens << current unless current.empty?
363
+ current = +""
364
+ else
365
+ current << char
366
+ end
367
+ end
368
+ tokens << current unless current.empty?
369
+ tokens
370
+ end
371
+
372
+ # 1 value -> all sides, 2 -> v/h, 3 -> t/h/b, 4 -> t/r/b/l.
373
+ def box_values(tokens)
374
+ case tokens.size
375
+ when 1 then [tokens[0]] * 4
376
+ when 2 then [tokens[0], tokens[1], tokens[0], tokens[1]]
377
+ when 3 then [tokens[0], tokens[1], tokens[2], tokens[1]]
378
+ else tokens[0, 4]
379
+ end
380
+ end
381
+
382
+ # Classify the tokens of a `border`/`border-<side>`/`outline` value into
383
+ # {width:, style:, color:} (order-independent, omitted parts nil).
384
+ def parse_border_shorthand(value)
385
+ parts = {width: nil, style: nil, color: nil}
386
+ split_tokens(value).each do |token|
387
+ low = token.downcase
388
+ if parts[:style].nil? && BORDER_STYLE_KEYWORDS.include?(low)
389
+ parts[:style] = token
390
+ elsif parts[:width].nil? && border_width_token?(low)
391
+ parts[:width] = token
392
+ else
393
+ parts[:color] ||= token
394
+ end
395
+ end
396
+ parts
397
+ end
398
+
399
+ def border_width_token?(low)
400
+ BORDER_WIDTH_KEYWORDS.include?(low) || low.match?(/\A[.\-\d]/)
401
+ end
402
+
403
+ # Omitted border sub-properties reset to their initial (shorthand
404
+ # semantics): width medium, style none, color currentColor.
405
+ def border_triple(prefix, parts)
406
+ [["#{prefix}-width", parts[:width] || "medium"],
407
+ ["#{prefix}-style", parts[:style] || "none"],
408
+ ["#{prefix}-color", parts[:color] || "currentColor"]]
409
+ end
410
+
411
+ def expand_border_all(value)
412
+ parts = parse_border_shorthand(value)
413
+ BORDER_SIDES.flat_map { |side| border_triple("border-#{side}", parts) }
414
+ end
415
+
416
+ def expand_border_side(side, value)
417
+ border_triple("border-#{side}", parse_border_shorthand(value))
418
+ end
419
+
420
+ # `border-width|style|color: <box>` — one kind across the four sides.
421
+ def expand_border_box(kind, value)
422
+ tokens = split_tokens(value)
423
+ return nil unless (1..4).cover?(tokens.size)
424
+
425
+ BORDER_SIDES.zip(box_values(tokens)).map { |side, v| ["border-#{side}-#{kind}", v] }
426
+ end
427
+
428
+ def expand_outline(value)
429
+ parts = parse_border_shorthand(value)
430
+ [["outline-width", parts[:width] || "medium"],
431
+ ["outline-style", parts[:style] || "none"],
432
+ ["outline-color", parts[:color] || "currentColor"]]
433
+ end
434
+
435
+ # `flex` per css-flexbox-1 §7.1.1: none -> 0 0 auto, auto -> 1 1 auto,
436
+ # a bare number -> grow 1 0%, etc. Omitted parts take the shorthand's
437
+ # reset values, not the longhand initials.
438
+ def expand_flex(value)
439
+ tokens = split_tokens(value)
440
+ grow, shrink, basis =
441
+ case tokens.size
442
+ when 1 then flex_one(tokens[0])
443
+ when 2 then flex_two(tokens[0], tokens[1])
444
+ else tokens[0, 3]
445
+ end
446
+ [["flex-grow", grow], ["flex-shrink", shrink], ["flex-basis", basis]]
447
+ end
448
+
449
+ def flex_one(token)
450
+ case token.downcase
451
+ when "none" then ["0", "0", "auto"]
452
+ when "auto" then ["1", "1", "auto"]
453
+ when "initial" then ["0", "1", "auto"]
454
+ else number?(token) ? [token, "1", "0%"] : ["1", "1", token]
455
+ end
456
+ end
457
+
458
+ def flex_two(first, second)
459
+ number?(second) ? [first, second, "0%"] : [first, "1", second]
460
+ end
461
+
462
+ def number?(token) = token.match?(/\A-?\d+(?:\.\d+)?\z/)
463
+
464
+ def expand_flex_flow(value)
465
+ direction = nil
466
+ wrap = nil
467
+ split_tokens(value).each do |token|
468
+ low = token.downcase
469
+ if %w[row row-reverse column column-reverse].include?(low)
470
+ direction = token
471
+ elsif %w[nowrap wrap wrap-reverse].include?(low)
472
+ wrap = token
473
+ end
474
+ end
475
+ [["flex-direction", direction || "row"], ["flex-wrap", wrap || "nowrap"]]
476
+ end
477
+
478
+ # `list-style: <type> || <position> || <image>` (any order). A `none`
479
+ # sets both type and image to none (css-lists-3).
480
+ def expand_list_style(value)
481
+ type = position = image = nil
482
+ none = false
483
+ split_tokens(value).each do |token|
484
+ low = token.downcase
485
+ if %w[inside outside].include?(low)
486
+ position = token
487
+ elsif low.start_with?("url(")
488
+ image = token
489
+ elsif low == "none"
490
+ none = true
491
+ else
492
+ type = token
493
+ end
494
+ end
495
+ type ||= "none" if none
496
+ image ||= "none" if none
497
+ [["list-style-type", type || "disc"],
498
+ ["list-style-position", position || "outside"],
499
+ ["list-style-image", image || "none"]]
500
+ end
501
+
502
+ # place-content/items/self: `<align> [<justify>]`; a single value
503
+ # applies to both. (Multi-keyword alignment values like `safe center`
504
+ # are not split — a documented simplification.)
505
+ def expand_place(suffix, value)
506
+ tokens = split_tokens(value)
507
+ [["align-#{suffix}", tokens[0]], ["justify-#{suffix}", tokens[1] || tokens[0]]]
508
+ end
509
+ end
510
+ end
511
+ end
512
+ end