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,26 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
3
4
  require "yaml"
4
5
 
5
6
  module JekyllImgFlow
6
7
  # PresetManager - translates YAML presets to tags:value format
7
8
  # Handles tag overrides (user tags override preset tags)
8
9
  # Passes combined markup to Parser for uniform validation flow
10
+ #
11
+ # Presets are loaded from two sources (user presets take precedence):
12
+ # 1. Site presets: _data/imgflow/presets/*.yml (user-defined, override built-ins)
13
+ # 2. Built-in presets: lib/jekyll-imgflow/presets/*.yml (shipped with the gem)
9
14
  class PresetManager
15
+ BUILTIN_PRESETS_DIR = File.expand_path("presets", __dir__).freeze
16
+
10
17
  def initialize(site, config)
11
18
  @site = site
12
19
  @config = config
13
20
  @presets = load_presets
14
21
  end
15
22
 
16
- # Load presets from _data/imgflow/presets/
23
+ # Load presets from the site's _data/imgflow/presets/ directory,
24
+ # then merge in built-in presets from the gem. User presets override
25
+ # built-in presets of the same name.
17
26
  def load_presets
18
- presets = {}
19
- presets_dir = File.join(@site.source, "_data", "imgflow", "presets")
27
+ presets = load_presets_from_dir(builtin_presets_dir)
28
+ presets.merge(load_presets_from_dir(site_presets_dir))
29
+ end
20
30
 
21
- return presets unless Dir.exist?(presets_dir)
31
+ # Path to built-in presets shipped with the gem
32
+ def builtin_presets_dir
33
+ BUILTIN_PRESETS_DIR
34
+ end
22
35
 
23
- Dir.glob(File.join(presets_dir, "*.yml")).each do |preset_file|
36
+ # Path to user-defined presets in the site
37
+ def site_presets_dir
38
+ File.join(@site.source, "_data", "imgflow", "presets")
39
+ end
40
+
41
+ # Load all preset YAML files from a directory
42
+ # @param dir [String] Directory containing *.yml preset files
43
+ # @return [Hash<String, Hash>] Preset name => preset data
44
+ def load_presets_from_dir(dir)
45
+ presets = {}
46
+ return presets unless dir && Dir.exist?(dir)
47
+
48
+ Dir.glob(File.join(dir, "*.yml")).each do |preset_file|
24
49
  preset_name = File.basename(preset_file, ".yml")
25
50
  begin
26
51
  preset_data = YAML.safe_load_file(preset_file)
@@ -48,12 +73,38 @@ module JekyllImgFlow
48
73
  @presets.key?(name.to_s)
49
74
  end
50
75
 
51
- # Get all available preset names
76
+ # Get all available preset names (user + built-in)
52
77
  # @return [Array<String>] Array of preset names
53
78
  def available_presets
54
79
  @presets.keys
55
80
  end
56
81
 
82
+ # Get names of built-in presets shipped with the gem
83
+ # @return [Array<String>] Array of built-in preset names
84
+ def builtin_preset_names
85
+ names = []
86
+ return names unless Dir.exist?(builtin_presets_dir)
87
+
88
+ Dir.glob(File.join(builtin_presets_dir, "*.yml")).each do |file|
89
+ names << File.basename(file, ".yml")
90
+ end
91
+ names
92
+ end
93
+
94
+ # Check if a preset is a built-in (shipped with the gem)
95
+ # @param name [String] Preset name
96
+ # @return [Boolean] True if the preset is built-in
97
+ def builtin_preset?(name)
98
+ builtin_preset_names.include?(name.to_s)
99
+ end
100
+
101
+ # Check if a preset is user-defined (in the site's _data/)
102
+ # @param name [String] Preset name
103
+ # @return [Boolean] True if the preset is user-defined
104
+ def user_preset?(name)
105
+ File.exist?(File.join(site_presets_dir, "#{name}.yml"))
106
+ end
107
+
57
108
  # Build markup from preset (tags:value format)
58
109
  # @param preset_name [String] Name of preset
59
110
  # @param user_options [Hash] User-provided options that override preset values
@@ -143,7 +194,7 @@ module JekyllImgFlow
143
194
  # @param tags [Hash] Tags in key => value format
144
195
  # @return [String] Markup string
