cataract 0.1.4 → 0.2.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/.github/workflows/ci-manual-rubies.yml +18 -1
- data/.rubocop.yml +36 -6
- data/.rubocop_todo.yml +7 -7
- data/BENCHMARKS.md +30 -30
- data/CHANGELOG.md +10 -0
- data/RAGEL_MIGRATION.md +2 -2
- data/README.md +7 -2
- data/Rakefile +24 -11
- data/cataract.gemspec +1 -1
- data/ext/cataract/cataract.c +12 -3
- data/ext/cataract/cataract.h +5 -3
- data/ext/cataract/css_parser.c +156 -32
- data/ext/cataract/extconf.rb +2 -2
- data/ext/cataract/{merge.c → flatten.c} +520 -468
- data/ext/cataract/shorthand_expander.c +164 -115
- data/lib/cataract/import_resolver.rb +60 -39
- data/lib/cataract/import_statement.rb +49 -0
- data/lib/cataract/pure/{merge.rb → flatten.rb} +39 -40
- data/lib/cataract/pure/imports.rb +13 -0
- data/lib/cataract/pure/parser.rb +108 -4
- data/lib/cataract/pure.rb +32 -9
- data/lib/cataract/rule.rb +51 -6
- data/lib/cataract/stylesheet.rb +343 -41
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +28 -24
- metadata +4 -3
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Pure Ruby CSS
|
|
3
|
+
# Pure Ruby CSS flatten implementation
|
|
4
4
|
# NO REGEXP ALLOWED - use string manipulation only
|
|
5
5
|
|
|
6
6
|
module Cataract
|
|
7
|
-
module
|
|
7
|
+
module Flatten
|
|
8
8
|
# Property name constants (US-ASCII for merge output)
|
|
9
9
|
PROP_MARGIN = 'margin'.encode(Encoding::US_ASCII).freeze
|
|
10
10
|
PROP_MARGIN_TOP = 'margin-top'.encode(Encoding::US_ASCII).freeze
|
|
@@ -138,7 +138,7 @@ module Cataract
|
|
|
138
138
|
# @param stylesheet [Stylesheet] Stylesheet to merge
|
|
139
139
|
# @param mutate [Boolean] If true, mutate the stylesheet; otherwise create new one
|
|
140
140
|
# @return [Stylesheet] Merged stylesheet
|
|
141
|
-
def self.
|
|
141
|
+
def self.flatten(stylesheet, mutate: false)
|
|
142
142
|
# Separate AtRules (pass-through) from regular Rules (to merge)
|
|
143
143
|
at_rules = []
|
|
144
144
|
regular_rules = []
|
|
@@ -152,7 +152,9 @@ module Cataract
|
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
# Expand shorthands in regular rules only (AtRules don't have declarations)
|
|
155
|
-
regular_rules.each
|
|
155
|
+
regular_rules.each do |rule|
|
|
156
|
+
rule.declarations.replace(rule.declarations.flat_map { |decl| _expand_shorthand(decl) })
|
|
157
|
+
end
|
|
156
158
|
|
|
157
159
|
merged_rules = []
|
|
158
160
|
|
|
@@ -160,7 +162,7 @@ module Cataract
|
|
|
160
162
|
# (Nesting is flattened during parsing, so we just merge by resolved selector)
|
|
161
163
|
grouped = regular_rules.group_by(&:selector)
|
|
162
164
|
grouped.each do |selector, rules|
|
|
163
|
-
merged_rule =
|
|
165
|
+
merged_rule = flatten_rules_for_selector(selector, rules)
|
|
164
166
|
merged_rules << merged_rule if merged_rule
|
|
165
167
|
end
|
|
166
168
|
|
|
@@ -195,7 +197,7 @@ module Cataract
|
|
|
195
197
|
# @param selector [String] The selector
|
|
196
198
|
# @param rules [Array<Rule>] Rules with this selector
|
|
197
199
|
# @return [Rule] Merged rule with cascaded declarations
|
|
198
|
-
def self.
|
|
200
|
+
def self.flatten_rules_for_selector(selector, rules)
|
|
199
201
|
# Build declaration map: property => [source_order, specificity, important, value]
|
|
200
202
|
decl_map = {}
|
|
201
203
|
|
|
@@ -284,42 +286,39 @@ module Cataract
|
|
|
284
286
|
Cataract.calculate_specificity(selector)
|
|
285
287
|
end
|
|
286
288
|
|
|
287
|
-
# Expand
|
|
289
|
+
# Expand a single shorthand declaration into longhand declarations.
|
|
290
|
+
# Returns an array of longhand declarations. If the declaration is not a shorthand,
|
|
291
|
+
# returns an array with just that declaration.
|
|
288
292
|
#
|
|
289
|
-
# @param
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
else
|
|
318
|
-
expanded << decl
|
|
319
|
-
end
|
|
293
|
+
# @param decl [Declaration] Declaration to expand
|
|
294
|
+
# @return [Array<Declaration>] Array of expanded longhand declarations
|
|
295
|
+
# @api private
|
|
296
|
+
def self._expand_shorthand(decl)
|
|
297
|
+
case decl.property
|
|
298
|
+
when 'margin'
|
|
299
|
+
expand_margin(decl)
|
|
300
|
+
when 'padding'
|
|
301
|
+
expand_padding(decl)
|
|
302
|
+
when 'border'
|
|
303
|
+
expand_border(decl)
|
|
304
|
+
when 'border-top', 'border-right', 'border-bottom', 'border-left'
|
|
305
|
+
expand_border_side(decl)
|
|
306
|
+
when 'border-width'
|
|
307
|
+
expand_border_width(decl)
|
|
308
|
+
when 'border-style'
|
|
309
|
+
expand_border_style(decl)
|
|
310
|
+
when 'border-color'
|
|
311
|
+
expand_border_color(decl)
|
|
312
|
+
when 'font'
|
|
313
|
+
expand_font(decl)
|
|
314
|
+
when 'background'
|
|
315
|
+
expand_background(decl)
|
|
316
|
+
when 'list-style'
|
|
317
|
+
expand_list_style(decl)
|
|
318
|
+
else
|
|
319
|
+
# Not a shorthand, return as-is in an array
|
|
320
|
+
[decl]
|
|
320
321
|
end
|
|
321
|
-
|
|
322
|
-
rule.declarations.replace(expanded)
|
|
323
322
|
end
|
|
324
323
|
|
|
325
324
|
# Expand margin shorthand
|
|
@@ -155,7 +155,20 @@ module Cataract
|
|
|
155
155
|
full_match = css_string[import_start...import_end]
|
|
156
156
|
|
|
157
157
|
imports << { url: url, media: media, full_match: full_match }
|
|
158
|
+
elsif match_ascii_ci?(css_string, i, '@charset')
|
|
159
|
+
# Skip @charset if present - it can come before @import
|
|
160
|
+
while i < len && css_string.getbyte(i) != BYTE_SEMICOLON
|
|
161
|
+
i += 1
|
|
162
|
+
end
|
|
163
|
+
i += 1 if i < len # Skip semicolon
|
|
158
164
|
else
|
|
165
|
+
# If we hit any other content (rules, other at-rules), stop scanning
|
|
166
|
+
# Per CSS spec, @import must be at the top (only @charset can come before)
|
|
167
|
+
byte = css_string.getbyte(i) if i < len
|
|
168
|
+
if i < len && !is_whitespace?(byte)
|
|
169
|
+
break
|
|
170
|
+
end
|
|
171
|
+
|
|
159
172
|
i += 1
|
|
160
173
|
end
|
|
161
174
|
end
|
data/lib/cataract/pure/parser.rb
CHANGED
|
@@ -74,6 +74,7 @@ module Cataract
|
|
|
74
74
|
# Parser state
|
|
75
75
|
@rules = [] # Flat array of Rule structs
|
|
76
76
|
@_media_index = {} # Symbol => Array of rule IDs
|
|
77
|
+
@imports = [] # Array of ImportStatement structs
|
|
77
78
|
@rule_id_counter = 0 # Next rule ID (0-indexed)
|
|
78
79
|
@media_query_count = 0 # Safety limit
|
|
79
80
|
@_has_nesting = false # Set to true if any nested rules found
|
|
@@ -82,9 +83,8 @@ module Cataract
|
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
def parse
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
skip_imports
|
|
86
|
+
# @import statements are now handled in parse_at_rule
|
|
87
|
+
# They must come before all rules (except @charset) per CSS spec
|
|
88
88
|
|
|
89
89
|
# Main parsing loop - char-by-char, NO REGEXP
|
|
90
90
|
until eof?
|
|
@@ -182,6 +182,7 @@ module Cataract
|
|
|
182
182
|
{
|
|
183
183
|
rules: @rules,
|
|
184
184
|
_media_index: @_media_index,
|
|
185
|
+
imports: @imports,
|
|
185
186
|
charset: @charset,
|
|
186
187
|
_has_nesting: @_has_nesting
|
|
187
188
|
}
|
|
@@ -658,6 +659,23 @@ module Cataract
|
|
|
658
659
|
return
|
|
659
660
|
end
|
|
660
661
|
|
|
662
|
+
# Handle @import - must come before rules (except @charset)
|
|
663
|
+
if at_rule_name == 'import'
|
|
664
|
+
# If we've already seen a rule, this @import is invalid
|
|
665
|
+
if @rules.size > 0
|
|
666
|
+
warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
|
|
667
|
+
# Skip to semicolon
|
|
668
|
+
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
669
|
+
@pos += 1
|
|
670
|
+
end
|
|
671
|
+
@pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
672
|
+
return
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
parse_import_statement
|
|
676
|
+
return
|
|
677
|
+
end
|
|
678
|
+
|
|
661
679
|
# Handle conditional group at-rules: @supports, @layer, @container, @scope
|
|
662
680
|
# These behave like @media but don't affect media context
|
|
663
681
|
if AT_RULE_TYPES.include?(at_rule_name)
|
|
@@ -1123,7 +1141,93 @@ module Cataract
|
|
|
1123
1141
|
@pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
1124
1142
|
end
|
|
1125
1143
|
|
|
1126
|
-
#
|
|
1144
|
+
# Parse an @import statement
|
|
1145
|
+
# @import "url" [media-query];
|
|
1146
|
+
# @import url("url") [media-query];
|
|
1147
|
+
def parse_import_statement
|
|
1148
|
+
skip_ws_and_comments
|
|
1149
|
+
|
|
1150
|
+
# Check for optional url(
|
|
1151
|
+
has_url_function = false
|
|
1152
|
+
if @pos + 4 <= @len && match_ascii_ci?(@css, @pos, 'url(')
|
|
1153
|
+
has_url_function = true
|
|
1154
|
+
@pos += 4
|
|
1155
|
+
skip_ws_and_comments
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
# Find opening quote
|
|
1159
|
+
byte = peek_byte
|
|
1160
|
+
if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
|
|
1161
|
+
# Invalid @import, skip to semicolon
|
|
1162
|
+
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1163
|
+
@pos += 1
|
|
1164
|
+
end
|
|
1165
|
+
@pos += 1 unless eof?
|
|
1166
|
+
return
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
quote_char = byte
|
|
1170
|
+
@pos += 1 # Skip opening quote
|
|
1171
|
+
|
|
1172
|
+
url_start = @pos
|
|
1173
|
+
|
|
1174
|
+
# Find closing quote (handle escaped quotes)
|
|
1175
|
+
while !eof? && peek_byte != quote_char
|
|
1176
|
+
@pos += if peek_byte == BYTE_BACKSLASH && @pos + 1 < @len
|
|
1177
|
+
2 # Skip escaped character
|
|
1178
|
+
else
|
|
1179
|
+
1
|
|
1180
|
+
end
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
if eof?
|
|
1184
|
+
# Unterminated string
|
|
1185
|
+
return
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
url = byteslice_encoded(url_start, @pos - url_start)
|
|
1189
|
+
@pos += 1 # Skip closing quote
|
|
1190
|
+
|
|
1191
|
+
# Skip closing paren if we had url(
|
|
1192
|
+
if has_url_function
|
|
1193
|
+
skip_ws_and_comments
|
|
1194
|
+
@pos += 1 if peek_byte == BYTE_RPAREN
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
skip_ws_and_comments
|
|
1198
|
+
|
|
1199
|
+
# Check for optional media query (everything until semicolon)
|
|
1200
|
+
media = nil
|
|
1201
|
+
if !eof? && peek_byte != BYTE_SEMICOLON
|
|
1202
|
+
media_start = @pos
|
|
1203
|
+
|
|
1204
|
+
# Find semicolon
|
|
1205
|
+
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1206
|
+
@pos += 1
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
media_end = @pos
|
|
1210
|
+
|
|
1211
|
+
# Trim trailing whitespace from media query
|
|
1212
|
+
while media_end > media_start && whitespace?(@css.getbyte(media_end - 1))
|
|
1213
|
+
media_end -= 1
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
if media_end > media_start
|
|
1217
|
+
media = byteslice_encoded(media_start, media_end - media_start).to_sym
|
|
1218
|
+
end
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
# Skip semicolon
|
|
1222
|
+
@pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
1223
|
+
|
|
1224
|
+
# Create ImportStatement (resolved: false by default)
|
|
1225
|
+
import_stmt = ImportStatement.new(@rule_id_counter, url, media, false)
|
|
1226
|
+
@imports << import_stmt
|
|
1227
|
+
@rule_id_counter += 1
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
# Skip @import statements at the beginning of CSS (DEPRECATED - now parsed)
|
|
1127
1231
|
# Per CSS spec, @import must come before all rules (except @charset)
|
|
1128
1232
|
def skip_imports
|
|
1129
1233
|
until eof?
|
data/lib/cataract/pure.rb
CHANGED
|
@@ -32,6 +32,7 @@ require_relative 'version'
|
|
|
32
32
|
require_relative 'declaration'
|
|
33
33
|
require_relative 'rule'
|
|
34
34
|
require_relative 'at_rule'
|
|
35
|
+
require_relative 'import_statement'
|
|
35
36
|
require_relative 'stylesheet_scope'
|
|
36
37
|
require_relative 'stylesheet'
|
|
37
38
|
require_relative 'declarations'
|
|
@@ -63,7 +64,7 @@ require_relative 'pure/specificity'
|
|
|
63
64
|
require_relative 'pure/imports'
|
|
64
65
|
require_relative 'pure/serializer'
|
|
65
66
|
require_relative 'pure/parser'
|
|
66
|
-
require_relative 'pure/
|
|
67
|
+
require_relative 'pure/flatten'
|
|
67
68
|
|
|
68
69
|
module Cataract
|
|
69
70
|
# Flag to indicate pure Ruby version is loaded
|
|
@@ -102,20 +103,42 @@ module Cataract
|
|
|
102
103
|
Stylesheet.parse(css)
|
|
103
104
|
end
|
|
104
105
|
|
|
105
|
-
#
|
|
106
|
+
# Flatten stylesheet rules according to CSS cascade rules
|
|
106
107
|
#
|
|
107
|
-
# @param stylesheet [Stylesheet] Stylesheet to
|
|
108
|
-
# @return [Stylesheet] New stylesheet with
|
|
109
|
-
def self.
|
|
110
|
-
|
|
108
|
+
# @param stylesheet [Stylesheet] Stylesheet to flatten
|
|
109
|
+
# @return [Stylesheet] New stylesheet with flattened rules
|
|
110
|
+
def self.flatten(stylesheet)
|
|
111
|
+
Flatten.flatten(stylesheet, mutate: false)
|
|
111
112
|
end
|
|
112
113
|
|
|
113
|
-
#
|
|
114
|
+
# Flatten stylesheet rules in-place (mutates receiver)
|
|
114
115
|
#
|
|
115
|
-
# @param stylesheet [Stylesheet] Stylesheet to
|
|
116
|
+
# @param stylesheet [Stylesheet] Stylesheet to flatten
|
|
116
117
|
# @return [Stylesheet] Same stylesheet (mutated)
|
|
118
|
+
def self.flatten!(stylesheet)
|
|
119
|
+
Flatten.flatten(stylesheet, mutate: true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Deprecated: Use flatten instead
|
|
123
|
+
def self.merge(stylesheet)
|
|
124
|
+
warn 'Cataract.merge is deprecated, use Cataract.flatten instead', uplevel: 1
|
|
125
|
+
flatten(stylesheet)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Deprecated: Use flatten! instead
|
|
117
129
|
def self.merge!(stylesheet)
|
|
118
|
-
|
|
130
|
+
warn 'Cataract.merge! is deprecated, use Cataract.flatten! instead', uplevel: 1
|
|
131
|
+
flatten!(stylesheet)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Expand a single shorthand declaration into longhand declarations.
|
|
135
|
+
# Underscore prefix indicates semi-private API - use with caution.
|
|
136
|
+
#
|
|
137
|
+
# @param decl [Declaration] Declaration to expand
|
|
138
|
+
# @return [Array<Declaration>] Array of expanded longhand declarations
|
|
139
|
+
# @api private
|
|
140
|
+
def self._expand_shorthand(decl)
|
|
141
|
+
Flatten._expand_shorthand(decl)
|
|
119
142
|
end
|
|
120
143
|
|
|
121
144
|
# Add stub method to Stylesheet for pure Ruby implementation
|
data/lib/cataract/rule.rb
CHANGED
|
@@ -123,18 +123,63 @@ module Cataract
|
|
|
123
123
|
# Compare rules for logical equality based on CSS semantics.
|
|
124
124
|
#
|
|
125
125
|
# Two rules are equal if they have the same selector and declarations.
|
|
126
|
+
# Shorthand properties are expanded before comparison, so
|
|
127
|
+
# `margin: 10px` equals `margin-top: 10px; margin-right: 10px; ...`
|
|
128
|
+
#
|
|
126
129
|
# Internal implementation details (id, specificity) are not considered
|
|
127
|
-
# since they don't affect the CSS semantics.
|
|
128
|
-
#
|
|
130
|
+
# since they don't affect the CSS semantics.
|
|
131
|
+
#
|
|
132
|
+
# Can also compare against a CSS string, which is parsed and compared.
|
|
129
133
|
#
|
|
130
|
-
# @param other [Object] Object to compare with
|
|
134
|
+
# @param other [Object] Object to compare with (Rule or String)
|
|
131
135
|
# @return [Boolean] true if rules have same selector and declarations
|
|
132
136
|
def ==(other)
|
|
133
|
-
|
|
137
|
+
case other
|
|
138
|
+
when Rule
|
|
139
|
+
return false unless selector == other.selector
|
|
134
140
|
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
expanded_declarations == other.expanded_declarations
|
|
142
|
+
when String
|
|
143
|
+
# Parse CSS string and compare to first rule
|
|
144
|
+
parsed = Cataract.parse_css(other)
|
|
145
|
+
return false unless parsed.rules.size == 1
|
|
146
|
+
|
|
147
|
+
self == parsed.rules.first
|
|
148
|
+
else
|
|
149
|
+
false
|
|
150
|
+
end
|
|
137
151
|
end
|
|
138
152
|
alias eql? ==
|
|
153
|
+
|
|
154
|
+
# Generate hash code for this rule.
|
|
155
|
+
#
|
|
156
|
+
# Hash is based on selector and expanded declarations to match the
|
|
157
|
+
# equality semantics. This allows rules to be used as Hash keys or
|
|
158
|
+
# in Sets correctly.
|
|
159
|
+
#
|
|
160
|
+
# @return [Integer] hash code
|
|
161
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName
|
|
162
|
+
def hash
|
|
163
|
+
@_hash ||= [self.class, selector, expanded_declarations].hash
|
|
164
|
+
end
|
|
165
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
166
|
+
|
|
167
|
+
protected
|
|
168
|
+
|
|
169
|
+
# Get expanded and normalized declarations for this rule.
|
|
170
|
+
#
|
|
171
|
+
# Shorthands are expanded into their longhand equivalents and sorted
|
|
172
|
+
# to enable semantic comparison. Result is cached.
|
|
173
|
+
#
|
|
174
|
+
# @return [Array<Declaration>] expanded declarations
|
|
175
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName
|
|
176
|
+
def expanded_declarations
|
|
177
|
+
@_expanded_declarations ||= begin
|
|
178
|
+
expanded = declarations.flat_map { |decl| Cataract._expand_shorthand(decl) }
|
|
179
|
+
expanded.sort_by! { |d| [d.property, d.value, d.important ? 1 : 0] }
|
|
180
|
+
expanded
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
139
184
|
end
|
|
140
185
|
end
|