jekyll-imgflow 0.1.5

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +661 -0
  3. data/README.md +214 -0
  4. data/lib/jekyll-imgflow/batch_manager.rb +263 -0
  5. data/lib/jekyll-imgflow/build_time_processor.rb +152 -0
  6. data/lib/jekyll-imgflow/config.rb +130 -0
  7. data/lib/jekyll-imgflow/filename_generator.rb +102 -0
  8. data/lib/jekyll-imgflow/helpers/http_downloader.rb +68 -0
  9. data/lib/jekyll-imgflow/hooks.rb +57 -0
  10. data/lib/jekyll-imgflow/html_generator.rb +335 -0
  11. data/lib/jekyll-imgflow/imgflow_tag.rb +247 -0
  12. data/lib/jekyll-imgflow/manifest_manager.rb +297 -0
  13. data/lib/jekyll-imgflow/operation_processor.rb +183 -0
  14. data/lib/jekyll-imgflow/parser.rb +199 -0
  15. data/lib/jekyll-imgflow/path_resolver.rb +130 -0
  16. data/lib/jekyll-imgflow/picture_tag_adaptor.rb +299 -0
  17. data/lib/jekyll-imgflow/picture_tag_preset_migrator.rb +210 -0
  18. data/lib/jekyll-imgflow/preset_manager.rb +149 -0
  19. data/lib/jekyll-imgflow/provider_registry.rb +106 -0
  20. data/lib/jekyll-imgflow/providers/base_provider.rb +198 -0
  21. data/lib/jekyll-imgflow/providers/flyimg.rb +187 -0
  22. data/lib/jekyll-imgflow/providers/imagemagick.rb +138 -0
  23. data/lib/jekyll-imgflow/providers/imgproxy.rb +164 -0
  24. data/lib/jekyll-imgflow/providers/libvips.rb +292 -0
  25. data/lib/jekyll-imgflow/providers/sharp.rb +213 -0
  26. data/lib/jekyll-imgflow/providers/weserv.rb +160 -0
  27. data/lib/jekyll-imgflow/tag_scanner.rb +227 -0
  28. data/lib/jekyll-imgflow/tags/base_tag.rb +52 -0
  29. data/lib/jekyll-imgflow/tags/crop_tag.rb +220 -0
  30. data/lib/jekyll-imgflow/tags/format_tag.rb +64 -0
  31. data/lib/jekyll-imgflow/tags/opacity_tag.rb +36 -0
  32. data/lib/jekyll-imgflow/tags/optimize_tag.rb +52 -0
  33. data/lib/jekyll-imgflow/tags/quality_tag.rb +31 -0
  34. data/lib/jekyll-imgflow/tags/resize_tag.rb +58 -0
  35. data/lib/jekyll-imgflow/tags/tag_registry.rb +70 -0
  36. data/lib/jekyll-imgflow/tags/watermark_tag.rb +51 -0
  37. data/lib/jekyll-imgflow/version.rb +5 -0
  38. data/lib/jekyll-imgflow.rb +30 -0
  39. metadata +261 -0
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllImgFlow
4
+ class Config
5
+ attr_reader :site, :originals, :output, :input_formats, :sizes, :formats,
6
+ :quality, :backend_priority, :imgproxy_url,
7
+ :image_compressor_url, :weserv_url, :flyimg_url,
8
+ :optimize_qualities
9
+
10
+ def initialize(site)
11
+ shared = site.config["shared_images_configs"] || {}
12
+ overrides = site.config["imgflow"] || {}
13
+ cfg = shared.merge(overrides)
14
+
15
+ @site = site
16
+ @originals = cfg["originals"]
17
+ @output = cfg["output"]
18
+ @input_formats = cfg["input_formats"]
19
+ @sizes = cfg["sizes"]
20
+ @formats = cfg["formats"]
21
+ @quality = cfg["quality"]
22
+ @backend_priority = cfg["backend_priority"]
23
+
24
+ # Validate required config fields
25
+ raise ArgumentError, "No originals configured in unified config" unless @originals
26
+ raise ArgumentError, "No output configured in unified config" unless @output
27
+ raise ArgumentError, "No input_formats configured in unified config" unless @input_formats
28
+ raise ArgumentError, "No formats configured in unified config" unless @formats
29
+ raise ArgumentError, "No sizes configured in unified config" unless @sizes
30
+
31
+ @imgproxy_url = cfg["imgproxy_url"]
32
+ @image_compressor_url = cfg["image_compressor_url"]
33
+ @weserv_url = cfg["weserv_url"]
34
+ @flyimg_url = cfg["flyimg_url"]
35
+ @optimize_qualities = cfg["optimize_qualities"] || {
36
+ "low" => 30,
37
+ "medium" => 50, # Uses default quality
38
+ "high" => 85, # Uses default quality
39
+ "maximum" => 95,
40
+ "default" => 75
41
+ }
42
+
43
+ validate_backend_priority!
44
+ end
45
+
46
+ def cache_dir
47
+ merged_config["cache_dir"] || ".cache/imgflow"
48
+ end
49
+
50
+ def docker_config
51
+ {
52
+ enabled: merged_config.key?("imgproxy_url") ||
53
+ merged_config.key?("weserv_url") ||
54
+ merged_config.key?("flyimg_url") ||
55
+ merged_config.key?("image_compressor_url"),
56
+ imgproxy_url: @imgproxy_url,
57
+ weserv_url: @weserv_url,
58
+ flyimg_url: @flyimg_url,
59
+ image_compressor_url: @image_compressor_url
60
+ }
61
+ end
62
+
63
+ def docker_enabled
64
+ docker_config[:enabled]
65
+ end
66
+
67
+ def sharp_url
68
+ merged_config["sharp_url"]
69
+ end
70
+
71
+ # Determine if params represent a default or specialized version
72
+ # @param params [Hash] Operation parameters
73
+ # @return [Symbol] :default or :specialized
74
+ def determine_version_type(params)
75
+ if params[:width] && @sizes.value?(params[:width]) &&
76
+ (!params[:format] || @formats.include?(params[:format])) &&
77
+ (!params[:quality] || params[:quality] == @quality)
78
+ :default
79
+ else
80
+ :specialized
81
+ end
82
+ end
83
+
84
+ # Format validation methods
85
+ def supported_input_format?(format)
86
+ normalized_format = format.to_s.downcase.delete(".")
87
+ @input_formats.map(&:downcase).include?(normalized_format)
88
+ end
89
+
90
+ def supported_output_format?(format)
91
+ normalized_format = format.to_s.downcase.delete(".")
92
+ @formats.map(&:downcase).include?(normalized_format)
93
+ end
94
+
95
+ def validate_input_format!(format)
96
+ return if supported_input_format?(format)
97
+
98
+ raise ArgumentError,
99
+ "Unsupported input format '#{format}'. Supported formats: #{@input_formats.join(', ')}"
100
+ end
101
+
102
+ def validate_output_format!(format)
103
+ return if supported_output_format?(format)
104
+
105
+ raise ArgumentError,
106
+ "Unsupported output format '#{format}'. Supported formats: #{@formats.join(', ')}"
107
+ end
108
+
109
+ # Get normalized format lists
110
+ def normalized_input_formats
111
+ @input_formats.map(&:downcase)
112
+ end
113
+
114
+ def normalized_output_formats
115
+ @formats.map(&:downcase)
116
+ end
117
+
118
+ private
119
+
120
+ def merged_config
121
+ shared = @site.config["shared_images_configs"] || {}
122
+ overrides = @site.config["imgflow"] || {}
123
+ shared.merge(overrides)
124
+ end
125
+
126
+ def validate_backend_priority!
127
+ # No manual-only providers to validate
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module JekyllImgFlow
7
+ # Generates unique filenames for processed images
8
+ # Compatible with Jekyll Picture Tag format
9
+ class FilenameGenerator
10
+ # Generate filename for processed image (Jekyll Picture Tag compatible)
11
+ # @param original_name [String] Original image filename
12
+ # @param operations [Hash] Operations applied to image
13
+ # @return [String] Generated filename (without path)
14
+ def generate_filename(original_name, operations)
15
+ base_name = File.basename(original_name, ".*")
16
+ width = operations[:width]
17
+ format = operations[:format] || File.extname(original_name).delete(".")
18
+
19
+ # Generate hash using Jekyll Picture Tag approach
20
+ hash = generate_jpt_hash(original_name, operations)
21
+
22
+ "#{base_name}-#{width}-#{hash}.#{format}"
23
+ end
24
+
25
+ # Generate cache key for operations (SHA256 for manifest tracking)
26
+ # @param operations [Hash] Operations hash
27
+ # @return [String] Cache key
28
+ def generate_cache_key(operations)
29
+ # Sort keys to ensure consistent hashing
30
+ sorted_operations = operations.sort.to_h
31
+ Digest::SHA256.hexdigest(sorted_operations.to_json)
32
+ end
33
+
34
+ # Get file digest for manifest tracking
35
+ # @param original_path [String] Path to original image
36
+ # @return [String] SHA256 digest of file
37
+ def file_digest(original_path)
38
+ Digest::SHA256.file(original_path).hexdigest
39
+ end
40
+
41
+ # Parse filename to extract width and hash
42
+ # @param filename [String] Generated filename
43
+ # @return [Hash] Parsed components {base_name, width, hash, format}
44
+ def parse_filename(filename)
45
+ # Parse pattern: basename-width-hash.format - simple string operations
46
+ # Find last dot to separate format
47
+ last_dot = filename.rindex(".")
48
+ return {} unless last_dot
49
+
50
+ format = filename[(last_dot + 1)..]
51
+ name_part = filename[0...last_dot]
52
+
53
+ # Find last dash to separate hash
54
+ last_dash = name_part.rindex("-")
55
+ return {} unless last_dash
56
+
57
+ hash = name_part[(last_dash + 1)..]
58
+ name_without_hash = name_part[0...last_dash]
59
+
60
+ # Find second-to-last dash to separate width
61
+ second_last_dash = name_without_hash.rindex("-")
62
+ return {} unless second_last_dash
63
+
64
+ width_str = name_without_hash[(second_last_dash + 1)..]
65
+ base_name = name_without_hash[0...second_last_dash]
66
+
67
+ # Validate components
68
+ return {} unless hash.match?(/^[a-f0-9]+$/)
69
+ return {} unless width_str.match?(/^\d+$/)
70
+
71
+ {
72
+ base_name: base_name,
73
+ width: width_str.to_i,
74
+ hash: hash,
75
+ format: format
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ # Generate hash using Jekyll Picture Tag approach
82
+ # @param original_path [String] Path to original image (relative or absolute)
83
+ # @param operations [Hash] Operations hash
84
+ # @return [String] 9-character MD5 hash
85
+ def generate_jpt_hash(original_path, operations)
86
+ # Get filename digest (MD5 of filename) - exactly like JPT
87
+ filename = File.basename(original_path)
88
+ file_digest = Digest::MD5.hexdigest(filename)
89
+
90
+ # Build settings array (like Jekyll Picture Tag)
91
+ settings = [
92
+ file_digest,
93
+ operations[:crop],
94
+ operations[:keep],
95
+ operations[:quality]
96
+ ]
97
+
98
+ # Generate MD5 hash of settings (9 chars like JPT)
99
+ Digest::MD5.hexdigest(settings.join)[0..8]
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ module JekyllImgFlow
10
+ module Helpers
11
+ # HTTP Downloader - handles downloading images from HTTP/HTTPS/file:// URLs
12
+ class HttpDownloader
13
+ # Download a file from URL to temporary location
14
+ # @param url [String] URL to download (http://, https://, or file://)
15
+ # @return [String] Path to downloaded temporary file
16
+ def self.download(url)
17
+ uri = URI(url)
18
+
19
+ # Handle file:// URLs for local file processing
20
+ if uri.scheme == "file"
21
+ download_file_url(uri)
22
+ else
23
+ download_http_url(uri)
24
+ end
25
+ end
26
+
27
+ private_class_method def self.download_file_url(uri)
28
+ # For file:// URLs, copy the local file to temp
29
+ source_path = uri.path
30
+ ext = File.extname(source_path)
31
+ tmp = File.join(Dir.tmpdir, "imgflow-#{SecureRandom.hex}#{ext}")
32
+ FileUtils.cp(source_path, tmp)
33
+ tmp
34
+ end
35
+
36
+ private_class_method def self.download_http_url(uri)
37
+ # For HTTP/HTTPS URLs, download the file
38
+ ext = File.extname(uri.path)
39
+ tmp = File.join(Dir.tmpdir, "imgflow-#{SecureRandom.hex}#{ext}")
40
+
41
+ begin
42
+ response = Net::HTTP.get_response(uri)
43
+
44
+ # Handle redirects
45
+ if response.is_a?(Net::HTTPRedirection)
46
+ location = response["location"]
47
+ raise "Redirect without location" unless location
48
+
49
+ # Follow the redirect
50
+ redirect_uri = URI(location)
51
+ redirect_uri = URI("#{uri.scheme}://#{uri.host}#{location}") if location.start_with?("/")
52
+
53
+ response = Net::HTTP.get_response(redirect_uri)
54
+ end
55
+
56
+ raise "HTTP request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
57
+
58
+ File.write(tmp, response.body, mode: "wb")
59
+ rescue StandardError => e
60
+ FileUtils.rm_f(tmp)
61
+ raise "Failed to download #{uri}: #{e.message}"
62
+ end
63
+
64
+ tmp
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllImgFlow
4
+ # Initialize ImgFlow components on site
5
+ Jekyll::Hooks.register :site, :after_init do |site|
6
+ Jekyll.logger.info "🔌 ImgFlow: Initializing components on site"
7
+
8
+ # Add imgflow_components attribute to site if not already present
9
+ unless site.respond_to?(:imgflow_components)
10
+ site.define_singleton_method(:imgflow_components) { @imgflow_components }
11
+ site.define_singleton_method(:imgflow_components=) { |value| @imgflow_components = value }
12
+ end
13
+
14
+ # Components will be initialized on first use (lazy loading)
15
+ Jekyll.logger.info "✅ ImgFlow: Components ready for initialization"
16
+ end
17
+
18
+ # BuildTimeProcessor runs BEFORE template rendering - ideal architecture
19
+ Jekyll::Hooks.register :site, :pre_render do |site|
20
+ Jekyll.logger.info "🔌 ImgFlow pre_render hook - creating default versions"
21
+
22
+ # Initialize and run BuildTimeProcessor
23
+ build_time_processor = BuildTimeProcessor.new(site)
24
+ build_time_processor.process_changed_images
25
+
26
+ Jekyll.logger.info "✅ ImgFlow: Default versions created and manifest saved"
27
+ end
28
+
29
+ # Save final manifest after template rendering (single writer)
30
+ Jekyll::Hooks.register :site, :post_write do |site|
31
+ Jekyll.logger.info "🔌 ImgFlow post_write hook - saving final manifest with page usage"
32
+
33
+ if site.imgflow_components && site.imgflow_components[:manifest]
34
+ site.imgflow_components[:manifest].save
35
+ Jekyll.logger.info "✅ ImgFlow: Final manifest saved with all page usage data"
36
+ else
37
+ Jekyll.logger.warn "⚠️ ImgFlow: No manifest found to save"
38
+ end
39
+
40
+ # Clean up orphaned specialized images in production
41
+ cleanup_orphaned_images(site) if Jekyll.env != "development"
42
+ end
43
+
44
+ def self.cleanup_orphaned_images(site)
45
+ Jekyll.logger.info "🧹 ImgFlow: Checking for orphaned specialized images..."
46
+
47
+ # Use ManifestManager to find and cleanup orphans
48
+ manifest = ManifestManager.new(site)
49
+ cleaned = manifest.cleanup_orphans
50
+
51
+ if cleaned.any?
52
+ Jekyll.logger.info "✅ ImgFlow: Removed #{cleaned.length} orphaned specialized images"
53
+ else
54
+ Jekyll.logger.info "✅ ImgFlow: No orphaned images found"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllImgFlow
4
+ # Unified HTML Generator for all markup formats
5
+ # Supports Picture Tag compatibility with multiple markup formats and attributes
6
+ class HtmlGenerator
7
+ MARKUP_FORMATS = %w[auto picture img data_auto data_picture data_img direct_url
8
+ naked_srcset].freeze
9
+
10
+ # Generate HTML based on markup format and attributes
11
+ # @param results [Array<String>] Processed image paths
12
+ # @param attributes [Hash] HTML attributes by element
13
+ # @param markup_format [String] Markup format (auto, picture, img, data_auto, etc.)
14
+ # @param context [Liquid::Context] Liquid context
15
+ # @param config [JekyllImgFlow::Config] ImgFlow configuration
16
+ # @return [String] Generated HTML
17
+ def self.generate(results, attributes, markup_format, context, config = nil)
18
+ return "" if results.empty?
19
+
20
+ generator = new(results, attributes, markup_format, context, config)
21
+ generator.generate
22
+ end
23
+
24
+ def initialize(results, attributes, markup_format, context, config = nil)
25
+ @results = results
26
+ @attributes = normalize_attributes(attributes)
27
+ @markup_format = markup_format || "img"
28
+ @context = context
29
+ @config = config
30
+ @path_resolver = config ? JekyllImgFlow::PathResolver.new(config) : nil
31
+ @fallback_formats = fallback_formats
32
+ end
33
+
34
+ def generate
35
+ html = case @markup_format
36
+ when "picture", "auto"
37
+ generate_picture_element
38
+ when "data_picture"
39
+ generate_data_picture_element
40
+ when "data_img", "data_auto"
41
+ generate_data_img_element
42
+ when "direct_url"
43
+ generate_direct_url
44
+ when "naked_srcset"
45
+ generate_naked_srcset
46
+ else
47
+ generate_img_element # Default fallback
48
+ end
49
+
50
+ # Wrap in link if specified
51
+ html = wrap_with_link(html) if @attributes[:link]
52
+
53
+ # Wrap in parent container if specified
54
+ html = wrap_with_parent(html) if @attributes[:parent]&.any?
55
+
56
+ html
57
+ end
58
+
59
+ private
60
+
61
+ # Get fallback formats from config or use sensible defaults
62
+ def fallback_formats
63
+ if @config&.formats
64
+ # Convert format names to extensions and add dots
65
+ @config.formats.map { |fmt| ".#{fmt}" }
66
+ else
67
+ # Fallback to common formats if no config available
68
+ %w[.jpg .jpeg .png]
69
+ end
70
+ end
71
+
72
+ # Normalize attributes from different sources
73
+ def normalize_attributes(attributes)
74
+ return default_attributes if attributes.nil? || attributes.empty?
75
+
76
+ {
77
+ img: attributes[:img] || {},
78
+ picture: attributes[:picture] || {},
79
+ source: attributes[:source] || {},
80
+ a: attributes[:a] || {},
81
+ parent: attributes[:parent] || {},
82
+ alt: attributes[:alt],
83
+ link: attributes[:link]
84
+ }
85
+ end
86
+
87
+ def default_attributes
88
+ {
89
+ img: {},
90
+ picture: {},
91
+ source: {},
92
+ a: {},
93
+ parent: {},
94
+ alt: nil,
95
+ link: nil
96
+ }
97
+ end
98
+
99
+ # Generate standard <picture> element with <source> tags
100
+ def generate_picture_element
101
+ picture_attrs = build_attributes(@attributes[:picture])
102
+ img_attrs = build_img_attributes
103
+
104
+ html = "<picture#{picture_attrs}>"
105
+
106
+ # Generate source elements for each format (except fallback)
107
+ format_groups = group_by_format(@results)
108
+
109
+ format_groups.each do |ext, files|
110
+ next if @fallback_formats.include?(ext) # Skip fallback formats
111
+
112
+ mime_type = mime_type_for(ext)
113
+ source_attrs = build_attributes(@attributes[:source])
114
+ srcset = files.map { |f| html_path(f) }.join(", ")
115
+
116
+ html << "<source srcset=\"#{srcset}\" type=\"#{mime_type}\"#{source_attrs}>"
117
+ end
118
+
119
+ # Fallback img element
120
+ fallback = find_fallback_image(@results)
121
+ html << "<img src=\"#{html_path(fallback)}\"#{img_attrs}>"
122
+ html << "</picture>"
123
+
124
+ html
125
+ end
126
+
127
+ # Generate standard <img> element
128
+ def generate_img_element
129
+ img_attrs = build_img_attributes
130
+ primary = @results.first
131
+
132
+ # Build srcset if multiple results
133
+ srcset_attr = if @results.length > 1
134
+ srcset = generate_srcset_string(@results)
135
+ " srcset=\"#{srcset}\""
136
+ else
137
+ ""
138
+ end
139
+
140
+ "<img src=\"#{html_path(primary)}\"#{srcset_attr}#{img_attrs}>"
141
+ end
142
+
143
+ # Generate <picture> element with data-* attributes for lazy loading
144
+ def generate_data_picture_element
145
+ picture_attrs = build_attributes(@attributes[:picture])
146
+ img_attrs = build_data_img_attributes
147
+
148
+ html = "<picture#{picture_attrs}>"
149
+
150
+ # Generate source elements with data-srcset
151
+ format_groups = group_by_format(@results)
152
+
153
+ format_groups.each do |ext, files|
154
+ next if @fallback_formats.include?(ext)
155
+
156
+ mime_type = mime_type_for(ext)
157
+ source_attrs = build_attributes(@attributes[:source])
158
+ srcset = files.map { |f| html_path(f) }.join(", ")
159
+
160
+ html << "<source data-srcset=\"#{srcset}\" type=\"#{mime_type}\"#{source_attrs}>"
161
+ end
162
+
163
+ # Fallback img with data-src
164
+ fallback = find_fallback_image(@results)
165
+ html << "<img data-src=\"#{html_path(fallback)}\"#{img_attrs}>"
166
+ html << "</picture>"
167
+
168
+ html
169
+ end
170
+
171
+ # Generate <img> element with data-* attributes for lazy loading
172
+ def generate_data_img_element
173
+ img_attrs = build_data_img_attributes
174
+ primary = @results.first
175
+
176
+ # Build data-srcset if multiple results
177
+ srcset_attr = if @results.length > 1
178
+ srcset = generate_srcset_string(@results)
179
+ " data-srcset=\"#{srcset}\""
180
+ else
181
+ ""
182
+ end
183
+
184
+ "<img data-src=\"#{html_path(primary)}\"#{srcset_attr}#{img_attrs}>"
185
+ end
186
+
187
+ # Generate just the URL (for direct_url format)
188
+ def generate_direct_url
189
+ absolute_url(@results.first)
190
+ end
191
+
192
+ # Generate just the srcset string (for naked_srcset format)
193
+ def generate_naked_srcset
194
+ generate_srcset_string(@results)
195
+ end
196
+
197
+ # Build img attributes including alt, loading, and custom attributes
198
+ def build_img_attributes
199
+ attrs = @attributes[:img].dup
200
+ attrs["alt"] = @attributes[:alt] if @attributes[:alt]
201
+ attrs["loading"] = "lazy" unless attrs["loading"]
202
+
203
+ build_attributes(attrs)
204
+ end
205
+
206
+ # Build img attributes with data-* for lazy loading
207
+ def build_data_img_attributes
208
+ attrs = @attributes[:img].dup
209
+ attrs["alt"] = @attributes[:alt] if @attributes[:alt]
210
+ attrs["class"] = [attrs["class"], "lazy"].compact.join(" ")
211
+
212
+ build_attributes(attrs)
213
+ end
214
+
215
+ # Build HTML attribute string from hash
216
+ def build_attributes(attrs_hash)
217
+ return "" if attrs_hash.nil? || attrs_hash.empty?
218
+
219
+ attrs_hash.filter_map do |key, value|
220
+ if value.is_a?(TrueClass)
221
+ " #{escape_attr_name(key)}" # Boolean attribute
222
+ elsif value.is_a?(FalseClass) || value.nil?
223
+ nil # Skip false/nil attributes
224
+ else
225
+ " #{escape_attr_name(key)}=\"#{escape_attr_value(value)}\""
226
+ end
227
+ end.join
228
+ end
229
+
230
+ # Wrap HTML in anchor tag
231
+ def wrap_with_link(html)
232
+ link_attrs = build_attributes(@attributes[:a])
233
+ href = escape_attr_value(@attributes[:link])
234
+
235
+ "<a href=\"#{href}\"#{link_attrs}>#{html}</a>"
236
+ end
237
+
238
+ # Wrap HTML in parent container
239
+ def wrap_with_parent(html)
240
+ parent_attrs = build_attributes(@attributes[:parent])
241
+
242
+ "<div#{parent_attrs}>#{html}</div>"
243
+ end
244
+
245
+ # Group results by file extension/format
246
+ def group_by_format(results)
247
+ results.group_by { |r| File.extname(r).downcase }
248
+ end
249
+
250
+ # Find fallback image (prefer jpg/jpeg/png)
251
+ def find_fallback_image(results)
252
+ results.find { |r| r.match?(/\.(jpe?g|png)$/i) } || results.first
253
+ end
254
+
255
+ # Generate srcset string from results
256
+ def generate_srcset_string(results)
257
+ results.map do |result|
258
+ width = extract_width_from_filename(result)
259
+ descriptor = width ? "#{width}w" : "1x"
260
+ "#{absolute_url(result)} #{descriptor}"
261
+ end.join(", ")
262
+ end
263
+
264
+ # Extract width from filename (format: base_name-width-hash.format)
265
+ def extract_width_from_filename(result)
266
+ filename = File.basename(result, ".*")
267
+ match = filename.match(/-(\d+)-[a-f0-9]+$/)
268
+ match ? match[1].to_i : nil
269
+ end
270
+
271
+ # Get MIME type for file extension
272
+ def mime_type_for(ext)
273
+ case ext.downcase
274
+ when ".webp"
275
+ "image/webp"
276
+ when ".avif"
277
+ "image/avif"
278
+ when ".jp2"
279
+ "image/jp2"
280
+ when ".jxr"
281
+ "image/jxr"
282
+ when ".png"
283
+ "image/png"
284
+ when ".svg"
285
+ "image/svg+xml"
286
+ when ".gif"
287
+ "image/gif"
288
+ else
289
+ "image/jpeg" # .jpg, .jpeg, and default
290
+ end
291
+ end
292
+
293
+ # Escape HTML attribute name
294
+ def escape_attr_name(name)
295
+ name.to_s.gsub(/[^a-zA-Z0-9\-_:]/, "")
296
+ end
297
+
298
+ # Escape HTML attribute value
299
+ def escape_attr_value(value)
300
+ value.to_s
301
+ .gsub("&", "&amp;")
302
+ .gsub('"', "&quot;")
303
+ .gsub("'", "&#39;")
304
+ .gsub("<", "&lt;")
305
+ .gsub(">", "&gt;")
306
+ end
307
+
308
+ # Get correct path for HTML generation (relative paths for Jekyll sites)
309
+ def html_path(relative_path)
310
+ # For HTML generation, we want relative paths within the site
311
+ # Remove leading slash to make it relative to site root
312
+ relative_path.start_with?("/") ? relative_path[1..] : relative_path
313
+ end
314
+
315
+ # Get absolute URL when needed (for direct_url format)
316
+ def absolute_url(relative_path)
317
+ return relative_path unless @context && @context.registers[:site]
318
+
319
+ site = @context.registers[:site]
320
+ base_url = site.config["url"] || "http://localhost:4000"
321
+ baseurl = site.config["baseurl"] || ""
322
+
323
+ # Ensure base_url ends without trailing slash and baseurl starts without slash
324
+ base_url = base_url.chomp("/")
325
+ baseurl = baseurl[1..] if baseurl.start_with?("/")
326
+
327
+ # Build URL parts carefully to avoid double slashes
328
+ url_parts = [base_url]
329
+ url_parts << baseurl unless baseurl.empty?
330
+ url_parts << html_path(relative_path)
331
+
332
+ url_parts.join("/")
333
+ end
334
+ end
335
+ end