jekyll-minifier 0.2.0 → 0.2.2

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