jekyll-imgflow 0.3.2 → 0.3.3

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: d59e73749066617a2c71322c3c80c5161ea847872a7864cf65c9133a8a315e24
4
+ data.tar.gz: 861a4df0428da9bdd3221b89838ebb4d85d598172465569b5cdc28e14f243ede
5
5
  SHA512:
6
- metadata.gz: c1f92e12c629652780356a972969ee435992b29dd62dea03ea263582084e8b512b35eb25e8bb1845c111834a5506cd1c3ffd5c9b4c5392ff6e17e20d3ea5c4ae
7
- data.tar.gz: 92b73297116fd7dd7d38a5aeb349c81aeab97709f3a85886469dc0443e3fa9e5e35c2c971b1c01bd6682db2c59776c54112682a31e3610c64a6254b3d4067441
6
+ metadata.gz: aae0d3bb14c346d432881a29844b1221225599dd77767d087faccc14023f0d527fcc36c4eeb0e5a1df4fa9a87f86276ea42388e2364f7158f12af8ba0512d459
7
+ data.tar.gz: ba868a71465128a9f6f7259ad2852b7b16093b30496fd9a2e9d9f259e811908b2541b3f14a002475201a26576236921f76dbd52782797facb2d4a27e496c81de
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)
@@ -202,8 +204,15 @@ bundle install
202
204
  # Download test images
203
205
  rake download_test_images
204
206
 
