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,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
|