145
196
  def tags_to_markup(tags)
146
- tags.map { |key, value| "#{key}:#{value}" }.join(" ")
197
+ tags.map { |key, value| "#{key}:#{Shellwords.escape(value.to_s)}" }.join(" ")
147
198
  end
148
199
  end
149
200
  end
@@ -0,0 +1,7 @@
1
+ operations:
2
+ - resize:
3
+ width: 400
4
+ - format:
5
+ formats: ["avif", "webp", "jpg"]
6
+ - quality:
7
+ quality: 80
@@ -0,0 +1,7 @@
1
+ operations:
2
+ - resize:
3
+ width: 800
4
+ - format:
5
+ formats: ["avif", "webp", "jpg"]
6
+ - quality:
7
+ quality: 85
@@ -0,0 +1,7 @@
1
+ operations:
2
+ - resize:
3
+ width: 150
4
+ - format:
5
+ formats: ["webp", "jpg"]
6
+ - quality:
7
+ quality: 75
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllImgFlow
4
+ # ProcessingStats — collects cache hit/miss, per-operation timing, and
5
+ # compression ratio metrics during a Jekyll build. Accessed by the
6
+ # performance benchmark to report beyond wall-clock time.
7
+ class ProcessingStats
8
+ attr_reader :cache_hits, :cache_misses, :operation_timings, :compression_ratios
9
+
10
+ def initialize
11
+ @cache_hits = 0
12
+ @cache_misses = 0
13
+ @operation_timings = Hash.new { |h, k| h[k] = 0.0 }
14
+ @compression_ratios = {}
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def record_cache_hit
19
+ @mutex.synchronize { @cache_hits += 1 }
20
+ end
21
+
22
+ def record_cache_miss
23
+ @mutex.synchronize { @cache_misses += 1 }
24
+ end
25
+
26
+ # Record wall-clock time for a specific operation type (:resize, :format, etc.)
27
+ def record_operation_time(type, seconds)
28
+ @mutex.synchronize { @operation_timings[type] += seconds }
29
+ end
30
+
31
+ # Record compression ratio for a format (e.g. "webp" => 78.5 percent saved)
32
+ def record_compression_ratio(format, original_size, output_size)
33
+ return if original_size.zero?
34
+
35
+ ratio = ((original_size - output_size).to_f / original_size * 100).round(1)
36
+ @mutex.synchronize do
37
+ @compression_ratios[format] ||= { ratios: [], count: 0 }
38
+ @compression_ratios[format][:ratios] << ratio
39
+ @compression_ratios[format][:count] += 1
40
+ end
41
+ end
42
+
43
+ # Average compression ratio per format
44
+ def average_compression_ratios
45
+ @compression_ratios.transform_values do |data|
46
+ (data[:ratios].sum / data[:count]).round(1) if data[:count].positive?
47
+ end
48
+ end
49
+
50
+ def total_processed
51
+ @cache_hits + @cache_misses
52
+ end
53
+
54
+ def cache_hit_rate
55
+ return 0.0 if total_processed.zero?
56
+
57
+ (@cache_hits.to_f / total_processed * 100).round(1)
58
+ end
59
+
60
+ def to_h
61
+ {
62
+ cache_hits: @cache_hits,
63
+ cache_misses: @cache_misses,
64
+ cache_hit_rate: cache_hit_rate,
65
+ total_processed: total_processed,
66
+ operation_timings: @operation_timings.transform_values { |v| v.round(3) },
67
+ compression_ratios: average_compression_ratios
68
+ }
69
+ end
70
+
71
+ def reset
72
+ @mutex.synchronize do
73
+ @cache_hits = 0
74
+ @cache_misses = 0
75
+ @operation_timings.clear
76
+ @compression_ratios.clear
77
+ end
78
+ end
79
+ end
80
+ end
@@ -12,6 +12,9 @@ module JekyllImgFlow
12
12
  self.class.discover_providers unless self.class.providers_discovered?
13
13
  end
14
14
 
15
+ # Files in providers/ that are not provider classes (modules, helpers)
16
+ NON_PROVIDER_FILES = %w[base_provider.rb].freeze
17
+
15
18
  # Dynamic provider discovery
16
19
  def self.discover_providers
17
20
  return if @providers_discovered
