cssminify2 2.0.1 → 2.1.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 +5 -5
- data/.travis.yml +10 -5
- data/CHANGELOG.md +121 -0
- data/Dockerfile +16 -0
- data/Gemfile +1 -1
- data/README.md +501 -42
- data/cssminify2.gemspec +6 -2
- data/docs/ADVANCED_USAGE.md +616 -0
- data/docs/API_REFERENCE.md +464 -0
- data/docs/EXAMPLES.md +844 -0
- data/docs/MIGRATION_GUIDE.md +586 -0
- data/lib/cssminify2/cssmin.rb +89 -7
- data/lib/cssminify2/cssmin_enhanced.rb +424 -0
- data/lib/cssminify2/enhanced.rb +818 -0
- data/lib/cssminify2/version.rb +1 -1
- data/lib/cssminify2.rb +53 -4
- data/spec/cssminify_spec.rb +49 -34
- data/spec/tests/README +6 -0
- data/spec/tests/_munge.js +8 -0
- data/spec/tests/_munge.js.min +1 -0
- data/spec/tests/_string_combo.js +5 -0
- data/spec/tests/_string_combo.js.min +1 -0
- data/spec/tests/_string_combo2.js +4 -0
- data/spec/tests/_string_combo2.js.min +1 -0
- data/spec/tests/_string_combo3.js +5 -0
- data/spec/tests/_string_combo3.js.min +1 -0
- data/spec/tests/_syntax_error.js +73 -0
- data/spec/tests/_syntax_error.js.min +1 -0
- data/spec/tests/border-none.css +6 -1
- data/spec/tests/border-none.css.min +1 -1
- data/spec/tests/bug-flex.css +3 -0
- data/spec/tests/bug-flex.css.min +1 -0
- data/spec/tests/bug-nested-pseudoclass.css +3 -0
- data/spec/tests/bug-nested-pseudoclass.css.min +1 -0
- data/spec/tests/bug-preservetoken-calc.css +8 -0
- data/spec/tests/bug-preservetoken-calc.css.min +1 -0
- data/spec/tests/color-keyword.css +1 -0
- data/spec/tests/color-keyword.css.min +1 -0
- data/spec/tests/color.css +2 -0
- data/spec/tests/color.css.min +1 -1
- data/spec/tests/concat-charset.css +2 -2
- data/spec/tests/concat-charset.css.min +1 -1
- data/spec/tests/dataurl-singlequote-font.css +1 -1
- data/spec/tests/dataurl-validity.html +29 -0
- data/spec/tests/float.js +2 -0
- data/spec/tests/float.js.min +1 -0
- data/spec/tests/hsla-issue81.css.FAIL +4 -0
- data/spec/tests/hsla-issue81.css.min +1 -0
- data/spec/tests/ie-backslash9-hack.css +2 -0
- data/spec/tests/ie-backslash9-hack.css.min +1 -0
- data/spec/tests/issue-59.css +7 -0
- data/spec/tests/issue-59.css.min +1 -0
- data/spec/tests/issue151.css +8 -0
- data/spec/tests/issue151.css.min +1 -0
- data/spec/tests/issue172.css.FAIL +4 -0
- data/spec/tests/issue172.css.min +1 -0
- data/spec/tests/issue180.css +16 -0
- data/spec/tests/issue180.css.min +1 -0
- data/spec/tests/issue205.css +2 -0
- data/spec/tests/issue205.css.min +1 -0
- data/spec/tests/issue221.css +1 -1
- data/spec/tests/issue221.css.min +1 -1
- data/spec/tests/issue222.css +2 -2
- data/spec/tests/issue222.css.min +1 -1
- data/spec/tests/issue71.js.FAIL +4 -0
- data/spec/tests/issue71.js.min +1 -0
- data/spec/tests/issue86.js +2 -0
- data/spec/tests/issue86.js.min +1 -0
- data/spec/tests/jquery-1.6.4.js +9046 -0
- data/spec/tests/jquery-1.6.4.js.min +23 -0
- data/spec/tests/lowercasing.css +63 -0
- data/spec/tests/lowercasing.css.min +1 -0
- data/spec/tests/media-test.css +2 -2
- data/spec/tests/old-ie-filter-matrix.css +8 -0
- data/spec/tests/old-ie-filter-matrix.css.min +1 -0
- data/spec/tests/opera-pixel-ratio.css +14 -0
- data/spec/tests/opera-pixel-ratio.css.min +1 -0
- data/spec/tests/pointzeros.css +6 -0
- data/spec/tests/pointzeros.css.min +1 -0
- data/spec/tests/preserve-important.css +1 -0
- data/spec/tests/preserve-important.css.min +1 -0
- data/spec/tests/promise-catch-finally-issue203.js +4 -0
- data/spec/tests/promise-catch-finally-issue203.js.min +1 -0
- data/spec/tests/pseudo-first.css +2 -2
- data/spec/tests/rgb-issue81.css.FAIL +4 -0
- data/spec/tests/rgb-issue81.css.min +1 -0
- data/spec/tests/suite.rhino +3 -0
- data/spec/tests/suite.sh +49 -0
- data/spec/tests/zeros.css +2 -2
- data/spec/tests/zeros.css.min +1 -1
- metadata +129 -14
- data/spec/tests/bug2528093.css +0 -3
- data/spec/tests/bug2528093.css.min +0 -1
- data/spec/tests/keyframe.css +0 -4
- data/spec/tests/keyframe.css.min +0 -1
@@ -0,0 +1,818 @@
|
|
1
|
+
# Enhanced CSS Compression Features (Optional)
|
2
|
+
#
|
3
|
+
# This file provides optional enhanced features for CSS compression
|
4
|
+
# while maintaining 100% backward compatibility with the original API.
|
5
|
+
#
|
6
|
+
# Usage:
|
7
|
+
# # Original API (unchanged)
|
8
|
+
# CSSminify2.compress(css)
|
9
|
+
#
|
10
|
+
# # Enhanced API (new, optional)
|
11
|
+
# CSSminify2Enhanced.compress(css, options)
|
12
|
+
# CSSminify2Enhanced.new(config).compress(css)
|
13
|
+
|
14
|
+
module CSSminify2Enhanced
|
15
|
+
|
16
|
+
# Configuration class for enhanced features
|
17
|
+
class Configuration
|
18
|
+
attr_accessor :merge_duplicate_selectors, :optimize_shorthand_properties,
|
19
|
+
:advanced_color_optimization, :preserve_ie_hacks,
|
20
|
+
:compress_css_variables, :strict_error_handling,
|
21
|
+
:generate_source_map, :statistics_enabled
|
22
|
+
|
23
|
+
def initialize
|
24
|
+
# All new features are opt-in by default for compatibility
|
25
|
+
@merge_duplicate_selectors = false
|
26
|
+
@optimize_shorthand_properties = false
|
27
|
+
@advanced_color_optimization = false
|
28
|
+
@preserve_ie_hacks = true
|
29
|
+
@compress_css_variables = false
|
30
|
+
@strict_error_handling = false
|
31
|
+
@generate_source_map = false
|
32
|
+
@statistics_enabled = false
|
33
|
+
end
|
34
|
+
|
35
|
+
# Preset configurations
|
36
|
+
def self.conservative
|
37
|
+
new # All features disabled
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.aggressive
|
41
|
+
config = new
|
42
|
+
config.merge_duplicate_selectors = true
|
43
|
+
config.optimize_shorthand_properties = true
|
44
|
+
config.advanced_color_optimization = true
|
45
|
+
config.compress_css_variables = true
|
46
|
+
config
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.modern
|
50
|
+
config = aggressive
|
51
|
+
config.generate_source_map = true
|
52
|
+
config.statistics_enabled = true
|
53
|
+
config
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Enhanced compressor with new features
|
58
|
+
class Compressor
|
59
|
+
attr_reader :config, :statistics
|
60
|
+
|
61
|
+
def initialize(config = Configuration.new)
|
62
|
+
@config = config
|
63
|
+
@statistics = {
|
64
|
+
original_size: 0,
|
65
|
+
compressed_size: 0,
|
66
|
+
compression_ratio: 0.0,
|
67
|
+
selectors_merged: 0,
|
68
|
+
properties_optimized: 0,
|
69
|
+
colors_converted: 0
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
def compress(css, linebreakpos = 5000)
|
74
|
+
@statistics[:original_size] = css.length
|
75
|
+
|
76
|
+
begin
|
77
|
+
# Validate CSS structure before processing
|
78
|
+
validate_css_structure(css) if @config.strict_error_handling
|
79
|
+
|
80
|
+
# Start with the original compression to maintain compatibility
|
81
|
+
result = CssCompressor.compress(css, linebreakpos)
|
82
|
+
|
83
|
+
# Apply enhanced optimizations if enabled
|
84
|
+
if any_enhancements_enabled?
|
85
|
+
result = apply_enhanced_optimizations_safely(result)
|
86
|
+
end
|
87
|
+
|
88
|
+
@statistics[:compressed_size] = result.length
|
89
|
+
@statistics[:compression_ratio] = calculate_compression_ratio
|
90
|
+
|
91
|
+
result
|
92
|
+
|
93
|
+
rescue => e
|
94
|
+
if @config.strict_error_handling
|
95
|
+
raise EnhancedCompressionError.new("Enhanced compression failed: #{e.message}", e)
|
96
|
+
else
|
97
|
+
# Graceful fallback to original compressor
|
98
|
+
fallback_result = safe_fallback_compression(css, linebreakpos)
|
99
|
+
@statistics[:compressed_size] = fallback_result.length
|
100
|
+
@statistics[:compression_ratio] = calculate_compression_ratio
|
101
|
+
@statistics[:fallback_used] = true
|
102
|
+
fallback_result
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
private
|
108
|
+
|
109
|
+
def any_enhancements_enabled?
|
110
|
+
@config.merge_duplicate_selectors ||
|
111
|
+
@config.optimize_shorthand_properties ||
|
112
|
+
@config.advanced_color_optimization ||
|
113
|
+
@config.compress_css_variables
|
114
|
+
end
|
115
|
+
|
116
|
+
def apply_enhanced_optimizations(css)
|
117
|
+
css = merge_duplicate_selectors(css) if @config.merge_duplicate_selectors
|
118
|
+
css = optimize_shorthand_properties(css) if @config.optimize_shorthand_properties
|
119
|
+
css = enhance_zero_value_optimization(css) if @config.optimize_shorthand_properties
|
120
|
+
css = optimize_modern_layout_properties(css) if @config.optimize_shorthand_properties
|
121
|
+
css = compress_css_variables(css) if @config.compress_css_variables
|
122
|
+
css = advanced_color_optimization(css) if @config.advanced_color_optimization
|
123
|
+
css
|
124
|
+
end
|
125
|
+
|
126
|
+
def apply_enhanced_optimizations_safely(css)
|
127
|
+
# Apply optimizations with individual error handling
|
128
|
+
optimizations = [
|
129
|
+
[:merge_duplicate_selectors, @config.merge_duplicate_selectors],
|
130
|
+
[:optimize_shorthand_properties, @config.optimize_shorthand_properties],
|
131
|
+
[:enhance_zero_value_optimization, @config.optimize_shorthand_properties],
|
132
|
+
[:optimize_modern_layout_properties, @config.optimize_shorthand_properties],
|
133
|
+
[:compress_css_variables, @config.compress_css_variables],
|
134
|
+
[:advanced_color_optimization, @config.advanced_color_optimization]
|
135
|
+
]
|
136
|
+
|
137
|
+
optimizations.each do |method_name, enabled|
|
138
|
+
next unless enabled
|
139
|
+
|
140
|
+
begin
|
141
|
+
css = send(method_name, css)
|
142
|
+
rescue => e
|
143
|
+
if @config.strict_error_handling
|
144
|
+
raise e
|
145
|
+
else
|
146
|
+
# Log error but continue with other optimizations
|
147
|
+
warn "Warning: #{method_name} optimization failed: #{e.message}"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
css
|
153
|
+
end
|
154
|
+
|
155
|
+
def validate_css_structure(css)
|
156
|
+
# Basic CSS validation to catch major structural issues
|
157
|
+
errors = []
|
158
|
+
|
159
|
+
# Check for balanced braces
|
160
|
+
open_braces = css.count('{')
|
161
|
+
close_braces = css.count('}')
|
162
|
+
if open_braces != close_braces
|
163
|
+
errors << "Unbalanced braces: #{open_braces} opening vs #{close_braces} closing"
|
164
|
+
end
|
165
|
+
|
166
|
+
# Check for balanced quotes
|
167
|
+
double_quotes = css.scan(/"/).length
|
168
|
+
single_quotes = css.scan(/'/).length
|
169
|
+
if double_quotes % 2 != 0
|
170
|
+
errors << "Unmatched double quotes"
|
171
|
+
end
|
172
|
+
if single_quotes % 2 != 0
|
173
|
+
errors << "Unmatched single quotes"
|
174
|
+
end
|
175
|
+
|
176
|
+
# Check for valid CSS structure patterns
|
177
|
+
if css.match(/\{[^{}]*\{/) # Nested braces outside of media queries/keyframes
|
178
|
+
unless css.match(/@(?:media|supports|keyframes|container)/)
|
179
|
+
errors << "Potentially invalid nested braces"
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Check for common syntax errors
|
184
|
+
if css.match(/[^;{}]\s*\}/) && !css.match(/@/)
|
185
|
+
errors << "Missing semicolon before closing brace"
|
186
|
+
end
|
187
|
+
|
188
|
+
if errors.any?
|
189
|
+
raise MalformedCSSError.new("CSS validation failed: #{errors.join(', ')}")
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def safe_fallback_compression(css, linebreakpos)
|
194
|
+
# Safe fallback with minimal error handling
|
195
|
+
begin
|
196
|
+
CssCompressor.compress(css, linebreakpos)
|
197
|
+
rescue => e
|
198
|
+
# Last resort: basic whitespace compression
|
199
|
+
warn "Warning: Fallback to basic compression due to: #{e.message}"
|
200
|
+
basic_compression_fallback(css)
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def basic_compression_fallback(css)
|
205
|
+
# Ultra-safe basic compression as last resort
|
206
|
+
css
|
207
|
+
.gsub(/\/\*.*?\*\//m, '') # Remove comments
|
208
|
+
.gsub(/\s+/, ' ') # Compress whitespace
|
209
|
+
.gsub(/\s*{\s*/, '{') # Clean braces
|
210
|
+
.gsub(/\s*}\s*/, '}')
|
211
|
+
.gsub(/\s*;\s*/, ';') # Clean semicolons
|
212
|
+
.gsub(/:\s+/, ':') # Clean colons
|
213
|
+
.strip
|
214
|
+
end
|
215
|
+
|
216
|
+
def merge_duplicate_selectors(css)
|
217
|
+
# Advanced duplicate selector merging with proper CSS parsing
|
218
|
+
# Handles media queries, keyframes, and preserves cascade order
|
219
|
+
|
220
|
+
selectors_merged = 0
|
221
|
+
|
222
|
+
# Split CSS into blocks (rules, at-rules, etc.)
|
223
|
+
css = css.gsub(/@media[^{]*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/m) do |media_block|
|
224
|
+
# Process media queries recursively
|
225
|
+
process_media_block(media_block)
|
226
|
+
end
|
227
|
+
|
228
|
+
# Process regular CSS rules outside of media queries
|
229
|
+
css = merge_regular_selectors(css)
|
230
|
+
|
231
|
+
@statistics[:selectors_merged] = selectors_merged
|
232
|
+
css
|
233
|
+
end
|
234
|
+
|
235
|
+
private
|
236
|
+
|
237
|
+
def process_media_block(media_block)
|
238
|
+
# Extract media query and content
|
239
|
+
if media_block.match(/@media([^{]*)\{(.*)\}/m)
|
240
|
+
media_query = $1.strip
|
241
|
+
content = $2
|
242
|
+
|
243
|
+
# Merge selectors within this media query
|
244
|
+
merged_content = merge_regular_selectors(content)
|
245
|
+
|
246
|
+
"@media#{media_query}{#{merged_content}}"
|
247
|
+
else
|
248
|
+
media_block
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
def merge_regular_selectors(css)
|
253
|
+
# Parse CSS rules more carefully
|
254
|
+
rules = parse_css_rules(css)
|
255
|
+
merged_rules = merge_parsed_rules(rules)
|
256
|
+
rebuild_css_from_rules(merged_rules)
|
257
|
+
end
|
258
|
+
|
259
|
+
def parse_css_rules(css)
|
260
|
+
rules = []
|
261
|
+
current_pos = 0
|
262
|
+
|
263
|
+
while current_pos < css.length
|
264
|
+
# Find next rule
|
265
|
+
rule_match = css.match(/([^{}]+)\{([^{}]*)\}/m, current_pos)
|
266
|
+
break unless rule_match
|
267
|
+
|
268
|
+
selector = rule_match[1].strip
|
269
|
+
declarations = rule_match[2].strip
|
270
|
+
position = rule_match.begin(0)
|
271
|
+
|
272
|
+
# Skip if this looks like an at-rule we shouldn't merge
|
273
|
+
unless selector.match(/^@(?:keyframes|font-face|page|supports|document)/)
|
274
|
+
rules << {
|
275
|
+
selector: normalize_selector(selector),
|
276
|
+
original_selector: selector,
|
277
|
+
declarations: declarations,
|
278
|
+
position: position
|
279
|
+
}
|
280
|
+
end
|
281
|
+
|
282
|
+
current_pos = rule_match.end(0)
|
283
|
+
end
|
284
|
+
|
285
|
+
rules
|
286
|
+
end
|
287
|
+
|
288
|
+
def normalize_selector(selector)
|
289
|
+
# Normalize selector for comparison (remove extra whitespace, etc.)
|
290
|
+
selector.gsub(/\s+/, ' ').strip
|
291
|
+
end
|
292
|
+
|
293
|
+
def merge_parsed_rules(rules)
|
294
|
+
# Group rules by selector, maintaining order
|
295
|
+
selector_groups = {}
|
296
|
+
merged_rules = []
|
297
|
+
|
298
|
+
rules.each do |rule|
|
299
|
+
selector = rule[:selector]
|
300
|
+
|
301
|
+
if selector_groups[selector]
|
302
|
+
# Merge with existing rule
|
303
|
+
existing_rule = selector_groups[selector]
|
304
|
+
existing_rule[:declarations] = merge_declarations(
|
305
|
+
existing_rule[:declarations],
|
306
|
+
rule[:declarations]
|
307
|
+
)
|
308
|
+
@statistics[:selectors_merged] += 1
|
309
|
+
else
|
310
|
+
# First occurrence of this selector
|
311
|
+
new_rule = rule.dup
|
312
|
+
selector_groups[selector] = new_rule
|
313
|
+
merged_rules << new_rule
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
merged_rules
|
318
|
+
end
|
319
|
+
|
320
|
+
def merge_declarations(existing_declarations, new_declarations)
|
321
|
+
# Parse declarations and merge, with later declarations overriding earlier ones
|
322
|
+
existing_props = parse_declarations(existing_declarations)
|
323
|
+
new_props = parse_declarations(new_declarations)
|
324
|
+
|
325
|
+
# Merge properties, with new_props taking precedence
|
326
|
+
merged_props = existing_props.merge(new_props)
|
327
|
+
|
328
|
+
# Rebuild declaration string
|
329
|
+
merged_props.map { |prop, value| "#{prop}:#{value}" }.join(';')
|
330
|
+
end
|
331
|
+
|
332
|
+
def parse_declarations(declarations)
|
333
|
+
properties = {}
|
334
|
+
return properties if declarations.empty?
|
335
|
+
|
336
|
+
declarations.split(';').each do |declaration|
|
337
|
+
if declaration.match(/^\s*([^:]+):\s*(.+)\s*$/)
|
338
|
+
property = $1.strip
|
339
|
+
value = $2.strip
|
340
|
+
properties[property] = value
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
properties
|
345
|
+
end
|
346
|
+
|
347
|
+
def rebuild_css_from_rules(rules)
|
348
|
+
rules.map do |rule|
|
349
|
+
declarations = rule[:declarations]
|
350
|
+
declarations = declarations.empty? ? '' : declarations
|
351
|
+
"#{rule[:original_selector]}{#{declarations}}"
|
352
|
+
end.join('')
|
353
|
+
end
|
354
|
+
|
355
|
+
def optimize_shorthand_properties(css)
|
356
|
+
original_length = css.length
|
357
|
+
|
358
|
+
# Advanced margin/padding optimization with units flexibility
|
359
|
+
css = optimize_box_model_properties(css, 'margin')
|
360
|
+
css = optimize_box_model_properties(css, 'padding')
|
361
|
+
|
362
|
+
# Background shorthand optimizations
|
363
|
+
css = optimize_background_properties(css)
|
364
|
+
|
365
|
+
# Border shorthand optimizations
|
366
|
+
css = optimize_border_properties(css)
|
367
|
+
|
368
|
+
# Font shorthand optimizations
|
369
|
+
css = optimize_font_properties(css)
|
370
|
+
|
371
|
+
# List-style optimizations
|
372
|
+
css = optimize_list_properties(css)
|
373
|
+
|
374
|
+
@statistics[:properties_optimized] += ((original_length - css.length) / 10).to_i # Rough estimate
|
375
|
+
css
|
376
|
+
end
|
377
|
+
|
378
|
+
private
|
379
|
+
|
380
|
+
def optimize_box_model_properties(css, property)
|
381
|
+
# Handle all units, not just px
|
382
|
+
unit_pattern = '(?:px|em|rem|%|vh|vw|pt|pc|in|cm|mm|ex|ch|vmin|vmax|0)'
|
383
|
+
|
384
|
+
# Four identical values: margin: 10px 10px 10px 10px → margin: 10px
|
385
|
+
css = css.gsub(/#{property}:\s*([+-]?\d*\.?\d+#{unit_pattern})\s+\1\s+\1\s+\1/i) { "#{property}:#{$1}" }
|
386
|
+
|
387
|
+
# Vertical/horizontal pairs: margin: 10px 20px 10px 20px → margin: 10px 20px
|
388
|
+
css = css.gsub(/#{property}:\s*([+-]?\d*\.?\d+#{unit_pattern})\s+([+-]?\d*\.?\d+#{unit_pattern})\s+\1\s+\2/i) { "#{property}:#{$1} #{$2}" }
|
389
|
+
|
390
|
+
# Three values where first and third are same: margin: 10px 20px 10px → margin: 10px 20px
|
391
|
+
css = css.gsub(/#{property}:\s*([+-]?\d*\.?\d+#{unit_pattern})\s+([+-]?\d*\.?\d+#{unit_pattern})\s+\1/i) { "#{property}:#{$1} #{$2}" }
|
392
|
+
|
393
|
+
css
|
394
|
+
end
|
395
|
+
|
396
|
+
def optimize_background_properties(css)
|
397
|
+
# background: none repeat scroll 0 0 color → background: color
|
398
|
+
css = css.gsub(/background:\s*none\s+repeat\s+scroll\s+0\s+0\s+([^;}]+)/i) { "background:#{$1}" }
|
399
|
+
|
400
|
+
# background-position: 0 center → background-position: 0
|
401
|
+
css = css.gsub(/background-position:\s*0\s+(?:center|50%)/i) { "background-position:0" }
|
402
|
+
|
403
|
+
# background-repeat: repeat repeat → background-repeat: repeat
|
404
|
+
css = css.gsub(/background-repeat:\s*repeat\s+repeat/i) { "background-repeat:repeat" }
|
405
|
+
|
406
|
+
css
|
407
|
+
end
|
408
|
+
|
409
|
+
def optimize_border_properties(css)
|
410
|
+
# Remove redundant border-width when already specified in border shorthand
|
411
|
+
css = css.gsub(/border:\s*(\d+\w*)\s+([^;}]+);\s*border-width:\s*\1/i) { "border:#{$1} #{$2}" }
|
412
|
+
|
413
|
+
# Remove redundant border-style when already specified
|
414
|
+
css = css.gsub(/border:\s*([^;}]*?)\s+(solid|dashed|dotted|double)([^;}]*?);\s*border-style:\s*\2/i) { "border:#{$1} #{$2}#{$3}" }
|
415
|
+
|
416
|
+
# Remove redundant border-color when already specified
|
417
|
+
css = css.gsub(/border:\s*([^;}]*?)\s+(#[0-9a-f]{3,6}|[a-z]+)([^;}]*?);\s*border-color:\s*\2/i) { "border:#{$1} #{$2}#{$3}" }
|
418
|
+
|
419
|
+
css
|
420
|
+
end
|
421
|
+
|
422
|
+
def optimize_font_properties(css)
|
423
|
+
# Font weight optimizations
|
424
|
+
css = css.gsub(/font-weight:\s*normal/i) { "font-weight:400" }
|
425
|
+
css = css.gsub(/font-weight:\s*bold/i) { "font-weight:700" }
|
426
|
+
|
427
|
+
# Font style optimizations
|
428
|
+
css = css.gsub(/font-style:\s*normal/i) { "font-style:normal" } # Normalize case
|
429
|
+
|
430
|
+
css
|
431
|
+
end
|
432
|
+
|
433
|
+
def optimize_list_properties(css)
|
434
|
+
# list-style: none inside → list-style: none (inside is default for none)
|
435
|
+
css = css.gsub(/list-style:\s*none\s+inside/i) { "list-style:none" }
|
436
|
+
|
437
|
+
css
|
438
|
+
end
|
439
|
+
|
440
|
+
def enhance_zero_value_optimization(css)
|
441
|
+
# Advanced zero value and unit optimizations beyond basic YUI compressor
|
442
|
+
original_length = css.length
|
443
|
+
|
444
|
+
# Remove unnecessary zeros in decimal values
|
445
|
+
css = optimize_decimal_zeros(css)
|
446
|
+
|
447
|
+
# Optimize calc() expressions with zeros
|
448
|
+
css = optimize_calc_zeros(css)
|
449
|
+
|
450
|
+
# Advanced unit optimizations
|
451
|
+
css = optimize_modern_units(css)
|
452
|
+
|
453
|
+
# Transform property optimizations
|
454
|
+
css = optimize_transform_zeros(css)
|
455
|
+
|
456
|
+
# Advanced background position optimizations
|
457
|
+
css = optimize_position_zeros(css)
|
458
|
+
|
459
|
+
# Box-shadow and text-shadow optimizations
|
460
|
+
css = optimize_shadow_zeros(css)
|
461
|
+
|
462
|
+
# Update statistics
|
463
|
+
chars_saved = original_length - css.length
|
464
|
+
@statistics[:properties_optimized] += (chars_saved / 3).to_i # Rough estimate
|
465
|
+
|
466
|
+
css
|
467
|
+
end
|
468
|
+
|
469
|
+
def optimize_decimal_zeros(css)
|
470
|
+
# More aggressive decimal optimization
|
471
|
+
css = css.gsub(/(\d)\.0+(?!\d)/) { $1 } # 1.0 → 1
|
472
|
+
css = css.gsub(/0+\.(\d+)/) { ".#{$1}" } # 0.5 → .5
|
473
|
+
css = css.gsub(/(\d+)\.0*(\d*?)0+(?!\d)/) { "#{$1}.#{$2}".gsub(/\.$/, '') } # 1.500 → 1.5
|
474
|
+
css
|
475
|
+
end
|
476
|
+
|
477
|
+
def optimize_calc_zeros(css)
|
478
|
+
# Optimize calc() expressions with zeros
|
479
|
+
css = css.gsub(/calc\([^)]*\)/) do |calc_expr|
|
480
|
+
# Simplify addition/subtraction with zero
|
481
|
+
calc_expr = calc_expr.gsub(/\+\s*0\w*/, '') # + 0px → nothing
|
482
|
+
calc_expr = calc_expr.gsub(/0\w*\s*\+/, '') # 0px + → nothing
|
483
|
+
calc_expr = calc_expr.gsub(/-\s*0\w*/, '') # - 0px → nothing
|
484
|
+
calc_expr = calc_expr.gsub(/\*\s*1(?:\.\d*)?/, '') # * 1 → nothing
|
485
|
+
calc_expr = calc_expr.gsub(/1(?:\.\d*)?\s*\*/, '') # 1 * → nothing
|
486
|
+
|
487
|
+
# Clean up extra spaces
|
488
|
+
calc_expr.gsub(/\s+/, ' ').strip
|
489
|
+
end
|
490
|
+
css
|
491
|
+
end
|
492
|
+
|
493
|
+
def optimize_modern_units(css)
|
494
|
+
# Optimize newer CSS units where possible
|
495
|
+
css = css.gsub(/0(?:ch|rem|em|vw|vh|vmin|vmax|fr)(?!\w)/, '0')
|
496
|
+
|
497
|
+
# Convert some units where it saves space (commented out for safety)
|
498
|
+
# css = css.gsub(/16px/, '1rem') # Only if 1rem saves space in context
|
499
|
+
|
500
|
+
css
|
501
|
+
end
|
502
|
+
|
503
|
+
def optimize_transform_zeros(css)
|
504
|
+
# Transform property optimizations
|
505
|
+
css = css.gsub(/translate\(0,\s*0\)/, 'translate(0)') # translate(0, 0) → translate(0)
|
506
|
+
css = css.gsub(/translate3d\(0,\s*0,\s*0\)/, 'translate3d(0)') # translate3d(0,0,0) → translate3d(0)
|
507
|
+
css = css.gsub(/scale\(1,\s*1\)/, 'scale(1)') # scale(1, 1) → scale(1)
|
508
|
+
css = css.gsub(/rotate\(0(?:deg|rad|turn)?\)/, '') # rotate(0deg) → remove
|
509
|
+
css = css.gsub(/skew\(0,\s*0\)/, '') # skew(0, 0) → remove
|
510
|
+
|
511
|
+
css
|
512
|
+
end
|
513
|
+
|
514
|
+
def optimize_position_zeros(css)
|
515
|
+
# Advanced background-position and object-position optimizations
|
516
|
+
css = css.gsub(/background-position:\s*0\s+0/, 'background-position:0')
|
517
|
+
css = css.gsub(/object-position:\s*0\s+0/, 'object-position:0')
|
518
|
+
css = css.gsub(/transform-origin:\s*0\s+0/, 'transform-origin:0')
|
519
|
+
|
520
|
+
css
|
521
|
+
end
|
522
|
+
|
523
|
+
def optimize_shadow_zeros(css)
|
524
|
+
# Optimize box-shadow and text-shadow with zeros
|
525
|
+
css = css.gsub(/(box-shadow|text-shadow):\s*0\s+0\s+0\s+([^;,}]+)/) { "#{$1}:0 0 #{$2}" }
|
526
|
+
css = css.gsub(/(box-shadow|text-shadow):\s*0\s+0\s+([^;,}]+)/) { "#{$1}:0 #{$2}" }
|
527
|
+
css = css.gsub(/(box-shadow|text-shadow):\s*0\s+0\s+0\s*(?:;|})/) { "#{$1}:0" }
|
528
|
+
|
529
|
+
css
|
530
|
+
end
|
531
|
+
|
532
|
+
def optimize_modern_layout_properties(css)
|
533
|
+
# Advanced CSS Grid and Flexbox optimizations
|
534
|
+
original_length = css.length
|
535
|
+
|
536
|
+
css = optimize_flexbox_properties(css)
|
537
|
+
css = optimize_grid_properties(css)
|
538
|
+
css = optimize_alignment_properties(css)
|
539
|
+
css = optimize_gap_properties(css)
|
540
|
+
|
541
|
+
# Update statistics
|
542
|
+
chars_saved = original_length - css.length
|
543
|
+
@statistics[:properties_optimized] += (chars_saved / 4).to_i # Rough estimate
|
544
|
+
|
545
|
+
css
|
546
|
+
end
|
547
|
+
|
548
|
+
def optimize_flexbox_properties(css)
|
549
|
+
# Flex shorthand optimizations
|
550
|
+
css = css.gsub(/flex:\s*1\s+1\s+auto/i, 'flex:1') # flex: 1 1 auto → flex: 1
|
551
|
+
css = css.gsub(/flex:\s*0\s+0\s+auto/i, 'flex:none') # flex: 0 0 auto → flex: none
|
552
|
+
css = css.gsub(/flex:\s*0\s+1\s+auto/i, 'flex:auto') # flex: 0 1 auto → flex: auto
|
553
|
+
css = css.gsub(/flex:\s*(\d+)\s+\1\s+0/i) { "flex:#{$1}" } # flex: 2 2 0 → flex: 2
|
554
|
+
|
555
|
+
# Flex-direction optimizations
|
556
|
+
css = css.gsub(/flex-direction:\s*row/i, 'flex-direction:row') # Normalize case
|
557
|
+
|
558
|
+
# Justify-content optimizations (use shorter values when supported)
|
559
|
+
css = css.gsub(/justify-content:\s*flex-start/i, 'justify-content:start')
|
560
|
+
css = css.gsub(/justify-content:\s*flex-end/i, 'justify-content:end')
|
561
|
+
css = css.gsub(/align-items:\s*flex-start/i, 'align-items:start')
|
562
|
+
css = css.gsub(/align-items:\s*flex-end/i, 'align-items:end')
|
563
|
+
css = css.gsub(/align-self:\s*flex-start/i, 'align-self:start')
|
564
|
+
css = css.gsub(/align-self:\s*flex-end/i, 'align-self:end')
|
565
|
+
|
566
|
+
css
|
567
|
+
end
|
568
|
+
|
569
|
+
def optimize_grid_properties(css)
|
570
|
+
# Grid shorthand optimizations
|
571
|
+
css = css.gsub(/grid-template-columns:\s*repeat\((\d+),\s*1fr\)/i) { "grid-template-columns:repeat(#{$1},1fr)" }
|
572
|
+
|
573
|
+
# Grid-area optimizations
|
574
|
+
css = css.gsub(/grid-area:\s*(\d+)\s*\/\s*(\d+)\s*\/\s*(\d+)\s*\/\s*(\d+)/i) do |match|
|
575
|
+
row_start, col_start, row_end, col_end = $1, $2, $3, $4
|
576
|
+
|
577
|
+
# Optimize common patterns
|
578
|
+
if row_start == row_end.to_i - 1 && col_start == col_end.to_i - 1
|
579
|
+
# Single cell: grid-area: 1 / 1 / 2 / 2 → grid-area: 1 / 1
|
580
|
+
"grid-area:#{row_start}/#{col_start}"
|
581
|
+
else
|
582
|
+
"grid-area:#{row_start}/#{col_start}/#{row_end}/#{col_end}"
|
583
|
+
end
|
584
|
+
end
|
585
|
+
|
586
|
+
# Grid-template optimizations
|
587
|
+
css = css.gsub(/grid-template:\s*none\s*\/\s*none/i, 'grid-template:none')
|
588
|
+
|
589
|
+
# Grid-auto-flow optimizations
|
590
|
+
css = css.gsub(/grid-auto-flow:\s*row/i, 'grid-auto-flow:row') # Default, can sometimes be omitted
|
591
|
+
|
592
|
+
css
|
593
|
+
end
|
594
|
+
|
595
|
+
def optimize_alignment_properties(css)
|
596
|
+
# Place-items and place-content shortcuts
|
597
|
+
css = css.gsub(/align-items:\s*([^;]+);\s*justify-items:\s*\1/i) { "place-items:#{$1}" }
|
598
|
+
css = css.gsub(/align-content:\s*([^;]+);\s*justify-content:\s*\1/i) { "place-content:#{$1}" }
|
599
|
+
css = css.gsub(/align-self:\s*([^;]+);\s*justify-self:\s*\1/i) { "place-self:#{$1}" }
|
600
|
+
|
601
|
+
# Center shorthand
|
602
|
+
css = css.gsub(/place-items:\s*center\s+center/i, 'place-items:center')
|
603
|
+
css = css.gsub(/place-content:\s*center\s+center/i, 'place-content:center')
|
604
|
+
|
605
|
+
css
|
606
|
+
end
|
607
|
+
|
608
|
+
def optimize_gap_properties(css)
|
609
|
+
# Gap property optimizations
|
610
|
+
css = css.gsub(/grid-gap:\s*(\d+\w*)\s+\1/i) { "grid-gap:#{$1}" } # grid-gap: 10px 10px → grid-gap: 10px
|
611
|
+
css = css.gsub(/gap:\s*(\d+\w*)\s+\1/i) { "gap:#{$1}" } # gap: 10px 10px → gap: 10px
|
612
|
+
css = css.gsub(/row-gap:\s*(\d+\w*);\s*column-gap:\s*\1/i) { "gap:#{$1}" } # Combine identical row/column gaps
|
613
|
+
|
614
|
+
# Use gap instead of grid-gap (modern syntax)
|
615
|
+
css = css.gsub(/grid-gap:/i, 'gap:') # grid-gap → gap (shorter and modern)
|
616
|
+
css = css.gsub(/grid-row-gap:/i, 'row-gap:') # grid-row-gap → row-gap
|
617
|
+
css = css.gsub(/grid-column-gap:/i, 'column-gap:') # grid-column-gap → column-gap
|
618
|
+
|
619
|
+
css
|
620
|
+
end
|
621
|
+
|
622
|
+
def compress_css_variables(css)
|
623
|
+
# Advanced CSS custom property optimization
|
624
|
+
original_length = css.length
|
625
|
+
|
626
|
+
# Parse variable declarations and usage
|
627
|
+
variable_data = analyze_css_variables(css)
|
628
|
+
|
629
|
+
# Apply optimizations based on analysis
|
630
|
+
css = inline_single_use_variables(css, variable_data)
|
631
|
+
css = remove_unused_variables(css, variable_data)
|
632
|
+
css = optimize_variable_names(css, variable_data)
|
633
|
+
|
634
|
+
# Update statistics
|
635
|
+
chars_saved = original_length - css.length
|
636
|
+
@statistics[:properties_optimized] += (chars_saved / 5).to_i # Rough estimate
|
637
|
+
|
638
|
+
css
|
639
|
+
end
|
640
|
+
|
641
|
+
def analyze_css_variables(css)
|
642
|
+
variables = {}
|
643
|
+
|
644
|
+
# Find all variable declarations with their values
|
645
|
+
css.scan(/(--[\w-]+):\s*([^;]+)/) do |var_name, var_value|
|
646
|
+
variables[var_name] ||= {
|
647
|
+
value: var_value.strip,
|
648
|
+
declarations: 0,
|
649
|
+
usages: 0,
|
650
|
+
total_value_length: 0
|
651
|
+
}
|
652
|
+
variables[var_name][:declarations] += 1
|
653
|
+
variables[var_name][:total_value_length] += var_value.length
|
654
|
+
end
|
655
|
+
|
656
|
+
# Count variable usages
|
657
|
+
css.scan(/var\((--[\w-]+)(?:,([^)]*))?\)/) do |var_name, fallback|
|
658
|
+
if variables[var_name]
|
659
|
+
variables[var_name][:usages] += 1
|
660
|
+
variables[var_name][:fallback] = fallback&.strip
|
661
|
+
end
|
662
|
+
end
|
663
|
+
|
664
|
+
variables
|
665
|
+
end
|
666
|
+
|
667
|
+
def inline_single_use_variables(css, variable_data)
|
668
|
+
# Inline variables that are used only once or twice and have short values
|
669
|
+
variables_to_inline = variable_data.select do |var_name, data|
|
670
|
+
data[:usages] <= 2 &&
|
671
|
+
data[:declarations] == 1 &&
|
672
|
+
data[:value].length <= 20 && # Only inline short values
|
673
|
+
!data[:value].include?('calc(') && # Don't inline complex calc expressions
|
674
|
+
!data[:value].include?('var(') # Don't inline variables that reference other variables
|
675
|
+
end
|
676
|
+
|
677
|
+
variables_to_inline.each do |var_name, data|
|
678
|
+
value = data[:value]
|
679
|
+
|
680
|
+
# Replace var() usages with the actual value
|
681
|
+
css = css.gsub(/var\(#{Regexp.escape(var_name)}(?:,[^)]*)?\)/, value)
|
682
|
+
|
683
|
+
# Remove the variable declaration
|
684
|
+
css = css.gsub(/#{Regexp.escape(var_name)}:\s*#{Regexp.escape(value)};?/, '')
|
685
|
+
end
|
686
|
+
|
687
|
+
css
|
688
|
+
end
|
689
|
+
|
690
|
+
def remove_unused_variables(css, variable_data)
|
691
|
+
# Remove variables that are declared but never used
|
692
|
+
unused_variables = variable_data.select { |var_name, data| data[:usages] == 0 }
|
693
|
+
|
694
|
+
unused_variables.each do |var_name, data|
|
695
|
+
# Remove unused variable declarations
|
696
|
+
css = css.gsub(/#{Regexp.escape(var_name)}:\s*[^;]+;?/, '')
|
697
|
+
end
|
698
|
+
|
699
|
+
css
|
700
|
+
end
|
701
|
+
|
702
|
+
def optimize_variable_names(css, variable_data)
|
703
|
+
# For frequently used variables with long names, consider shorter aliases
|
704
|
+
# This is more conservative - only optimize very long names that are used frequently
|
705
|
+
|
706
|
+
frequent_long_variables = variable_data.select do |var_name, data|
|
707
|
+
data[:usages] >= 3 && var_name.length > 15
|
708
|
+
end
|
709
|
+
|
710
|
+
frequent_long_variables.each_with_index do |(var_name, data), index|
|
711
|
+
# Create a shorter name (be careful not to conflict with existing names)
|
712
|
+
short_name = "--v#{index + 1}"
|
713
|
+
|
714
|
+
# Make sure the short name doesn't already exist
|
715
|
+
next if css.include?(short_name)
|
716
|
+
|
717
|
+
# Replace all occurrences of the long variable name
|
718
|
+
css = css.gsub(var_name, short_name)
|
719
|
+
end
|
720
|
+
|
721
|
+
css
|
722
|
+
end
|
723
|
+
|
724
|
+
def advanced_color_optimization(css)
|
725
|
+
# More aggressive color optimization beyond basic YUI compressor
|
726
|
+
color_count_before = css.scan(/#[0-9a-f]{3,6}|rgb\([^)]+\)/i).length
|
727
|
+
|
728
|
+
# Add HSL to RGB conversion
|
729
|
+
css = css.gsub(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/i) do
|
730
|
+
h, s, l = $1.to_i, $2.to_i / 100.0, $3.to_i / 100.0
|
731
|
+
rgb = hsl_to_rgb(h, s, l)
|
732
|
+
"rgb(#{rgb.join(',')})"
|
733
|
+
end
|
734
|
+
|
735
|
+
color_count_after = css.scan(/#[0-9a-f]{3,6}|rgb\([^)]+\)/i).length
|
736
|
+
@statistics[:colors_converted] += color_count_before - color_count_after
|
737
|
+
|
738
|
+
css
|
739
|
+
end
|
740
|
+
|
741
|
+
def hsl_to_rgb(h, s, l)
|
742
|
+
h = h / 360.0
|
743
|
+
|
744
|
+
if s == 0
|
745
|
+
r = g = b = l
|
746
|
+
else
|
747
|
+
hue2rgb = lambda do |p, q, t|
|
748
|
+
t += 1 if t < 0
|
749
|
+
t -= 1 if t > 1
|
750
|
+
return p + (q - p) * 6 * t if t < 1.0/6
|
751
|
+
return q if t < 1.0/2
|
752
|
+
return p + (q - p) * (2.0/3 - t) * 6 if t < 2.0/3
|
753
|
+
p
|
754
|
+
end
|
755
|
+
|
756
|
+
q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
757
|
+
p = 2 * l - q
|
758
|
+
r = hue2rgb.call(p, q, h + 1.0/3)
|
759
|
+
g = hue2rgb.call(p, q, h)
|
760
|
+
b = hue2rgb.call(p, q, h - 1.0/3)
|
761
|
+
end
|
762
|
+
|
763
|
+
[(r * 255).round, (g * 255).round, (b * 255).round]
|
764
|
+
end
|
765
|
+
|
766
|
+
def calculate_compression_ratio
|
767
|
+
return 0.0 if @statistics[:original_size] == 0
|
768
|
+
((@statistics[:original_size] - @statistics[:compressed_size]).to_f / @statistics[:original_size]) * 100
|
769
|
+
end
|
770
|
+
end
|
771
|
+
|
772
|
+
# Convenience class methods for enhanced compression
|
773
|
+
def self.compress(css, options = {})
|
774
|
+
if options.is_a?(Hash) && !options.empty?
|
775
|
+
config = Configuration.new
|
776
|
+
options.each { |key, value| config.send("#{key}=", value) if config.respond_to?("#{key}=") }
|
777
|
+
Compressor.new(config).compress(css, options[:linebreakpos] || 5000)
|
778
|
+
else
|
779
|
+
# Fallback to original API for backward compatibility
|
780
|
+
linebreakpos = options.is_a?(Integer) ? options : 5000
|
781
|
+
CssCompressor.compress(css, linebreakpos)
|
782
|
+
end
|
783
|
+
end
|
784
|
+
|
785
|
+
def self.compress_with_stats(css, options = {})
|
786
|
+
config = Configuration.new
|
787
|
+
options.each { |key, value| config.send("#{key}=", value) if config.respond_to?("#{key}=") }
|
788
|
+
config.statistics_enabled = true
|
789
|
+
|
790
|
+
compressor = Compressor.new(config)
|
791
|
+
result = compressor.compress(css, options[:linebreakpos] || 5000)
|
792
|
+
|
793
|
+
{
|
794
|
+
compressed_css: result,
|
795
|
+
statistics: compressor.statistics
|
796
|
+
}
|
797
|
+
end
|
798
|
+
|
799
|
+
# Error class for enhanced features
|
800
|
+
class EnhancedCompressionError < StandardError
|
801
|
+
attr_reader :original_error
|
802
|
+
|
803
|
+
def initialize(message, original_error = nil)
|
804
|
+
super(message)
|
805
|
+
@original_error = original_error
|
806
|
+
end
|
807
|
+
end
|
808
|
+
|
809
|
+
# Error class for malformed CSS
|
810
|
+
class MalformedCSSError < StandardError
|
811
|
+
attr_reader :css_errors
|
812
|
+
|
813
|
+
def initialize(message, css_errors = [])
|
814
|
+
super(message)
|
815
|
+
@css_errors = css_errors
|
816
|
+
end
|
817
|
+
end
|
818
|
+
end
|