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.
- checksums.yaml +7 -0
- data/LICENSE +661 -0
- data/README.md +214 -0
- data/lib/jekyll-imgflow/batch_manager.rb +263 -0
- data/lib/jekyll-imgflow/build_time_processor.rb +152 -0
- data/lib/jekyll-imgflow/config.rb +130 -0
- data/lib/jekyll-imgflow/filename_generator.rb +102 -0
- data/lib/jekyll-imgflow/helpers/http_downloader.rb +68 -0
- data/lib/jekyll-imgflow/hooks.rb +57 -0
- data/lib/jekyll-imgflow/html_generator.rb +335 -0
- data/lib/jekyll-imgflow/imgflow_tag.rb +247 -0
- data/lib/jekyll-imgflow/manifest_manager.rb +297 -0
- data/lib/jekyll-imgflow/operation_processor.rb +183 -0
- data/lib/jekyll-imgflow/parser.rb +199 -0
- data/lib/jekyll-imgflow/path_resolver.rb +130 -0
- data/lib/jekyll-imgflow/picture_tag_adaptor.rb +299 -0
- data/lib/jekyll-imgflow/picture_tag_preset_migrator.rb +210 -0
- data/lib/jekyll-imgflow/preset_manager.rb +149 -0
- data/lib/jekyll-imgflow/provider_registry.rb +106 -0
- data/lib/jekyll-imgflow/providers/base_provider.rb +198 -0
- data/lib/jekyll-imgflow/providers/flyimg.rb +187 -0
- data/lib/jekyll-imgflow/providers/imagemagick.rb +138 -0
- data/lib/jekyll-imgflow/providers/imgproxy.rb +164 -0
- data/lib/jekyll-imgflow/providers/libvips.rb +292 -0
- data/lib/jekyll-imgflow/providers/sharp.rb +213 -0
- data/lib/jekyll-imgflow/providers/weserv.rb +160 -0
- data/lib/jekyll-imgflow/tag_scanner.rb +227 -0
- data/lib/jekyll-imgflow/tags/base_tag.rb +52 -0
- data/lib/jekyll-imgflow/tags/crop_tag.rb +220 -0
- data/lib/jekyll-imgflow/tags/format_tag.rb +64 -0
- data/lib/jekyll-imgflow/tags/opacity_tag.rb +36 -0
- data/lib/jekyll-imgflow/tags/optimize_tag.rb +52 -0
- data/lib/jekyll-imgflow/tags/quality_tag.rb +31 -0
- data/lib/jekyll-imgflow/tags/resize_tag.rb +58 -0
- data/lib/jekyll-imgflow/tags/tag_registry.rb +70 -0
- data/lib/jekyll-imgflow/tags/watermark_tag.rb +51 -0
- data/lib/jekyll-imgflow/version.rb +5 -0
- data/lib/jekyll-imgflow.rb +30 -0
- 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("&", "&")
|
|
302
|
+
.gsub('"', """)
|
|
303
|
+
.gsub("'", "'")
|
|
304
|
+
.gsub("<", "<")
|
|
305
|
+
.gsub(">", ">")
|
|
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
|