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.
- checksums.yaml +4 -4
- data/README.md +24 -13
- data/lib/jekyll-imgflow/batch_manager.rb +10 -7
- data/lib/jekyll-imgflow/build_time_processor.rb +70 -61
- data/lib/jekyll-imgflow/config.rb +3 -6
- data/lib/jekyll-imgflow/filename_generator.rb +8 -10
- data/lib/jekyll-imgflow/generated_file_cleaner.rb +81 -0
- data/lib/jekyll-imgflow/hooks.rb +13 -6
- data/lib/jekyll-imgflow/html_generator.rb +14 -19
- data/lib/jekyll-imgflow/imgflow_tag.rb +84 -69
- data/lib/jekyll-imgflow/manifest_manager.rb +132 -53
- data/lib/jekyll-imgflow/operation_processor.rb +43 -22
- data/lib/jekyll-imgflow/picture_tag_adaptor.rb +1 -1
- data/lib/jekyll-imgflow/preset_manager.rb +58 -7
- data/lib/jekyll-imgflow/presets/gallery.yml +7 -0
- data/lib/jekyll-imgflow/presets/hero.yml +7 -0
- data/lib/jekyll-imgflow/presets/thumbnail.yml +7 -0
- data/lib/jekyll-imgflow/processing_stats.rb +80 -0
- data/lib/jekyll-imgflow/provider_registry.rb +18 -3
- data/lib/jekyll-imgflow/providers/base_provider.rb +12 -1
- data/lib/jekyll-imgflow/providers/flyimg.rb +2 -1
- data/lib/jekyll-imgflow/providers/imagemagick.rb +72 -3
- data/lib/jekyll-imgflow/providers/imgproxy.rb +2 -1
- data/lib/jekyll-imgflow/providers/libvips.rb +152 -100
- data/lib/jekyll-imgflow/providers/sharp.rb +2 -1
- data/lib/jekyll-imgflow/providers/weserv.rb +3 -2
- data/lib/jekyll-imgflow/tags/opacity_tag.rb +8 -1
- data/lib/jekyll-imgflow/tags/resize_tag.rb +2 -0
- data/lib/jekyll-imgflow/tags/watermark_tag.rb +8 -1
- data/lib/jekyll-imgflow/tasks.rb +129 -0
- data/lib/jekyll-imgflow/version.rb +1 -1
- data/lib/jekyll-imgflow.rb +2 -0
- metadata +24 -156
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
3
5
|
module Jekyll
|
|
4
6
|
class ImgflowTag < Liquid::Tag
|
|
5
7
|
def initialize(tag_name, markup, tokens)
|
|
@@ -36,7 +38,7 @@ module Jekyll
|
|
|
36
38
|
|
|
37
39
|
def expand_preset_markup(markup, preset_manager)
|
|
38
40
|
# Parse markup to extract image path, preset name, and user options
|
|
39
|
-
parts =
|
|
41
|
+
parts = Shellwords.split(markup)
|
|
40
42
|
image_path = parts.first
|
|
41
43
|
|
|
42
44
|
# Extract preset name and user options
|
|
@@ -55,8 +57,14 @@ module Jekyll
|
|
|
55
57
|
# Get preset markup from PresetManager
|
|
56
58
|
preset_markup = preset_manager.build_markup_from_preset(preset_name, user_options)
|
|
57
59
|
|
|
58
|
-
# Combine image path with preset markup
|
|
59
|
-
|
|
60
|
+
# Combine image path with preset markup. Keep the path quoted because
|
|
61
|
+
# filenames may contain spaces.
|
|
62
|
+
image_markup = if image_path.include?(" ")
|
|
63
|
+
"\"#{image_path.gsub('"', '\\"')}\""
|
|
64
|
+
else
|
|
65
|
+
image_path
|
|
66
|
+
end
|
|
67
|
+
"#{image_markup} #{preset_markup}"
|
|
60
68
|
end
|
|
61
69
|
|
|
62
70
|
def process_operations(components, parsed, context)
|
|
@@ -82,73 +90,77 @@ module Jekyll
|
|
|
82
90
|
"unknown"
|
|
83
91
|
end
|
|
84
92
|
|
|
85
|
-
# Process operations using clean architecture
|
|
86
93
|
operations = parsed[:operations]
|
|
94
|
+
results = if operations.empty?
|
|
95
|
+
[input_path]
|
|
96
|
+
else
|
|
97
|
+
process_variants(components, operations.first, original_name, input_path,
|
|
98
|
+
page_path)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
relative_results = results.uniq.map { |result| relative_result_path(result, site) }
|
|
102
|
+
parsed = parsed.merge(markup_format: "picture") if relative_results.length > 1
|
|
103
|
+
generate_html(relative_results, parsed, context)
|
|
104
|
+
end
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
version_type = determine_version_type(params, components[:config])
|
|
112
|
-
|
|
113
|
-
# Check if version exists in manifest
|
|
114
|
-
Jekyll.logger.debug "🔍 ImgflowTag: Checking existence - original_name: #{original_name}, version_type: #{version_type}, page_path: #{page_path}"
|
|
115
|
-
Jekyll.logger.debug "🔍 ImgflowTag: Params being checked: #{params.inspect}"
|
|
116
|
-
|
|
117
|
-
if components[:manifest].version_exists?(original_name, params, version_type)
|
|
118
|
-
# Version exists - update page usage and return path
|
|
119
|
-
Jekyll.logger.debug "✅ ImgflowTag: Version exists! Updating page usage"
|
|
120
|
-
components[:manifest].update_page_usage(original_name, params, version_type,
|
|
121
|
-
page_path)
|
|
122
|
-
output_path
|
|
123
|
-
else
|
|
124
|
-
# Version doesn't exist - create it
|
|
125
|
-
Jekyll.logger.debug "❌ ImgflowTag: Version doesn't exist - creating"
|
|
126
|
-
versions = components[:manifest].get_versions(original_name)
|
|
127
|
-
Jekyll.logger.debug "🔍 ImgflowTag: Manifest has - default: #{versions['default']&.length || 0}, specialized: #{versions['specialized']&.length || 0}"
|
|
128
|
-
|
|
129
|
-
components[:operation_processor].process_operation(
|
|
130
|
-
original_name,
|
|
131
|
-
operation,
|
|
132
|
-
input_path,
|
|
133
|
-
page_path
|
|
134
|
-
)
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# Convert absolute paths to relative for HTML generation
|
|
139
|
-
relative_result = if result.start_with?(site.dest)
|
|
140
|
-
# Processed images - remove site.dest and leading /
|
|
141
|
-
result.sub(%r{^#{Regexp.escape(site.dest)}/}, "")
|
|
142
|
-
elsif result.start_with?(site.source)
|
|
143
|
-
# Original images - convert from source to relative and remove leading /
|
|
144
|
-
result.sub(%r{^#{Regexp.escape(site.source)}/}, "")
|
|
145
|
-
else
|
|
146
|
-
# Already relative or other format - remove leading / if present
|
|
147
|
-
result.start_with?("/") ? result[1..] : result
|
|
148
|
-
end
|
|
106
|
+
def process_variants(components, operation, original_name, input_path, page_path)
|
|
107
|
+
params = operation[:params].dup
|
|
108
|
+
formats = Array(params.delete(:formats) || params[:format])
|
|
109
|
+
# When no format is explicitly specified, generate all configured formats
|
|
110
|
+
# (avif, webp, png, jpg) so browsers get a <picture> with <source> tags for
|
|
111
|
+
# modern formats and an <img> fallback. This applies to both default-width
|
|
112
|
+
# versions (which find pre-generated files in the manifest cache) and
|
|
113
|
+
# specialized versions (which are generated on-demand).
|
|
114
|
+
formats = components[:config].formats if formats.empty?
|
|
115
|
+
|
|
116
|
+
formats.map do |format|
|
|
117
|
+
variant_params = params.dup
|
|
118
|
+
variant_params[:format] = format
|
|
119
|
+
process_variant(components, operation, variant_params, original_name, input_path, page_path)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def process_variant(components, operation, params, original_name, input_path, page_path)
|
|
124
|
+
config = components[:config]
|
|
125
|
+
if determine_version_type(params, config) == :default
|
|
126
|
+
params[:format] ||= config.formats.first
|
|
127
|
+
params[:quality] ||= config.quality
|
|
128
|
+
end
|
|
149
129
|
|
|
150
|
-
|
|
151
|
-
|
|
130
|
+
digest = components[:filename_generator].file_digest(input_path)
|
|
131
|
+
variant = operation.merge(params: params, file_digest: digest)
|
|
132
|
+
subdir = File.dirname(original_name)
|
|
133
|
+
subdir = nil if subdir == "."
|
|
134
|
+
filename = components[:filename_generator].generate_filename(input_path, params)
|
|
135
|
+
output_path = components[:path_resolver].resolve_source_output_path(filename, subdir)
|
|
136
|
+
version_type = determine_version_type(params, config)
|
|
137
|
+
|
|
138
|
+
if components[:manifest].version_exists?(original_name, params, version_type, digest) &&
|
|
139
|
+
File.file?(output_path)
|
|
140
|
+
components[:stats]&.record_cache_hit
|
|
141
|
+
components[:manifest].update_page_usage(original_name, params, version_type, page_path)
|
|
142
|
+
return output_path
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
unless components[:operation_processor]
|
|
146
|
+
Jekyll.logger.warn "ImgFlow:", "No image provider available — " \
|
|
147
|
+
"rendering original without optimization."
|
|
148
|
+
return input_path
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
components[:operation_processor].process_operation(
|
|
152
|
+
original_name, variant.merge(force_processing: true), input_path, page_path
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def relative_result_path(result, site)
|
|
157
|
+
if result.start_with?(site.dest)
|
|
158
|
+
result.sub(%r{^#{Regexp.escape(site.dest)}/}, "")
|
|
159
|
+
elsif result.start_with?(site.source)
|
|
160
|
+
result.sub(%r{^#{Regexp.escape(site.source)}/}, "")
|
|
161
|
+
else
|
|
162
|
+
result.delete_prefix("/")
|
|
163
|
+
end
|
|
152
164
|
end
|
|
153
165
|
|
|
154
166
|
def generate_html(results, parsed, context)
|
|
@@ -232,8 +244,10 @@ module Jekyll
|
|
|
232
244
|
filename_generator = JekyllImgFlow::FilenameGenerator.new
|
|
233
245
|
registry = JekyllImgFlow::ProviderRegistry.new(config)
|
|
234
246
|
provider = registry.current_provider
|
|
235
|
-
|
|
236
|
-
|
|
247
|
+
if provider
|
|
248
|
+
operation_processor = JekyllImgFlow::OperationProcessor.new(provider, path_resolver,
|
|
249
|
+
manifest, config)
|
|
250
|
+
end
|
|
237
251
|
preset_manager = JekyllImgFlow::PresetManager.new(site, config)
|
|
238
252
|
|
|
239
253
|
site.imgflow_components = {
|
|
@@ -244,6 +258,7 @@ module Jekyll
|
|
|
244
258
|
registry: registry,
|
|
245
259
|
provider: provider,
|
|
246
260
|
operation_processor: operation_processor,
|
|
261
|
+
stats: operation_processor&.stats,
|
|
247
262
|
preset_manager: preset_manager
|
|
248
263
|
}
|
|
249
264
|
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
-
require "digest"
|
|
6
5
|
|
|
7
6
|
module JekyllImgFlow
|
|
8
7
|
# Manages the manifest of generated images
|
|
@@ -13,9 +12,11 @@ module JekyllImgFlow
|
|
|
13
12
|
|
|
14
13
|
def initialize(site)
|
|
15
14
|
@site = site
|
|
16
|
-
|
|
17
|
-
@
|
|
18
|
-
@
|
|
15
|
+
@config = JekyllImgFlow::Config.new(site)
|
|
16
|
+
@file_cleaner = GeneratedFileCleaner.new(site, @config)
|
|
17
|
+
@manifest_path = File.join(@site.source, @config.cache_dir, "imgflow-manifest.json")
|
|
18
|
+
@legacy_manifest_paths = legacy_manifest_paths
|
|
19
|
+
@manifest = load_manifest(manifest_load_path)
|
|
19
20
|
@current_provider = current_provider
|
|
20
21
|
|
|
21
22
|
# Check if provider changed and invalidate cache if needed
|
|
@@ -23,20 +24,22 @@ module JekyllImgFlow
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Load existing manifest or create new one
|
|
26
|
-
def load_manifest
|
|
27
|
-
if File.exist?(
|
|
27
|
+
def load_manifest(path = @manifest_path)
|
|
28
|
+
if path && File.exist?(path)
|
|
28
29
|
begin
|
|
29
|
-
data = JSON.parse(File.read(
|
|
30
|
+
data = JSON.parse(File.read(path))
|
|
30
31
|
|
|
31
32
|
# Handle new format with provider at top level
|
|
32
33
|
if data.is_a?(Hash) && data.key?("images")
|
|
33
34
|
@cached_provider = data["provider"]
|
|
34
|
-
data["images"] || {}
|
|
35
|
+
images = data["images"] || {}
|
|
35
36
|
else
|
|
36
37
|
# Old format - just image data
|
|
37
38
|
@cached_provider = nil
|
|
38
|
-
data
|
|
39
|
+
images = data
|
|
39
40
|
end
|
|
41
|
+
|
|
42
|
+
migrate_legacy_manifest(images)
|
|
40
43
|
rescue JSON::ParserError => e
|
|
41
44
|
Jekyll.logger.warn "ImgFlow: Corrupt manifest file, starting fresh: #{e.message}"
|
|
42
45
|
@cached_provider = nil
|
|
@@ -48,6 +51,23 @@ module JekyllImgFlow
|
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
54
|
+
# Detect and migrate legacy manifest entries (pre-0.1.11 format).
|
|
55
|
+
# Old entries have operations like {"width": 400} without format/quality
|
|
56
|
+
# and no file_digest. Clearing them lets the build re-register existing
|
|
57
|
+
# output files without reprocessing (output_up_to_date? check handles this).
|
|
58
|
+
def migrate_legacy_manifest(images)
|
|
59
|
+
legacy = images.any? do |_name, data|
|
|
60
|
+
versions = data.dig("versions", "default") || []
|
|
61
|
+
versions.any? { |v| v["file_digest"].nil? || !v["operations"].key?("format") }
|
|
62
|
+
end
|
|
63
|
+
if legacy
|
|
64
|
+
Jekyll.logger.info "ImgFlow: Detected legacy manifest format, " \
|
|
65
|
+
"clearing entries for re-registration."
|
|
66
|
+
images.clear
|
|
67
|
+
end
|
|
68
|
+
images
|
|
69
|
+
end
|
|
70
|
+
|
|
51
71
|
# Save manifest to disk
|
|
52
72
|
def save
|
|
53
73
|
# Store current provider at top level of manifest
|
|
@@ -55,44 +75,98 @@ module JekyllImgFlow
|
|
|
55
75
|
"provider" => @current_provider,
|
|
56
76
|
"images" => @manifest
|
|
57
77
|
}
|
|
78
|
+
content = JSON.pretty_generate(manifest_data)
|
|
79
|
+
if File.exist?(@manifest_path) && File.binread(@manifest_path) == content
|
|
80
|
+
cleanup_legacy_manifests
|
|
81
|
+
return
|
|
82
|
+
end
|
|
58
83
|
|
|
59
|
-
|
|
60
|
-
|
|
84
|
+
directory = File.dirname(@manifest_path)
|
|
85
|
+
temporary_path = "#{@manifest_path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
86
|
+
FileUtils.mkdir_p(directory)
|
|
87
|
+
File.open(temporary_path, "wb") do |file|
|
|
88
|
+
file.write(content)
|
|
89
|
+
file.flush
|
|
90
|
+
file.fsync
|
|
91
|
+
end
|
|
92
|
+
File.rename(temporary_path, @manifest_path)
|
|
93
|
+
cleanup_legacy_manifests
|
|
94
|
+
ensure
|
|
95
|
+
FileUtils.rm_f(temporary_path) if temporary_path
|
|
61
96
|
end
|
|
62
97
|
|
|
63
98
|
# Get current provider from site config
|
|
64
|
-
def current_provider
|
|
65
|
-
config = JekyllImgFlow::Config.new(@site)
|
|
66
|
-
# Get first available provider from backend_priority
|
|
67
|
-
config.backend_priority&.first || "unknown"
|
|
68
|
-
end
|
|
99
|
+
def current_provider = @config.backend_priority&.first || "unknown"
|
|
69
100
|
|
|
70
101
|
# Clean up manifest entries for deleted original images
|
|
71
|
-
# @param current_originals [Array<String>]
|
|
102
|
+
# @param current_originals [Array<String>] Paths relative to the originals directory
|
|
72
103
|
def cleanup_deleted_originals(current_originals)
|
|
73
104
|
current_set = current_originals.to_set
|
|
74
105
|
|
|
75
106
|
@manifest.each_key do |original_name|
|
|
76
|
-
|
|
77
|
-
|
|
107
|
+
next if current_set.include?(original_name)
|
|
108
|
+
|
|
109
|
+
data = @manifest.delete(original_name)
|
|
110
|
+
VERSION_TYPES.each do |type|
|
|
111
|
+
(data.dig("versions", type) || []).each do |version|
|
|
112
|
+
@file_cleaner.delete(version["output"])
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
Jekyll.logger.debug "🗑️ Removed manifest entry for deleted original: #{original_name}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def cleanup_obsolete_defaults(expected_operations)
|
|
120
|
+
@manifest.each_value do |data|
|
|
121
|
+
versions = data.dig("versions", "default") || []
|
|
122
|
+
versions.reject! do |version|
|
|
123
|
+
obsolete = expected_operations.none? do |operations|
|
|
124
|
+
same_operations?(version["operations"], operations)
|
|
125
|
+
end
|
|
126
|
+
@file_cleaner.delete(version["output"]) if obsolete
|
|
127
|
+
obsolete
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def reset_page_usage
|
|
133
|
+
@manifest.each_value do |data|
|
|
134
|
+
(data.dig("versions", "specialized") || []).each do |version|
|
|
135
|
+
version["used_on"] = []
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
78
139
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
140
|
+
# Normalize operations for consistent comparison after JSON persistence.
|
|
141
|
+
def normalize_operations(operations)
|
|
142
|
+
case operations
|
|
143
|
+
when Hash
|
|
144
|
+
operations.to_h do |key, value|
|
|
145
|
+
[key.to_s, normalize_operations(value)]
|
|
83
146
|
end
|
|
147
|
+
when Array
|
|
148
|
+
operations.map { |value| normalize_operations(value) }
|
|
149
|
+
when Symbol
|
|
150
|
+
operations.to_s
|
|
151
|
+
else
|
|
152
|
+
operations
|
|
84
153
|
end
|
|
85
154
|
end
|
|
86
155
|
|
|
156
|
+
def same_operations?(left, right) = normalize_operations(left) == normalize_operations(right)
|
|
157
|
+
|
|
87
158
|
# Check if a specific image version exists and is up-to-date
|
|
88
|
-
def version_exists?(original_name, operations, type = :specialized)
|
|
159
|
+
def version_exists?(original_name, operations, type = :specialized, file_digest = nil)
|
|
89
160
|
return false unless @manifest[original_name]
|
|
90
161
|
|
|
91
162
|
# Provider change is already handled in initialize, so just check operations
|
|
92
163
|
versions = @manifest.dig(original_name, "versions", type.to_s) || []
|
|
93
164
|
Jekyll.logger.debug "🔍 ManifestManager: Looking for operations: #{operations.inspect} in #{versions.length} #{type} versions"
|
|
94
165
|
|
|
95
|
-
result = versions.any?
|
|
166
|
+
result = versions.any? do |version|
|
|
167
|
+
same_operations?(version["operations"], operations) &&
|
|
168
|
+
(!file_digest || version["file_digest"] == file_digest)
|
|
169
|
+
end
|
|
96
170
|
Jekyll.logger.debug "🔍 ManifestManager: Final result: #{result}"
|
|
97
171
|
result
|
|
98
172
|
end
|
|
@@ -112,25 +186,9 @@ module JekyllImgFlow
|
|
|
112
186
|
"Provider changed from '#{@cached_provider}' to '#{@current_provider}'"
|
|
113
187
|
Jekyll.logger.warn "ImgFlow:", "Invalidating cache and cleaning optimized directory..."
|
|
114
188
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
# Safety guard: prevent deletion of _site itself
|
|
120
|
-
raise "Refusing to delete optimized directory: path resolves to site dest or is empty" if optimized_dir == @site.dest || optimized_dir.empty?
|
|
121
|
-
|
|
122
|
-
# Delete all files in optimized directory
|
|
123
|
-
if Dir.exist?(optimized_dir)
|
|
124
|
-
deleted_count = 0
|
|
125
|
-
Dir.glob(File.join(optimized_dir, "**", "*")).each do |file|
|
|
126
|
-
if File.file?(file)
|
|
127
|
-
File.delete(file)
|
|
128
|
-
deleted_count += 1
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
Jekyll.logger.warn "ImgFlow:",
|
|
132
|
-
"Deleted #{deleted_count} cached images from optimized directory"
|
|
133
|
-
end
|
|
189
|
+
deleted_count = @file_cleaner.clear
|
|
190
|
+
Jekyll.logger.warn "ImgFlow:",
|
|
191
|
+
"Deleted #{deleted_count} cached images from optimized directory"
|
|
134
192
|
|
|
135
193
|
# Clear manifest cache
|
|
136
194
|
@manifest.clear
|
|
@@ -146,7 +204,7 @@ module JekyllImgFlow
|
|
|
146
204
|
return unless @manifest[original_name]
|
|
147
205
|
|
|
148
206
|
versions = @manifest.dig(original_name, "versions", type.to_s) || []
|
|
149
|
-
version = versions.find { |v| v["operations"]
|
|
207
|
+
version = versions.find { |v| same_operations?(v["operations"], operations) }
|
|
150
208
|
version&.dig("output")
|
|
151
209
|
end
|
|
152
210
|
|
|
@@ -162,7 +220,7 @@ module JekyllImgFlow
|
|
|
162
220
|
|
|
163
221
|
# Get the existing version to preserve provider
|
|
164
222
|
versions = @manifest.dig(original_name, "versions", type.to_s) || []
|
|
165
|
-
version = versions.find { |v| v["operations"]
|
|
223
|
+
version = versions.find { |v| same_operations?(v["operations"], operations) }
|
|
166
224
|
provider = version&.dig("provider")
|
|
167
225
|
Jekyll.logger.debug "🔍 update_page_usage: provider=#{provider}, current used_on=#{version&.dig('used_on')&.inspect}"
|
|
168
226
|
|
|
@@ -183,9 +241,10 @@ module JekyllImgFlow
|
|
|
183
241
|
@manifest[original_name]["file_digest"] = file_digest if file_digest
|
|
184
242
|
|
|
185
243
|
versions = @manifest[original_name]["versions"][type.to_s] ||= []
|
|
244
|
+
normalized_operations = normalize_operations(operations)
|
|
186
245
|
|
|
187
246
|
# Find or create version entry
|
|
188
|
-
version = versions.find { |v| v["operations"]
|
|
247
|
+
version = versions.find { |v| same_operations?(v["operations"], normalized_operations) }
|
|
189
248
|
|
|
190
249
|
# Normalize page_path to array
|
|
191
250
|
page_paths = if page_path.is_a?(Array)
|
|
@@ -200,16 +259,19 @@ module JekyllImgFlow
|
|
|
200
259
|
page_paths.each do |path|
|
|
201
260
|
version["used_on"] << path if path && !version["used_on"].include?(path)
|
|
202
261
|
end
|
|
262
|
+
version["output"] = output_path
|
|
263
|
+
version["file_digest"] = file_digest if file_digest
|
|
203
264
|
# Update provider if provided
|
|
204
265
|
version["provider"] = provider if provider
|
|
205
266
|
else
|
|
206
267
|
# Create new version entry
|
|
207
268
|
versions << {
|
|
208
269
|
"output" => output_path,
|
|
209
|
-
"operations" =>
|
|
270
|
+
"operations" => normalized_operations,
|
|
210
271
|
"type" => type.to_s,
|
|
211
272
|
"used_on" => page_paths,
|
|
212
273
|
"created_at" => Time.now.to_i,
|
|
274
|
+
"file_digest" => file_digest,
|
|
213
275
|
"provider" => provider
|
|
214
276
|
}
|
|
215
277
|
end
|
|
@@ -220,7 +282,7 @@ module JekyllImgFlow
|
|
|
220
282
|
return false unless @manifest[original_name]
|
|
221
283
|
|
|
222
284
|
versions = @manifest.dig(original_name, "versions", "default") || []
|
|
223
|
-
versions.any? { |v| v["operations"]
|
|
285
|
+
versions.any? { |v| same_operations?(v["operations"], operations) }
|
|
224
286
|
end
|
|
225
287
|
|
|
226
288
|
# Find orphaned specialized images (not used on any page)
|
|
@@ -261,16 +323,14 @@ module JekyllImgFlow
|
|
|
261
323
|
cleaned = []
|
|
262
324
|
|
|
263
325
|
orphans.each do |orphan|
|
|
264
|
-
|
|
265
|
-
if File.exist?(output_file)
|
|
266
|
-
File.delete(output_file)
|
|
326
|
+
if @file_cleaner.delete(orphan["output"])
|
|
267
327
|
cleaned << orphan["output"]
|
|
268
328
|
Jekyll.logger.info "🗑️ Cleaned orphaned image: #{orphan['output']}"
|
|
269
329
|
end
|
|
270
330
|
|
|
271
331
|
# Remove from manifest
|
|
272
332
|
versions = @manifest.dig(orphan["original"], "versions", "specialized") || []
|
|
273
|
-
versions.reject! { |v| v["operations"]
|
|
333
|
+
versions.reject! { |v| same_operations?(v["operations"], orphan["operations"]) }
|
|
274
334
|
end
|
|
275
335
|
|
|
276
336
|
cleaned
|
|
@@ -288,5 +348,24 @@ module JekyllImgFlow
|
|
|
288
348
|
versions = @manifest[original_name]["versions"]
|
|
289
349
|
(versions["default"]&.any? || false) || (versions["specialized"]&.any? || false)
|
|
290
350
|
end
|
|
351
|
+
|
|
352
|
+
private
|
|
353
|
+
|
|
354
|
+
def legacy_manifest_paths
|
|
355
|
+
[
|
|
356
|
+
File.join(@site.source, "assets", "images", "imgflow-manifest.json"),
|
|
357
|
+
File.join(@site.dest, "assets", "images", "imgflow-manifest.json")
|
|
358
|
+
].reject { |path| path == @manifest_path }
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def manifest_load_path
|
|
362
|
+
return @manifest_path if File.exist?(@manifest_path)
|
|
363
|
+
|
|
364
|
+
@legacy_manifest_paths.find { |path| File.exist?(path) }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def cleanup_legacy_manifests
|
|
368
|
+
@legacy_manifest_paths.each { |path| FileUtils.rm_f(path) }
|
|
369
|
+
end
|
|
291
370
|
end
|
|
292
371
|
end
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "filename_generator"
|
|
4
|
+
require "benchmark"
|
|
4
5
|
|
|
5
6
|
module JekyllImgFlow
|
|
6
7
|
# OperationProcessor - processes image operations using providers
|
|
7
8
|
# Handles both single operations and batch operations
|
|
8
9
|
class OperationProcessor
|
|
10
|
+
attr_accessor :stats
|
|
11
|
+
|
|
9
12
|
def initialize(provider, path_resolver, manifest = nil, config = nil)
|
|
10
13
|
@provider = provider
|
|
11
14
|
@path_resolver = path_resolver
|
|
12
15
|
@filename_generator = FilenameGenerator.new
|
|
13
16
|
@manifest = manifest
|
|
14
17
|
@config = config
|
|
18
|
+
@stats = ProcessingStats.new
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# Process a single operation on an image
|
|
@@ -41,6 +45,7 @@ module JekyllImgFlow
|
|
|
41
45
|
def process_operation(original_name, operation, input_path, page_path = nil)
|
|
42
46
|
type = operation[:type]
|
|
43
47
|
params = operation[:params]
|
|
48
|
+
file_digest = operation[:file_digest] || @filename_generator.file_digest(input_path)
|
|
44
49
|
|
|
45
50
|
# Determine version type
|
|
46
51
|
version_type = determine_version_type(params)
|
|
@@ -58,17 +63,37 @@ module JekyllImgFlow
|
|
|
58
63
|
# Ensure output directory exists before processing
|
|
59
64
|
FileUtils.mkdir_p(File.dirname(actual_output_path))
|
|
60
65
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
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}"
|
|
73
|
+
@stats.record_cache_hit
|
|
74
|
+
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.
|
|
65
78
|
Jekyll.logger.warn "🖼️ ImgFlow: Skipping resize for animated GIF " \
|
|
66
79
|
"'#{original_name}' — copying original as-is to " \
|
|
67
80
|
"preserve animation."
|
|
68
81
|
FileUtils.cp(input_path, actual_output_path)
|
|
82
|
+
@stats.record_cache_miss
|
|
69
83
|
else
|
|
70
84
|
# Process the operation (create the image)
|
|
71
|
-
|
|
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
97
|
end
|
|
73
98
|
|
|
74
99
|
# Register in manifest
|
|
@@ -76,14 +101,14 @@ module JekyllImgFlow
|
|
|
76
101
|
# Store relative path (with leading /) for manifest storage
|
|
77
102
|
relative_path = "/#{@path_resolver.resolve_relative_output_path(filename, subdir)}"
|
|
78
103
|
|
|
79
|
-
provider_name = @provider
|
|
104
|
+
provider_name = @provider&.class&.provider_name || "unknown"
|
|
80
105
|
@manifest.register_version(
|
|
81
106
|
original_name,
|
|
82
107
|
relative_path,
|
|
83
108
|
params,
|
|
84
109
|
version_type,
|
|
85
110
|
page_path,
|
|
86
|
-
|
|
111
|
+
file_digest,
|
|
87
112
|
provider_name
|
|
88
113
|
)
|
|
89
114
|
end
|
|
@@ -170,26 +195,22 @@ module JekyllImgFlow
|
|
|
170
195
|
# @param output_path [String] Path to output image
|
|
171
196
|
# @param operations [Hash] Operations to apply
|
|
172
197
|
# @return [Boolean] True if processing needed
|
|
173
|
-
def needs_processing?(input_path, output_path,
|
|
198
|
+
def needs_processing?(input_path, output_path, _operations = nil)
|
|
174
199
|
# If output doesn't exist, needs processing
|
|
175
200
|
return true unless File.exist?(output_path)
|
|
176
201
|
|
|
177
202
|
# If input is newer than output, needs processing
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
# Check if operations changed by comparing cache key
|
|
181
|
-
# stored alongside the output file
|
|
182
|
-
cache_key = @filename_generator.generate_cache_key(operations)
|
|
183
|
-
cache_file = "#{output_path}.cache_key"
|
|
184
|
-
|
|
185
|
-
return true unless File.exist?(cache_file)
|
|
186
|
-
|
|
187
|
-
stored_key = File.read(cache_file).strip
|
|
188
|
-
return true if stored_key != cache_key
|
|
189
|
-
|
|
190
|
-
# No cache key file - needs processing to create it
|
|
203
|
+
File.mtime(input_path) > File.mtime(output_path)
|
|
204
|
+
end
|
|
191
205
|
|
|
192
|
-
|
|
206
|
+
# Check if the output file exists and the input is not newer than the
|
|
207
|
+
# output. Used to skip provider calls when the optimized file is already
|
|
208
|
+
# on disk (e.g. legacy manifest, manifest out of sync).
|
|
209
|
+
# @param input_path [String] Path to input image
|
|
210
|
+
# @param output_path [String] Path to output image
|
|
211
|
+
# @return [Boolean] True if output exists and is up-to-date
|
|
212
|
+
def output_up_to_date?(input_path, output_path)
|
|
213
|
+
File.file?(output_path) && File.mtime(input_path) <= File.mtime(output_path)
|
|
193
214
|
end
|
|
194
215
|
|
|
195
216
|
# Build operation structure from params hash
|
|
@@ -43,7 +43,7 @@ module JekyllImgFlow
|
|
|
43
43
|
# @return [Hash] Translation result with markup and attributes
|
|
44
44
|
def translate_to_imgflow(picture_markup)
|
|
45
45
|
# Extract content between {% picture ... %}
|
|
46
|
-
match = picture_markup.match(/\{%\s*picture\s+(.+?)
|
|
46
|
+
match = picture_markup.match(/\{%\s*picture\s+(.+?)%\}/)
|
|
47
47
|
return { markup: "", attributes: {} } unless match
|
|
48
48
|
|
|
49
49
|
content = match[1].strip
|