jekyll-imgflow 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa56ff31b7cfe312dfe202726e049c99db5511dc35b2e82d721abde41c76488e
4
- data.tar.gz: 88ff3c89e44a1dfe0fe022a016bbeba79185c137bca201c4e2c66c9d06cc5a92
3
+ metadata.gz: 8400a4bc3d213b557802452b3af0816098443aa5542f27e81bf690dd2ee3f7a8
4
+ data.tar.gz: 1f8562de9d69d7451603623ab2e63741f4889a4d04d35d9ae01a561878e8d539
5
5
  SHA512:
6
- metadata.gz: c1f92e12c629652780356a972969ee435992b29dd62dea03ea263582084e8b512b35eb25e8bb1845c111834a5506cd1c3ffd5c9b4c5392ff6e17e20d3ea5c4ae
7
- data.tar.gz: 92b73297116fd7dd7d38a5aeb349c81aeab97709f3a85886469dc0443e3fa9e5e35c2c971b1c01bd6682db2c59776c54112682a31e3610c64a6254b3d4067441
6
+ metadata.gz: cbd0f3c0d194bd7acfdc199c9c1eb78dcb69d09c4cc9f213ceb48d1c1c5cb4402518a87a14f44f234df1487ae6eba4495f92aae2b31bb602b199bcaaab4438ef
7
+ data.tar.gz: 7c07124101bf734ebedce868d1329900c79acde6d90463d5fbd680fe49f02517e743ba4d7d37ebf3e26454e3e9dd4df80ada8265874bb96b1659767a33a8306f
data/README.md CHANGED
@@ -6,6 +6,8 @@ A [Jekyll](https://jekyllrb.com/) plugin for automatic image optimization with m
6
6
 
7
7
  [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/gundestrup/jekyll-imgflow)
8
8
  [![Status](https://img.shields.io/badge/status-active-success)](https://github.com/gundestrup/jekyll-imgflow)
9
+ [![Semgrep](https://img.shields.io/badge/Semgrep-scanned-1B2B34?logo=semgrep&logoColor=white)](https://semgrep.dev/)
10
+ [![CodeFactor](https://www.codefactor.io/repository/github/gundestrup/jekyll-imgflow/badge)](https://www.codefactor.io/repository/github/gundestrup/jekyll-imgflow)
9
11
  [![codecov](https://codecov.io/gh/gundestrup/jekyll-imgflow/branch/main/graph/badge.svg)](https://codecov.io/gh/gundestrup/jekyll-imgflow)
10
12
  [![VS Code Extension](https://img.shields.io/badge/VS%20Code%20Companion-jekyll--imgflow-blue?logo=visual-studio-code)](https://github.com/gundestrup/jekyll-imgflow-vscode)
11
13
  [![License](https://img.shields.io/github/license/gundestrup/jekyll-imgflow)](LICENSE)
@@ -96,6 +98,28 @@ ImgFlow has two processing flows: **Build-Time** (pre-generation) and **Runtime*
96
98
 
97
99
  **See:** [providers.md](docs/providers.md) for detailed provider comparison and setup
98
100
 
101
+ ### SVG File Handling
102
+
103
+ SVG files are supported as input by all providers. Since SVGs are vector
104
+ graphics, they are rasterized before format conversion (WebP, AVIF, JPG, PNG).
105
+
106
+ - **Weserv** (via librsvg): When an SVG has no explicit pixel dimensions
107
+ (e.g. `width="100%"` with a large `viewBox`), ImgFlow automatically
108
+ caps the rasterization at 2000px wide to prevent memory exhaustion.
109
+ If a resize or crop operation is present, the target dimensions are used
110
+ directly.
111
+ - **Sharp, LibVips, Imgproxy, Flyimg**: Handle SVGs via their respective
112
+ rasterization backends. SVGs with very large viewBox dimensions may be
113
+ slow or fail depending on the provider's memory limits.
114
+ - **ImageMagick**: SVG processing is slower than other providers without
115
+ the `librsvg` delegate installed. Set `FULL_SVG_TEST=true` to include
116
+ SVGs in ImageMagick test runs.
117
+
118
+ SVGs with explicit `width` and `height` attributes (in pixels, not
119
+ percentages) are always processed at the specified size first, then resized
120
+ to the target dimensions — this is the recommended way to author SVGs for
121
+ image processing.
122
+
99
123
  ## 🚀 Quick Commands
100
124
 
101
125
  ```bash
@@ -202,8 +226,15 @@ bundle install
202
226
  # Download test images
203
227
  rake download_test_images
204
228
 
205
- # Run tests
206
- rake quick
229
+ # Run the CI-equivalent checks locally
230
+ rake ci
231
+
232
+ # Run the local Semgrep Pro rules directly
233
+ semgrep scan --pro --config .semgrep.yml --error lib/
234
+
235
+ # Sync the repository scan and findings with the Semgrep dashboard
236
+ # (requires semgrep login or SEMGREP_APP_TOKEN)
237
+ semgrep ci
207
238
  ```
208
239
 
209
240
  ### Key Files
@@ -78,8 +78,9 @@ module JekyllImgFlow
78
78
  results = @batch_manager.process_all
79
79
  register_skipped_tasks(@batch_manager.completed)
80
80
 
81
- # Save manifest so it's available when ImgflowTag runs
82
- @manifest.save
81
+ # Persist newly processed defaults; unchanged builds keep page-usage resets in memory
82
+ # until post_write saves the final stable manifest.
83
+ @manifest.save unless results[:completed].zero? && results[:failed].zero?
83
84
 
84
85
  Jekyll.logger.info "✅ BuildTimeProcessor complete: #{results[:completed]} completed, #{results[:failed]} failed"
85
86
 
@@ -28,33 +28,40 @@ module JekyllImgFlow
28
28
  :optimize_qualities, :fallback_format, :image_modal
29
29
 
30
30
  def initialize(site)
31
- shared = site.config["shared_images_configs"] || {}
32
- overrides = site.config["imgflow"] || {}
33
- cfg = shared.merge(overrides)
34
-
35
- @site = site
36
- @originals = cfg["originals"] || DEFAULT_ORIGINALS
37
- @output = cfg["output"] || DEFAULT_OUTPUT
38
- @input_formats = cfg["input_formats"] || DEFAULT_INPUT_FORMATS
39
- @sizes = cfg["sizes"] || DEFAULT_SIZES
40
- @formats = cfg["formats"] || DEFAULT_FORMATS
41
- @quality = cfg["quality"] || DEFAULT_QUALITY
31
+ @site = site
32
+ cfg = merged_config
33
+ assign_core_config(cfg)
34
+ assign_provider_config(cfg)
35
+ @optimize_qualities = cfg["optimize_qualities"] || default_optimize_qualities
36
+ validate_backend_priority!
37
+ end
38
+
39
+ def assign_core_config(cfg)
40
+ @originals = cfg["originals"] || DEFAULT_ORIGINALS
41
+ @output = cfg["output"] || DEFAULT_OUTPUT
42
+ @input_formats = cfg["input_formats"] || DEFAULT_INPUT_FORMATS
43
+ @sizes = cfg["sizes"] || DEFAULT_SIZES
44
+ @formats = cfg["formats"] || DEFAULT_FORMATS
45
+ @quality = cfg["quality"] || DEFAULT_QUALITY
42
46
  @backend_priority = cfg["backend_priority"] || DEFAULT_BACKEND_PRIORITY
43
- @fallback_format = cfg["fallback_format"] || DEFAULT_FALLBACK_FORMAT
44
- @image_modal = cfg.fetch("image_modal", DEFAULT_IMAGE_MODAL)
47
+ @fallback_format = cfg["fallback_format"] || DEFAULT_FALLBACK_FORMAT
48
+ @image_modal = cfg.fetch("image_modal", DEFAULT_IMAGE_MODAL)
49
+ end
45
50
 
51
+ def assign_provider_config(cfg)
46
52
  @imgproxy_url = cfg["imgproxy_url"]
47
- @weserv_url = cfg["weserv_url"]
48
- @flyimg_url = cfg["flyimg_url"]
49
- @optimize_qualities = cfg["optimize_qualities"] || {
53
+ @weserv_url = cfg["weserv_url"]
54
+ @flyimg_url = cfg["flyimg_url"]
55
+ end
56
+
57
+ def default_optimize_qualities
58
+ {
50
59
  "low" => 30,
51
60
  "medium" => 50, # Uses default quality
52
61
  "high" => 85, # Uses default quality
53
62
  "maximum" => 95,
54
63
  "default" => 75
55
64
  }
56
-
57
- validate_backend_priority!
58
65
  end
59
66
 
60
67
  def cache_dir
@@ -83,6 +83,7 @@ module JekyllImgFlow
83
83
  def generate_jpt_hash(original_path, operations)
84
84
  # Get filename digest (MD5 of filename) - exactly like JPT
85
85
  filename = File.basename(original_path)
86
+ # nosemgrep: ruby.lang.security.weak-hashes-md5.weak-hashes-md5 -- Jekyll Picture Tag filename compatibility, not security.
86
87
  file_digest = Digest::MD5.hexdigest(filename)
87
88
 
88
89
  # Build settings array (like Jekyll Picture Tag)
@@ -94,6 +95,7 @@ module JekyllImgFlow
94
95
  ]
95
96
 
96
97
  # Generate MD5 hash of settings (9 chars like JPT)
98
+ # nosemgrep: ruby.lang.security.weak-hashes-md5.weak-hashes-md5 -- Jekyll Picture Tag filename compatibility, not security.
97
99
  Digest::MD5.hexdigest(settings.join)[0..8]
98
100
  end
99
101
  end
@@ -76,15 +76,15 @@ module JekyllImgFlow
76
76
  return default_attributes if attributes.nil? || attributes.empty?
77
77
 
78
78
  {
79
- img: attributes[:img] || {},
80
- picture: attributes[:picture] || {},
81
- source: attributes[:source] || {},
82
- a: attributes[:a] || {},
83
- parent: attributes[:parent] || {},
79
+ img: attributes.fetch(:img, {}),
80
+ picture: attributes.fetch(:picture, {}),
81
+ source: attributes.fetch(:source, {}),
82
+ a: attributes.fetch(:a, {}),
83
+ parent: attributes.fetch(:parent, {}),
84
84
  alt: attributes[:alt],
85
85
  link: attributes[:link],
86
86
  modal: attributes[:modal],
87
- modal_results: attributes[:modal_results] || []
87
+ modal_results: attributes.fetch(:modal_results, [])
88
88
  }
89
89
  end
90
90
 
@@ -73,46 +73,48 @@ module Jekyll
73
73
  return "" unless parsed[:image_path]
74
74
 
75
75
  site = context.registers[:site]
76
- page = context.registers[:page]
76
+ input_path = resolve_input_path(parsed[:image_path], site, components[:config])
77
+ original_name = original_image_name(input_path, site, components[:config])
78
+ page_path = page_identifier(context.registers[:page])
79
+ results, modal_results = process_image_variants(components, parsed, original_name, input_path,
80
+ page_path)
81
+ render_processed_results(results, modal_results, parsed, site, context)
82
+ end
77
83
 
78
- # Resolve input path
79
- input_path = resolve_image_path(parsed[:image_path], site, components[:config])
84
+ def resolve_input_path(image_path, site, config)
85
+ input_path = resolve_image_path(image_path, site, config)
80
86
  raise ArgumentError, "Input image file not found: #{input_path}" unless File.file?(input_path)
81
87
 
82
- # Store original name relative to the configured originals directory
83
- # so output can mirror the original directory structure
84
- originals_dir = File.join(site.source, components[:config].originals)
85
- original_name = input_path.sub("#{originals_dir}/", "")
88
+ input_path
89
+ end
86
90
 
87
- # Get page path for manifest tracking
88
- # Try multiple attributes to get the page identifier
89
- page_path = if page
90
- page["path"] || page["url"] || page["name"] || "unknown"
91
- else
92
- "unknown"
93
- end
91
+ def original_image_name(input_path, site, config)
92
+ originals_dir = File.join(site.source, config.originals)
93
+ input_path.sub("#{originals_dir}/", "")
94
+ end
94
95
 
96
+ def page_identifier(page)
97
+ (page && (page["path"] || page["url"] || page["name"])) || "unknown"
98
+ end
99
+
100
+ def process_image_variants(components, parsed, original_name, input_path, page_path)
95
101
  operations = parsed[:operations]
96
- results = if operations.empty?
97
- [input_path]
98
- else
99
- process_variants(components, operations.first, original_name, input_path,
100
- page_path)
101
- end
102
-
103
- modal_results = if operations.empty?
104
- []
105
- else
106
- process_modal_variants(components, operations.first, parsed, original_name,
107
- input_path, page_path)
108
- end
102
+ return [[input_path], []] if operations.empty?
103
+
104
+ operation = operations.first
105
+ results = process_variants(components, operation, original_name, input_path, page_path)
106
+ modal_results = process_modal_variants(components, operation, parsed, original_name,
107
+ input_path, page_path)
108
+ [results, modal_results]
109
+ end
110
+
111
+ def render_processed_results(results, modal_results, parsed, site, context)
109
112
  relative_results = results.uniq.map { |result| relative_result_path(result, site) }
110
113
  relative_modal_results = modal_results.uniq.map do |result|
111
114
  relative_result_path(result, site)
112
115
  end
113
116
  parsed = parsed.merge(markup_format: "picture") if relative_results.length > 1
114
- parsed = parsed.merge(modal_results: relative_modal_results)
115
- generate_html(relative_results, parsed, context)
117
+ generate_html(relative_results, parsed.merge(modal_results: relative_modal_results), context)
116
118
  end
117
119
 
118
120
  def process_modal_variants(components, operation, parsed, original_name, input_path, page_path)
@@ -123,18 +125,22 @@ module Jekyll
123
125
  return [] unless original_width
124
126
 
125
127
  params = operation[:params].dup
126
- formats = Array(params.delete(:formats) || params[:format])
127
- formats = config.formats if formats.empty?
128
+ formats = modal_formats(params, config)
129
+ params.delete(:formats)
128
130
  modal_width = [original_width, config.sizes.values.max].min
129
-
130
131
  formats.map do |format|
131
- modal_params = params.merge(width: modal_width, format: format)
132
- process_variant(components, operation, modal_params, original_name, input_path, page_path)
132
+ process_variant(components, operation, params.merge(width: modal_width, format: format),
133
+ original_name, input_path, page_path)
133
134
  end
134
135
  rescue FastImage::ImageFetchError, FastImage::UnknownImageType
135
136
  []
136
137
  end
137
138
 
139
+ def modal_formats(params, config)
140
+ formats = Array(params[:formats] || params[:format])
141
+ formats.empty? ? config.formats : formats
142
+ end
143
+
138
144
  def modal_requested?(parsed, config)
139
145
  return false if %w[direct_url naked_srcset].include?(parsed[:markup_format])
140
146
 
@@ -164,35 +170,63 @@ module Jekyll
164
170
 
165
171
  def process_variant(components, operation, params, original_name, input_path, page_path)
166
172
  config = components[:config]
167
- if determine_version_type(params, config) == :default
168
- params[:format] ||= config.formats.first
169
- params[:quality] ||= config.quality
173
+ apply_default_params(params, config)
174
+ variant = variant_context(components, params, original_name, input_path, config)
175
+
176
+ if variant_cached?(components, original_name, params, variant[:version_type],
177
+ variant[:digest], variant[:output_path])
178
+ return cached_variant(components, original_name, params, variant[:version_type],
179
+ variant[:digest], variant[:output_path], page_path)
170
180
  end
171
181
 
182
+ process_variant_output(components, operation, params, variant, original_name, input_path,
183
+ page_path)
184
+ end
185
+
186
+ def variant_context(components, params, original_name, input_path, config)
172
187
  digest = components[:filename_generator].file_digest(input_path)
173
- variant = operation.merge(params: params, file_digest: digest)
174
- subdir = File.dirname(original_name)
175
- subdir = nil if subdir == "."
176
188
  filename = components[:filename_generator].generate_filename(input_path, params)
177
- output_path = components[:path_resolver].resolve_source_output_path(filename, subdir)
178
- version_type = determine_version_type(params, config)
179
-
180
- if components[:manifest].version_exists?(original_name, params, version_type, digest) &&
181
- File.file?(output_path)
182
- components[:stats]&.record_cache_hit
183
- components[:manifest].update_page_usage(original_name, params, version_type, page_path)
184
- return output_path
185
- end
189
+ subdir = output_subdir(original_name)
190
+ {
191
+ digest: digest,
192
+ output_path: components[:path_resolver].resolve_source_output_path(filename, subdir),
193
+ version_type: determine_version_type(params, config)
194
+ }
195
+ end
186
196
 
197
+ def process_variant_output(components, operation, params, variant, original_name, input_path,
198
+ page_path)
187
199
  unless components[:operation_processor]
188
200
  Jekyll.logger.warn "ImgFlow:", "No image provider available — " \
189
201
  "rendering original without optimization."
190
202
  return input_path
191
203
  end
192
204
 
193
- components[:operation_processor].process_operation(
194
- original_name, variant.merge(force_processing: true), input_path, page_path
195
- )
205
+ operation_params = operation.merge(params: params, file_digest: variant[:digest],
206
+ force_processing: true)
207
+ components[:operation_processor].process_operation(original_name, operation_params, input_path,
208
+ page_path)
209
+ end
210
+
211
+ def apply_default_params(params, config)
212
+ return unless determine_version_type(params, config) == :default
213
+
214
+ params[:format] ||= config.formats.first
215
+ params[:quality] ||= config.quality
216
+ end
217
+
218
+ def output_subdir(original_name)
219
+ File.dirname(original_name).then { |dir| dir == "." ? nil : dir }
220
+ end
221
+
222
+ def variant_cached?(components, original_name, params, version_type, digest, output_path)
223
+ components[:manifest].version_exists?(original_name, params, version_type, digest) && File.file?(output_path)
224
+ end
225
+
226
+ def cached_variant(components, original_name, params, version_type, _digest, output_path, page_path)
227
+ components[:stats]&.record_cache_hit
228
+ components[:manifest].update_page_usage(original_name, params, version_type, page_path)
229
+ output_path
196
230
  end
197
231
 
198
232
  def relative_result_path(result, site)
@@ -25,32 +25,35 @@ module JekyllImgFlow
25
25
 
26
26
  # Load existing manifest or create new one
27
27
  def load_manifest(path = @manifest_path)
28
- if path && File.exist?(path)
29
- begin
30
- data = JSON.parse(File.read(path))
31
-
32
- # Handle new format with provider at top level
33
- if data.is_a?(Hash) && data.key?("images")
34
- @cached_provider = data["provider"]
35
- images = data["images"] || {}
36
- else
37
- # Old format - just image data
38
- @cached_provider = nil
39
- images = data
40
- end
28
+ return empty_manifest unless path && File.exist?(path)
41
29
 
42
- migrate_legacy_manifest(images)
43
- rescue JSON::ParserError => e
44
- Jekyll.logger.warn "ImgFlow: Corrupt manifest file, starting fresh: #{e.message}"
45
- @cached_provider = nil
46
- {}
47
- end
30
+ load_manifest_file(path)
31
+ rescue JSON::ParserError => e
32
+ Jekyll.logger.warn "ImgFlow: Corrupt manifest file, starting fresh: #{e.message}"
33
+ empty_manifest
34
+ end
35
+
36
+ def load_manifest_file(path)
37
+ data = JSON.parse(File.read(path))
38
+ images = manifest_images(data)
39
+ migrate_legacy_manifest(images)
40
+ end
41
+
42
+ def manifest_images(data)
43
+ if data.is_a?(Hash) && data.key?("images")
44
+ @cached_provider = data["provider"]
45
+ data["images"] || {}
48
46
  else
49
47
  @cached_provider = nil
50
- {}
48
+ data
51
49
  end
52
50
  end
53
51
 
52
+ def empty_manifest
53
+ @cached_provider = nil
54
+ {}
55
+ end
56
+
54
57
  # Detect and migrate legacy manifest entries (pre-0.1.11 format).
55
58
  # Old entries have operations like {"width": 400} without format/quality
56
59
  # and no file_digest. Clearing them lets the build re-register existing
@@ -232,51 +235,55 @@ module JekyllImgFlow
232
235
  # Register a new image version
233
236
  def register_version(original_name, output_path, operations, type, page_path,
234
237
  file_digest = nil, provider = nil)
238
+ image = manifest_image(original_name, file_digest)
239
+ image["file_digest"] = file_digest if file_digest
240
+ versions = image["versions"][type.to_s] ||= []
241
+ normalized = normalize_operations(operations)
242
+ page_paths = normalize_page_paths(page_path)
243
+ version = versions.find { |entry| same_operations?(entry["operations"], normalized) }
244
+
245
+ if version
246
+ update_version(version, output_path, page_paths, file_digest, provider)
247
+ else
248
+ versions << new_version(output_path, normalized, type, page_paths, file_digest, provider)
249
+ end
250
+ end
251
+
252
+ def manifest_image(original_name, file_digest)
235
253
  @manifest[original_name] ||= {
236
254
  "versions" => { "default" => [], "specialized" => [] },
237
- "file_digest" => file_digest # Store SHA256 digest of original file
255
+ "file_digest" => file_digest
238
256
  }
257
+ end
239
258
 
240
- # Update file digest if provided
241
- @manifest[original_name]["file_digest"] = file_digest if file_digest
242
-
243
- versions = @manifest[original_name]["versions"][type.to_s] ||= []
244
- normalized_operations = normalize_operations(operations)
245
-
246
- # Find or create version entry
247
- version = versions.find { |v| same_operations?(v["operations"], normalized_operations) }
248
-
249
- # Normalize page_path to array
250
- page_paths = if page_path.is_a?(Array)
251
- page_path
252
- else
253
- (page_path ? [page_path] : [])
254
- end
255
-
256
- if version
257
- # Add pages to used_on list if not already present
258
- version["used_on"] ||= []
259
- page_paths.each do |path|
260
- version["used_on"] << path if path && !version["used_on"].include?(path)
261
- end
262
- version["output"] = output_path
263
- version["file_digest"] = file_digest if file_digest
264
- # Update provider if provided
265
- version["provider"] = provider if provider
259
+ def normalize_page_paths(page_path)
260
+ if page_path.is_a?(Array)
261
+ page_path
266
262
  else
267
- # Create new version entry
268
- versions << {
269
- "output" => output_path,
270
- "operations" => normalized_operations,
271
- "type" => type.to_s,
272
- "used_on" => page_paths,
273
- "created_at" => Time.now.to_i,
274
- "file_digest" => file_digest,
275
- "provider" => provider
276
- }
263
+ (page_path ? [page_path] : [])
277
264
  end
278
265
  end
279
266
 
267
+ def update_version(version, output_path, page_paths, file_digest, provider)
268
+ version["used_on"] ||= []
269
+ page_paths.each { |path| version["used_on"] << path if path && !version["used_on"].include?(path) }
270
+ version["output"] = output_path
271
+ version["file_digest"] = file_digest if file_digest
272
+ version["provider"] = provider if provider
273
+ end
274
+
275
+ def new_version(output_path, operations, type, page_paths, file_digest, provider)
276
+ {
277
+ "output" => output_path,
278
+ "operations" => operations,
279
+ "type" => type.to_s,
280
+ "used_on" => page_paths,
281
+ "created_at" => Time.now.to_i,
282
+ "file_digest" => file_digest,
283
+ "provider" => provider
284
+ }
285
+ end
286
+
280
287
  # Check if image version is default type
281
288
  def default_version?(original_name, operations)
282
289
  return false unless @manifest[original_name]
@@ -43,84 +43,64 @@ module JekyllImgFlow
43
43
  # @param page_path [String] Optional page path for manifest tracking
44
44
  # @return [String] Path to processed image
45
45
  def process_operation(original_name, operation, input_path, page_path = nil)
46
- type = operation[:type]
47
46
  params = operation[:params]
48
47
  file_digest = operation[:file_digest] || @filename_generator.file_digest(input_path)
49
-
50
- # Determine version type
51
48
  version_type = determine_version_type(params)
52
-
53
- # Preserve original directory structure under output
54
- subdir = File.dirname(original_name)
55
- subdir = nil if subdir == "."
56
-
57
- # Generate filename using FilenameGenerator (JPT compatible)
58
49
  filename = @filename_generator.generate_filename(input_path, params)
50
+ subdir = output_subdir(original_name)
51
+ output_path = @path_resolver.resolve_source_output_path(filename, subdir)
52
+ FileUtils.mkdir_p(File.dirname(output_path))
59
53
 
60
- # Write to source directory so Jekyll copies files to _site during the write phase
61
- actual_output_path = @path_resolver.resolve_source_output_path(filename, subdir)
54
+ process_output(operation, original_name, input_path, output_path, params)
55
+ register_manifest(original_name, filename, params, version_type, page_path, file_digest, subdir)
56
+ register_jekyll_static_file(output_path)
57
+ output_path
58
+ end
62
59
 
63
- # Ensure output directory exists before processing
64
- FileUtils.mkdir_p(File.dirname(actual_output_path))
60
+ def output_subdir(original_name)
61
+ subdir = File.dirname(original_name)
62
+ subdir == "." ? nil : subdir
63
+ end
65
64
 
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}"
65
+ def process_output(operation, original_name, input_path, output_path, params)
66
+ if !operation[:force_processing] && output_up_to_date?(input_path, output_path)
67
+ Jekyll.logger.debug "⏭️ ImgFlow: Output exists and up-to-date, skipping provider call for #{original_name}"
73
68
  @stats.record_cache_hit
74
69
  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.
78
- Jekyll.logger.warn "🖼️ ImgFlow: Skipping resize for animated GIF " \
79
- "'#{original_name}' — copying original as-is to " \
80
- "preserve animation."
81
- FileUtils.cp(input_path, actual_output_path)
82
- @stats.record_cache_miss
70
+ copy_animated_image(original_name, input_path, output_path)
83
71
  else
84
- # Process the operation (create the image)
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
+ process_image(operation[:type], input_path, output_path, params)
97
73
  end
74
+ end
98
75
 
99
- # Register in manifest
100
- if @manifest
101
- # Store relative path (with leading /) for manifest storage
102
- relative_path = "/#{@path_resolver.resolve_relative_output_path(filename, subdir)}"
103
-
104
- provider_name = @provider&.class&.provider_name || "unknown"
105
- @manifest.register_version(
106
- original_name,
107
- relative_path,
108
- params,
109
- version_type,
110
- page_path,
111
- file_digest,
112
- provider_name
113
- )
114
- end
76
+ def copy_animated_image(original_name, input_path, output_path)
77
+ Jekyll.logger.warn "🖼️ ImgFlow: Skipping resize for animated GIF '#{original_name}' — " \
78
+ "copying original as-is to preserve animation."
79
+ FileUtils.cp(input_path, output_path)
80
+ @stats.record_cache_miss
81
+ end
82
+
83
+ def process_image(type, input_path, output_path, params)
84
+ elapsed = Benchmark.measure { process_single_operation(type, input_path, output_path, params) }
85
+ @stats.record_operation_time(type, elapsed.real)
86
+ @stats.record_cache_miss
87
+ record_compression_ratio(input_path, output_path, params)
88
+ end
89
+
90
+ def record_compression_ratio(input_path, output_path, params)
91
+ return unless File.file?(input_path) && File.file?(output_path)
92
+
93
+ format = params[:format] || File.extname(input_path).delete(".")
94
+ @stats.record_compression_ratio(format.to_s, File.size(input_path), File.size(output_path))
95
+ end
115
96
 
116
- # Register as Jekyll static file so Jekyll copies it to _site during
117
- # the write phase. Without this, files created during pre_render (or
118
- # during render via imgflow tags) are not picked up by Jekyll's static
119
- # file reader, which runs before pre_render. This would leave _site
120
- # without optimized images until a second build.
121
- register_jekyll_static_file(actual_output_path)
97
+ def register_manifest(original_name, filename, params, version_type, page_path, file_digest, subdir)
98
+ return unless @manifest
122
99
 
123
- actual_output_path
100
+ relative_path = "/#{@path_resolver.resolve_relative_output_path(filename, subdir)}"
101
+ provider_name = @provider&.class&.provider_name || "unknown"
102
+ @manifest.register_version(original_name, relative_path, params, version_type, page_path,
103
+ file_digest, provider_name)
124
104
  end
125
105
 
126
106
  # Register a generated file as a Jekyll::StaticFile so Jekyll copies it