205
- # Run tests
206
- rake quick
207
+ # Run the CI-equivalent checks locally
208
+ rake ci
209
+
210
+ # Run the local Semgrep Pro rules directly
211
+ semgrep scan --pro --config .semgrep.yml --error lib/
212
+
213
+ # Sync the repository scan and findings with the Semgrep dashboard
214
+ # (requires semgrep login or SEMGREP_APP_TOKEN)
215
+ semgrep ci
207
216
  ```
208
217
 
209
218
  ### 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
@@ -16,26 +16,19 @@ module JekyllImgFlow
16
16
  result = translate_to_imgflow(picture_markup)
17
17
  return "" if result[:markup].empty?
18
18
 
19
- # Build ImgFlow tag with attributes
20
- tag_parts = ["{% imgflow", result[:markup]]
21
-
22
- # Add HTML attributes
23
- tag_parts << "alt:\"#{result[:attributes][:alt]}\"" if result[:attributes][:alt]
24
-
25
- if result[:attributes][:img]&.any?
26
- result[:attributes][:img].each do |name, value|
27
- tag_parts << "img-#{name}:\"#{value}\""
28
- end
29
- end
19
+ "{% imgflow #{[result[:markup], *translated_attributes(result[:attributes]), '%}'].join(' ')}"
20
+ end
30
21
 
31
- if result[:attributes][:picture]&.any?
32
- result[:attributes][:picture].each do |name, value|
33
- tag_parts << "picture-#{name}:\"#{value}\""
34
- end
35
- end
22
+ def translated_attributes(attributes)
23
+ parts = []
24
+ parts << "alt:\"#{attributes[:alt]}\"" if attributes[:alt]
25
+ parts.concat(prefixed_attributes(attributes[:img], "img"))
26
+ parts.concat(prefixed_attributes(attributes[:picture], "picture"))
27
+ parts
28
+ end
36
29
 
37
- tag_parts << "%}"
38
- tag_parts.join(" ")
30
+ def prefixed_attributes(attributes, prefix)
31
+ attributes.to_h.map { |name, value| "#{prefix}-#{name}:\"#{value}\"" }
39
32
  end
40
33
 
41
34
  # Convert Picture Tag markup to ImgFlow markup with HTML attributes
@@ -57,17 +50,9 @@ module JekyllImgFlow
57
50
  categorized = categorize_arguments(args)
58
51
  return { markup: "", attributes: {} } unless categorized[:image]
59
52
 
60
- # Step 2: Translate each category to ImgFlow syntax
61
- imgflow_parts = []
62
- imgflow_parts << categorized[:image]
63
- imgflow_parts += translate_media_queries(categorized[:media_queries])
64
- imgflow_parts += translate_operations(categorized[:operations])
65
- imgflow_parts << "formats:webp,jpg" unless formats?(imgflow_parts)
66
- imgflow_parts << "markup:#{categorized[:markup_format]}" if categorized[:markup_format] && categorized[:markup_format] != "auto"
67
-
68
- # Step 3: Assemble result
53
+ imgflow_parts = translated_parts(categorized)
69
54
  {
70
- markup: imgflow_parts.compact.join(" "),
55
+ markup: imgflow_parts.join(" "),
71
56
  attributes: categorized[:html_attributes],
72
57
  markup_format: categorized[:markup_format]
73
58
  }
@@ -75,6 +60,16 @@ module JekyllImgFlow
75
60
 
76
61
  private
77
62
 
63
+ def translated_parts(categorized)
64
+ parts = [categorized[:image]]
65
+ parts.concat(translate_media_queries(categorized[:media_queries]))
66
+ parts.concat(translate_operations(categorized[:operations]))
67
+ parts << "formats:webp,jpg" unless formats?(parts)
68
+ format = categorized[:markup_format]
69
+ parts << "markup:#{format}" if format && format != "auto"
70
+ parts.compact
71
+ end
72
+
78
73
  def extract_picture_content(markup)
79
74
  offset = 0
80
75
  while (opening = markup.index("{%", offset))
@@ -105,37 +100,43 @@ module JekyllImgFlow
105
100
  # @param content [String] Raw content
106
101
  # @return [Array] Parsed arguments
107
102
  def parse_arguments(content)
108
- args = []
109
- current = +"" # Create unfrozen string
110
- in_quotes = false
111
- quote_char = nil
112
-
113
- content.dup.each_char do |char|
114
- case char
115
- when '"', "'"
116
- if !in_quotes
117
- in_quotes = true
118
- quote_char = char
119
- elsif char == quote_char
120
- in_quotes = false
121
- quote_char = nil
122
- else
123
- current << char
124
- end
125
- when " "
126
- if in_quotes
127
- current << char
128
- elsif !current.empty?
129
- args << current.dup
130
- current = +""
131
- end
132
- else
133
- current << char
134
- end
103
+ state = { args: [], current: +"", in_quotes: false, quote_char: nil }
104
+ content.each_char { |char| consume_argument_character(state, char) }
105
+ state[:args] << state[:current].dup unless state[:current].empty?
106
+ state[:args]
107
+ end
108
+
109
+ def consume_argument_character(state, char)
110
+ if quote_character?(char)
111
+ consume_quote(state, char)
112
+ elsif char == " " && !state[:in_quotes]
113
+ append_argument(state)
114
+ else
115
+ state[:current] << char
116
+ end
117
+ end
118
+
119
+ def quote_character?(char)
120
+ ["\"", "'"].include?(char)
121
+ end
122
+
123
+ def consume_quote(state, char)
124
+ if !state[:in_quotes]
125
+ state[:in_quotes] = true
126
+ state[:quote_char] = char
127
+ elsif char == state[:quote_char]
128
+ state[:in_quotes] = false
129
+ state[:quote_char] = nil
130
+ else
131
+ state[:current] << char
135
132
  end
133
+ end
134
+
135
+ def append_argument(state)
136
+ return if state[:current].empty?
136
137
 
137
- args << current.dup unless current.empty?
138
- args
138
+ state[:args] << state[:current].dup
139
+ state[:current] = +""
139
140
  end
140
141
 
141
142
  # Categorize arguments into types
@@ -149,59 +150,65 @@ module JekyllImgFlow
149
150
  html_attributes: default_html_attributes,
150
151
  markup_format: nil
151
152
  }
153
+ index = 0
154
+ index = categorize_argument(args, result, index) while index < args.length
155
+ result
156
+ end
152
157
 
153
- i = 0
154
- while i < args.length
155
- arg = args[i]
158
+ def categorize_argument(args, result, index)
159
+ arg = args[index]
160
+ return parse_alt_argument(args, result, index) if arg == "--alt"
161
+ return parse_link_argument(args, result, index) if arg == "--link"
162
+ return parse_element_arguments(args, result, index) if arg.match?(/^--(img|picture|source|a|parent)$/)
163
+ return parse_media_argument(args, result, index) if arg.match?(/(mobile|tablet|desktop):/)
156
164
 
157
- case arg
158
- when /^--alt$/
159
- # Collect alt text until next --
160
- i += 1
161
- alt_parts = []
162
- while i < args.length && !args[i].start_with?("--")
163
- alt_parts << args[i]
164
- i += 1
165
- end
166
- result[:html_attributes][:alt] = strip_quotes(alt_parts.join(" "))
167
- next
168
- when /^--link$/
169
- result[:html_attributes][:link] = strip_quotes(args[i + 1]) if i + 1 < args.length
170
- i += 2
171
- next
172
- when /^--(img|picture|source|a|parent)$/
173
- element = ::Regexp.last_match(1).to_sym
174
- i += 1
175
- while i < args.length && !args[i].start_with?("--")
176
- parse_element_attribute(args[i], result[:html_attributes][element])
177
- i += 1
178
- end
179
- next
180
- when /(mobile|tablet|desktop):/
181
- device = ::Regexp.last_match(1)
182
- i += 1
183
- if i < args.length && args[i].include?(".")
184
- image = args[i]
185
- i += 1
186
- crop = parse_crop_from_arg(args[i]) if i < args.length && args[i] =~ /^\d+:\d+/
187
- i += 1 if crop
188
- result[:media_queries][device] = { image: image, crop: crop }
189
- end
190
- next
191
- when /^(auto|data_auto|picture|img)$/
192
- result[:markup_format] = arg
193
- when /\./
194
- # Image path (has extension, no colon)
195
- result[:image] ||= arg unless arg.include?(":")
196
- else
197
- # Operation argument
198
- result[:operations] << arg unless arg.start_with?("--")
199
- end
165
+ categorize_simple_argument(arg, result)
166
+ index + 1
167
+ end
200
168
 
201
- i += 1
202
- end
169
+ def parse_alt_argument(args, result, index)
170
+ values, next_index = values_until_option(args, index + 1)
171
+ result[:html_attributes][:alt] = strip_quotes(values.join(" "))
172
+ next_index
173
+ end
203
174
 
204
- result
175
+ def parse_link_argument(args, result, index)
176
+ result[:html_attributes][:link] = strip_quotes(args[index + 1]) if args[index + 1]
177
+ index + 2
178
+ end
179
+
180
+ def parse_element_arguments(args, result, index)
181
+ element = args[index].match(/^--(img|picture|source|a|parent)$/)[1].to_sym
182
+ values, next_index = values_until_option(args, index + 1)
183
+ values.each { |value| parse_element_attribute(value, result[:html_attributes][element]) }
184
+ next_index
185
+ end
186
+
187
+ def values_until_option(args, index)
188
+ start = index
189
+ index += 1 while index < args.length && !args[index].start_with?("--")
190
+ [args[start...index], index]
191
+ end
192
+
193
+ def parse_media_argument(args, result, index)
194
+ device = args[index].match(/(mobile|tablet|desktop):/)[1]
195
+ image = args[index + 1]
196
+ return index + 1 unless image&.include?(".")
197
+
198
+ crop = parse_crop_from_arg(args[index + 2])
199
+ result[:media_queries][device] = { image: image, crop: crop }
200
+ index + 2 + (crop ? 1 : 0)
201
+ end
202
+
203
+ def categorize_simple_argument(arg, result)
204
+ case arg
205
+ when /^(auto|data_auto|picture|img)$/
206
+ result[:markup_format] = arg
207
+ when /\./
208
+ result[:image] ||= arg unless arg.include?(":")
209
+ else
210
+ result[:operations] << arg unless arg.start_with?("--")
211
+ end
205
212
  end
206
213
 
207
214
  # Translate media queries to ImgFlow crop syntax
@@ -165,6 +165,7 @@ module JekyllImgFlow
165
165
  end
166
166
 
167
167
  def execute_command(command)
168
+ # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- provider commands shell-escape paths; pipelines are intentional for Sharp watermark operations.
168
169
  stdout, stderr, status = Open3.capture3(command)
169
170
  raise "Command failed: #{command}\nError: #{stderr.strip}" unless status.success?
170
171
 
@@ -9,7 +9,7 @@ module JekyllImgFlow
9
9
  module Providers
10
10
  # Flyimg provider implementation using the standardized tag interface
11
11
  class Flyimg < BaseProvider
12
- TIMEOUT = 10
12
+ TIMEOUT = 60
13
13
 
14
14
  def available?
15
15
  # Check if flyimg service is running
@@ -31,24 +31,32 @@ module JekyllImgFlow
31
31
  # Each sub-array is passed directly to Open3.capture3 (no shell).
32
32
  # Returns Array[Array[String]].
33
33
  def build_vips_commands(input_path, output_path)
34
- has_crop = @operations.any? { |op| op[:type] == :crop }
35
- has_resize = @operations.any? { |op| op[:type] == :resize }
36
- has_watermark = @operations.any? { |op| op[:type] == :watermark }
37
- has_alpha = @operations.any? { |op| op[:type] == :alpha_opacity }
38
-
39
- if has_watermark
40
- build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
41
- elsif has_alpha
42
- build_alpha_pipeline(input_path, output_path, has_crop, has_resize)
43
- elsif has_crop && has_resize
44
- build_sequential_crop_resize(input_path, output_path)
45
- elsif has_resize
46
- [build_resize_command(input_path, output_path)]
47
- elsif has_crop
48
- svg?(input_path) ? build_svg_crop_pipeline(input_path, output_path) : [build_crop_command(input_path, output_path)]
49
- else
50
- [build_copy_command(input_path, output_path)]
51
- end
34
+ flags = operation_flags
35
+ operation_builder = select_operation_builder(flags)
36
+ operation_builder.call(input_path, output_path)
37
+ end
38
+
39
+ def select_operation_builder(flags)
40
+ return ->(input, output) { build_watermark_pipeline(input, output, flags[:crop], flags[:resize]) } if flags[:watermark]
41
+ return ->(input, output) { build_alpha_pipeline(input, output, flags[:crop], flags[:resize]) } if flags[:alpha]
42
+ return ->(input, output) { build_sequential_crop_resize(input, output) } if flags[:crop] && flags[:resize]
43
+ return ->(input, output) { [build_resize_command(input, output)] } if flags[:resize]
44
+ return ->(input, output) { crop_commands(input, output) } if flags[:crop]
45
+
46
+ ->(input, output) { [build_copy_command(input, output)] }
47
+ end
48
+
49
+ def operation_flags
50
+ {
51
+ crop: @operations.any? { |op| op[:type] == :crop },
52
+ resize: @operations.any? { |op| op[:type] == :resize },
53
+ watermark: @operations.any? { |op| op[:type] == :watermark },
54
+ alpha: @operations.any? { |op| op[:type] == :alpha_opacity }
55
+ }
56
+ end
57
+
58
+ def crop_commands(input_path, output_path)
59
+ svg?(input_path) ? build_svg_crop_pipeline(input_path, output_path) : [build_crop_command(input_path, output_path)]
52
60
  end
53
61
 
54
62
  # Convenience wrapper for backward compatibility with tests.
@@ -64,22 +72,8 @@ module JekyllImgFlow
64
72
  end
65
73
 
66
74
  def build_alpha_pipeline(input_path, output_path, has_crop, has_resize)
67
- commands = []
68
75
  temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
69
-
70
- if has_crop && has_resize
71
- commands.concat(build_sequential_crop_resize(input_path, temp_path))
72
- base_path = temp_path
73
- elsif has_resize
74
- commands << build_resize_command(input_path, temp_path)
75
- base_path = temp_path
76
- elsif has_crop
77
- commands << build_crop_command(input_path, temp_path)
78
- base_path = temp_path
79
- else
80
- base_path = input_path
81
- end
82
-
76
+ commands, base_path = build_base_pipeline(input_path, temp_path, has_crop, has_resize)
83
77
  commands << build_alpha_command(base_path, output_path)
84
78
  commands << [:cleanup, temp_path] unless base_path == input_path
85
79
  commands
@@ -87,39 +81,32 @@ module JekyllImgFlow
87
81
 
88
82
  def build_watermark_pipeline(input_path, output_path, has_crop, has_resize)
89
83
  temp_path = input_path.gsub(/\.[^.]+$/, ".tmp_base.jpg")
84
+ commands, base_path = build_base_pipeline(input_path, temp_path, has_crop, has_resize)
85
+ base_path, commands = apply_watermark_alpha(base_path, input_path, commands)
86
+ commands.concat(build_composite_commands(base_path, output_path))
87
+ cleanup_paths(commands, input_path, temp_path, base_path)
88
+ commands
89
+ end
90
90
 
91
- commands = []
92
-
93
- # Step 1: Process crop/resize/copy to produce base image
94
- if has_crop && has_resize
95
- commands.concat(build_sequential_crop_resize(input_path, temp_path))
96
- base_path = temp_path
97
- elsif has_resize
98
- commands << build_resize_command(input_path, temp_path)
99
- base_path = temp_path
100
- elsif has_crop
101
- commands << build_crop_command(input_path, temp_path)
102
- base_path = temp_path
103
- else
104
- base_path = input_path
105
- end
91
+ def build_base_pipeline(input_path, temp_path, has_crop, has_resize)
92
+ return [build_sequential_crop_resize(input_path, temp_path), temp_path] if has_crop && has_resize
93
+ return [[build_resize_command(input_path, temp_path)], temp_path] if has_resize
94
+ return [[build_crop_command(input_path, temp_path)], temp_path] if has_crop
95
+
96
+ [[], input_path]
97
+ end
106
98
 
107
- # Step 2: Apply alpha opacity if present (before watermark)
99
+ def apply_watermark_alpha(base_path, input_path, commands)
108
100
  alpha_op = @operations.find { |op| op[:type] == :alpha_opacity }
109
- if alpha_op
110
- alpha_temp = input_path.gsub(/\.[^.]+$/, ".tmp_alpha.jpg")
111
- commands << build_alpha_command(base_path, alpha_temp)
112
- base_path = alpha_temp
113
- end
101
+ return [base_path, commands] unless alpha_op
114
102
 
115
- # Step 3: Composite watermark over base
116
- commands.concat(build_composite_commands(base_path, output_path))
103
+ alpha_temp = input_path.gsub(/\.[^.]+$/, ".tmp_alpha.jpg")
104
+ [alpha_temp, commands << build_alpha_command(base_path, alpha_temp)]
105
+ end
117
106
 
118
- # Step 4: Cleanup temp files
107
+ def cleanup_paths(commands, input_path, temp_path, base_path)
119
108
  temp_files = [temp_path, base_path == input_path ? nil : base_path].compact
120
- temp_files.each { |f| commands << [:cleanup, f] }
121
-
122
- commands
109
+ temp_files.each { |file| commands << [:cleanup, file] }
123
110
  end
124
111
 
125
112
  def build_composite_commands(base_path, output_path)
@@ -169,6 +156,9 @@ module JekyllImgFlow
169
156
  # Get an image dimension (width or height) using vips header.
170
157
  # Returns integer or 0 if vips is unavailable.
171
158
  def vips_image_dimension(image_path, field)
159
+ return 0 unless %w[width height].include?(field)
160
+
161
+ # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- Open3 receives an argument array and field is allowlisted.
172
162
  stdout, _, status = Open3.capture3("vips", "header", image_path, field)
173
163
  return 0 unless status.success?
174
164
 
@@ -230,27 +220,31 @@ module JekyllImgFlow
230
220
  opts = crop_op[:options] || {}
231
221
  params = crop_op[:params] || {}
232
222
  keep = opts[:keep] || params[:keep] || params[:position]
223
+ return build_smartcrop_command(crop_op, opts, keep, input_path, output_path) if smartcrop?(crop_op, keep)
233
224
 
234
- if crop_op[:ratio] && keep && %w[attention entropy center
235
- centre].include?(keep.to_s)
236
- crop_width = opts[:calculated_width]
237
- crop_height = opts[:calculated_height]
238
- interestingness = case keep.to_s
239
- when "entropy" then "entropy"
240
- when "center", "centre" then "centre"
241
- else "attention"
242
- end
243
- ["vips", "smartcrop", input_path, output_path,
244
- crop_width.to_s, crop_height.to_s,
245
- "--interesting=#{interestingness}"]
246
- else
247
- crop_x = crop_op[:ratio] ? opts[:calculated_x] : (opts[:x] || 0)
248
- crop_y = crop_op[:ratio] ? opts[:calculated_y] : (opts[:y] || 0)
249
- crop_width = crop_op[:ratio] ? opts[:calculated_width] : opts[:width]
250
- crop_height = crop_op[:ratio] ? opts[:calculated_height] : opts[:height]
251
- ["vips", "extract_area", input_path, output_path,
252
- crop_x.to_s, crop_y.to_s, crop_width.to_s, crop_height.to_s]
253
- end
225
+ build_extract_command(crop_op, opts, input_path, output_path)
226
+ end
227
+
228
+ def smartcrop?(crop_op, keep)
229
+ crop_op[:ratio] && keep && %w[attention entropy center centre].include?(keep.to_s)
230
+ end
231
+
232
+ def build_smartcrop_command(_crop_op, opts, keep, input_path, output_path)
233
+ interestingness = %w[center centre].include?(keep.to_s) ? "centre" : keep.to_s
234
+ interestingness = "attention" unless %w[entropy centre].include?(interestingness)
235
+ ["vips", "smartcrop", input_path, output_path,
236
+ opts[:calculated_width].to_s, opts[:calculated_height].to_s,
237
+ "--interesting=#{interestingness}"]
238
+ end
239
+
240
+ def build_extract_command(crop_op, opts, input_path, output_path)
241
+ ratio = crop_op[:ratio]
242
+ x = ratio ? opts[:calculated_x] : (opts[:x] || 0)
243
+ y = ratio ? opts[:calculated_y] : (opts[:y] || 0)
244
+ width = ratio ? opts[:calculated_width] : opts[:width]
245
+ height = ratio ? opts[:calculated_height] : opts[:height]
246
+ ["vips", "extract_area", input_path, output_path,
247
+ x.to_s, y.to_s, width.to_s, height.to_s]
254
248
  end
255
249
 
256
250
  # SVG crop: use `vips thumbnail` with --crop to crop during thumbnailing.
@@ -323,6 +317,7 @@ module JekyllImgFlow
323
317
  Jekyll.logger.debug "LibVips command: #{command.join(' ')}"
324
318
 
325
319
  # Execute command array directly (no shell)
320
+ # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- command is an argument array; no shell is invoked.
326
321
  stdout, stderr, status = Open3.capture3(*command)
327
322
  output = "#{stdout}#{stderr}"
328
323
  success = status.success?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JekyllImgFlow
4
- VERSION = "0.3.2"
4
+ VERSION = "0.3.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-imgflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup