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.
@@ -4,168 +4,1182 @@ require 'cssminify2'
4
4
  require 'json/minify'
5
5
 
6
6
  module Jekyll
7
- module Compressor
8
- def output_file(dest, content)
9
- FileUtils.mkdir_p(File.dirname(dest))
10
- File.open(dest, 'w') do |f|
11
- f.write(content)
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
- def output_compressed(path, context)
16
- case File.extname(path)
17
- when '.js'
18
- if path.end_with?('.min.js')
19
- output_file(path, context)
20
- else
21
- output_js(path, context)
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
- when '.json'
24
- output_json(path, context)
25
- when '.css'
26
- if path.end_with?('.min.css')
27
- output_file(path, context)
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
- output_css(path, context)
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
- output_html(path, context)
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
- def output_html(path, content)
37
- if ( ENV['JEKYLL_ENV'] == "production" )
38
- html_args = { remove_comments: true, compress_css: true, compress_javascript: true, preserve_patterns: [] }
39
- js_args = {}
40
-
41
- opts = @site.config['jekyll-minifier']
42
- if ( !opts.nil? )
43
- # Javascript Arguments (support both terser_args and uglifier_args for backward compatibility)
44
- terser_options = opts['terser_args'] || opts['uglifier_args']
45
- if terser_options && terser_options.respond_to?(:map)
46
- # Filter out Uglifier-specific options that don't have Terser equivalents
47
- filtered_options = terser_options.reject { |k, v| k.to_s == 'harmony' }
48
- js_args[:terser_args] = Hash[filtered_options.map{|(k,v)| [k.to_sym,v]}] unless filtered_options.empty?
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
- # HTML Arguments
52
- html_args[:remove_spaces_inside_tags] = opts['remove_spaces_inside_tags'] if opts.has_key?('remove_spaces_inside_tags')
53
- html_args[:remove_multi_spaces] = opts['remove_multi_spaces'] if opts.has_key?('remove_multi_spaces')
54
- html_args[:remove_comments] = opts['remove_comments'] if opts.has_key?('remove_comments')
55
- html_args[:remove_intertag_spaces] = opts['remove_intertag_spaces'] if opts.has_key?('remove_intertag_spaces')
56
- html_args[:remove_quotes] = opts['remove_quotes'] if opts.has_key?('remove_quotes')
57
- html_args[:compress_css] = opts['compress_css'] if opts.has_key?('compress_css')
58
- html_args[:compress_javascript] = opts['compress_javascript'] if opts.has_key?('compress_javascript')
59
- html_args[:simple_doctype] = opts['simple_doctype'] if opts.has_key?('simple_doctype')
60
- html_args[:remove_script_attributes] = opts['remove_script_attributes'] if opts.has_key?('remove_script_attributes')
61
- html_args[:remove_style_attributes] = opts['remove_style_attributes'] if opts.has_key?('remove_style_attributes')
62
- html_args[:remove_link_attributes] = opts['remove_link_attributes'] if opts.has_key?('remove_link_attributes')
63
- html_args[:remove_form_attributes] = opts['remove_form_attributes'] if opts.has_key?('remove_form_attributes')
64
- html_args[:remove_input_attributes] = opts['remove_input_attributes'] if opts.has_key?('remove_input_attributes')
65
- html_args[:remove_javascript_protocol] = opts['remove_javascript_protocol'] if opts.has_key?('remove_javascript_protocol')
66
- html_args[:remove_http_protocol] = opts['remove_http_protocol'] if opts.has_key?('remove_http_protocol')
67
- html_args[:remove_https_protocol] = opts['remove_https_protocol'] if opts.has_key?('remove_https_protocol')
68
- html_args[:preserve_line_breaks] = opts['preserve_line_breaks'] if opts.has_key?('preserve_line_breaks')
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
- compressor = HtmlCompressor::Compressor.new(html_args)
84
- output_file(path, compressor.compress(content))
85
- else
86
- output_file(path, content)
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
- def output_js(path, content)
91
- if ( ENV['JEKYLL_ENV'] == "production" )
92
- js_args = {}
93
- opts = @site.config['jekyll-minifier']
94
- compress = true
95
- if ( !opts.nil? )
96
- compress = opts['compress_javascript'] if opts.has_key?('compress_javascript')
97
- # Support both terser_args and uglifier_args for backward compatibility
98
- terser_options = opts['terser_args'] || opts['uglifier_args']
99
- if terser_options && terser_options.respond_to?(:map)
100
- # Filter out Uglifier-specific options that don't have Terser equivalents
101
- filtered_options = terser_options.reject { |k, v| k.to_s == 'harmony' }
102
- js_args[:terser_args] = Hash[filtered_options.map{|(k,v)| [k.to_sym,v]}] unless filtered_options.empty?
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
- if ( compress )
107
- if ( !js_args[:terser_args].nil? )
108
- compressor = ::Terser.new(js_args[:terser_args])
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
- compressor = ::Terser.new()
468
+ ::Terser.new()
111
469
  end
470
+ end
471
+ end
112
472
 
113
- output_file(path, compressor.compile(content))
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
- output_file(path, content)
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
- def output_json(path, content)
123
- if ( ENV['JEKYLL_ENV'] == "production" )
124
- opts = @site.config['jekyll-minifier']
125
- compress = true
126
- if ( !opts.nil? )
127
- compress = opts['compress_json'] if opts.has_key?('compress_json')
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
- if ( compress )
131
- output_file(path, JSON.minify(content))
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
- output_file(path, content)
866
+ # Pass through other values for backward compatibility
867
+ value
134
868
  end
135
- else
136
- output_file(path, content)
137
869
  end
138
- end
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
- def output_css(path, content)
141
- if ( ENV['JEKYLL_ENV'] == "production" )
142
- opts = @site.config['jekyll-minifier']
143
- compress = true
144
- if ( !opts.nil? )
145
- compress = opts['compress_css'] if opts.has_key?('compress_css')
146
- end
147
- if ( compress )
148
- compressor = CSSminify2.new()
149
- # Pass nil to disable line breaks completely for performance (PR #61)
150
- output_file(path, compressor.compress(content, nil))
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
- output_file(path, content)
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 ||= Array(@site.config.dig('jekyll-minifier', '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
- case File.extname(dest_path)
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