@@ -20,7 +23,8 @@ module JekyllImgFlow
20
23
  providers_dir = File.join(File.dirname(__FILE__), "providers")
21
24
 
22
25
  Dir.glob(File.join(providers_dir, "*.rb")).each do |file|
23
- next if File.basename(file) == "base_provider.rb"
26
+ basename = File.basename(file)
27
+ next if NON_PROVIDER_FILES.include?(basename)
24
28
 
25
29
  provider_name = File.basename(file, ".rb")
26
30
  register_provider(provider_name)
@@ -72,9 +76,20 @@ module JekyllImgFlow
72
76
  end
73
77
  end
74
78
 
75
- # Get current provider (first available from priority list)
79
+ # Get current provider (first available from priority list).
80
+ # If no provider is available, logs a warning and returns nil.
81
+ # The caller should check for nil and handle gracefully.
76
82
  def current_provider
77
- providers.find(&:available?)
83
+ provider = providers.find(&:available?)
84
+ return provider if provider
85
+
86
+ configured = @config.backend_priority.join(", ")
87
+ Jekyll.logger.warn "ImgFlow:",
88
+ "No available image provider found. " \
89
+ "Configured backends: #{configured}. " \
90
+ "Install one of: sharp, libvips, imagemagick, " \
91
+ "or start the Docker HTTP providers."
92
+ nil
78
93
  end
79
94
 
80
95
  # Get list of available provider names
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "English"
4
3
  require "open3"
5
4
  require "pathname"
6
5
 
@@ -158,6 +157,13 @@ module JekyllImgFlow
158
157
  # Helper methods
159
158
  protected
160
159
 
160
+ # Check if the input file is an SVG (vector format).
161
+ # SVGs require special handling: they have no fixed pixel dimensions,
162
+ # so providers must choose a rasterization size.
163
+ def svg?(input_path)
164
+ File.extname(input_path).downcase == ".svg"
165
+ end
166
+
161
167
  def execute_command(command)
162
168
  stdout, stderr, status = Open3.capture3(command)
163
169
  raise "Command failed: #{command}\nError: #{stderr.strip}" unless status.success?
@@ -185,11 +191,16 @@ module JekyllImgFlow
185
191
  end
186
192
 
187
193
  # Provider capability methods
194
+ KNOWN_OPERATIONS = %i[crop format opacity optimize quality resize
195
+ watermark alpha_opacity].freeze
196
+
188
197
  def unsupported_operations
189
198
  [] # Default: no unsupported operations
190
199
  end
191
200
 
192
201
  def supports_operation?(operation)
202
+ return false unless KNOWN_OPERATIONS.include?(operation)
203
+
193
204
  !unsupported_operations.include?(operation)
194
205
  end
195
206
  end
@@ -26,8 +26,9 @@ module JekyllImgFlow
26
26
 
27
27
  # Fetch the result in one request
28
28
  fetch_and_save(url, output_path)
29
- reset_operations
30
29
  output_path
30
+ ensure
31
+ reset_operations
31
32
  end
32
33
 
33
34
  def build_combined_flyimg_url(input_path)
@@ -8,6 +8,10 @@ module JekyllImgFlow
8
8
  module Providers
9
9
  # ImageMagick provider implementation using the standardized tag interface
10
10
  class Imagemagick < BaseProvider
11
+ # Cache rsvg delegate check across all instances (checked once per build)
12
+ @rsvg_available = nil
13
+ @svg_warning_shown = false
14
+
11
15
  def available?
12
16
  # Check if magick or convert CLI is available
13
17
  _, _, status1 = Open3.capture3("which", "magick")
@@ -18,17 +22,30 @@ module JekyllImgFlow
18
22
  def execute(input_path, output_path)
19
23
  return if @operations.empty?
20
24
 
25
+ # Warn once per build about SVG performance with ImageMagick
26
+ warn_svg_performance if svg?(input_path) && !self.class.instance_variable_get(:@svg_warning_shown)
27
+
21
28
  # Build single ImageMagick command with all operations combined
22
29
  command = build_combined_imagemagick_command(input_path, output_path)
23
30
  execute_command(command)
24
31
 
25
- reset_operations
26
32
  output_path
33
+ ensure
34
+ reset_operations
27
35
  end
28
36
 
