jekyll-imgflow 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -13
  3. data/lib/jekyll-imgflow/batch_manager.rb +10 -7
  4. data/lib/jekyll-imgflow/build_time_processor.rb +70 -61
  5. data/lib/jekyll-imgflow/config.rb +3 -6
  6. data/lib/jekyll-imgflow/filename_generator.rb +8 -10
  7. data/lib/jekyll-imgflow/generated_file_cleaner.rb +81 -0
  8. data/lib/jekyll-imgflow/hooks.rb +13 -6
  9. data/lib/jekyll-imgflow/html_generator.rb +14 -19
  10. data/lib/jekyll-imgflow/imgflow_tag.rb +84 -69
  11. data/lib/jekyll-imgflow/manifest_manager.rb +132 -53
  12. data/lib/jekyll-imgflow/operation_processor.rb +43 -22
  13. data/lib/jekyll-imgflow/picture_tag_adaptor.rb +1 -1
  14. data/lib/jekyll-imgflow/preset_manager.rb +58 -7
  15. data/lib/jekyll-imgflow/presets/gallery.yml +7 -0
  16. data/lib/jekyll-imgflow/presets/hero.yml +7 -0
  17. data/lib/jekyll-imgflow/presets/thumbnail.yml +7 -0
  18. data/lib/jekyll-imgflow/processing_stats.rb +80 -0
  19. data/lib/jekyll-imgflow/provider_registry.rb +18 -3
  20. data/lib/jekyll-imgflow/providers/base_provider.rb +12 -1
  21. data/lib/jekyll-imgflow/providers/flyimg.rb +2 -1
  22. data/lib/jekyll-imgflow/providers/imagemagick.rb +72 -3
  23. data/lib/jekyll-imgflow/providers/imgproxy.rb +2 -1
  24. data/lib/jekyll-imgflow/providers/libvips.rb +152 -100
  25. data/lib/jekyll-imgflow/providers/sharp.rb +2 -1
  26. data/lib/jekyll-imgflow/providers/weserv.rb +3 -2
  27. data/lib/jekyll-imgflow/tags/opacity_tag.rb +8 -1
  28. data/lib/jekyll-imgflow/tags/resize_tag.rb +2 -0
  29. data/lib/jekyll-imgflow/tags/watermark_tag.rb +8 -1
  30. data/lib/jekyll-imgflow/tasks.rb +129 -0
  31. data/lib/jekyll-imgflow/version.rb +1 -1
  32. data/lib/jekyll-imgflow.rb +2 -0
  33. metadata +24 -156
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
4
+
3
5
  module Jekyll
4
6
  class ImgflowTag < Liquid::Tag
5
7
  def initialize(tag_name, markup, tokens)
@@ -36,7 +38,7 @@ module Jekyll
36
38
 
37
39
  def expand_preset_markup(markup, preset_manager)
38
40
  # Parse markup to extract image path, preset name, and user options
39
- parts = markup.split
41
+ parts = Shellwords.split(markup)
40
42
  image_path = parts.first
41
43
 
42
44
  # Extract preset name and user options
@@ -55,8 +57,14 @@ module Jekyll
55
57
  # Get preset markup from PresetManager
56
58
  preset_markup = preset_manager.build_markup_from_preset(preset_name, user_options)
57
59
 
58
- # Combine image path with preset markup
59
- "#{image_path} #{preset_markup}"
60
+ # Combine image path with preset markup. Keep the path quoted because
61
+ # filenames may contain spaces.
62
+ image_markup = if image_path.include?(" ")
63
+ "\"#{image_path.gsub('"', '\\"')}\""
64
+ else
65
+ image_path
66
+ end
67
+ "#{image_markup} #{preset_markup}"
60
68
  end
61
69
 
62
70
  def process_operations(components, parsed, context)
@@ -82,73 +90,77 @@ module Jekyll
82
90
  "unknown"
