cataract 0.3.0 → 0.4.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/.gitignore +0 -3
- data/.rubocop_todo.yml +11 -19
- data/BENCHMARKS.md +40 -40
- data/CHANGELOG.md +9 -0
- data/ext/cataract/cataract.c +72 -103
- data/ext/cataract/cataract.h +0 -1
- data/ext/cataract/css_parser.c +0 -18
- data/lib/cataract/declarations.rb +14 -4
- data/lib/cataract/native.rb +30 -0
- data/lib/cataract/pure/byte_constants.rb +70 -66
- data/lib/cataract/pure/declarations.rb +125 -0
- data/lib/cataract/pure/flatten.rb +1197 -1182
- data/lib/cataract/pure/parser.rb +1725 -1729
- data/lib/cataract/pure/serializer.rb +575 -715
- data/lib/cataract/pure/specificity.rb +165 -144
- data/lib/cataract/pure.rb +73 -101
- data/lib/cataract/rule.rb +6 -3
- data/lib/cataract/stylesheet.rb +19 -7
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +41 -30
- data/lib/tasks/profile.rake +6 -3
- metadata +3 -2
- data/lib/cataract/pure/helpers.rb +0 -35
|
@@ -1,801 +1,661 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Pure Ruby CSS parser - Serialization
|
|
3
|
+
# Pure Ruby CSS parser - Serialization
|
|
4
4
|
# NO REGEXP ALLOWED - char-by-char parsing only
|
|
5
|
-
#
|
|
6
|
-
# @api private
|
|
7
|
-
# This module contains internal serialization methods for converting parsed CSS
|
|
8
|
-
# back to strings. These methods are called by Stylesheet#to_s and should not be
|
|
9
|
-
# used directly. The public API is through the Stylesheet class.
|
|
10
5
|
|
|
11
6
|
module Cataract
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
7
|
+
module Backends
|
|
8
|
+
class PureImpl
|
|
9
|
+
# Serializes one stylesheet's rules to a CSS string, compact or formatted.
|
|
10
|
+
# One instance per #stylesheet_to_s / #stylesheet_to_formatted_s call - the
|
|
11
|
+
# accumulating result, the rules/media being serialized, and the
|
|
12
|
+
# compact-vs-formatted knobs all live on ivars instead of being threaded
|
|
13
|
+
# through every helper as parameters.
|
|
14
|
+
class Serializer
|
|
15
|
+
def initialize(rules, charset, has_nesting, selector_lists, media_queries, media_query_lists, formatted:)
|
|
16
|
+
@rules = rules
|
|
17
|
+
@charset = charset
|
|
18
|
+
@has_nesting = has_nesting
|
|
19
|
+
@selector_lists = selector_lists || {}
|
|
20
|
+
@media_queries = media_queries || []
|
|
21
|
+
@media_query_lists = media_query_lists || {}
|
|
22
|
+
@formatted = formatted
|
|
23
|
+
@result = +''
|
|
24
|
+
|
|
25
|
+
if formatted
|
|
26
|
+
@opening_brace = " {\n"
|
|
27
|
+
@closing_brace = "}\n"
|
|
28
|
+
@media_indent = ' '
|
|
29
|
+
@decl_indent_base = ' '
|
|
30
|
+
@decl_indent_media = ' '
|
|
31
|
+
@add_blank_lines = true
|
|
32
|
+
else
|
|
33
|
+
@opening_brace = ' { '
|
|
34
|
+
@closing_brace = " }\n"
|
|
35
|
+
@media_indent = ''
|
|
36
|
+
@decl_indent_base = nil
|
|
37
|
+
@decl_indent_media = nil
|
|
38
|
+
@add_blank_lines = false
|
|
39
|
+
end
|
|
29
40
|
end
|
|
30
|
-
end.join(', ')
|
|
31
|
-
else
|
|
32
|
-
# Single query
|
|
33
|
-
if media_query.conditions
|
|
34
|
-
media_query.type == :all ? media_query.conditions : "#{media_query.type} and #{media_query.conditions}"
|
|
35
|
-
else
|
|
36
|
-
media_query.type.to_s
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# @api private
|
|
42
|
-
# Helper: does the rule's media query differ from the currently open media
|
|
43
|
-
# block, requiring a new "@media ... {" to be opened? Comma-separated
|
|
44
|
-
# lists are compared by list id; single queries are compared by content
|
|
45
|
-
# (type + conditions), since two separately-parsed queries with the same
|
|
46
|
-
# content should still be grouped under one block.
|
|
47
|
-
# @return [Boolean]
|
|
48
|
-
def self._needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
|
|
49
|
-
rule_media_query_list_id)
|
|
50
|
-
if rule_media_query_list_id
|
|
51
|
-
current_media_query_list_id != rule_media_query_list_id
|
|
52
|
-
else
|
|
53
|
-
!current_media_query ||
|
|
54
|
-
current_media_query.type != rule_media_query.type ||
|
|
55
|
-
current_media_query.conditions != rule_media_query.conditions
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# @api private
|
|
60
|
-
# Helper: read a rule's parent_rule_id, guarding against AtRule (which has
|
|
61
|
-
# no such member - only Rule participates in CSS nesting, AtRule never is
|
|
62
|
-
# nested via '&' nesting). Calling .parent_rule_id directly on an AtRule
|
|
63
|
-
# raises NoMethodError.
|
|
64
|
-
# @param rule [Rule, AtRule] The rule to check
|
|
65
|
-
# @return [Integer, nil] parent_rule_id, or nil if the rule isn't a Rule
|
|
66
|
-
def self._rule_parent_id(rule)
|
|
67
|
-
rule.is_a?(Rule) ? rule.parent_rule_id : nil
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Serialize stylesheet to compact CSS string
|
|
71
|
-
#
|
|
72
|
-
# @param rules [Array<Rule>] Array of rules
|
|
73
|
-
# @param charset [String, nil] @charset value
|
|
74
|
-
# @param has_nesting [Boolean] Whether any nested rules exist
|
|
75
|
-
# @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
|
|
76
|
-
# @param media_queries [Array<MediaQuery>] Array of MediaQuery objects (optional, for proper serialization)
|
|
77
|
-
# @param media_query_lists [Hash] List ID => array of MediaQuery IDs (optional, for comma-separated queries)
|
|
78
|
-
# @return [String] Compact CSS string
|
|
79
|
-
def self.stylesheet_to_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [], media_query_lists = {})
|
|
80
|
-
result = +''
|
|
81
|
-
|
|
82
|
-
# Add @charset if present
|
|
83
|
-
unless charset.nil?
|
|
84
|
-
result << "@charset \"#{charset}\";\n"
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Fast path: no nesting - use simple algorithm
|
|
88
|
-
unless has_nesting
|
|
89
|
-
return _stylesheet_to_s_without_nesting(rules, result, selector_lists, media_queries, media_query_lists)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Build parent-child relationships
|
|
93
|
-
rule_children = {}
|
|
94
|
-
rules.each do |rule|
|
|
95
|
-
parent_rule_id = _rule_parent_id(rule)
|
|
96
|
-
next unless parent_rule_id
|
|
97
|
-
|
|
98
|
-
parent_id = parent_rule_id.is_a?(Integer) ? parent_rule_id : parent_rule_id.to_i
|
|
99
|
-
rule_children[parent_id] ||= []
|
|
100
|
-
rule_children[parent_id] << rule
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# Build reverse map: media_query_id => list_id
|
|
104
|
-
mq_id_to_list_id = {}
|
|
105
|
-
media_query_lists.each do |list_id, mq_ids|
|
|
106
|
-
mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Serialize top-level rules only (those without parent_rule_id)
|
|
110
|
-
current_media_query_list_id = nil
|
|
111
|
-
current_media_query = nil
|
|
112
|
-
in_media_block = false
|
|
113
41
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
42
|
+
# Serialize the stylesheet to a CSS string (compact or formatted,
|
|
43
|
+
# depending on how this Serializer was constructed).
|
|
44
|
+
#
|
|
45
|
+
# @return [String] CSS string
|
|
46
|
+
def to_s
|
|
47
|
+
@result << "@charset \"#{@charset}\";\n" unless @charset.nil?
|
|
117
48
|
|
|
118
|
-
|
|
119
|
-
rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
|
|
120
|
-
rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
|
|
49
|
+
build_mq_id_to_list_id!
|
|
121
50
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
in_media_block = false
|
|
127
|
-
current_media_query = nil
|
|
128
|
-
current_media_query_list_id = nil
|
|
129
|
-
end
|
|
130
|
-
else
|
|
131
|
-
# Check if we need to open a new media block
|
|
132
|
-
# For lists: compare list_id
|
|
133
|
-
# For single queries: compare by content (type + conditions)
|
|
134
|
-
needs_new_block = _needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
|
|
135
|
-
rule_media_query_list_id)
|
|
136
|
-
|
|
137
|
-
if needs_new_block
|
|
138
|
-
if in_media_block
|
|
139
|
-
result << "}\n"
|
|
51
|
+
if @has_nesting
|
|
52
|
+
@formatted ? serialize_with_nesting_formatted : serialize_with_nesting_compact
|
|
53
|
+
else
|
|
54
|
+
serialize_with_grouping
|
|
140
55
|
end
|
|
141
|
-
current_media_query = rule_media_query
|
|
142
|
-
current_media_query_list_id = rule_media_query_list_id
|
|
143
56
|
|
|
144
|
-
|
|
145
|
-
media_query_string = _build_media_query_string(rule_media_query, rule_media_query_list_id, media_query_lists, media_queries)
|
|
146
|
-
result << "@media #{media_query_string} {\n"
|
|
147
|
-
in_media_block = true
|
|
57
|
+
@result
|
|
148
58
|
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
_serialize_rule_with_nesting(result, rule, rule_children, media_queries)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
if in_media_block
|
|
155
|
-
result << "}\n"
|
|
156
|
-
end
|
|
157
59
|
|
|
158
|
-
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Helper: serialize rules without nesting support (compact format)
|
|
162
|
-
def self._stylesheet_to_s_without_nesting(rules, result, selector_lists, media_queries = [], media_query_lists = {})
|
|
163
|
-
_serialize_stylesheet_with_grouping(
|
|
164
|
-
rules: rules,
|
|
165
|
-
result: result,
|
|
166
|
-
selector_lists: selector_lists,
|
|
167
|
-
media_queries: media_queries,
|
|
168
|
-
media_query_lists: media_query_lists,
|
|
169
|
-
opening_brace: ' { ',
|
|
170
|
-
closing_brace: " }\n",
|
|
171
|
-
media_indent: '',
|
|
172
|
-
decl_indent_base: nil,
|
|
173
|
-
decl_indent_media: nil,
|
|
174
|
-
add_blank_lines: false
|
|
175
|
-
)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# Helper: serialize a rule with its nested children
|
|
179
|
-
def self._serialize_rule_with_nesting(result, rule, rule_children, media_queries)
|
|
180
|
-
# Check if this is an AtRule - it can never have nested children of its
|
|
181
|
-
# own via rule_children (only Rule participates in CSS nesting), so it
|
|
182
|
-
# always serializes the same way regardless of has_nesting
|
|
183
|
-
if rule.is_a?(AtRule)
|
|
184
|
-
_serialize_at_rule(result, rule)
|
|
185
|
-
return
|
|
186
|
-
end
|
|
60
|
+
private
|
|
187
61
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
_serialize_children(result, rule.selector, rule_children[rule.id] || [], rule_children, media_queries, has_declarations)
|
|
198
|
-
|
|
199
|
-
result << " }\n"
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# Helper: recursively serialize a rule's nested children. CSS nesting can go
|
|
203
|
-
# as deep as the parser allows (MAX_PARSE_DEPTH), so this recurses rather
|
|
204
|
-
# than hand-unrolling a fixed number of levels - a nested rule's own nested
|
|
205
|
-
# rules are found the same way its parent's were, via rule_children[id].
|
|
206
|
-
def self._serialize_children(result, parent_selector, children, rule_children, media_queries, parent_has_declarations)
|
|
207
|
-
children.each_with_index do |child, index|
|
|
208
|
-
# Add space before nested content
|
|
209
|
-
# - Always add space if the parent had declarations
|
|
210
|
-
# - Add space between nested rules (not before first if no declarations)
|
|
211
|
-
if parent_has_declarations || index > 0
|
|
212
|
-
result << ' '
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# Check if this child has @media nesting (parent_rule_id present but nesting_style is nil)
|
|
216
|
-
if child.nesting_style.nil? && child.media_query_id && media_queries[child.media_query_id]
|
|
217
|
-
# This is a nested @media rule
|
|
218
|
-
mq = media_queries[child.media_query_id]
|
|
219
|
-
media_query_string = _build_media_query_string(mq, nil, nil, media_queries)
|
|
220
|
-
result << "@media #{media_query_string} { "
|
|
221
|
-
_serialize_declarations(result, child.declarations)
|
|
222
|
-
|
|
223
|
-
# Serialize any children of this @media rule
|
|
224
|
-
media_children = rule_children[child.id] || []
|
|
225
|
-
media_children.each_with_index do |media_child, media_idx|
|
|
226
|
-
result << ' ' if media_idx > 0 || !child.declarations.empty?
|
|
227
|
-
|
|
228
|
-
nested_media_selector = _reconstruct_nested_selector(
|
|
229
|
-
child.selector, media_child.selector,
|
|
230
|
-
media_child.nesting_style
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
result << "#{nested_media_selector} { "
|
|
234
|
-
_serialize_declarations(result, media_child.declarations)
|
|
235
|
-
result << ' }'
|
|
62
|
+
# Build reverse map: media_query_id => list_id (used by both the
|
|
63
|
+
# nesting-aware and selector-list-grouping paths)
|
|
64
|
+
def build_mq_id_to_list_id!
|
|
65
|
+
@mq_id_to_list_id = {}
|
|
66
|
+
@media_query_lists.each do |list_id, mq_ids|
|
|
67
|
+
mq_ids.each { |mq_id| @mq_id_to_list_id[mq_id] = list_id }
|
|
68
|
+
end
|
|
236
69
|
end
|
|
237
70
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
result << ' }'
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# Reconstruct nested selector representation
|
|
255
|
-
# If nesting_style == 1 (explicit), try to use & notation
|
|
256
|
-
# If nesting_style == 0 (implicit), use plain selector
|
|
257
|
-
def self._reconstruct_nested_selector(parent_selector, child_selector, nesting_style)
|
|
258
|
-
return child_selector if nesting_style.nil?
|
|
259
|
-
|
|
260
|
-
if nesting_style == 1 # NESTING_STYLE_EXPLICIT
|
|
261
|
-
# Try to reconstruct & notation
|
|
262
|
-
# ".parent .child" with parent ".parent" => "& .child"
|
|
263
|
-
# ".parent:hover" with parent ".parent" => "&:hover"
|
|
264
|
-
if child_selector.start_with?(parent_selector)
|
|
265
|
-
rest = child_selector[parent_selector.length..-1]
|
|
266
|
-
return "&#{rest}"
|
|
267
|
-
end
|
|
268
|
-
# More complex cases like ".parent .foo .child"
|
|
269
|
-
child_selector.sub(parent_selector, '&')
|
|
270
|
-
else # NESTING_STYLE_IMPLICIT
|
|
271
|
-
# Remove parent prefix for implicit nesting
|
|
272
|
-
# ".parent .child" with parent ".parent" => ".child"
|
|
273
|
-
if child_selector.start_with?(parent_selector)
|
|
274
|
-
rest = child_selector[parent_selector.length..-1]
|
|
275
|
-
return rest.lstrip
|
|
276
|
-
end
|
|
277
|
-
child_selector
|
|
278
|
-
end
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
# Helper: find all selectors from same list with matching declarations
|
|
282
|
-
# Returns array of selectors that can be grouped, marks rules as processed
|
|
283
|
-
def self._find_groupable_selectors(rule:, rules:, selector_lists:, processed_rule_ids:, current_media_query_id:)
|
|
284
|
-
list_id = rule.selector_list_id
|
|
285
|
-
rule_ids_in_list = selector_lists[list_id]
|
|
286
|
-
|
|
287
|
-
# If no other rules in this list, return just this selector
|
|
288
|
-
if rule_ids_in_list.nil? || rule_ids_in_list.size <= 1
|
|
289
|
-
processed_rule_ids[rule.id] = true
|
|
290
|
-
return [rule.selector]
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
# Find all rules in this list that have identical declarations AND same media context
|
|
294
|
-
matching_selectors = []
|
|
295
|
-
rule_ids_in_list.each do |rid|
|
|
296
|
-
# Direct array access (O(1)) - rules[i].id == i invariant is guaranteed by parser
|
|
297
|
-
other_rule = rules[rid]
|
|
298
|
-
next unless other_rule
|
|
299
|
-
next if processed_rule_ids[rid]
|
|
300
|
-
|
|
301
|
-
# Check same media context (compare media_query_id directly)
|
|
302
|
-
next if other_rule.media_query_id != current_media_query_id
|
|
303
|
-
|
|
304
|
-
# Check declarations match (compare arrays directly for performance)
|
|
305
|
-
if _declarations_equal?(rule.declarations, other_rule.declarations)
|
|
306
|
-
matching_selectors << other_rule.selector
|
|
307
|
-
processed_rule_ids[rid] = true
|
|
308
|
-
end
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
matching_selectors
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
# Private shared implementation for stylesheet serialization with optional selector list grouping
|
|
315
|
-
# All formatting behavior controlled by kwargs to avoid mode flags and if/else branches
|
|
316
|
-
def self._serialize_stylesheet_with_grouping(
|
|
317
|
-
rules:,
|
|
318
|
-
result:,
|
|
319
|
-
selector_lists:,
|
|
320
|
-
opening_brace:, # ' { ' (compact) vs " {\n" (formatted)
|
|
321
|
-
closing_brace:, # " }\n" (compact) vs "}\n" (formatted)
|
|
322
|
-
media_indent:, # '' (compact) vs ' ' (formatted)
|
|
323
|
-
decl_indent_base:, # nil (compact) vs ' ' (formatted base rules)
|
|
324
|
-
decl_indent_media:, # nil (compact) vs ' ' (formatted media rules)
|
|
325
|
-
add_blank_lines:, # false (compact) vs true (formatted)
|
|
326
|
-
media_queries: [], # Array of MediaQuery objects
|
|
327
|
-
media_query_lists: {} # Hash: list_id => array of MediaQuery IDs
|
|
328
|
-
)
|
|
329
|
-
grouping_enabled = selector_lists && !selector_lists.empty?
|
|
330
|
-
|
|
331
|
-
# Build reverse map: media_query_id => list_id
|
|
332
|
-
mq_id_to_list_id = {}
|
|
333
|
-
media_query_lists.each do |list_id, mq_ids|
|
|
334
|
-
mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
# Track processed rules to avoid duplicates when grouping
|
|
338
|
-
processed_rule_ids = {}
|
|
339
|
-
|
|
340
|
-
# Iterate through rules in insertion order, grouping consecutive media queries
|
|
341
|
-
current_media_query_list_id = nil
|
|
342
|
-
current_media_query = nil
|
|
343
|
-
in_media_block = false
|
|
344
|
-
rule_index = 0
|
|
71
|
+
# Build id => rule lookup (used only by the selector-list-grouping
|
|
72
|
+
# path). @rules is NOT guaranteed to satisfy rules[i].id == i here -
|
|
73
|
+
# that invariant only holds for the full, freshly-parsed rules array;
|
|
74
|
+
# Stylesheet#to_s(media: ...) passes a filtered subset whenever the
|
|
75
|
+
# media filter isn't :all, so a plain array index would silently
|
|
76
|
+
# fetch the wrong rule (or even an AtRule, which has no
|
|
77
|
+
# #declarations) once any rule has been filtered out.
|
|
78
|
+
def build_rules_by_id!
|
|
79
|
+
@rules_by_id = {}
|
|
80
|
+
@rules.each { |rule| @rules_by_id[rule.id] = rule }
|
|
81
|
+
end
|
|
345
82
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
83
|
+
# Build parent-child relationships (used only by the nesting-aware path)
|
|
84
|
+
def build_rule_children!
|
|
85
|
+
@rule_children = {}
|
|
86
|
+
@rules.each do |rule|
|
|
87
|
+
parent_rule_id = rule_parent_id(rule)
|
|
88
|
+
next unless parent_rule_id
|
|
349
89
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
90
|
+
parent_id = parent_rule_id.is_a?(Integer) ? parent_rule_id : parent_rule_id.to_i
|
|
91
|
+
@rule_children[parent_id] ||= []
|
|
92
|
+
@rule_children[parent_id] << rule
|
|
93
|
+
end
|
|
94
|
+
end
|
|
354
95
|
|
|
355
|
-
|
|
356
|
-
#
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
current_media_query_list_id = nil
|
|
96
|
+
# Read a rule's parent_rule_id, guarding against AtRule (which has no
|
|
97
|
+
# such member - only Rule participates in CSS nesting, AtRule never is
|
|
98
|
+
# nested via '&' nesting). Calling .parent_rule_id directly on an AtRule
|
|
99
|
+
# raises NoMethodError.
|
|
100
|
+
def rule_parent_id(rule)
|
|
101
|
+
rule.is_a?(Rule) ? rule.parent_rule_id : nil
|
|
362
102
|
end
|
|
363
103
|
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
#
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
104
|
+
# Build media query string from a MediaQuery object or list.
|
|
105
|
+
# @param media_query [MediaQuery] The MediaQuery object
|
|
106
|
+
# @param media_query_list_id [Integer, nil] Optional list ID if this is part of a comma-separated list
|
|
107
|
+
# @return [String] e.g. "screen", "screen, print", "screen and (min-width: 768px)"
|
|
108
|
+
def build_media_query_string(media_query, media_query_list_id)
|
|
109
|
+
if media_query_list_id
|
|
110
|
+
mq_ids = @media_query_lists[media_query_list_id]
|
|
111
|
+
mq_ids.map do |mq_id|
|
|
112
|
+
mq = @media_queries[mq_id]
|
|
113
|
+
if mq.conditions
|
|
114
|
+
mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
|
|
115
|
+
else
|
|
116
|
+
mq.type.to_s
|
|
117
|
+
end
|
|
118
|
+
end.join(', ')
|
|
119
|
+
elsif media_query.conditions
|
|
120
|
+
media_query.type == :all ? media_query.conditions : "#{media_query.type} and #{media_query.conditions}"
|
|
381
121
|
else
|
|
382
|
-
|
|
122
|
+
media_query.type.to_s
|
|
383
123
|
end
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Does the rule's media query differ from the currently open media
|
|
127
|
+
# block, requiring a new "@media ... {" to be opened? Comma-separated
|
|
128
|
+
# lists are compared by list id; single queries are compared by content
|
|
129
|
+
# (type + conditions), since two separately-parsed queries with the same
|
|
130
|
+
# content should still be grouped under one block.
|
|
131
|
+
def needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
|
|
132
|
+
rule_media_query_list_id)
|
|
133
|
+
if rule_media_query_list_id
|
|
134
|
+
current_media_query_list_id != rule_media_query_list_id
|
|
389
135
|
else
|
|
390
|
-
|
|
136
|
+
!current_media_query ||
|
|
137
|
+
current_media_query.type != rule_media_query.type ||
|
|
138
|
+
current_media_query.conditions != rule_media_query.conditions
|
|
391
139
|
end
|
|
392
|
-
processed_rule_ids[rule.id] = true
|
|
393
140
|
end
|
|
394
|
-
else
|
|
395
|
-
# This rule is in a media query
|
|
396
|
-
# For lists: compare list_id
|
|
397
|
-
# For single queries: compare by content (type + conditions)
|
|
398
|
-
needs_new_block = _needs_new_media_block?(current_media_query, current_media_query_list_id, rule_media_query,
|
|
399
|
-
rule_media_query_list_id)
|
|
400
|
-
|
|
401
|
-
if needs_new_block
|
|
402
|
-
# Close previous media block if open
|
|
403
|
-
if in_media_block
|
|
404
|
-
result << "}\n"
|
|
405
|
-
end
|
|
406
141
|
|
|
407
|
-
|
|
408
|
-
result << "\n" if add_blank_lines && !is_first_rule
|
|
142
|
+
# ---- Nesting-aware path (has_nesting == true), compact format ----
|
|
409
143
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
current_media_query_list_id = rule_media_query_list_id
|
|
144
|
+
def serialize_with_nesting_compact
|
|
145
|
+
build_rule_children!
|
|
413
146
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
in_media_block = true
|
|
418
|
-
end
|
|
147
|
+
current_media_query_list_id = nil
|
|
148
|
+
current_media_query = nil
|
|
149
|
+
in_media_block = false
|
|
419
150
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
151
|
+
@rules.each do |rule|
|
|
152
|
+
next if rule_parent_id(rule)
|
|
153
|
+
|
|
154
|
+
rule_media_query_id = rule.media_query_id
|
|
155
|
+
rule_media_query = rule_media_query_id ? @media_queries[rule_media_query_id] : nil
|
|
156
|
+
rule_media_query_list_id = rule_media_query_id ? @mq_id_to_list_id[rule_media_query_id] : nil
|
|
157
|
+
|
|
158
|
+
if rule_media_query.nil?
|
|
159
|
+
if in_media_block
|
|
160
|
+
@result << "}\n"
|
|
161
|
+
in_media_block = false
|
|
162
|
+
current_media_query = nil
|
|
163
|
+
current_media_query_list_id = nil
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
needs_new_block = needs_new_media_block?(current_media_query, current_media_query_list_id,
|
|
167
|
+
rule_media_query, rule_media_query_list_id)
|
|
168
|
+
|
|
169
|
+
if needs_new_block
|
|
170
|
+
@result << "}\n" if in_media_block
|
|
171
|
+
current_media_query = rule_media_query
|
|
172
|
+
current_media_query_list_id = rule_media_query_list_id
|
|
173
|
+
|
|
174
|
+
media_query_string = build_media_query_string(rule_media_query, rule_media_query_list_id)
|
|
175
|
+
@result << "@media #{media_query_string} {\n"
|
|
176
|
+
in_media_block = true
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
serialize_rule_with_nesting(rule)
|
|
444
181
|
end
|
|
445
|
-
|
|
182
|
+
|
|
183
|
+
@result << "}\n" if in_media_block
|
|
446
184
|
end
|
|
447
|
-
end
|
|
448
185
|
|
|
449
|
-
|
|
450
|
-
|
|
186
|
+
def serialize_rule_with_nesting(rule)
|
|
187
|
+
# AtRule can never have nested children of its own via @rule_children
|
|
188
|
+
# (only Rule participates in CSS nesting), so it always serializes the
|
|
189
|
+
# same way regardless of has_nesting.
|
|
190
|
+
if rule.is_a?(AtRule)
|
|
191
|
+
serialize_at_rule(rule)
|
|
192
|
+
return
|
|
193
|
+
end
|
|
451
194
|
|
|
452
|
-
|
|
453
|
-
if in_media_block
|
|
454
|
-
result << "}\n"
|
|
455
|
-
end
|
|
195
|
+
@result << "#{rule.selector} { "
|
|
456
196
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
private_class_method :_serialize_stylesheet_with_grouping
|
|
197
|
+
has_declarations = !rule.declarations.empty?
|
|
198
|
+
serialize_declarations(rule.declarations) if has_declarations
|
|
460
199
|
|
|
461
|
-
|
|
462
|
-
def self._declarations_equal?(decls1, decls2)
|
|
463
|
-
return false if decls1.size != decls2.size
|
|
200
|
+
serialize_children(rule.selector, @rule_children[rule.id] || [], has_declarations)
|
|
464
201
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return false if d1.property != d2.property
|
|
468
|
-
return false if d1.value != d2.value
|
|
469
|
-
return false if d1.important != d2.important
|
|
470
|
-
end
|
|
202
|
+
@result << " }\n"
|
|
203
|
+
end
|
|
471
204
|
|
|
472
|
-
|
|
473
|
-
|
|
205
|
+
# Recursively serialize a rule's nested children. CSS nesting can go as
|
|
206
|
+
# deep as the parser allows (MAX_PARSE_DEPTH), so this recurses rather
|
|
207
|
+
# than hand-unrolling a fixed number of levels - a nested rule's own
|
|
208
|
+
# nested rules are found the same way its parent's were, via
|
|
209
|
+
# @rule_children[id].
|
|
210
|
+
def serialize_children(parent_selector, children, parent_has_declarations)
|
|
211
|
+
children.each_with_index do |child, index|
|
|
212
|
+
# Add space before nested content - always if the parent had
|
|
213
|
+
# declarations, otherwise between nested rules (not before the first).
|
|
214
|
+
@result << ' ' if parent_has_declarations || index > 0
|
|
215
|
+
|
|
216
|
+
if child.nesting_style.nil? && child.media_query_id && @media_queries[child.media_query_id]
|
|
217
|
+
# Nested @media rule (parent_rule_id present but nesting_style is nil)
|
|
218
|
+
mq = @media_queries[child.media_query_id]
|
|
219
|
+
media_query_string = build_media_query_string(mq, nil)
|
|
220
|
+
@result << "@media #{media_query_string} { "
|
|
221
|
+
serialize_declarations(child.declarations)
|
|
222
|
+
|
|
223
|
+
media_children = @rule_children[child.id] || []
|
|
224
|
+
media_children.each_with_index do |media_child, media_idx|
|
|
225
|
+
@result << ' ' if media_idx > 0 || !child.declarations.empty?
|
|
226
|
+
|
|
227
|
+
nested_media_selector = reconstruct_nested_selector(
|
|
228
|
+
child.selector, media_child.selector, media_child.nesting_style
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@result << "#{nested_media_selector} { "
|
|
232
|
+
serialize_declarations(media_child.declarations)
|
|
233
|
+
@result << ' }'
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
@result << ' }'
|
|
237
|
+
else
|
|
238
|
+
# Regular nested selector - reconstruct it with & if needed
|
|
239
|
+
nested_selector = reconstruct_nested_selector(parent_selector, child.selector, child.nesting_style)
|
|
240
|
+
|
|
241
|
+
@result << "#{nested_selector} { "
|
|
242
|
+
serialize_declarations(child.declarations)
|
|
243
|
+
|
|
244
|
+
serialize_children(child.selector, @rule_children[child.id] || [], !child.declarations.empty?)
|
|
245
|
+
|
|
246
|
+
@result << ' }'
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
474
250
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
251
|
+
# Reconstruct nested selector representation.
|
|
252
|
+
# If nesting_style == 1 (explicit), try to use & notation.
|
|
253
|
+
# If nesting_style == 0 (implicit), use plain selector.
|
|
254
|
+
def reconstruct_nested_selector(parent_selector, child_selector, nesting_style)
|
|
255
|
+
return child_selector if nesting_style.nil?
|
|
256
|
+
|
|
257
|
+
if nesting_style == 1 # NESTING_STYLE_EXPLICIT
|
|
258
|
+
# ".parent .child" with parent ".parent" => "& .child"
|
|
259
|
+
# ".parent:hover" with parent ".parent" => "&:hover"
|
|
260
|
+
if child_selector.start_with?(parent_selector)
|
|
261
|
+
rest = child_selector[parent_selector.length..-1]
|
|
262
|
+
return "&#{rest}"
|
|
263
|
+
end
|
|
264
|
+
# More complex cases like ".parent .foo .child"
|
|
265
|
+
child_selector.sub(parent_selector, '&')
|
|
266
|
+
else # NESTING_STYLE_IMPLICIT
|
|
267
|
+
# ".parent .child" with parent ".parent" => ".child"
|
|
268
|
+
if child_selector.start_with?(parent_selector)
|
|
269
|
+
rest = child_selector[parent_selector.length..-1]
|
|
270
|
+
return rest.lstrip
|
|
271
|
+
end
|
|
272
|
+
child_selector
|
|
273
|
+
end
|
|
274
|
+
end
|
|
482
275
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
end
|
|
276
|
+
# ---- Selector-list-grouping path (has_nesting == false) ----
|
|
277
|
+
# Shared between compact and formatted output - all formatting behavior
|
|
278
|
+
# is controlled by ivars set in #initialize, so there's no mode-flag
|
|
279
|
+
# kwarg pile to keep in sync.
|
|
488
280
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
separator = i < declarations.length - 1 ? ' ' : ''
|
|
494
|
-
result << "#{decl.property}: #{decl.value}#{important_suffix}#{separator}"
|
|
495
|
-
end
|
|
496
|
-
end
|
|
281
|
+
def serialize_with_grouping
|
|
282
|
+
@processed_rule_ids = {}
|
|
283
|
+
grouping_enabled = @selector_lists && !@selector_lists.empty?
|
|
284
|
+
build_rules_by_id! if grouping_enabled
|
|
497
285
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
286
|
+
current_media_query_list_id = nil
|
|
287
|
+
current_media_query = nil
|
|
288
|
+
in_media_block = false
|
|
289
|
+
rule_index = 0
|
|
290
|
+
|
|
291
|
+
@rules.each do |rule|
|
|
292
|
+
next if @processed_rule_ids[rule.id]
|
|
293
|
+
|
|
294
|
+
rule_media_query_id = rule.media_query_id
|
|
295
|
+
rule_media_query = rule_media_query_id ? @media_queries[rule_media_query_id] : nil
|
|
296
|
+
rule_media_query_list_id = rule_media_query_id ? @mq_id_to_list_id[rule_media_query_id] : nil
|
|
297
|
+
is_first_rule = (rule_index == 0)
|
|
298
|
+
|
|
299
|
+
if rule_media_query.nil?
|
|
300
|
+
if in_media_block
|
|
301
|
+
@result << "}\n"
|
|
302
|
+
in_media_block = false
|
|
303
|
+
current_media_query = nil
|
|
304
|
+
current_media_query_list_id = nil
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
@result << "\n" if @add_blank_lines && !is_first_rule
|
|
308
|
+
|
|
309
|
+
if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
|
|
310
|
+
selectors = find_groupable_selectors(rule, rule_media_query_id)
|
|
311
|
+
|
|
312
|
+
@result << selectors.join(', ') << @opening_brace
|
|
313
|
+
if @decl_indent_base
|
|
314
|
+
serialize_declarations_formatted(rule.declarations, @decl_indent_base)
|
|
315
|
+
else
|
|
316
|
+
serialize_declarations(rule.declarations)
|
|
317
|
+
end
|
|
318
|
+
@result << @closing_brace
|
|
319
|
+
else
|
|
320
|
+
if @decl_indent_base
|
|
321
|
+
serialize_rule_formatted(rule, '', true)
|
|
322
|
+
else
|
|
323
|
+
serialize_rule(rule)
|
|
324
|
+
end
|
|
325
|
+
@processed_rule_ids[rule.id] = true
|
|
326
|
+
end
|
|
327
|
+
else
|
|
328
|
+
needs_new_block = needs_new_media_block?(current_media_query, current_media_query_list_id,
|
|
329
|
+
rule_media_query, rule_media_query_list_id)
|
|
330
|
+
|
|
331
|
+
if needs_new_block
|
|
332
|
+
@result << "}\n" if in_media_block
|
|
333
|
+
@result << "\n" if @add_blank_lines && !is_first_rule
|
|
334
|
+
|
|
335
|
+
current_media_query = rule_media_query
|
|
336
|
+
current_media_query_list_id = rule_media_query_list_id
|
|
337
|
+
|
|
338
|
+
media_query_string = build_media_query_string(rule_media_query, rule_media_query_list_id)
|
|
339
|
+
@result << "@media #{media_query_string} {\n"
|
|
340
|
+
in_media_block = true
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
|
|
344
|
+
selectors = find_groupable_selectors(rule, rule_media_query_id)
|
|
345
|
+
|
|
346
|
+
@result << @media_indent << selectors.join(', ') << @opening_brace
|
|
347
|
+
if @decl_indent_media
|
|
348
|
+
serialize_declarations_formatted(rule.declarations, @decl_indent_media)
|
|
349
|
+
else
|
|
350
|
+
serialize_declarations(rule.declarations)
|
|
351
|
+
end
|
|
352
|
+
@result << @media_indent << @closing_brace
|
|
353
|
+
else
|
|
354
|
+
if @decl_indent_media
|
|
355
|
+
serialize_rule_formatted(rule, @media_indent, true)
|
|
356
|
+
else
|
|
357
|
+
serialize_rule(rule)
|
|
358
|
+
end
|
|
359
|
+
@processed_rule_ids[rule.id] = true
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
rule_index += 1
|
|
364
|
+
end
|
|
505
365
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
end
|
|
366
|
+
@result << "}\n" if in_media_block
|
|
367
|
+
end
|
|
509
368
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
369
|
+
# Find all selectors from the same selector list with matching
|
|
370
|
+
# declarations (and the same media context). Returns the array of
|
|
371
|
+
# selectors that can be grouped, marking them processed as it goes.
|
|
372
|
+
def find_groupable_selectors(rule, current_media_query_id)
|
|
373
|
+
list_id = rule.selector_list_id
|
|
374
|
+
rule_ids_in_list = @selector_lists[list_id]
|
|
513
375
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
376
|
+
if rule_ids_in_list.nil? || rule_ids_in_list.size <= 1
|
|
377
|
+
@processed_rule_ids[rule.id] = true
|
|
378
|
+
return [rule.selector]
|
|
379
|
+
end
|
|
517
380
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
381
|
+
matching_selectors = []
|
|
382
|
+
rule_ids_in_list.each do |rid|
|
|
383
|
+
other_rule = @rules_by_id[rid]
|
|
384
|
+
next unless other_rule
|
|
385
|
+
next if @processed_rule_ids[rid]
|
|
386
|
+
next if other_rule.media_query_id != current_media_query_id
|
|
387
|
+
|
|
388
|
+
if declarations_equal?(rule.declarations, other_rule.declarations)
|
|
389
|
+
matching_selectors << other_rule.selector
|
|
390
|
+
@processed_rule_ids[rid] = true
|
|
391
|
+
end
|
|
392
|
+
end
|
|
521
393
|
|
|
522
|
-
|
|
523
|
-
# Serialize as nested rules (e.g., @keyframes)
|
|
524
|
-
at_rule.content.each do |nested_rule|
|
|
525
|
-
result << " #{nested_rule.selector} { "
|
|
526
|
-
_serialize_declarations(result, nested_rule.declarations)
|
|
527
|
-
result << " }\n"
|
|
394
|
+
matching_selectors
|
|
528
395
|
end
|
|
529
|
-
else
|
|
530
|
-
# Serialize as declarations (e.g., @font-face)
|
|
531
|
-
result << ' '
|
|
532
|
-
_serialize_declarations(result, at_rule.content)
|
|
533
|
-
result << "\n"
|
|
534
|
-
end
|
|
535
|
-
end
|
|
536
|
-
|
|
537
|
-
result << "}\n"
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
# Serialize stylesheet to formatted CSS string (with indentation)
|
|
541
|
-
#
|
|
542
|
-
# @param rules [Array<Rule>] Array of rules
|
|
543
|
-
# @param charset [String, nil] @charset value
|
|
544
|
-
# @param has_nesting [Boolean] Whether any nested rules exist
|
|
545
|
-
# @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
|
|
546
|
-
# @param media_queries [Array<MediaQuery>] Array of MediaQuery objects (optional, for proper serialization)
|
|
547
|
-
# @param media_query_lists [Hash] List ID => array of MediaQuery IDs (optional, for comma-separated queries)
|
|
548
|
-
# @return [String] Formatted CSS string
|
|
549
|
-
def self.stylesheet_to_formatted_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [], media_query_lists = {})
|
|
550
|
-
result = +''
|
|
551
|
-
|
|
552
|
-
# Add @charset if present
|
|
553
|
-
unless charset.nil?
|
|
554
|
-
result << "@charset \"#{charset}\";\n"
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
# Fast path: no nesting - use simple algorithm
|
|
558
|
-
unless has_nesting
|
|
559
|
-
return _stylesheet_to_formatted_s_without_nesting(rules, result, selector_lists, media_queries, media_query_lists)
|
|
560
|
-
end
|
|
561
396
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
rules.each do |rule|
|
|
565
|
-
parent_rule_id = _rule_parent_id(rule)
|
|
566
|
-
next unless parent_rule_id
|
|
397
|
+
def declarations_equal?(decls1, decls2)
|
|
398
|
+
return false if decls1.size != decls2.size
|
|
567
399
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
400
|
+
decls1.each_with_index do |d1, i|
|
|
401
|
+
d2 = decls2[i]
|
|
402
|
+
return false if d1.property != d2.property
|
|
403
|
+
return false if d1.value != d2.value
|
|
404
|
+
return false if d1.important != d2.important
|
|
405
|
+
end
|
|
572
406
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
media_query_lists.each do |list_id, mq_ids|
|
|
576
|
-
mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
|
|
577
|
-
end
|
|
407
|
+
true
|
|
408
|
+
end
|
|
578
409
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
410
|
+
def serialize_rule(rule)
|
|
411
|
+
if rule.is_a?(AtRule)
|
|
412
|
+
serialize_at_rule(rule)
|
|
413
|
+
return
|
|
414
|
+
end
|
|
583
415
|
|
|
584
|
-
|
|
585
|
-
|
|
416
|
+
@result << "#{rule.selector} { "
|
|
417
|
+
serialize_declarations(rule.declarations)
|
|
418
|
+
@result << " }\n"
|
|
419
|
+
end
|
|
586
420
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
421
|
+
# Declarations, compact (single line)
|
|
422
|
+
def serialize_declarations(declarations)
|
|
423
|
+
declarations.each_with_index do |decl, i|
|
|
424
|
+
important_suffix = decl.important ? ' !important;' : ';'
|
|
425
|
+
separator = i < declarations.length - 1 ? ' ' : ''
|
|
426
|
+
@result << "#{decl.property}: #{decl.value}#{important_suffix}#{separator}"
|
|
427
|
+
end
|
|
428
|
+
end
|
|
590
429
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
430
|
+
# Declarations, formatted (one per line)
|
|
431
|
+
def serialize_declarations_formatted(declarations, indent)
|
|
432
|
+
declarations.each do |decl|
|
|
433
|
+
@result << indent
|
|
434
|
+
@result << decl.property
|
|
435
|
+
@result << ': '
|
|
436
|
+
@result << decl.value
|
|
437
|
+
@result << ' !important' if decl.important
|
|
438
|
+
@result << ";\n"
|
|
439
|
+
end
|
|
597
440
|
end
|
|
598
441
|
|
|
599
|
-
#
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
442
|
+
# An at-rule (@keyframes, @font-face, etc), compact
|
|
443
|
+
def serialize_at_rule(at_rule)
|
|
444
|
+
@result << "#{at_rule.selector} {\n"
|
|
445
|
+
|
|
446
|
+
if at_rule.content.length > 0
|
|
447
|
+
first = at_rule.content[0]
|
|
448
|
+
|
|
449
|
+
if first.is_a?(Rule)
|
|
450
|
+
at_rule.content.each do |nested_rule|
|
|
451
|
+
@result << " #{nested_rule.selector} { "
|
|
452
|
+
serialize_declarations(nested_rule.declarations)
|
|
453
|
+
@result << " }\n"
|
|
454
|
+
end
|
|
455
|
+
else
|
|
456
|
+
@result << ' '
|
|
457
|
+
serialize_declarations(at_rule.content)
|
|
458
|
+
@result << "\n"
|
|
459
|
+
end
|
|
614
460
|
end
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
# Serialize the media query (or list)
|
|
618
|
-
media_query_string = _build_media_query_string(rule_media_query, rule_media_query_list_id, media_query_lists, media_queries)
|
|
619
|
-
result << "@media #{media_query_string} {\n"
|
|
620
|
-
in_media_block = true
|
|
461
|
+
|
|
462
|
+
@result << "}\n"
|
|
621
463
|
end
|
|
622
464
|
|
|
623
|
-
|
|
624
|
-
end
|
|
625
|
-
end
|
|
465
|
+
# ---- Nesting-aware path (has_nesting == true), formatted ----
|
|
626
466
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
end
|
|
467
|
+
def serialize_with_nesting_formatted
|
|
468
|
+
build_rule_children!
|
|
630
469
|
|
|
631
|
-
|
|
632
|
-
|
|
470
|
+
current_media_query_list_id = nil
|
|
471
|
+
current_media_query = nil
|
|
472
|
+
in_media_block = false
|
|
633
473
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
474
|
+
@rules.each do |rule|
|
|
475
|
+
next if rule_parent_id(rule)
|
|
476
|
+
|
|
477
|
+
rule_media_query_id = rule.media_query_id
|
|
478
|
+
rule_media_query = rule_media_query_id ? @media_queries[rule_media_query_id] : nil
|
|
479
|
+
rule_media_query_list_id = rule_media_query_id ? @mq_id_to_list_id[rule_media_query_id] : nil
|
|
480
|
+
|
|
481
|
+
if rule_media_query.nil?
|
|
482
|
+
if in_media_block
|
|
483
|
+
@result << "}\n"
|
|
484
|
+
in_media_block = false
|
|
485
|
+
current_media_query = nil
|
|
486
|
+
current_media_query_list_id = nil
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Blank line before a base rule if we just closed a media block (ends with "}\n")
|
|
490
|
+
if @result.length > 1 && @result.getbyte(-1) == BYTE_NEWLINE && @result.getbyte(-2) == BYTE_RBRACE
|
|
491
|
+
@result << "\n"
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
serialize_rule_with_nesting_formatted(rule, '')
|
|
495
|
+
else
|
|
496
|
+
needs_new_block = needs_new_media_block?(current_media_query, current_media_query_list_id,
|
|
497
|
+
rule_media_query, rule_media_query_list_id)
|
|
498
|
+
|
|
499
|
+
if needs_new_block
|
|
500
|
+
if in_media_block
|
|
501
|
+
@result << "}\n"
|
|
502
|
+
elsif @result.length > 0
|
|
503
|
+
@result << "\n"
|
|
504
|
+
end
|
|
505
|
+
current_media_query = rule_media_query
|
|
506
|
+
current_media_query_list_id = rule_media_query_list_id
|
|
507
|
+
media_query_string = build_media_query_string(rule_media_query, rule_media_query_list_id)
|
|
508
|
+
@result << "@media #{media_query_string} {\n"
|
|
509
|
+
in_media_block = true
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
serialize_rule_with_nesting_formatted(rule, ' ')
|
|
513
|
+
end
|
|
514
|
+
end
|
|
650
515
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
# Check if this is an AtRule - it can never have nested children of its
|
|
654
|
-
# own via rule_children (only Rule participates in CSS nesting), so it
|
|
655
|
-
# always serializes the same way regardless of has_nesting
|
|
656
|
-
if rule.is_a?(AtRule)
|
|
657
|
-
_serialize_at_rule_formatted(result, rule, indent)
|
|
658
|
-
return
|
|
659
|
-
end
|
|
516
|
+
@result << "}\n" if in_media_block
|
|
517
|
+
end
|
|
660
518
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
519
|
+
def serialize_rule_with_nesting_formatted(rule, indent)
|
|
520
|
+
if rule.is_a?(AtRule)
|
|
521
|
+
serialize_at_rule_formatted(rule, indent)
|
|
522
|
+
return
|
|
523
|
+
end
|
|
665
524
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
end
|
|
525
|
+
@result << indent
|
|
526
|
+
@result << rule.selector
|
|
527
|
+
@result << " {\n"
|
|
670
528
|
|
|
671
|
-
|
|
529
|
+
serialize_declarations_formatted(rule.declarations, "#{indent} ") unless rule.declarations.empty?
|
|
672
530
|
|
|
673
|
-
|
|
674
|
-
result << indent
|
|
675
|
-
result << "}\n"
|
|
676
|
-
end
|
|
531
|
+
serialize_children_formatted(rule.selector, @rule_children[rule.id] || [], "#{indent} ")
|
|
677
532
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
# recurses rather than hand-unrolling a fixed number of levels - a nested
|
|
681
|
-
# rule's own nested rules are found the same way its parent's were, via
|
|
682
|
-
# rule_children[id], with the indent growing by one level each call.
|
|
683
|
-
def self._serialize_children_formatted(result, parent_selector, children, rule_children, media_queries, indent)
|
|
684
|
-
children.each do |child|
|
|
685
|
-
if child.nesting_style.nil? && child.media_query_id && media_queries[child.media_query_id]
|
|
686
|
-
# Nested @media
|
|
687
|
-
mq = media_queries[child.media_query_id]
|
|
688
|
-
media_query_string = _build_media_query_string(mq, nil, nil, media_queries)
|
|
689
|
-
result << indent
|
|
690
|
-
result << "@media #{media_query_string} {\n"
|
|
691
|
-
|
|
692
|
-
unless child.declarations.empty?
|
|
693
|
-
_serialize_declarations_formatted(result, child.declarations, "#{indent} ")
|
|
533
|
+
@result << indent
|
|
534
|
+
@result << "}\n"
|
|
694
535
|
end
|
|
695
536
|
|
|
696
|
-
#
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
537
|
+
# Recursively serialize a rule's nested children with indentation. CSS
|
|
538
|
+
# nesting can go as deep as the parser allows (MAX_PARSE_DEPTH), so this
|
|
539
|
+
# recurses rather than hand-unrolling a fixed number of levels - a nested
|
|
540
|
+
# rule's own nested rules are found the same way its parent's were, via
|
|
541
|
+
# @rule_children[id], with the indent growing by one level each call.
|
|
542
|
+
def serialize_children_formatted(parent_selector, children, indent)
|
|
543
|
+
children.each do |child|
|
|
544
|
+
if child.nesting_style.nil? && child.media_query_id && @media_queries[child.media_query_id]
|
|
545
|
+
mq = @media_queries[child.media_query_id]
|
|
546
|
+
media_query_string = build_media_query_string(mq, nil)
|
|
547
|
+
@result << indent
|
|
548
|
+
@result << "@media #{media_query_string} {\n"
|
|
549
|
+
|
|
550
|
+
serialize_declarations_formatted(child.declarations, "#{indent} ") unless child.declarations.empty?
|
|
551
|
+
|
|
552
|
+
media_children = @rule_children[child.id] || []
|
|
553
|
+
media_children.each do |media_child|
|
|
554
|
+
nested_media_selector = reconstruct_nested_selector(
|
|
555
|
+
child.selector, media_child.selector, media_child.nesting_style
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
@result << indent
|
|
559
|
+
@result << " #{nested_media_selector} {\n"
|
|
560
|
+
unless media_child.declarations.empty?
|
|
561
|
+
serialize_declarations_formatted(media_child.declarations, "#{indent} ")
|
|
562
|
+
end
|
|
563
|
+
@result << indent
|
|
564
|
+
@result << " }\n"
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
@result << indent
|
|
568
|
+
@result << "}\n"
|
|
569
|
+
else
|
|
570
|
+
nested_selector = reconstruct_nested_selector(parent_selector, child.selector, child.nesting_style)
|
|
571
|
+
|
|
572
|
+
@result << indent
|
|
573
|
+
@result << "#{nested_selector} {\n"
|
|
574
|
+
|
|
575
|
+
serialize_declarations_formatted(child.declarations, "#{indent} ") unless child.declarations.empty?
|
|
576
|
+
|
|
577
|
+
serialize_children_formatted(child.selector, @rule_children[child.id] || [], "#{indent} ")
|
|
578
|
+
|
|
579
|
+
@result << indent
|
|
580
|
+
@result << "}\n"
|
|
581
|
+
end
|
|
709
582
|
end
|
|
710
|
-
result << indent
|
|
711
|
-
result << " }\n"
|
|
712
583
|
end
|
|
713
584
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
585
|
+
def serialize_rule_formatted(rule, indent, is_last_rule = false)
|
|
586
|
+
if rule.is_a?(AtRule)
|
|
587
|
+
serialize_at_rule_formatted(rule, indent)
|
|
588
|
+
return
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
@result << indent
|
|
592
|
+
@result << rule.selector
|
|
593
|
+
@result << " {\n"
|
|
719
594
|
|
|
720
|
-
|
|
721
|
-
result << "#{nested_selector} {\n"
|
|
595
|
+
serialize_declarations_formatted(rule.declarations, "#{indent} ")
|
|
722
596
|
|
|
723
|
-
|
|
724
|
-
|
|
597
|
+
# Closing brace - double newline for all except last rule
|
|
598
|
+
@result << indent
|
|
599
|
+
@result << (is_last_rule ? "}\n" : "}\n\n")
|
|
725
600
|
end
|
|
726
601
|
|
|
727
|
-
|
|
728
|
-
|
|
602
|
+
def serialize_at_rule_formatted(at_rule, indent)
|
|
603
|
+
@result << indent
|
|
604
|
+
@result << at_rule.selector
|
|
605
|
+
@result << " {\n"
|
|
606
|
+
|
|
607
|
+
if at_rule.content.length > 0
|
|
608
|
+
first = at_rule.content[0]
|
|
609
|
+
|
|
610
|
+
if first.is_a?(Rule)
|
|
611
|
+
at_rule.content.each do |nested_rule|
|
|
612
|
+
@result << indent
|
|
613
|
+
@result << ' '
|
|
614
|
+
@result << nested_rule.selector
|
|
615
|
+
@result << " {\n"
|
|
616
|
+
|
|
617
|
+
serialize_declarations_formatted(nested_rule.declarations, "#{indent} ")
|
|
618
|
+
|
|
619
|
+
@result << indent
|
|
620
|
+
@result << " }\n"
|
|
621
|
+
end
|
|
622
|
+
else
|
|
623
|
+
serialize_declarations_formatted(at_rule.content, "#{indent} ")
|
|
624
|
+
end
|
|
625
|
+
end
|
|
729
626
|
|
|
730
|
-
|
|
731
|
-
|
|
627
|
+
@result << indent
|
|
628
|
+
@result << "}\n"
|
|
629
|
+
end
|
|
732
630
|
end
|
|
733
|
-
end
|
|
734
|
-
end
|
|
735
|
-
|
|
736
|
-
# Helper: serialize a single rule with formatting
|
|
737
|
-
def self._serialize_rule_formatted(result, rule, indent, is_last_rule = false)
|
|
738
|
-
# Check if this is an AtRule
|
|
739
|
-
if rule.is_a?(AtRule)
|
|
740
|
-
_serialize_at_rule_formatted(result, rule, indent)
|
|
741
|
-
return
|
|
742
|
-
end
|
|
743
|
-
|
|
744
|
-
# Regular Rule serialization with formatting
|
|
745
|
-
# Selector line with opening brace
|
|
746
|
-
result << indent
|
|
747
|
-
result << rule.selector
|
|
748
|
-
result << " {\n"
|
|
749
|
-
|
|
750
|
-
# Declarations (one per line)
|
|
751
|
-
_serialize_declarations_formatted(result, rule.declarations, "#{indent} ")
|
|
752
631
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
632
|
+
# Serialize stylesheet to compact CSS string.
|
|
633
|
+
#
|
|
634
|
+
# @param rules [Array<Rule>] Array of rules
|
|
635
|
+
# @param charset [String, nil] @charset value
|
|
636
|
+
# @param has_nesting [Boolean] Whether any nested rules exist
|
|
637
|
+
# @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
|
|
638
|
+
# @param media_queries [Array<MediaQuery>] Array of MediaQuery objects
|
|
639
|
+
# @param media_query_lists [Hash] List ID => array of MediaQuery IDs (for comma-separated queries)
|
|
640
|
+
# @return [String] Compact CSS string
|
|
641
|
+
def stylesheet_to_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [], media_query_lists = {})
|
|
642
|
+
Serializer.new(rules, charset, has_nesting, selector_lists, media_queries, media_query_lists, formatted: false).to_s
|
|
643
|
+
end
|
|
757
644
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
# Nested selector with opening brace (2-space indent)
|
|
772
|
-
result << indent
|
|
773
|
-
result << ' '
|
|
774
|
-
result << nested_rule.selector
|
|
775
|
-
result << " {\n"
|
|
776
|
-
|
|
777
|
-
# Declarations (one per line, 4-space indent)
|
|
778
|
-
_serialize_declarations_formatted(result, nested_rule.declarations, "#{indent} ")
|
|
779
|
-
|
|
780
|
-
# Closing brace (2-space indent)
|
|
781
|
-
result << indent
|
|
782
|
-
result << " }\n"
|
|
783
|
-
end
|
|
784
|
-
else
|
|
785
|
-
# Serialize as declarations (e.g., @font-face, one per line)
|
|
786
|
-
_serialize_declarations_formatted(result, at_rule.content, "#{indent} ")
|
|
645
|
+
# Serialize stylesheet to formatted CSS string (with indentation).
|
|
646
|
+
#
|
|
647
|
+
# @param rules [Array<Rule>] Array of rules
|
|
648
|
+
# @param charset [String, nil] @charset value
|
|
649
|
+
# @param has_nesting [Boolean] Whether any nested rules exist
|
|
650
|
+
# @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
|
|
651
|
+
# @param media_queries [Array<MediaQuery>] Array of MediaQuery objects
|
|
652
|
+
# @param media_query_lists [Hash] List ID => array of MediaQuery IDs (for comma-separated queries)
|
|
653
|
+
# @return [String] Formatted CSS string
|
|
654
|
+
def stylesheet_to_formatted_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [],
|
|
655
|
+
media_query_lists = {})
|
|
656
|
+
Serializer.new(rules, charset, has_nesting, selector_lists, media_queries, media_query_lists,
|
|
657
|
+
formatted: true).to_s
|
|
787
658
|
end
|
|
788
659
|
end
|
|
789
|
-
|
|
790
|
-
result << indent
|
|
791
|
-
result << "}\n"
|
|
792
660
|
end
|
|
793
|
-
|
|
794
|
-
# Mark helper methods as private (public APIs: stylesheet_to_s, stylesheet_to_formatted_s)
|
|
795
|
-
private_class_method :_build_media_query_string, :_needs_new_media_block?, :_stylesheet_to_s_without_nesting,
|
|
796
|
-
:_serialize_rule_with_nesting,
|
|
797
|
-
:_reconstruct_nested_selector, :_find_groupable_selectors, :_declarations_equal?,
|
|
798
|
-
:_serialize_rule, :_serialize_declarations, :_serialize_declarations_formatted,
|
|
799
|
-
:_serialize_at_rule, :_stylesheet_to_formatted_s_without_nesting, :_serialize_rule_with_nesting_formatted,
|
|
800
|
-
:_serialize_rule_formatted, :_serialize_at_rule_formatted
|
|
801
661
|
end
|