29
37
  def build_combined_imagemagick_command(input_path, output_path)
30
- # Start with base command
31
- command_parts = ["magick", input_path.shellescape]
38
+ # Start with base command.
39
+ # For SVGs, set -density before the input so ImageMagick rasterizes
40
+ # at a reasonable resolution instead of the full viewBox (which can
41
+ # be 10000x8500 = 85M pixels, making each conversion take 10+ seconds).
42
+ # With the rsvg delegate installed, -density controls the render DPI.
43
+ # Without rsvg, ImageMagick uses its slow internal MSVG parser.
44
+ command_parts = if svg?(input_path)
45
+ ["magick", "-density", svg_density.to_s, input_path.shellescape]
46
+ else
47
+ ["magick", input_path.shellescape]
48
+ end
32
49
 
33
50
  # Add all operations
34
51
  @operations.each do |operation|
@@ -133,6 +150,58 @@ module JekyllImgFlow
133
150
  # ImageMagick uses 1-100 directly, no translation needed
134
151
  quality
135
152
  end
153
+
154
+ # Choose a rasterization density (DPI) for SVG input.
155
+ # ImageMagick's internal SVG parser renders at the full viewBox size
156
+ # (e.g. 10000x8500 = 85M pixels) before applying -resize, which is
157
+ # extremely slow. Setting -density before the input tells the rsvg
158
+ # delegate to render at a lower resolution.
159
+ # We target ~2x the largest resize dimension for good quality,
160
+ # assuming a ~10 inch viewBox (common for SVGs). This avoids the
161
+ # massive internal canvas while preserving output quality.
162
+ # For a 400px output: density = 80px/in / 10in = 8 DPI → renders at
163
+ # ~800px instead of 10000px, giving ~10x speedup.
164
+ def svg_density
165
+ max_width = @operations.filter_map { |op| op[:type] == :resize && op[:width] }.max
166
+ # Default to 36 DPI if no resize (e.g. format-only conversion)
167
+ return 36 unless max_width
168
+
169
+ # Target 2x output width for quality. Assume ~10 inch viewBox.
170
+ # No upper cap — even 2000px output only needs 40 DPI.
171
+ (max_width * 2 / 10).to_i
172
+ end
173
+
174
+ # Check if ImageMagick has the rsvg delegate installed.
175
+ # Without it, ImageMagick uses its slow internal MSVG parser.
176
+ # Install with: macOS: `brew install librsvg`, Ubuntu: `apt install librsvg2-bin`
177
+ def rsvg_available?
178
+ return self.class.instance_variable_get(:@rsvg_available) unless self.class.instance_variable_get(:@rsvg_available).nil?
179
+
180
+ stdout, _, status = Open3.capture3("magick", "-list", "delegate")
181
+ result = status.success? && stdout.include?("rsvg-convert")
182
+ self.class.instance_variable_set(:@rsvg_available, result)
183
+ result
184
+ end
185
+
186
+ # Warn once per build about SVG performance with ImageMagick.
187
+ # Suggests installing rsvg-convert or using a different provider.
188
+ def warn_svg_performance
189
+ return if self.class.instance_variable_get(:@svg_warning_shown)
190
+
191
+ self.class.instance_variable_set(:@svg_warning_shown, true)
192
+ if rsvg_available?
193
+ Jekyll.logger.info "ImgFlow:",
194
+ "ImageMagick processing SVG with rsvg delegate " \
195
+ "(density=#{svg_density})."
196
+ else
197
+ Jekyll.logger.warn "ImgFlow:",
198
+ "ImageMagick is processing SVGs without the rsvg " \
199
+ "delegate — this is VERY slow. Install librsvg " \
200
+ "(macOS: `brew install librsvg`, " \
201
+ "Ubuntu: `apt install librsvg2-bin`) or use a " \
202
+ "different provider (sharp/libvips) for SVGs."
203
+ end
204
+ end
136
205
  end
137
206
  end
138
207
  end
@@ -26,8 +26,9 @@ module JekyllImgFlow
26
26
 
27
27
  # Fetch the result in one request
28
28
  fetch_and_save(url, output_path)
29
- reset_operations
30
29
  output_path
30
+ ensure
31
+ reset_operations
31
32
  end
32
33
 
33
34
  def build_combined_imgproxy_url(input_path)