83
91
  end
84
92
 
85
- # Process operations using clean architecture
86
93
  operations = parsed[:operations]
94
+ results = if operations.empty?
95
+ [input_path]
96
+ else
97
+ process_variants(components, operations.first, original_name, input_path,
98
+ page_path)
99
+ end
100
+
101
+ relative_results = results.uniq.map { |result| relative_result_path(result, site) }
102
+ parsed = parsed.merge(markup_format: "picture") if relative_results.length > 1
103
+ generate_html(relative_results, parsed, context)
104
+ end
87
105
 
88
- result = if operations.empty?
89
- # No operations - use original image
90
- input_path
91
- else
92
- # Generate filename early to check existence
93
- operation = operations.first
94
- params = operation[:params]
95
-
96
- # For default versions, include default format and quality for proper identification
97
- if determine_version_type(params, components[:config]) == :default
98
- params = params.dup
99
- params[:format] ||= components[:config].formats.first # Use default format
100
- params[:quality] ||= components[:config].quality # Use default quality
101
- end
102
-
103
- # Preserve original directory structure under output
104
- subdir = File.dirname(original_name)
105
- subdir = nil if subdir == "."
106
-
107
- filename = components[:filename_generator].generate_filename(input_path, params)
108
- output_path = components[:path_resolver].resolve_source_output_path(filename, subdir)
109
-
110
- # Determine version type
111
- version_type = determine_version_type(params, components[:config])
112
-
113
- # Check if version exists in manifest
114
- Jekyll.logger.debug "🔍 ImgflowTag: Checking existence - original_name: #{original_name}, version_type: #{version_type}, page_path: #{page_path}"
115
- Jekyll.logger.debug "🔍 ImgflowTag: Params being checked: #{params.inspect}"
116
-
117
- if components[:manifest].version_exists?(original_name, params, version_type)
118
- # Version exists - update page usage and return path
119
- Jekyll.logger.debug "✅ ImgflowTag: Version exists! Updating page usage"
120
- components[:manifest].update_page_usage(original_name, params, version_type,
121
- page_path)
122
- output_path
123
- else
124
- # Version doesn't exist - create it
125
- Jekyll.logger.debug "❌ ImgflowTag: Version doesn't exist - creating"
126
- versions = components[:manifest].get_versions(original_name)
127
- Jekyll.logger.debug "🔍 ImgflowTag: Manifest has - default: #{versions['default']&.length || 0}, specialized: #{versions['specialized']&.length || 0}"
128
-
129
- components[:operation_processor].process_operation(
130
- original_name,
131
- operation,
132
- input_path,
133
- page_path
134
- )
135
- end
136
- end
137
-
138
- # Convert absolute paths to relative for HTML generation
139
- relative_result = if result.start_with?(site.dest)
140
- # Processed images - remove site.dest and leading /
141
- result.sub(%r{^#{Regexp.escape(site.dest)}/}, "")
142
- elsif result.start_with?(site.source)
143
- # Original images - convert from source to relative and remove leading /
144
- result.sub(%r{^#{Regexp.escape(site.source)}/}, "")
145
- else
146
- # Already relative or other format - remove leading / if present
147
- result.start_with?("/") ? result[1..] : result
148
- end
106
+ def process_variants(components, operation, original_name, input_path, page_path)
107
+ params = operation[:params].dup
108
+ formats = Array(params.delete(:formats) || params[:format])
109
+ # When no format is explicitly specified, generate all configured formats
110
+ # (avif, webp, png, jpg) so browsers get a <picture> with <source> tags for
111
+ # modern formats and an <img> fallback. This applies to both default-width
112
+ # versions (which find pre-generated files in the manifest cache) and
113
+ # specialized versions (which are generated on-demand).
114
+ formats = components[:config].formats if formats.empty?
115
+
116
+ formats.map do |format|
117
+ variant_params = params.dup
118
+ variant_params[:format] = format
119
+ process_variant(components, operation, variant_params, original_name, input_path, page_path)
120
+ end
121
+ end
122
+
123
+ def process_variant(components, operation, params, original_name, input_path, page_path)
124
+ config = components[:config]
125
+ if determine_version_type(params, config) == :default
126
+ params[:format] ||= config.formats.first
127
+ params[:quality] ||= config.quality
128
+ end
149
129
 
150
- # Generate HTML for the result
151
- generate_html([relative_result], parsed, context)
130
+ digest = components[:filename_generator].file_digest(input_path)
131
+ variant = operation.merge(params: params, file_digest: digest)
132
+ subdir = File.dirname(original_name)
133
+ subdir = nil if subdir == "."
134
+ filename = components[:filename_generator].generate_filename(input_path, params)
135
+ output_path = components[:path_resolver].resolve_source_output_path(filename, subdir)
136
+ version_type = determine_version_type(params, config)
137
+
138
+ if components[:manifest].version_exists?(original_name, params, version_type, digest) &&
139
+ File.file?(output_path)
140
+ components[:stats]&.record_cache_hit
141
+ components[:manifest].update_page_usage(original_name, params, version_type, page_path)
142
+ return output_path
143
+ end
144
+
145
+ unless components[:operation_processor]
146
+ Jekyll.logger.warn "ImgFlow:", "No image provider available — " \
147
+ "rendering original without optimization."
148
+ return input_path
149
+ end
150
+
151
+ components[:operation_processor].process_operation(
152
+ original_name, variant.merge(force_processing: true), input_path, page_path
153
+ )
154
+ end
155
+
156
+ def relative_result_path(result, site)
157
+ if result.start_with?(site.dest)
158
+ result.sub(%r{^#{Regexp.escape(site.dest)}/}, "")
159
+ elsif result.start_with?(site.source)
160
+ result.sub(%r{^#{Regexp.escape(site.source)}/}, "")
161
+ else
162
+ result.delete_prefix("/")
163
+ end
152
164
  end
153
165
 
154
166
  def generate_html(results, parsed, context)
@@ -232,8 +244,10 @@ module Jekyll
232
244
  filename_generator = JekyllImgFlow::FilenameGenerator.new
233
245
  registry = JekyllImgFlow::ProviderRegistry.new(config)
234
246
  provider = registry.current_provider
235
- operation_processor = JekyllImgFlow::OperationProcessor.new(provider, path_resolver,
236
- manifest, config)
247
+ if provider
248
+ operation_processor = JekyllImgFlow::OperationProcessor.new(provider, path_resolver,
249
+ manifest, config)
250
+ end
237
251
  preset_manager = JekyllImgFlow::PresetManager.new(site, config)
238
252
 
239
253
  site.imgflow_components = {
@@ -244,6 +258,7 @@ module Jekyll
244
258
  registry: registry,
245
259
  provider: provider,
246
260
  operation_processor: operation_processor,
261
+ stats: operation_processor&.stats,
247
262
  preset_manager: preset_manager
248
263
  }
249
264
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
- require "digest"
6
5
 
7
6
  module JekyllImgFlow
8
7
  # Manages the manifest of generated images
@@ -13,9 +12,11 @@ module JekyllImgFlow
13
12
 
14
13
  def initialize(site)
15
14
  @site = site
16
- # Place manifest in _site - rebuilt on each Jekyll build
17
- @manifest_path = File.join(@site.dest, "assets", "images", "imgflow-manifest.json")
18
- @manifest = load_manifest
15
+ @config = JekyllImgFlow::Config.new(site)
16
+ @file_cleaner = GeneratedFileCleaner.new(site, @config)
17
+ @manifest_path = File.join(@site.source, @config.cache_dir, "imgflow-manifest.json")
18
+ @legacy_manifest_paths = legacy_manifest_paths
19
+ @manifest = load_manifest(manifest_load_path)
19
20
  @current_provider = current_provider
20
21
 
21
22
  # Check if provider changed and invalidate cache if needed
@@ -23,20 +24,22 @@ module JekyllImgFlow
23
24
  end
24
25
 
25
26
  # Load existing manifest or create new one
26
- def load_manifest
27
- if File.exist?(@manifest_path)
27
+ def load_manifest(path = @manifest_path)
28
+ if path && File.exist?(path)
28
29
  begin
29
- data = JSON.parse(File.read(@manifest_path))
30
+ data = JSON.parse(File.read(path))
30
31
 
31
32
  # Handle new format with provider at top level
32
33
  if data.is_a?(Hash) && data.key?("images")
33
34
  @cached_provider = data["provider"]
34
- data["images"] || {}
35
+ images = data["images"] || {}
35
36
  else
36
37
  # Old format - just image data
37
38
  @cached_provider = nil
38
- data
39
+ images = data
39
40
  end
41
+
42
+ migrate_legacy_manifest(images)
40
43
  rescue JSON::ParserError => e
41
44
  Jekyll.logger.warn "ImgFlow: Corrupt manifest file, starting fresh: #{e.message}"
42
45
  @cached_provider = nil
@@ -48,6 +51,23 @@ module JekyllImgFlow
48
51
  end
49
52
  end
50
53
 
54
+ # Detect and migrate legacy manifest entries (pre-0.1.11 format).
55
+ # Old entries have operations like {"width": 400} without format/quality
56
+ # and no file_digest. Clearing them lets the build re-register existing
57
+ # output files without reprocessing (output_up_to_date? check handles this).
58
+ def migrate_legacy_manifest(images)
59
+ legacy = images.any? do |_name, data|
60
+ versions = data.dig("versions", "default") || []
61
+ versions.any? { |v| v["file_digest"].nil? || !v["operations"].key?("format") }
62
+ end
63
+ if legacy
64
+ Jekyll.logger.info "ImgFlow: Detected legacy manifest format, " \
65
+ "clearing entries for re-registration."
66
+ images.clear
67
+ end
68
+ images
69
+ end
70
+
51
71
  # Save manifest to disk
52
72
  def save
53
73
  # Store current provider at top level of manifest
@@ -55,44 +75,98 @@ module JekyllImgFlow
55
75
  "provider" => @current_provider,
56
76
  "images" => @manifest
57
77
  }
78
+ content = JSON.pretty_generate(manifest_data)
79
+ if File.exist?(@manifest_path) && File.binread(@manifest_path) == content
80
+ cleanup_legacy_manifests
81
+ return
82
+ end
58
83
 
59
- FileUtils.mkdir_p(File.dirname(@manifest_path))
60
- File.write(@manifest_path, JSON.pretty_generate(manifest_data))
84
+ directory = File.dirname(@manifest_path)
85
+ temporary_path = "#{@manifest_path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
86
+ FileUtils.mkdir_p(directory)
87
+ File.open(temporary_path, "wb") do |file|
88
+ file.write(content)
89
+ file.flush
90
+ file.fsync
91
+ end
92
+ File.rename(temporary_path, @manifest_path)
93
+ cleanup_legacy_manifests
94
+ ensure
95
+ FileUtils.rm_f(temporary_path) if temporary_path
61
96
  end
62
97
 
63
98
  # Get current provider from site config
64
- def current_provider
65
- config = JekyllImgFlow::Config.new(@site)
66
- # Get first available provider from backend_priority
67
- config.backend_priority&.first || "unknown"
68
- end
99
+ def current_provider = @config.backend_priority&.first || "unknown"
69
100
 
70
101
  # Clean up manifest entries for deleted original images
71
- # @param current_originals [Array<String>] Array of current original image basenames
102
+ # @param current_originals [Array<String>] Paths relative to the originals directory
72
103
  def cleanup_deleted_originals(current_originals)
73
104
  current_set = current_originals.to_set
74
105
 
75
106
  @manifest.each_key do |original_name|
76
- # Extract basename without extension
77
- basename = File.basename(original_name, ".*")
107
+ next if current_set.include?(original_name)
108
+
109
+ data = @manifest.delete(original_name)
110
+ VERSION_TYPES.each do |type|
111
+ (data.dig("versions", type) || []).each do |version|
112
+ @file_cleaner.delete(version["output"])
113
+ end
114
+ end
115
+ Jekyll.logger.debug "🗑️ Removed manifest entry for deleted original: #{original_name}"
116
+ end
117
+ end
118
+
119
+ def cleanup_obsolete_defaults(expected_operations)
120
+ @manifest.each_value do |data|
121
+ versions = data.dig("versions", "default") || []
122
+ versions.reject! do |version|
123
+ obsolete = expected_operations.none? do |operations|
124
+ same_operations?(version["operations"], operations)
125
+ end
126
+ @file_cleaner.delete(version["output"]) if obsolete
127
+ obsolete
128
+ end
129
+ end
130
+ end
131
+
132
+ def reset_page_usage
133
+ @manifest.each_value do |data|
134
+ (data.dig("versions", "specialized") || []).each do |version|
135
+ version["used_on"] = []
136
+ end
137
+ end
138
+ end
78
139
 
79
- # If original no longer exists, remove from manifest
80
- unless current_set.include?(basename)
81
- @manifest.delete(original_name)
82
- Jekyll.logger.debug "🗑️ Removed manifest entry for deleted original: #{original_name}"
140
+ # Normalize operations for consistent comparison after JSON persistence.
141
+ def normalize_operations(operations)
142
+ case operations
143
+ when Hash
144
+ operations.to_h do |key, value|
145
+ [key.to_s, normalize_operations(value)]
83
146
  end
147
+ when Array
148
+ operations.map { |value| normalize_operations(value) }
149
+ when Symbol
150
+ operations.to_s
151
+ else
152
+ operations
84
153
  end
85
154
  end
86
155
 
156
+ def same_operations?(left, right) = normalize_operations(left) == normalize_operations(right)
157
+
87
158
  # Check if a specific image version exists and is up-to-date
88
- def version_exists?(original_name, operations, type = :specialized)
159
+ def version_exists?(original_name, operations, type = :specialized, file_digest = nil)
89
160
  return false unless @manifest[original_name]
90
161
 
91
162
  # Provider change is already handled in initialize, so just check operations
92
163
  versions = @manifest.dig(original_name, "versions", type.to_s) || []
93
164
  Jekyll.logger.debug "🔍 ManifestManager: Looking for operations: #{operations.inspect} in #{versions.length} #{type} versions"
94
165
 
95
- result = versions.any? { |v| v["operations"] == operations }
166
+ result = versions.any? do |version|
167
+ same_operations?(version["operations"], operations) &&
168
+ (!file_digest || version["file_digest"] == file_digest)
169
+ end
96
170
  Jekyll.logger.debug "🔍 ManifestManager: Final result: #{result}"
97
171
  result
98
172
  end
@@ -112,25 +186,9 @@ module JekyllImgFlow
112
186
  "Provider changed from '#{@cached_provider}' to '#{@current_provider}'"
113
187
  Jekyll.logger.warn "ImgFlow:", "Invalidating cache and cleaning optimized directory..."
114
188
 
115
- # Get optimized directory path
116
- config = JekyllImgFlow::Config.new(@site)
117
- optimized_dir = File.join(@site.source, config.output)
118
-
119
- # Safety guard: prevent deletion of _site itself
120
- raise "Refusing to delete optimized directory: path resolves to site dest or is empty" if optimized_dir == @site.dest || optimized_dir.empty?
121
-
122
- # Delete all files in optimized directory
123
- if Dir.exist?(optimized_dir)
124
- deleted_count = 0
125
- Dir.glob(File.join(optimized_dir, "**", "*")).each do |file|
126
- if File.file?(file)
127
- File.delete(file)
128
- deleted_count += 1
129
- end
130
- end
131
- Jekyll.logger.warn "ImgFlow:",
132
- "Deleted #{deleted_count} cached images from optimized directory"
133
- end
189
+ deleted_count = @file_cleaner.clear
190
+ Jekyll.logger.warn "ImgFlow:",
191
+ "Deleted #{deleted_count} cached images from optimized directory"
134
192
 
135
193
  # Clear manifest cache
136
194
  @manifest.clear
@@ -146,7 +204,7 @@ module JekyllImgFlow
146
204
  return unless @manifest[original_name]
147
205
 
148
206
  versions = @manifest.dig(original_name, "versions", type.to_s) || []
149
- version = versions.find { |v| v["operations"] == operations }
207
+ version = versions.find { |v| same_operations?(v["operations"], operations) }
150
208
  version&.dig("output")
151
209
  end
152
210
 
@@ -162,7 +220,7 @@ module JekyllImgFlow
162
220
 
163
221
  # Get the existing version to preserve provider
164
222
  versions = @manifest.dig(original_name, "versions", type.to_s) || []
165
- version = versions.find { |v| v["operations"] == operations }
223
+ version = versions.find { |v| same_operations?(v["operations"], operations) }
166
224
  provider = version&.dig("provider")
167
225
  Jekyll.logger.debug "🔍 update_page_usage: provider=#{provider}, current used_on=#{version&.dig('used_on')&.inspect}"
168
226
 
@@ -183,9 +241,10 @@ module JekyllImgFlow
183
241
  @manifest[original_name]["file_digest"] = file_digest if file_digest
184
242
 
185
243
  versions = @manifest[original_name]["versions"][type.to_s] ||= []
244
+ normalized_operations = normalize_operations(operations)
186
245
 
187
246
  # Find or create version entry
188
- version = versions.find { |v| v["operations"] == operations }
247
+ version = versions.find { |v| same_operations?(v["operations"], normalized_operations) }
189
248
 
190
249
  # Normalize page_path to array
191
250
  page_paths = if page_path.is_a?(Array)
@@ -200,16 +259,19 @@ module JekyllImgFlow
200
259
  page_paths.each do |path|
201
260
  version["used_on"] << path if path && !version["used_on"].include?(path)
202
261
  end
262
+ version["output"] = output_path
263
+ version["file_digest"] = file_digest if file_digest
203
264
  # Update provider if provided
204
265
  version["provider"] = provider if provider
205
266
  else
206
267
  # Create new version entry
207
268
  versions << {
208
269
  "output" => output_path,
209
- "operations" => operations,
270
+ "operations" => normalized_operations,
210
271
  "type" => type.to_s,
211
272
  "used_on" => page_paths,
212
273
  "created_at" => Time.now.to_i,
274
+ "file_digest" => file_digest,
213
275
  "provider" => provider
214
276
  }
215
277
  end
@@ -220,7 +282,7 @@ module JekyllImgFlow
220
282
  return false unless @manifest[original_name]
221
283
 
222
284
  versions = @manifest.dig(original_name, "versions", "default") || []
223
- versions.any? { |v| v["operations"] == operations }
285
+ versions.any? { |v| same_operations?(v["operations"], operations) }
224
286
  end
225
287
 
226
288
  # Find orphaned specialized images (not used on any page)
@@ -261,16 +323,14 @@ module JekyllImgFlow
261
323
  cleaned = []
262
324
 
263
325
  orphans.each do |orphan|
264
- output_file = File.join(@site.source, orphan["output"])
265
- if File.exist?(output_file)
266
- File.delete(output_file)
326
+ if @file_cleaner.delete(orphan["output"])
267
327
  cleaned << orphan["output"]
268
328
  Jekyll.logger.info "🗑️ Cleaned orphaned image: #{orphan['output']}"
269
329
  end
270
330
 
271
331
  # Remove from manifest
272
332
  versions = @manifest.dig(orphan["original"], "versions", "specialized") || []
273
- versions.reject! { |v| v["operations"] == orphan["operations"] }
333
+ versions.reject! { |v| same_operations?(v["operations"], orphan["operations"]) }
274
334
  end
275
335
 
276
336
  cleaned
@@ -288,5 +348,24 @@ module JekyllImgFlow
288
348
  versions = @manifest[original_name]["versions"]
289
349
  (versions["default"]&.any? || false) || (versions["specialized"]&.any? || false)
290
350
  end
351
+
352
+ private
353
+
354
+ def legacy_manifest_paths
355
+ [
356
+ File.join(@site.source, "assets", "images", "imgflow-manifest.json"),
357
+ File.join(@site.dest, "assets", "images", "imgflow-manifest.json")
358
+ ].reject { |path| path == @manifest_path }
359
+ end
360
+
361
+ def manifest_load_path
362
+ return @manifest_path if File.exist?(@manifest_path)
363
+
364
+ @legacy_manifest_paths.find { |path| File.exist?(path) }
365
+ end
366
+
367
+ def cleanup_legacy_manifests
368
+ @legacy_manifest_paths.each { |path| FileUtils.rm_f(path) }
369
+ end
291
370
  end
292
371
  end
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "filename_generator"
4
+ require "benchmark"
4
5
 
5
6
  module JekyllImgFlow
6
7
  # OperationProcessor - processes image operations using providers
7
8
  # Handles both single operations and batch operations
8
9
  class OperationProcessor
10
+ attr_accessor :stats
11
+
9
12
  def initialize(provider, path_resolver, manifest = nil, config = nil)
10
13
  @provider = provider
11
14
  @path_resolver = path_resolver
12
15
  @filename_generator = FilenameGenerator.new
13
16
  @manifest = manifest
14
17
  @config = config
18
+ @stats = ProcessingStats.new
15
19
  end
16
20
 
17
21
  # Process a single operation on an image
@@ -41,6 +45,7 @@ module JekyllImgFlow
41
45
  def process_operation(original_name, operation, input_path, page_path = nil)
42
46
  type = operation[:type]
43
47
  params = operation[:params]
48
+ file_digest = operation[:file_digest] || @filename_generator.file_digest(input_path)
44
49
 
45
50
  # Determine version type
46
51
  version_type = determine_version_type(params)
@@ -58,17 +63,37 @@ module JekyllImgFlow
58
63
  # Ensure output directory exists before processing
59
64
  FileUtils.mkdir_p(File.dirname(actual_output_path))
60
65
 
61
- # Animated GIFs must not be resized or converted the operation
62
- # would destroy the animation. Copy the original file as-is so the
63
- # manifest can track it and HTML references stay valid.
64
- if AnimatedGifDetector.animated?(input_path)
66
+ # Skip processing if the output file already exists and is up-to-date.
67
+ # This handles legacy manifests, corrupted manifests, and any case where
68
+ # optimized files exist on disk but the manifest is out of sync — the
69
+ # version is registered in the manifest without re-running the provider.
70
+ if !operation[:force_processing] && output_up_to_date?(input_path, actual_output_path)
71
+ Jekyll.logger.debug "⏭️ ImgFlow: Output exists and up-to-date, " \
72
+ "skipping provider call for #{original_name}"
73
+ @stats.record_cache_hit
74
+ elsif AnimatedGifDetector.animated?(input_path)
75
+ # Animated GIFs must not be resized or converted — the operation
76
+ # would destroy the animation. Copy the original file as-is so the
77
+ # manifest can track it and HTML references stay valid.
65
78
  Jekyll.logger.warn "🖼️ ImgFlow: Skipping resize for animated GIF " \
66
79
  "'#{original_name}' — copying original as-is to " \
67
80
  "preserve animation."
68
81
  FileUtils.cp(input_path, actual_output_path)
82
+ @stats.record_cache_miss
69
83
  else
70
84
  # Process the operation (create the image)
71
- process_single_operation(type, input_path, actual_output_path, params)
85
+ elapsed = Benchmark.measure do
86
+ process_single_operation(type, input_path, actual_output_path, params)
87
+ end
88
+ @stats.record_operation_time(type, elapsed.real)
89
+ @stats.record_cache_miss
90
+
91
+ # Record compression ratio if we have the original size
92
+ if File.file?(input_path) && File.file?(actual_output_path)
93
+ format = params[:format] || File.extname(input_path).delete(".")
94
+ @stats.record_compression_ratio(format.to_s, File.size(input_path),
95
+ File.size(actual_output_path))
96
+ end
72
97
  end
73
98
 
74
99
  # Register in manifest
@@ -76,14 +101,14 @@ module JekyllImgFlow
76
101
  # Store relative path (with leading /) for manifest storage
77
102
  relative_path = "/#{@path_resolver.resolve_relative_output_path(filename, subdir)}"
78
103
 
79
- provider_name = @provider.class.provider_name
104
+ provider_name = @provider&.class&.provider_name || "unknown"
80
105
  @manifest.register_version(
81
106
  original_name,
82
107
  relative_path,
83
108
  params,
84
109
  version_type,
85
110
  page_path,
86
- nil, # file_digest
111
+ file_digest,
87
112
  provider_name
88
113
  )
89
114
  end
@@ -170,26 +195,22 @@ module JekyllImgFlow
170
195
  # @param output_path [String] Path to output image
171
196
  # @param operations [Hash] Operations to apply
172
197
  # @return [Boolean] True if processing needed
173
- def needs_processing?(input_path, output_path, operations)
198
+ def needs_processing?(input_path, output_path, _operations = nil)
174
199
  # If output doesn't exist, needs processing
175
200
  return true unless File.exist?(output_path)
176
201
 
177
202
  # If input is newer than output, needs processing
178
- return true if File.mtime(input_path) > File.mtime(output_path)
179
-
180
- # Check if operations changed by comparing cache key
181
- # stored alongside the output file
182
- cache_key = @filename_generator.generate_cache_key(operations)
183
- cache_file = "#{output_path}.cache_key"
184
-
185
- return true unless File.exist?(cache_file)
186
-
187
- stored_key = File.read(cache_file).strip
188
- return true if stored_key != cache_key
189
-
190
- # No cache key file - needs processing to create it
203
+ File.mtime(input_path) > File.mtime(output_path)
204
+ end
191
205
 
192
- false
206
+ # Check if the output file exists and the input is not newer than the
207
+ # output. Used to skip provider calls when the optimized file is already
208
+ # on disk (e.g. legacy manifest, manifest out of sync).
209
+ # @param input_path [String] Path to input image
210
+ # @param output_path [String] Path to output image
211
+ # @return [Boolean] True if output exists and is up-to-date
212
+ def output_up_to_date?(input_path, output_path)
213
+ File.file?(output_path) && File.mtime(input_path) <= File.mtime(output_path)
193
214
  end
194
215
 
195
216
  # Build operation structure from params hash
@@ -43,7 +43,7 @@ module JekyllImgFlow
43
43
  # @return [Hash] Translation result with markup and attributes
44
44
  def translate_to_imgflow(picture_markup)
45
45
  # Extract content between {% picture ... %}
46
- match = picture_markup.match(/\{%\s*picture\s+(.+?)\s*%}/)
46
+ match = picture_markup.match(/\{%\s*picture\s+(.+?)%\}/)
47
47
  return { markup: "", attributes: {} } unless match
48
48
 
49
49
  content = match[1].strip