jekyll-minifier 0.2.0 → 0.2.1
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/CLAUDE.md +10 -10
- data/COVERAGE_ANALYSIS.md +228 -0
- data/FINAL_TEST_REPORT.md +164 -0
- data/SECURITY.md +155 -0
- data/SECURITY_FIX_SUMMARY.md +141 -0
- data/VALIDATION_FEATURES.md +254 -0
- data/example_config.yml +127 -0
- data/jekyll-minifier.gemspec +1 -1
- data/lib/jekyll-minifier/version.rb +1 -1
- data/lib/jekyll-minifier.rb +1165 -134
- data/spec/caching_performance_spec.rb +238 -0
- data/spec/compressor_cache_spec.rb +326 -0
- data/spec/coverage_enhancement_spec.rb +391 -0
- data/spec/enhanced_css_spec.rb +277 -0
- data/spec/environment_validation_spec.rb +84 -0
- data/spec/fixtures/assets/data.json +25 -0
- data/spec/input_validation_spec.rb +514 -0
- data/spec/jekyll-minifier_enhanced_spec.rb +211 -0
- data/spec/performance_spec.rb +232 -0
- data/spec/security_redos_spec.rb +306 -0
- data/spec/security_validation_spec.rb +253 -0
- metadata +36 -28
- data/spec/fixtures/_site/404.html +0 -1
- data/spec/fixtures/_site/assets/css/style.css +0 -1
- data/spec/fixtures/_site/assets/js/script.js +0 -1
- data/spec/fixtures/_site/atom.xml +0 -1
- data/spec/fixtures/_site/index.html +0 -1
- data/spec/fixtures/_site/random/index.html +0 -1
- data/spec/fixtures/_site/random/random.html +0 -1
- data/spec/fixtures/_site/reviews/index.html +0 -1
- data/spec/fixtures/_site/reviews/test-review-1.html +0 -1
- data/spec/fixtures/_site/reviews/test-review-2.html +0 -1
data/lib/jekyll-minifier.rb
CHANGED
@@ -4,168 +4,1182 @@ require 'cssminify2'
|
|
4
4
|
require 'json/minify'
|
5
5
|
|
6
6
|
module Jekyll
|
7
|
-
module
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
end
|
13
|
-
end
|
7
|
+
module Minifier
|
8
|
+
# ValidationHelpers module provides comprehensive input validation
|
9
|
+
# for Jekyll Minifier configurations and content processing
|
10
|
+
module ValidationHelpers
|
11
|
+
module_function
|
14
12
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
13
|
+
# Maximum safe file size for processing (50MB)
|
14
|
+
MAX_SAFE_FILE_SIZE = 50 * 1024 * 1024
|
15
|
+
|
16
|
+
# Maximum safe configuration value sizes
|
17
|
+
MAX_SAFE_STRING_LENGTH = 10_000
|
18
|
+
MAX_SAFE_ARRAY_SIZE = 1_000
|
19
|
+
MAX_SAFE_HASH_SIZE = 100
|
20
|
+
|
21
|
+
# Validates boolean configuration values
|
22
|
+
# @param [Object] value The value to validate
|
23
|
+
# @param [String] key Configuration key name for error messages
|
24
|
+
# @return [Boolean, nil] Validated boolean value or nil for invalid
|
25
|
+
def validate_boolean(value, key = 'unknown')
|
26
|
+
return nil if value.nil?
|
27
|
+
|
28
|
+
case value
|
29
|
+
when true, false
|
30
|
+
value
|
31
|
+
when 'true', '1', 1
|
32
|
+
true
|
33
|
+
when 'false', '0', 0
|
34
|
+
false
|
35
|
+
else
|
36
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Invalid boolean value for '#{key}': #{value.inspect}. Using default.")
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Validates integer configuration values with range checking
|
42
|
+
# @param [Object] value The value to validate
|
43
|
+
# @param [String] key Configuration key name
|
44
|
+
# @param [Integer] min Minimum allowed value
|
45
|
+
# @param [Integer] max Maximum allowed value
|
46
|
+
# @return [Integer, nil] Validated integer or nil for invalid
|
47
|
+
def validate_integer(value, key = 'unknown', min = 0, max = 1_000_000)
|
48
|
+
return nil if value.nil?
|
49
|
+
|
50
|
+
begin
|
51
|
+
int_value = Integer(value)
|
52
|
+
|
53
|
+
if int_value < min || int_value > max
|
54
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Integer value for '#{key}' out of range [#{min}-#{max}]: #{int_value}. Using default.")
|
55
|
+
return nil
|
22
56
|
end
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
57
|
+
|
58
|
+
int_value
|
59
|
+
rescue ArgumentError, TypeError
|
60
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Invalid integer value for '#{key}': #{value.inspect}. Using default.")
|
61
|
+
nil
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Validates string configuration values with length and safety checks
|
66
|
+
# @param [Object] value The value to validate
|
67
|
+
# @param [String] key Configuration key name
|
68
|
+
# @param [Integer] max_length Maximum allowed string length
|
69
|
+
# @return [String, nil] Validated string or nil for invalid
|
70
|
+
def validate_string(value, key = 'unknown', max_length = MAX_SAFE_STRING_LENGTH)
|
71
|
+
return nil if value.nil?
|
72
|
+
return nil unless value.respond_to?(:to_s)
|
73
|
+
|
74
|
+
str_value = value.to_s
|
75
|
+
|
76
|
+
if str_value.length > max_length
|
77
|
+
Jekyll.logger.warn("Jekyll Minifier:", "String value for '#{key}' too long (#{str_value.length} > #{max_length}). Using default.")
|
78
|
+
return nil
|
79
|
+
end
|
80
|
+
|
81
|
+
# Basic safety check for control characters
|
82
|
+
if str_value.match?(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/)
|
83
|
+
Jekyll.logger.warn("Jekyll Minifier:", "String value for '#{key}' contains unsafe control characters. Using default.")
|
84
|
+
return nil
|
85
|
+
end
|
86
|
+
|
87
|
+
str_value
|
88
|
+
end
|
89
|
+
|
90
|
+
# Validates array configuration values with size and content checks
|
91
|
+
# @param [Object] value The value to validate
|
92
|
+
# @param [String] key Configuration key name
|
93
|
+
# @param [Integer] max_size Maximum allowed array size
|
94
|
+
# @return [Array, nil] Validated array or empty array for invalid
|
95
|
+
def validate_array(value, key = 'unknown', max_size = MAX_SAFE_ARRAY_SIZE)
|
96
|
+
return [] if value.nil?
|
97
|
+
|
98
|
+
# Convert single values to arrays
|
99
|
+
array_value = value.respond_to?(:to_a) ? value.to_a : [value]
|
100
|
+
|
101
|
+
if array_value.size > max_size
|
102
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Array value for '#{key}' too large (#{array_value.size} > #{max_size}). Truncating.")
|
103
|
+
array_value = array_value.take(max_size)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Filter out invalid elements
|
107
|
+
valid_elements = array_value.filter_map do |element|
|
108
|
+
next nil if element.nil?
|
109
|
+
|
110
|
+
if element.respond_to?(:to_s)
|
111
|
+
str_element = element.to_s
|
112
|
+
next nil if str_element.empty? || str_element.length > MAX_SAFE_STRING_LENGTH
|
113
|
+
str_element
|
28
114
|
else
|
29
|
-
|
115
|
+
nil
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
valid_elements
|
120
|
+
end
|
121
|
+
|
122
|
+
# Validates hash configuration values with size and content checks
|
123
|
+
# @param [Object] value The value to validate
|
124
|
+
# @param [String] key Configuration key name
|
125
|
+
# @param [Integer] max_size Maximum allowed hash size
|
126
|
+
# @return [Hash, nil] Validated hash or nil for invalid
|
127
|
+
def validate_hash(value, key = 'unknown', max_size = MAX_SAFE_HASH_SIZE)
|
128
|
+
return nil if value.nil?
|
129
|
+
return nil unless value.respond_to?(:to_h)
|
130
|
+
|
131
|
+
begin
|
132
|
+
hash_value = value.to_h
|
133
|
+
|
134
|
+
if hash_value.size > max_size
|
135
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Hash value for '#{key}' too large (#{hash_value.size} > #{max_size}). Using default.")
|
136
|
+
return nil
|
137
|
+
end
|
138
|
+
|
139
|
+
# Validate hash keys and values
|
140
|
+
validated_hash = {}
|
141
|
+
hash_value.each do |k, v|
|
142
|
+
# Convert keys to symbols for consistency
|
143
|
+
key_sym = k.respond_to?(:to_sym) ? k.to_sym : nil
|
144
|
+
next unless key_sym
|
145
|
+
|
146
|
+
# Basic validation of values
|
147
|
+
case v
|
148
|
+
when String
|
149
|
+
validated_value = validate_string(v, "#{key}[#{key_sym}]")
|
150
|
+
validated_hash[key_sym] = validated_value if validated_value
|
151
|
+
when Integer, Numeric
|
152
|
+
validated_hash[key_sym] = v
|
153
|
+
when true, false
|
154
|
+
validated_hash[key_sym] = v
|
155
|
+
when nil
|
156
|
+
# Allow nil values
|
157
|
+
validated_hash[key_sym] = nil
|
158
|
+
else
|
159
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Unsupported value type for '#{key}[#{key_sym}]': #{v.class}. Skipping.")
|
160
|
+
end
|
30
161
|
end
|
162
|
+
|
163
|
+
validated_hash
|
164
|
+
rescue => e
|
165
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Failed to validate hash for '#{key}': #{e.message}. Using default.")
|
166
|
+
nil
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# Validates file content size and encoding
|
171
|
+
# @param [String] content File content to validate
|
172
|
+
# @param [String] file_type Type of file (css, js, html, json)
|
173
|
+
# @param [String] file_path Path to file for error messages
|
174
|
+
# @return [Boolean] True if content is safe to process
|
175
|
+
def validate_file_content(content, file_type = 'unknown', file_path = 'unknown')
|
176
|
+
return false if content.nil?
|
177
|
+
return false unless content.respond_to?(:bytesize)
|
178
|
+
|
179
|
+
# Check file size
|
180
|
+
if content.bytesize > MAX_SAFE_FILE_SIZE
|
181
|
+
Jekyll.logger.warn("Jekyll Minifier:", "File too large for safe processing: #{file_path} (#{content.bytesize} bytes > #{MAX_SAFE_FILE_SIZE})")
|
182
|
+
return false
|
183
|
+
end
|
184
|
+
|
185
|
+
# Check encoding validity
|
186
|
+
unless content.valid_encoding?
|
187
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Invalid encoding in file: #{file_path}. Skipping minification.")
|
188
|
+
return false
|
189
|
+
end
|
190
|
+
|
191
|
+
# Basic content validation per file type
|
192
|
+
case file_type
|
193
|
+
when 'css'
|
194
|
+
validate_css_content(content, file_path)
|
195
|
+
when 'js', 'javascript'
|
196
|
+
validate_js_content(content, file_path)
|
197
|
+
when 'json'
|
198
|
+
validate_json_content(content, file_path)
|
199
|
+
when 'html', 'xml'
|
200
|
+
validate_html_content(content, file_path)
|
31
201
|
else
|
32
|
-
|
202
|
+
true # Unknown types pass through
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
# Validates CSS content for basic syntax safety
|
207
|
+
# @param [String] content CSS content
|
208
|
+
# @param [String] file_path File path for error messages
|
209
|
+
# @return [Boolean] True if content appears safe
|
210
|
+
def validate_css_content(content, file_path = 'unknown')
|
211
|
+
# Check for extremely unbalanced braces (potential malformed CSS)
|
212
|
+
open_braces = content.count('{')
|
213
|
+
close_braces = content.count('}')
|
214
|
+
|
215
|
+
if (open_braces - close_braces).abs > 100
|
216
|
+
Jekyll.logger.warn("Jekyll Minifier:", "CSS file appears malformed (unbalanced braces): #{file_path}")
|
217
|
+
return false
|
218
|
+
end
|
219
|
+
|
220
|
+
true
|
221
|
+
end
|
222
|
+
|
223
|
+
# Validates JavaScript content for basic syntax safety
|
224
|
+
# @param [String] content JavaScript content
|
225
|
+
# @param [String] file_path File path for error messages
|
226
|
+
# @return [Boolean] True if content appears safe
|
227
|
+
def validate_js_content(content, file_path = 'unknown')
|
228
|
+
# Check for extremely unbalanced braces and parentheses
|
229
|
+
open_braces = content.count('{')
|
230
|
+
close_braces = content.count('}')
|
231
|
+
open_parens = content.count('(')
|
232
|
+
close_parens = content.count(')')
|
233
|
+
|
234
|
+
if (open_braces - close_braces).abs > 100 || (open_parens - close_parens).abs > 100
|
235
|
+
Jekyll.logger.warn("Jekyll Minifier:", "JavaScript file appears malformed (unbalanced braces/parens): #{file_path}")
|
236
|
+
return false
|
237
|
+
end
|
238
|
+
|
239
|
+
true
|
240
|
+
end
|
241
|
+
|
242
|
+
# Validates JSON content for syntax safety
|
243
|
+
# @param [String] content JSON content
|
244
|
+
# @param [String] file_path File path for error messages
|
245
|
+
# @return [Boolean] True if content appears safe
|
246
|
+
def validate_json_content(content, file_path = 'unknown')
|
247
|
+
# Basic JSON structure validation without full parsing
|
248
|
+
trimmed = content.strip
|
249
|
+
|
250
|
+
unless (trimmed.start_with?('{') && trimmed.end_with?('}')) ||
|
251
|
+
(trimmed.start_with?('[') && trimmed.end_with?(']'))
|
252
|
+
Jekyll.logger.warn("Jekyll Minifier:", "JSON file doesn't appear to have valid structure: #{file_path}")
|
253
|
+
return false
|
254
|
+
end
|
255
|
+
|
256
|
+
true
|
257
|
+
end
|
258
|
+
|
259
|
+
# Validates HTML content for basic syntax safety
|
260
|
+
# @param [String] content HTML content
|
261
|
+
# @param [String] file_path File path for error messages
|
262
|
+
# @return [Boolean] True if content appears safe
|
263
|
+
def validate_html_content(content, file_path = 'unknown')
|
264
|
+
# Basic HTML tag balance check
|
265
|
+
open_tags = content.scan(/<[^\/>]+>/).length
|
266
|
+
close_tags = content.scan(/<\/[^>]+>/).length
|
267
|
+
self_closing = content.scan(/<[^>]+\/>/).length
|
268
|
+
|
269
|
+
# Allow for reasonable imbalance (HTML5 void elements, etc.)
|
270
|
+
if (open_tags - close_tags - self_closing).abs > 50
|
271
|
+
Jekyll.logger.warn("Jekyll Minifier:", "HTML file appears malformed (unbalanced tags): #{file_path}")
|
272
|
+
return false
|
273
|
+
end
|
274
|
+
|
275
|
+
true
|
276
|
+
end
|
277
|
+
|
278
|
+
# Validates file paths for security issues
|
279
|
+
# @param [String] path File path to validate
|
280
|
+
# @return [Boolean] True if path is safe
|
281
|
+
def validate_file_path(path)
|
282
|
+
return false if path.nil? || path.empty?
|
283
|
+
return false unless path.respond_to?(:to_s)
|
284
|
+
|
285
|
+
path_str = path.to_s
|
286
|
+
|
287
|
+
# Check for directory traversal attempts
|
288
|
+
if path_str.include?('../') || path_str.include?('..\\') || path_str.include?('~/')
|
289
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Unsafe file path detected: #{path_str}")
|
290
|
+
return false
|
291
|
+
end
|
292
|
+
|
293
|
+
# Check for null bytes
|
294
|
+
if path_str.include?("\0")
|
295
|
+
Jekyll.logger.warn("Jekyll Minifier:", "File path contains null byte: #{path_str}")
|
296
|
+
return false
|
297
|
+
end
|
298
|
+
|
299
|
+
true
|
33
300
|
end
|
34
301
|
end
|
35
302
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
303
|
+
# CompressorCache module provides thread-safe caching for compressor objects
|
304
|
+
# to improve performance by reusing configured compressor instances
|
305
|
+
module CompressorCache
|
306
|
+
module_function
|
307
|
+
|
308
|
+
# Cache storage with thread-safe access
|
309
|
+
@cache_mutex = Mutex.new
|
310
|
+
@compressor_caches = {
|
311
|
+
css: {},
|
312
|
+
js: {},
|
313
|
+
html: {}
|
314
|
+
}
|
315
|
+
@cache_stats = {
|
316
|
+
hits: 0,
|
317
|
+
misses: 0,
|
318
|
+
evictions: 0
|
319
|
+
}
|
320
|
+
|
321
|
+
# Maximum cache size per compressor type (reasonable memory limit)
|
322
|
+
MAX_CACHE_SIZE = 10
|
323
|
+
|
324
|
+
# Get cached compressor or create and cache new one
|
325
|
+
# @param [Symbol] type Compressor type (:css, :js, :html)
|
326
|
+
# @param [String] cache_key Unique key for this configuration
|
327
|
+
# @param [Proc] factory_block Block that creates the compressor if not cached
|
328
|
+
# @return [Object] Cached or newly created compressor instance
|
329
|
+
def get_or_create(type, cache_key, &factory_block)
|
330
|
+
@cache_mutex.synchronize do
|
331
|
+
cache = @compressor_caches[type]
|
332
|
+
|
333
|
+
if cache.key?(cache_key)
|
334
|
+
# Cache hit - move to end for LRU
|
335
|
+
compressor = cache.delete(cache_key)
|
336
|
+
cache[cache_key] = compressor
|
337
|
+
@cache_stats[:hits] += 1
|
338
|
+
compressor
|
339
|
+
else
|
340
|
+
# Cache miss - create new compressor
|
341
|
+
compressor = factory_block.call
|
342
|
+
|
343
|
+
# Evict oldest entry if cache is full
|
344
|
+
if cache.size >= MAX_CACHE_SIZE
|
345
|
+
evicted_key = cache.keys.first
|
346
|
+
cache.delete(evicted_key)
|
347
|
+
@cache_stats[:evictions] += 1
|
348
|
+
end
|
349
|
+
|
350
|
+
cache[cache_key] = compressor
|
351
|
+
@cache_stats[:misses] += 1
|
352
|
+
compressor
|
49
353
|
end
|
354
|
+
end
|
355
|
+
end
|
50
356
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
html_args[:simple_boolean_attributes] = opts['simple_boolean_attributes'] if opts.has_key?('simple_boolean_attributes')
|
70
|
-
html_args[:compress_js_templates] = opts['compress_js_templates'] if opts.has_key?('compress_js_templates')
|
71
|
-
html_args[:preserve_patterns] += [/<\?php.*?\?>/im] if opts['preserve_php'] == true
|
72
|
-
html_args[:preserve_patterns] += opts['preserve_patterns'].map { |pattern| Regexp.new(pattern)} if opts.has_key?('preserve_patterns') && opts['preserve_patterns'] && opts['preserve_patterns'].respond_to?(:map)
|
73
|
-
end
|
74
|
-
|
75
|
-
html_args[:css_compressor] = CSSminify2.new()
|
76
|
-
|
77
|
-
if ( !js_args[:terser_args].nil? )
|
78
|
-
html_args[:javascript_compressor] = ::Terser.new(js_args[:terser_args])
|
79
|
-
else
|
80
|
-
html_args[:javascript_compressor] = ::Terser.new()
|
357
|
+
# Generate cache key from configuration hash
|
358
|
+
# @param [Hash] config_hash Configuration parameters
|
359
|
+
# @return [String] Unique cache key
|
360
|
+
def generate_cache_key(config_hash)
|
361
|
+
return 'default' if config_hash.nil? || config_hash.empty?
|
362
|
+
|
363
|
+
# Sort keys for consistent hashing
|
364
|
+
sorted_config = config_hash.sort.to_h
|
365
|
+
# Use SHA256 for consistent, collision-resistant keys
|
366
|
+
require 'digest'
|
367
|
+
Digest::SHA256.hexdigest(sorted_config.to_s)[0..16] # First 16 chars for brevity
|
368
|
+
end
|
369
|
+
|
370
|
+
# Clear all caches (useful for testing and memory management)
|
371
|
+
def clear_all
|
372
|
+
@cache_mutex.synchronize do
|
373
|
+
@compressor_caches.each { |_, cache| cache.clear }
|
374
|
+
@cache_stats = { hits: 0, misses: 0, evictions: 0 }
|
81
375
|
end
|
376
|
+
end
|
82
377
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
378
|
+
# Get cache statistics (for monitoring and testing)
|
379
|
+
# @return [Hash] Cache hit/miss/eviction statistics
|
380
|
+
def stats
|
381
|
+
@cache_mutex.synchronize { @cache_stats.dup }
|
382
|
+
end
|
383
|
+
|
384
|
+
# Get cache sizes (for monitoring)
|
385
|
+
# @return [Hash] Current cache sizes by type
|
386
|
+
def cache_sizes
|
387
|
+
@cache_mutex.synchronize do
|
388
|
+
{
|
389
|
+
css: @compressor_caches[:css].size,
|
390
|
+
js: @compressor_caches[:js].size,
|
391
|
+
html: @compressor_caches[:html].size,
|
392
|
+
total: @compressor_caches.values.sum(&:size)
|
393
|
+
}
|
394
|
+
end
|
395
|
+
end
|
396
|
+
|
397
|
+
# Check if caching is effectively working
|
398
|
+
# @return [Float] Cache hit ratio (0.0 to 1.0)
|
399
|
+
def hit_ratio
|
400
|
+
@cache_mutex.synchronize do
|
401
|
+
total = @cache_stats[:hits] + @cache_stats[:misses]
|
402
|
+
return 0.0 if total == 0
|
403
|
+
@cache_stats[:hits].to_f / total
|
404
|
+
end
|
87
405
|
end
|
88
406
|
end
|
89
407
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
408
|
+
# Wrapper class to provide enhanced CSS compression for HTML compressor
|
409
|
+
# This maintains the same interface as CSSminify2 while adding enhanced features
|
410
|
+
class CSSEnhancedWrapper
|
411
|
+
def initialize(enhanced_options = {})
|
412
|
+
@enhanced_options = enhanced_options
|
413
|
+
end
|
414
|
+
|
415
|
+
# Interface method expected by HtmlCompressor
|
416
|
+
# @param [String] css CSS content to compress
|
417
|
+
# @return [String] Compressed CSS
|
418
|
+
def compress(css)
|
419
|
+
CSSminify2.compress_enhanced(css, @enhanced_options)
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
# CompressorFactory module extracts complex compressor setup logic
|
424
|
+
# Reduces complexity and centralizes compressor configuration
|
425
|
+
module CompressorFactory
|
426
|
+
module_function
|
427
|
+
|
428
|
+
# Creates CSS compressor based on configuration
|
429
|
+
# @param [CompressionConfig] config Configuration instance
|
430
|
+
# @return [Object] CSS compressor instance
|
431
|
+
def create_css_compressor(config)
|
432
|
+
# Generate cache key from configuration
|
433
|
+
if config.css_enhanced_mode? && config.css_enhanced_options
|
434
|
+
cache_key = CompressorCache.generate_cache_key({
|
435
|
+
enhanced_mode: true,
|
436
|
+
options: config.css_enhanced_options
|
437
|
+
})
|
438
|
+
else
|
439
|
+
cache_key = CompressorCache.generate_cache_key({ enhanced_mode: false })
|
440
|
+
end
|
441
|
+
|
442
|
+
# Use cache to get or create compressor
|
443
|
+
CompressorCache.get_or_create(:css, cache_key) do
|
444
|
+
if config.css_enhanced_mode? && config.css_enhanced_options
|
445
|
+
CSSEnhancedWrapper.new(config.css_enhanced_options)
|
446
|
+
else
|
447
|
+
CSSminify2.new()
|
103
448
|
end
|
104
449
|
end
|
450
|
+
end
|
451
|
+
|
452
|
+
# Creates JavaScript compressor based on configuration
|
453
|
+
# @param [CompressionConfig] config Configuration instance
|
454
|
+
# @return [Terser] JavaScript compressor instance
|
455
|
+
def create_js_compressor(config)
|
456
|
+
# Generate cache key from Terser configuration
|
457
|
+
cache_key = if config.has_terser_args?
|
458
|
+
CompressorCache.generate_cache_key({ terser_args: config.terser_args })
|
459
|
+
else
|
460
|
+
CompressorCache.generate_cache_key({ terser_args: nil })
|
461
|
+
end
|
105
462
|
|
106
|
-
|
107
|
-
|
108
|
-
|
463
|
+
# Use cache to get or create compressor
|
464
|
+
CompressorCache.get_or_create(:js, cache_key) do
|
465
|
+
if config.has_terser_args?
|
466
|
+
::Terser.new(config.terser_args)
|
109
467
|
else
|
110
|
-
|
468
|
+
::Terser.new()
|
111
469
|
end
|
470
|
+
end
|
471
|
+
end
|
112
472
|
|
113
|
-
|
473
|
+
# Creates HTML compressor with configured CSS and JS compressors
|
474
|
+
# @param [CompressionConfig] config Configuration instance
|
475
|
+
# @return [HtmlCompressor::Compressor] HTML compressor instance
|
476
|
+
def create_html_compressor(config)
|
477
|
+
# Generate cache key from HTML compressor configuration
|
478
|
+
html_args = config.html_compressor_args
|
479
|
+
cache_key = CompressorCache.generate_cache_key({
|
480
|
+
html_args: html_args,
|
481
|
+
css_enhanced: config.css_enhanced_mode?,
|
482
|
+
css_options: config.css_enhanced_options,
|
483
|
+
terser_args: config.terser_args
|
484
|
+
})
|
485
|
+
|
486
|
+
# Use cache to get or create HTML compressor
|
487
|
+
# Avoid deadlock by creating sub-compressors outside the cache lock
|
488
|
+
CompressorCache.get_or_create(:html, cache_key) do
|
489
|
+
# Create sub-compressors first (outside the HTML cache lock)
|
490
|
+
css_compressor = create_css_compressor_uncached(config)
|
491
|
+
js_compressor = create_js_compressor_uncached(config)
|
492
|
+
|
493
|
+
# Create fresh args hash for this instance
|
494
|
+
fresh_html_args = html_args.dup
|
495
|
+
fresh_html_args[:css_compressor] = css_compressor
|
496
|
+
fresh_html_args[:javascript_compressor] = js_compressor
|
497
|
+
HtmlCompressor::Compressor.new(fresh_html_args)
|
498
|
+
end
|
499
|
+
end
|
500
|
+
|
501
|
+
# Internal method to create CSS compressor without caching (avoids deadlock)
|
502
|
+
# @param [CompressionConfig] config Configuration instance
|
503
|
+
# @return [Object] CSS compressor instance
|
504
|
+
def create_css_compressor_uncached(config)
|
505
|
+
if config.css_enhanced_mode? && config.css_enhanced_options
|
506
|
+
CSSEnhancedWrapper.new(config.css_enhanced_options)
|
114
507
|
else
|
115
|
-
|
508
|
+
CSSminify2.new()
|
509
|
+
end
|
510
|
+
end
|
511
|
+
|
512
|
+
# Internal method to create JS compressor without caching (avoids deadlock)
|
513
|
+
# @param [CompressionConfig] config Configuration instance
|
514
|
+
# @return [Terser] JavaScript compressor instance
|
515
|
+
def create_js_compressor_uncached(config)
|
516
|
+
if config.has_terser_args?
|
517
|
+
::Terser.new(config.terser_args)
|
518
|
+
else
|
519
|
+
::Terser.new()
|
520
|
+
end
|
521
|
+
end
|
522
|
+
|
523
|
+
# Compresses CSS content using appropriate compressor with validation
|
524
|
+
# @param [String] content CSS content to compress
|
525
|
+
# @param [CompressionConfig] config Configuration instance
|
526
|
+
# @param [String] file_path Optional file path for error messages
|
527
|
+
# @return [String] Compressed CSS content
|
528
|
+
def compress_css(content, config, file_path = 'unknown')
|
529
|
+
# Validate content before processing
|
530
|
+
unless ValidationHelpers.validate_file_content(content, 'css', file_path)
|
531
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Skipping CSS compression for unsafe content: #{file_path}")
|
532
|
+
return content
|
533
|
+
end
|
534
|
+
|
535
|
+
begin
|
536
|
+
if config.css_enhanced_mode? && config.css_enhanced_options
|
537
|
+
CSSminify2.compress_enhanced(content, config.css_enhanced_options)
|
538
|
+
else
|
539
|
+
compressor = create_css_compressor(config)
|
540
|
+
# Pass nil to disable line breaks completely for performance (PR #61)
|
541
|
+
compressor.compress(content, nil)
|
542
|
+
end
|
543
|
+
rescue => e
|
544
|
+
Jekyll.logger.warn("Jekyll Minifier:", "CSS compression failed for #{file_path}: #{e.message}. Using original content.")
|
545
|
+
content
|
546
|
+
end
|
547
|
+
end
|
548
|
+
|
549
|
+
# Compresses JavaScript content using Terser with validation
|
550
|
+
# @param [String] content JavaScript content to compress
|
551
|
+
# @param [CompressionConfig] config Configuration instance
|
552
|
+
# @param [String] file_path Optional file path for error messages
|
553
|
+
# @return [String] Compressed JavaScript content
|
554
|
+
def compress_js(content, config, file_path = 'unknown')
|
555
|
+
# Validate content before processing
|
556
|
+
unless ValidationHelpers.validate_file_content(content, 'js', file_path)
|
557
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Skipping JavaScript compression for unsafe content: #{file_path}")
|
558
|
+
return content
|
559
|
+
end
|
560
|
+
|
561
|
+
begin
|
562
|
+
compressor = create_js_compressor(config)
|
563
|
+
compressor.compile(content)
|
564
|
+
rescue => e
|
565
|
+
Jekyll.logger.warn("Jekyll Minifier:", "JavaScript compression failed for #{file_path}: #{e.message}. Using original content.")
|
566
|
+
content
|
567
|
+
end
|
568
|
+
end
|
569
|
+
|
570
|
+
# Compresses JSON content with validation
|
571
|
+
# @param [String] content JSON content to compress
|
572
|
+
# @param [String] file_path Optional file path for error messages
|
573
|
+
# @return [String] Compressed JSON content
|
574
|
+
def compress_json(content, file_path = 'unknown')
|
575
|
+
# Validate content before processing
|
576
|
+
unless ValidationHelpers.validate_file_content(content, 'json', file_path)
|
577
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Skipping JSON compression for unsafe content: #{file_path}")
|
578
|
+
return content
|
579
|
+
end
|
580
|
+
|
581
|
+
begin
|
582
|
+
JSON.minify(content)
|
583
|
+
rescue => e
|
584
|
+
Jekyll.logger.warn("Jekyll Minifier:", "JSON compression failed for #{file_path}: #{e.message}. Using original content.")
|
585
|
+
content
|
116
586
|
end
|
117
|
-
else
|
118
|
-
output_file(path, content)
|
119
587
|
end
|
120
588
|
end
|
589
|
+
# Configuration manager class that eliminates repetitive configuration handling
|
590
|
+
# Provides clean accessors while maintaining 100% backward compatibility
|
591
|
+
class CompressionConfig
|
592
|
+
# Configuration key constants to eliminate magic strings
|
593
|
+
CONFIG_ROOT = 'jekyll-minifier'
|
594
|
+
|
595
|
+
# HTML Compression Options
|
596
|
+
HTML_REMOVE_SPACES_INSIDE_TAGS = 'remove_spaces_inside_tags'
|
597
|
+
HTML_REMOVE_MULTI_SPACES = 'remove_multi_spaces'
|
598
|
+
HTML_REMOVE_COMMENTS = 'remove_comments'
|
599
|
+
HTML_REMOVE_INTERTAG_SPACES = 'remove_intertag_spaces'
|
600
|
+
HTML_REMOVE_QUOTES = 'remove_quotes'
|
601
|
+
HTML_COMPRESS_CSS = 'compress_css'
|
602
|
+
HTML_COMPRESS_JAVASCRIPT = 'compress_javascript'
|
603
|
+
HTML_SIMPLE_DOCTYPE = 'simple_doctype'
|
604
|
+
HTML_REMOVE_SCRIPT_ATTRIBUTES = 'remove_script_attributes'
|
605
|
+
HTML_REMOVE_STYLE_ATTRIBUTES = 'remove_style_attributes'
|
606
|
+
HTML_REMOVE_LINK_ATTRIBUTES = 'remove_link_attributes'
|
607
|
+
HTML_REMOVE_FORM_ATTRIBUTES = 'remove_form_attributes'
|
608
|
+
HTML_REMOVE_INPUT_ATTRIBUTES = 'remove_input_attributes'
|
609
|
+
HTML_REMOVE_JAVASCRIPT_PROTOCOL = 'remove_javascript_protocol'
|
610
|
+
HTML_REMOVE_HTTP_PROTOCOL = 'remove_http_protocol'
|
611
|
+
HTML_REMOVE_HTTPS_PROTOCOL = 'remove_https_protocol'
|
612
|
+
HTML_PRESERVE_LINE_BREAKS = 'preserve_line_breaks'
|
613
|
+
HTML_SIMPLE_BOOLEAN_ATTRIBUTES = 'simple_boolean_attributes'
|
614
|
+
HTML_COMPRESS_JS_TEMPLATES = 'compress_js_templates'
|
615
|
+
|
616
|
+
# File Type Compression Toggles
|
617
|
+
COMPRESS_CSS = 'compress_css'
|
618
|
+
COMPRESS_JAVASCRIPT = 'compress_javascript'
|
619
|
+
COMPRESS_JSON = 'compress_json'
|
620
|
+
|
621
|
+
# Enhanced CSS Compression Options (cssminify2 v2.1.0+)
|
622
|
+
CSS_MERGE_DUPLICATE_SELECTORS = 'css_merge_duplicate_selectors'
|
623
|
+
CSS_OPTIMIZE_SHORTHAND_PROPERTIES = 'css_optimize_shorthand_properties'
|
624
|
+
CSS_ADVANCED_COLOR_OPTIMIZATION = 'css_advanced_color_optimization'
|
625
|
+
CSS_PRESERVE_IE_HACKS = 'css_preserve_ie_hacks'
|
626
|
+
CSS_COMPRESS_VARIABLES = 'css_compress_variables'
|
627
|
+
CSS_ENHANCED_MODE = 'css_enhanced_mode'
|
628
|
+
|
629
|
+
# JavaScript/Terser Configuration
|
630
|
+
TERSER_ARGS = 'terser_args'
|
631
|
+
UGLIFIER_ARGS = 'uglifier_args' # Backward compatibility
|
632
|
+
|
633
|
+
# Pattern Preservation
|
634
|
+
PRESERVE_PATTERNS = 'preserve_patterns'
|
635
|
+
PRESERVE_PHP = 'preserve_php'
|
636
|
+
|
637
|
+
# File Exclusions
|
638
|
+
EXCLUDE = 'exclude'
|
121
639
|
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
640
|
+
def initialize(site_config)
|
641
|
+
@config = site_config || {}
|
642
|
+
@raw_minifier_config = @config[CONFIG_ROOT] || {}
|
643
|
+
|
644
|
+
# Validate and sanitize the configuration
|
645
|
+
@minifier_config = validate_configuration(@raw_minifier_config)
|
646
|
+
|
647
|
+
# Pre-compute commonly used values for performance
|
648
|
+
@computed_values = {}
|
649
|
+
|
650
|
+
# Pre-compile terser arguments for JavaScript compression
|
651
|
+
_compute_terser_args
|
652
|
+
end
|
653
|
+
|
654
|
+
# HTML Compression Configuration Accessors
|
655
|
+
# Dynamically define accessor methods to reduce repetition
|
656
|
+
{
|
657
|
+
remove_spaces_inside_tags: [HTML_REMOVE_SPACES_INSIDE_TAGS, nil],
|
658
|
+
remove_multi_spaces: [HTML_REMOVE_MULTI_SPACES, nil],
|
659
|
+
remove_comments: [HTML_REMOVE_COMMENTS, true],
|
660
|
+
remove_intertag_spaces: [HTML_REMOVE_INTERTAG_SPACES, nil],
|
661
|
+
remove_quotes: [HTML_REMOVE_QUOTES, nil],
|
662
|
+
compress_css_in_html: [HTML_COMPRESS_CSS, true],
|
663
|
+
compress_javascript_in_html: [HTML_COMPRESS_JAVASCRIPT, true],
|
664
|
+
simple_doctype: [HTML_SIMPLE_DOCTYPE, nil],
|
665
|
+
remove_script_attributes: [HTML_REMOVE_SCRIPT_ATTRIBUTES, nil],
|
666
|
+
remove_style_attributes: [HTML_REMOVE_STYLE_ATTRIBUTES, nil],
|
667
|
+
remove_link_attributes: [HTML_REMOVE_LINK_ATTRIBUTES, nil],
|
668
|
+
remove_form_attributes: [HTML_REMOVE_FORM_ATTRIBUTES, nil],
|
669
|
+
remove_input_attributes: [HTML_REMOVE_INPUT_ATTRIBUTES, nil],
|
670
|
+
remove_javascript_protocol: [HTML_REMOVE_JAVASCRIPT_PROTOCOL, nil],
|
671
|
+
remove_http_protocol: [HTML_REMOVE_HTTP_PROTOCOL, nil],
|
672
|
+
remove_https_protocol: [HTML_REMOVE_HTTPS_PROTOCOL, nil],
|
673
|
+
preserve_line_breaks: [HTML_PRESERVE_LINE_BREAKS, nil],
|
674
|
+
simple_boolean_attributes: [HTML_SIMPLE_BOOLEAN_ATTRIBUTES, nil],
|
675
|
+
compress_js_templates: [HTML_COMPRESS_JS_TEMPLATES, nil]
|
676
|
+
}.each do |method_name, (config_key, default_value)|
|
677
|
+
define_method(method_name) do
|
678
|
+
get_boolean(config_key, default_value)
|
679
|
+
end
|
680
|
+
end
|
681
|
+
|
682
|
+
# File Type Compression Toggles
|
683
|
+
def compress_css?
|
684
|
+
get_boolean(COMPRESS_CSS, true) # Default to true
|
685
|
+
end
|
686
|
+
|
687
|
+
def compress_javascript?
|
688
|
+
get_boolean(COMPRESS_JAVASCRIPT, true) # Default to true
|
689
|
+
end
|
690
|
+
|
691
|
+
def compress_json?
|
692
|
+
get_boolean(COMPRESS_JSON, true) # Default to true
|
693
|
+
end
|
694
|
+
|
695
|
+
# Enhanced CSS Compression Configuration
|
696
|
+
# Dynamically define CSS enhancement accessor methods
|
697
|
+
{
|
698
|
+
css_enhanced_mode?: [CSS_ENHANCED_MODE, false],
|
699
|
+
css_merge_duplicate_selectors?: [CSS_MERGE_DUPLICATE_SELECTORS, false],
|
700
|
+
css_optimize_shorthand_properties?: [CSS_OPTIMIZE_SHORTHAND_PROPERTIES, false],
|
701
|
+
css_advanced_color_optimization?: [CSS_ADVANCED_COLOR_OPTIMIZATION, false],
|
702
|
+
css_preserve_ie_hacks?: [CSS_PRESERVE_IE_HACKS, true],
|
703
|
+
css_compress_variables?: [CSS_COMPRESS_VARIABLES, false]
|
704
|
+
}.each do |method_name, (config_key, default_value)|
|
705
|
+
define_method(method_name) do
|
706
|
+
get_boolean(config_key, default_value)
|
707
|
+
end
|
708
|
+
end
|
709
|
+
|
710
|
+
# Generate enhanced CSS compression options hash
|
711
|
+
def css_enhanced_options
|
712
|
+
return nil unless css_enhanced_mode?
|
713
|
+
|
714
|
+
{
|
715
|
+
merge_duplicate_selectors: css_merge_duplicate_selectors?,
|
716
|
+
optimize_shorthand_properties: css_optimize_shorthand_properties?,
|
717
|
+
advanced_color_optimization: css_advanced_color_optimization?,
|
718
|
+
preserve_ie_hacks: css_preserve_ie_hacks?,
|
719
|
+
compress_css_variables: css_compress_variables?
|
720
|
+
}
|
721
|
+
end
|
722
|
+
|
723
|
+
# JavaScript/Terser Configuration
|
724
|
+
def terser_args
|
725
|
+
@computed_values[:terser_args]
|
726
|
+
end
|
727
|
+
|
728
|
+
def has_terser_args?
|
729
|
+
!terser_args.nil?
|
730
|
+
end
|
731
|
+
|
732
|
+
# Pattern Preservation
|
733
|
+
def preserve_patterns
|
734
|
+
patterns = get_array(PRESERVE_PATTERNS)
|
735
|
+
return patterns unless patterns.empty?
|
736
|
+
|
737
|
+
# Return empty array if no patterns configured
|
738
|
+
[]
|
739
|
+
end
|
740
|
+
|
741
|
+
def preserve_php?
|
742
|
+
get_boolean(PRESERVE_PHP, false)
|
743
|
+
end
|
744
|
+
|
745
|
+
def php_preserve_pattern
|
746
|
+
/<\?php.*?\?>/im
|
747
|
+
end
|
748
|
+
|
749
|
+
# File Exclusions
|
750
|
+
def exclude_patterns
|
751
|
+
get_array(EXCLUDE)
|
752
|
+
end
|
753
|
+
|
754
|
+
# Generate HTML compressor arguments hash
|
755
|
+
# Maintains exact same behavior as original implementation
|
756
|
+
def html_compressor_args
|
757
|
+
args = base_html_args
|
758
|
+
apply_html_config_overrides(args)
|
759
|
+
apply_preserve_patterns(args)
|
760
|
+
args
|
761
|
+
end
|
762
|
+
|
763
|
+
private
|
764
|
+
|
765
|
+
def base_html_args
|
766
|
+
{
|
767
|
+
remove_comments: true,
|
768
|
+
compress_css: true,
|
769
|
+
compress_javascript: true,
|
770
|
+
preserve_patterns: []
|
771
|
+
}
|
772
|
+
end
|
773
|
+
|
774
|
+
def apply_html_config_overrides(args)
|
775
|
+
html_config_methods.each do |method, key|
|
776
|
+
value = send(method)
|
777
|
+
args[key] = value unless value.nil?
|
778
|
+
end
|
779
|
+
end
|
780
|
+
|
781
|
+
def html_config_methods
|
782
|
+
{
|
783
|
+
remove_spaces_inside_tags: :remove_spaces_inside_tags,
|
784
|
+
remove_multi_spaces: :remove_multi_spaces,
|
785
|
+
remove_comments: :remove_comments,
|
786
|
+
remove_intertag_spaces: :remove_intertag_spaces,
|
787
|
+
remove_quotes: :remove_quotes,
|
788
|
+
compress_css_in_html: :compress_css,
|
789
|
+
compress_javascript_in_html: :compress_javascript,
|
790
|
+
simple_doctype: :simple_doctype,
|
791
|
+
remove_script_attributes: :remove_script_attributes,
|
792
|
+
remove_style_attributes: :remove_style_attributes,
|
793
|
+
remove_link_attributes: :remove_link_attributes,
|
794
|
+
remove_form_attributes: :remove_form_attributes,
|
795
|
+
remove_input_attributes: :remove_input_attributes,
|
796
|
+
remove_javascript_protocol: :remove_javascript_protocol,
|
797
|
+
remove_http_protocol: :remove_http_protocol,
|
798
|
+
remove_https_protocol: :remove_https_protocol,
|
799
|
+
preserve_line_breaks: :preserve_line_breaks,
|
800
|
+
simple_boolean_attributes: :simple_boolean_attributes,
|
801
|
+
compress_js_templates: :compress_js_templates
|
802
|
+
}
|
803
|
+
end
|
804
|
+
|
805
|
+
def apply_preserve_patterns(args)
|
806
|
+
args[:preserve_patterns] += [php_preserve_pattern] if preserve_php?
|
807
|
+
|
808
|
+
configured_patterns = preserve_patterns
|
809
|
+
if !configured_patterns.empty? && configured_patterns.respond_to?(:map)
|
810
|
+
compiled_patterns = compile_preserve_patterns(configured_patterns)
|
811
|
+
args[:preserve_patterns] += compiled_patterns
|
128
812
|
end
|
813
|
+
end
|
129
814
|
|
130
|
-
|
131
|
-
|
815
|
+
# Validates the entire minifier configuration structure
|
816
|
+
# @param [Hash] raw_config Raw configuration hash
|
817
|
+
# @return [Hash] Validated and sanitized configuration
|
818
|
+
def validate_configuration(raw_config)
|
819
|
+
return {} unless raw_config.respond_to?(:to_h)
|
820
|
+
|
821
|
+
validated_config = {}
|
822
|
+
|
823
|
+
raw_config.each do |key, value|
|
824
|
+
validated_key = ValidationHelpers.validate_string(key, "config_key", 100)
|
825
|
+
next unless validated_key
|
826
|
+
|
827
|
+
validated_value = validate_config_value(validated_key, value)
|
828
|
+
validated_config[validated_key] = validated_value unless validated_value.nil?
|
829
|
+
end
|
830
|
+
|
831
|
+
validated_config
|
832
|
+
rescue => e
|
833
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Configuration validation failed: #{e.message}. Using defaults.")
|
834
|
+
{}
|
835
|
+
end
|
836
|
+
|
837
|
+
# Validates individual configuration values based on their key
|
838
|
+
# @param [String] key Configuration key
|
839
|
+
# @param [Object] value Configuration value
|
840
|
+
# @return [Object, nil] Validated value or nil for invalid
|
841
|
+
def validate_config_value(key, value)
|
842
|
+
case key
|
843
|
+
# Boolean HTML compression options
|
844
|
+
when HTML_REMOVE_SPACES_INSIDE_TAGS, HTML_REMOVE_MULTI_SPACES, HTML_REMOVE_COMMENTS,
|
845
|
+
HTML_REMOVE_INTERTAG_SPACES, HTML_REMOVE_QUOTES, HTML_COMPRESS_CSS,
|
846
|
+
HTML_COMPRESS_JAVASCRIPT, HTML_SIMPLE_DOCTYPE, HTML_REMOVE_SCRIPT_ATTRIBUTES,
|
847
|
+
HTML_REMOVE_STYLE_ATTRIBUTES, HTML_REMOVE_LINK_ATTRIBUTES, HTML_REMOVE_FORM_ATTRIBUTES,
|
848
|
+
HTML_REMOVE_INPUT_ATTRIBUTES, HTML_REMOVE_JAVASCRIPT_PROTOCOL, HTML_REMOVE_HTTP_PROTOCOL,
|
849
|
+
HTML_REMOVE_HTTPS_PROTOCOL, HTML_PRESERVE_LINE_BREAKS, HTML_SIMPLE_BOOLEAN_ATTRIBUTES,
|
850
|
+
HTML_COMPRESS_JS_TEMPLATES, COMPRESS_CSS, COMPRESS_JAVASCRIPT, COMPRESS_JSON,
|
851
|
+
CSS_MERGE_DUPLICATE_SELECTORS, CSS_OPTIMIZE_SHORTHAND_PROPERTIES,
|
852
|
+
CSS_ADVANCED_COLOR_OPTIMIZATION, CSS_PRESERVE_IE_HACKS, CSS_COMPRESS_VARIABLES,
|
853
|
+
CSS_ENHANCED_MODE, PRESERVE_PHP
|
854
|
+
ValidationHelpers.validate_boolean(value, key)
|
855
|
+
|
856
|
+
# Array configurations - for backward compatibility, don't validate these strictly
|
857
|
+
when PRESERVE_PATTERNS, EXCLUDE
|
858
|
+
# Let the existing get_array method handle the conversion for backward compatibility
|
859
|
+
value
|
860
|
+
|
861
|
+
# Hash configurations (Terser/Uglifier args)
|
862
|
+
when TERSER_ARGS, UGLIFIER_ARGS
|
863
|
+
validate_compressor_args(value, key)
|
864
|
+
|
132
865
|
else
|
133
|
-
|
866
|
+
# Pass through other values for backward compatibility
|
867
|
+
value
|
134
868
|
end
|
135
|
-
else
|
136
|
-
output_file(path, content)
|
137
869
|
end
|
138
|
-
|
870
|
+
|
871
|
+
# Validates compressor arguments (Terser/Uglifier) with security checks
|
872
|
+
# @param [Object] value Compressor arguments
|
873
|
+
# @param [String] key Configuration key name
|
874
|
+
# @return [Hash, nil] Validated compressor arguments or nil
|
875
|
+
def validate_compressor_args(value, key)
|
876
|
+
validated_hash = ValidationHelpers.validate_hash(value, key, 20) # Limit to 20 options
|
877
|
+
return nil unless validated_hash
|
878
|
+
|
879
|
+
# Additional validation for known dangerous options
|
880
|
+
safe_args = {}
|
881
|
+
validated_hash.each do |k, v|
|
882
|
+
case k.to_s
|
883
|
+
when 'eval', 'with', 'toplevel'
|
884
|
+
# These options can be dangerous - validate more strictly
|
885
|
+
safe_value = ValidationHelpers.validate_boolean(v, "#{key}[#{k}]")
|
886
|
+
safe_args[k] = safe_value unless safe_value.nil?
|
887
|
+
when 'compress', 'mangle', 'output'
|
888
|
+
# These can be hashes or booleans
|
889
|
+
if v.respond_to?(:to_h)
|
890
|
+
# Handle as hash - preserve the structure for Terser compatibility
|
891
|
+
safe_args[k] = v.to_h
|
892
|
+
else
|
893
|
+
safe_value = ValidationHelpers.validate_boolean(v, "#{key}[#{k}]")
|
894
|
+
safe_args[k] = safe_value unless safe_value.nil?
|
895
|
+
end
|
896
|
+
when 'ecma', 'ie8', 'safari10'
|
897
|
+
# Numeric or boolean options
|
898
|
+
if v.is_a?(Numeric)
|
899
|
+
safe_args[k] = ValidationHelpers.validate_integer(v, "#{key}[#{k}]", 3, 2020)
|
900
|
+
else
|
901
|
+
safe_value = ValidationHelpers.validate_boolean(v, "#{key}[#{k}]")
|
902
|
+
safe_args[k] = safe_value unless safe_value.nil?
|
903
|
+
end
|
904
|
+
when 'harmony'
|
905
|
+
# Legacy Uglifier option - filter out for Terser
|
906
|
+
Jekyll.logger.info("Jekyll Minifier:", "Filtering out legacy 'harmony' option from #{key}")
|
907
|
+
# Don't add to safe_args
|
908
|
+
else
|
909
|
+
# Other options - basic validation
|
910
|
+
case v
|
911
|
+
when String
|
912
|
+
safe_value = ValidationHelpers.validate_string(v, "#{key}[#{k}]", 500)
|
913
|
+
safe_args[k] = safe_value if safe_value
|
914
|
+
when Numeric
|
915
|
+
safe_args[k] = ValidationHelpers.validate_integer(v, "#{key}[#{k}]", -1000, 1000)
|
916
|
+
when true, false
|
917
|
+
safe_args[k] = v
|
918
|
+
when nil
|
919
|
+
safe_args[k] = nil
|
920
|
+
else
|
921
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Unsupported option type for #{key}[#{k}]: #{v.class}")
|
922
|
+
end
|
923
|
+
end
|
924
|
+
end
|
925
|
+
|
926
|
+
safe_args.empty? ? nil : safe_args
|
927
|
+
end
|
139
928
|
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
929
|
+
def get_boolean(key, default = nil)
|
930
|
+
return default unless @minifier_config.has_key?(key)
|
931
|
+
# Additional runtime validation for boolean values
|
932
|
+
value = @minifier_config[key]
|
933
|
+
validated = ValidationHelpers.validate_boolean(value, key)
|
934
|
+
validated.nil? ? default : validated
|
935
|
+
end
|
936
|
+
|
937
|
+
def get_array(key)
|
938
|
+
value = @minifier_config[key]
|
939
|
+
return [] if value.nil?
|
940
|
+
|
941
|
+
# For backward compatibility, if value exists but isn't an array, convert it
|
942
|
+
return value if value.respond_to?(:to_a)
|
943
|
+
[value]
|
944
|
+
end
|
945
|
+
|
946
|
+
# Pre-compute terser arguments for performance
|
947
|
+
def _compute_terser_args
|
948
|
+
# Support both terser_args and uglifier_args for backward compatibility
|
949
|
+
# Use validated configuration
|
950
|
+
terser_options = @minifier_config[TERSER_ARGS] || @minifier_config[UGLIFIER_ARGS]
|
951
|
+
|
952
|
+
if terser_options && terser_options.respond_to?(:map)
|
953
|
+
# Apply validation to the terser options
|
954
|
+
validated_options = validate_compressor_args(terser_options, TERSER_ARGS)
|
955
|
+
|
956
|
+
if validated_options && !validated_options.empty?
|
957
|
+
# Convert keys to symbols for consistency
|
958
|
+
@computed_values[:terser_args] = Hash[validated_options.map{|(k,v)| [k.to_sym,v]}]
|
959
|
+
else
|
960
|
+
# Fallback to original logic if validation fails
|
961
|
+
filtered_options = terser_options.reject { |k, v| k.to_s == 'harmony' }
|
962
|
+
@computed_values[:terser_args] = Hash[filtered_options.map{|(k,v)| [k.to_sym,v]}] unless filtered_options.empty?
|
963
|
+
end
|
151
964
|
else
|
152
|
-
|
965
|
+
@computed_values[:terser_args] = nil
|
966
|
+
end
|
967
|
+
end
|
968
|
+
|
969
|
+
# Import the compile_preserve_patterns method to maintain exact same behavior
|
970
|
+
# This will be made accessible through dependency injection
|
971
|
+
def compile_preserve_patterns(patterns)
|
972
|
+
return [] unless patterns.respond_to?(:map)
|
973
|
+
|
974
|
+
patterns.filter_map { |pattern| compile_single_pattern(pattern) }
|
975
|
+
end
|
976
|
+
|
977
|
+
private
|
978
|
+
|
979
|
+
def compile_single_pattern(pattern)
|
980
|
+
begin
|
981
|
+
# ReDoS protection: validate pattern complexity and add timeout
|
982
|
+
if valid_regex_pattern?(pattern)
|
983
|
+
# Use timeout to prevent ReDoS attacks during compilation
|
984
|
+
regex = compile_regex_with_timeout(pattern, 1.0) # 1 second timeout
|
985
|
+
return regex if regex
|
986
|
+
else
|
987
|
+
# Log invalid pattern but continue processing (graceful degradation)
|
988
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Skipping potentially unsafe regex pattern: #{pattern.inspect}")
|
989
|
+
end
|
990
|
+
rescue => e
|
991
|
+
# Graceful error handling - log warning but don't fail the build
|
992
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Failed to compile preserve pattern #{pattern.inspect}: #{e.message}")
|
153
993
|
end
|
994
|
+
nil
|
995
|
+
end
|
996
|
+
|
997
|
+
def valid_regex_pattern?(pattern)
|
998
|
+
return false unless pattern.is_a?(String) && !pattern.empty? && !pattern.strip.empty?
|
999
|
+
return false if pattern.length > 1000
|
1000
|
+
|
1001
|
+
# Basic ReDoS vulnerability checks using a more efficient approach
|
1002
|
+
redos_checks = [
|
1003
|
+
/\([^)]*[+*]\)[+*]/, # nested quantifiers
|
1004
|
+
/\([^)]*\|[^)]*\)[+*]/ # alternation with overlapping patterns
|
1005
|
+
]
|
1006
|
+
|
1007
|
+
return false if redos_checks.any? { |check| pattern =~ check }
|
1008
|
+
return false if pattern.count('(') > 10 # excessive nesting
|
1009
|
+
return false if pattern.scan(/[+*?]\??/).length > 20 # excessive quantifiers
|
1010
|
+
|
1011
|
+
true
|
1012
|
+
end
|
1013
|
+
|
1014
|
+
def compile_regex_with_timeout(pattern, timeout_seconds)
|
1015
|
+
result = nil
|
1016
|
+
thread = Thread.new { result = create_regex_safely(pattern) }
|
1017
|
+
|
1018
|
+
if thread.join(timeout_seconds)
|
1019
|
+
result
|
1020
|
+
else
|
1021
|
+
thread.kill
|
1022
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Regex compilation timeout for pattern: #{pattern.inspect}")
|
1023
|
+
nil
|
1024
|
+
end
|
1025
|
+
end
|
1026
|
+
|
1027
|
+
def create_regex_safely(pattern)
|
1028
|
+
Regexp.new(pattern)
|
1029
|
+
rescue RegexpError => e
|
1030
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Invalid regex pattern #{pattern.inspect}: #{e.message}")
|
1031
|
+
nil
|
1032
|
+
end
|
1033
|
+
end
|
1034
|
+
end
|
1035
|
+
module Compressor
|
1036
|
+
def output_file(dest, content)
|
1037
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
1038
|
+
File.open(dest, 'w') do |f|
|
1039
|
+
f.write(content)
|
1040
|
+
end
|
1041
|
+
end
|
1042
|
+
|
1043
|
+
def output_compressed(path, context)
|
1044
|
+
extension = File.extname(path)
|
1045
|
+
|
1046
|
+
case extension
|
1047
|
+
when '.js'
|
1048
|
+
output_js_or_file(path, context)
|
1049
|
+
when '.json'
|
1050
|
+
output_json(path, context)
|
1051
|
+
when '.css'
|
1052
|
+
output_css_or_file(path, context)
|
1053
|
+
else
|
1054
|
+
output_html(path, context)
|
1055
|
+
end
|
1056
|
+
end
|
1057
|
+
|
1058
|
+
def output_js_or_file(path, context)
|
1059
|
+
if path.end_with?('.min.js')
|
1060
|
+
output_file(path, context)
|
1061
|
+
else
|
1062
|
+
output_js(path, context)
|
1063
|
+
end
|
1064
|
+
end
|
1065
|
+
|
1066
|
+
def output_css_or_file(path, context)
|
1067
|
+
if path.end_with?('.min.css')
|
1068
|
+
output_file(path, context)
|
154
1069
|
else
|
1070
|
+
output_css(path, context)
|
1071
|
+
end
|
1072
|
+
end
|
1073
|
+
|
1074
|
+
def output_html(path, content)
|
1075
|
+
return output_file(path, content) unless production_environment?
|
1076
|
+
|
1077
|
+
# Validate file path for security
|
1078
|
+
unless Jekyll::Minifier::ValidationHelpers.validate_file_path(path)
|
1079
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Unsafe file path detected, skipping compression: #{path}")
|
1080
|
+
return output_file(path, content)
|
1081
|
+
end
|
1082
|
+
|
1083
|
+
# Validate content before compression
|
1084
|
+
unless Jekyll::Minifier::ValidationHelpers.validate_file_content(content, 'html', path)
|
1085
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Unsafe HTML content detected, skipping compression: #{path}")
|
1086
|
+
return output_file(path, content)
|
1087
|
+
end
|
1088
|
+
|
1089
|
+
config = Jekyll::Minifier::CompressionConfig.new(@site.config)
|
1090
|
+
|
1091
|
+
begin
|
1092
|
+
compressor = Jekyll::Minifier::CompressorFactory.create_html_compressor(config)
|
1093
|
+
compressed_content = compressor.compress(content)
|
1094
|
+
output_file(path, compressed_content)
|
1095
|
+
rescue => e
|
1096
|
+
Jekyll.logger.warn("Jekyll Minifier:", "HTML compression failed for #{path}: #{e.message}. Using original content.")
|
155
1097
|
output_file(path, content)
|
156
1098
|
end
|
1099
|
+
end
|
1100
|
+
|
1101
|
+
def output_js(path, content)
|
1102
|
+
return output_file(path, content) unless production_environment?
|
1103
|
+
|
1104
|
+
# Validate file path for security
|
1105
|
+
unless Jekyll::Minifier::ValidationHelpers.validate_file_path(path)
|
1106
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Unsafe file path detected, skipping compression: #{path}")
|
1107
|
+
return output_file(path, content)
|
1108
|
+
end
|
1109
|
+
|
1110
|
+
config = Jekyll::Minifier::CompressionConfig.new(@site.config)
|
1111
|
+
return output_file(path, content) unless config.compress_javascript?
|
1112
|
+
|
1113
|
+
compressed_content = Jekyll::Minifier::CompressorFactory.compress_js(content, config, path)
|
1114
|
+
output_file(path, compressed_content)
|
1115
|
+
end
|
1116
|
+
|
1117
|
+
def output_json(path, content)
|
1118
|
+
return output_file(path, content) unless production_environment?
|
1119
|
+
|
1120
|
+
# Validate file path for security
|
1121
|
+
unless Jekyll::Minifier::ValidationHelpers.validate_file_path(path)
|
1122
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Unsafe file path detected, skipping compression: #{path}")
|
1123
|
+
return output_file(path, content)
|
1124
|
+
end
|
1125
|
+
|
1126
|
+
config = Jekyll::Minifier::CompressionConfig.new(@site.config)
|
1127
|
+
return output_file(path, content) unless config.compress_json?
|
1128
|
+
|
1129
|
+
compressed_content = Jekyll::Minifier::CompressorFactory.compress_json(content, path)
|
1130
|
+
output_file(path, compressed_content)
|
1131
|
+
end
|
157
1132
|
|
1133
|
+
def output_css(path, content)
|
1134
|
+
return output_file(path, content) unless production_environment?
|
1135
|
+
|
1136
|
+
# Validate file path for security
|
1137
|
+
unless Jekyll::Minifier::ValidationHelpers.validate_file_path(path)
|
1138
|
+
Jekyll.logger.warn("Jekyll Minifier:", "Unsafe file path detected, skipping compression: #{path}")
|
1139
|
+
return output_file(path, content)
|
1140
|
+
end
|
1141
|
+
|
1142
|
+
config = Jekyll::Minifier::CompressionConfig.new(@site.config)
|
1143
|
+
return output_file(path, content) unless config.compress_css?
|
1144
|
+
|
1145
|
+
compressed_content = Jekyll::Minifier::CompressorFactory.compress_css(content, config, path)
|
1146
|
+
output_file(path, compressed_content)
|
158
1147
|
end
|
159
1148
|
|
160
1149
|
private
|
161
1150
|
|
1151
|
+
def production_environment?
|
1152
|
+
ENV['JEKYLL_ENV'] == "production"
|
1153
|
+
end
|
1154
|
+
|
1155
|
+
# Delegator methods for backward compatibility with existing tests
|
1156
|
+
# These delegate to the CompressionConfig class methods
|
1157
|
+
def compile_preserve_patterns(patterns)
|
1158
|
+
config = Jekyll::Minifier::CompressionConfig.new(@site.config)
|
1159
|
+
config.send(:compile_preserve_patterns, patterns)
|
1160
|
+
end
|
1161
|
+
|
1162
|
+
def valid_regex_pattern?(pattern)
|
1163
|
+
config = Jekyll::Minifier::CompressionConfig.new(@site.config)
|
1164
|
+
config.send(:valid_regex_pattern?, pattern)
|
1165
|
+
end
|
1166
|
+
|
1167
|
+
def compile_regex_with_timeout(pattern, timeout_seconds)
|
1168
|
+
config = Jekyll::Minifier::CompressionConfig.new(@site.config)
|
1169
|
+
config.send(:compile_regex_with_timeout, pattern, timeout_seconds)
|
1170
|
+
end
|
1171
|
+
|
1172
|
+
|
162
1173
|
def exclude?(dest, dest_path)
|
163
1174
|
file_name = dest_path.slice(dest.length+1..dest_path.length)
|
164
1175
|
exclude.any? { |e| e == file_name || File.fnmatch(e, file_name) }
|
165
1176
|
end
|
166
1177
|
|
167
1178
|
def exclude
|
168
|
-
@exclude ||=
|
1179
|
+
@exclude ||= begin
|
1180
|
+
config = Jekyll::Minifier::CompressionConfig.new(@site.config)
|
1181
|
+
config.exclude_patterns
|
1182
|
+
end
|
169
1183
|
end
|
170
1184
|
end
|
171
1185
|
|
@@ -214,28 +1228,45 @@ module Jekyll
|
|
214
1228
|
if exclude?(dest, dest_path)
|
215
1229
|
copy_file(path, dest_path)
|
216
1230
|
else
|
217
|
-
|
218
|
-
when '.js'
|
219
|
-
if dest_path.end_with?('.min.js')
|
220
|
-
copy_file(path, dest_path)
|
221
|
-
else
|
222
|
-
output_js(dest_path, File.read(path))
|
223
|
-
end
|
224
|
-
when '.json'
|
225
|
-
output_json(dest_path, File.read(path))
|
226
|
-
when '.css'
|
227
|
-
if dest_path.end_with?('.min.css')
|
228
|
-
copy_file(path, dest_path)
|
229
|
-
else
|
230
|
-
output_css(dest_path, File.read(path))
|
231
|
-
end
|
232
|
-
when '.xml'
|
233
|
-
output_html(dest_path, File.read(path))
|
234
|
-
else
|
235
|
-
copy_file(path, dest_path)
|
236
|
-
end
|
1231
|
+
process_static_file(dest_path)
|
237
1232
|
end
|
238
1233
|
true
|
239
1234
|
end
|
1235
|
+
|
1236
|
+
private
|
1237
|
+
|
1238
|
+
def process_static_file(dest_path)
|
1239
|
+
extension = File.extname(dest_path)
|
1240
|
+
content = File.read(path)
|
1241
|
+
|
1242
|
+
case extension
|
1243
|
+
when '.js'
|
1244
|
+
process_js_file(dest_path, content)
|
1245
|
+
when '.json'
|
1246
|
+
output_json(dest_path, content)
|
1247
|
+
when '.css'
|
1248
|
+
process_css_file(dest_path, content)
|
1249
|
+
when '.xml'
|
1250
|
+
output_html(dest_path, content)
|
1251
|
+
else
|
1252
|
+
copy_file(path, dest_path)
|
1253
|
+
end
|
1254
|
+
end
|
1255
|
+
|
1256
|
+
def process_js_file(dest_path, content)
|
1257
|
+
if dest_path.end_with?('.min.js')
|
1258
|
+
copy_file(path, dest_path)
|
1259
|
+
else
|
1260
|
+
output_js(dest_path, content)
|
1261
|
+
end
|
1262
|
+
end
|
1263
|
+
|
1264
|
+
def process_css_file(dest_path, content)
|
1265
|
+
if dest_path.end_with?('.min.css')
|
1266
|
+
copy_file(path, dest_path)
|
1267
|
+
else
|
1268
|
+
output_css(dest_path, content)
|
1269
|
+
end
|
1270
|
+
end
|
240
1271
|
end
|
241
1272
|
end
|