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,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ class ImgflowTag < Liquid::Tag
5
+ def initialize(tag_name, markup, tokens)
6
+ super
7
+ @markup = markup.strip
8
+ end
9
+
10
+ def render(context)
11
+ # Get components
12
+ components = get_imgflow_components(context)
13
+
14
+ # Check if markup contains preset
15
+ markup_to_parse = if @markup.include?("preset:")
16
+ # Route through PresetManager: preset → tags:value → Parser
17
+ expand_preset_markup(@markup, components[:preset_manager])
18
+ else
19
+ # Direct route: markup → Parser
20
+ @markup
21
+ end
22
+
23
+ # Use central parser to parse markup (uniform for both paths)
24
+ parsed = JekyllImgFlow::Parser.parse(markup_to_parse, context)
25
+
26
+ # Process operations through the new architecture
27
+ process_operations(components, parsed, context)
28
+ rescue StandardError => e
29
+ # Handle errors gracefully - don't crash the build for one image
30
+ Jekyll.logger.error "ImgFlow Error: #{e.message}"
31
+ Jekyll.logger.debug "ImgFlow Backtrace: #{e.backtrace.first(5).join("\n")}"
32
+ "<!-- ImgFlow Error: #{e.message} -->"
33
+ end
34
+
35
+ private
36
+
37
+ def expand_preset_markup(markup, preset_manager)
38
+ # Parse markup to extract image path, preset name, and user options
39
+ parts = markup.split
40
+ image_path = parts.first
41
+
42
+ # Extract preset name and user options
43
+ preset_name = nil
44
+ user_options = {}
45
+
46
+ parts[1..].each do |part|
47
+ if part.start_with?("preset:")
48
+ preset_name = part.split(":", 2).last
49
+ elsif part.include?(":")
50
+ key, value = part.split(":", 2)
51
+ user_options[key.to_sym] = value
52
+ end
53
+ end
54
+
55
+ # Get preset markup from PresetManager
56
+ preset_markup = preset_manager.build_markup_from_preset(preset_name, user_options)
57
+
58
+ # Combine image path with preset markup
59
+ "#{image_path} #{preset_markup}"
60
+ end
61
+
62
+ def process_operations(components, parsed, context)
63
+ return "" unless parsed[:image_path]
64
+
65
+ site = context.registers[:site]
66
+ page = context.registers[:page]
67
+ original_name = File.basename(parsed[:image_path])
68
+
69
+ # Get page path for manifest tracking
70
+ # Try multiple attributes to get the page identifier
71
+ page_path = if page
72
+ page["path"] || page["url"] || page["name"] || "unknown"
73
+ else
74
+ "unknown"
75
+ end
76
+
77
+ # Resolve input path
78
+ input_path = resolve_image_path(parsed[:image_path], site, components[:config])
79
+ raise ArgumentError, "Input image file not found: #{input_path}" unless File.file?(input_path)
80
+
81
+ # Process operations using clean architecture
82
+ operations = parsed[:operations]
83
+
84
+ result = if operations.empty?
85
+ # No operations - use original image
86
+ input_path
87
+ else
88
+ # Generate filename early to check existence
89
+ operation = operations.first
90
+ params = operation[:params]
91
+
92
+ # For default versions, include default format and quality for proper identification
93
+ if determine_version_type(params, components[:config]) == :default
94
+ params = params.dup
95
+ params[:format] ||= components[:config].formats.first # Use default format
96
+ params[:quality] ||= components[:config].quality # Use default quality
97
+ end
98
+
99
+ filename = components[:filename_generator].generate_filename(input_path, params)
100
+ output_path = components[:path_resolver].resolve_output_path(filename)
101
+
102
+ # Determine version type
103
+ version_type = determine_version_type(params, components[:config])
104
+
105
+ # Check if version exists in manifest
106
+ Jekyll.logger.debug "🔍 ImgflowTag: Checking existence - original_name: #{original_name}, version_type: #{version_type}, page_path: #{page_path}"
107
+ Jekyll.logger.debug "🔍 ImgflowTag: Params being checked: #{params.inspect}"
108
+
109
+ if components[:manifest].version_exists?(original_name, params, version_type)
110
+ # Version exists - update page usage and return path
111
+ Jekyll.logger.debug "✅ ImgflowTag: Version exists! Updating page usage"
112
+ components[:manifest].update_page_usage(original_name, params, version_type,
113
+ page_path)
114
+ output_path
115
+ else
116
+ # Version doesn't exist - create it
117
+ Jekyll.logger.debug "❌ ImgflowTag: Version doesn't exist - creating"
118
+ versions = components[:manifest].get_versions(original_name)
119
+ Jekyll.logger.debug "🔍 ImgflowTag: Manifest has - default: #{versions['default']&.length || 0}, specialized: #{versions['specialized']&.length || 0}"
120
+
121
+ components[:operation_processor].process_operation(
122
+ original_name,
123
+ operation,
124
+ input_path,
125
+ page_path
126
+ )
127
+ end
128
+ end
129
+
130
+ # Convert absolute paths to relative for HTML generation
131
+ relative_result = if result.start_with?(site.dest)
132
+ # Processed images - remove site.dest and leading /
133
+ result.sub(%r{^#{Regexp.escape(site.dest)}/}, "")
134
+ elsif result.start_with?(site.source)
135
+ # Original images - convert from source to relative and remove leading /
136
+ result.sub(%r{^#{Regexp.escape(site.source)}/}, "")
137
+ else
138
+ # Already relative or other format - remove leading / if present
139
+ result.start_with?("/") ? result[1..] : result
140
+ end
141
+
142
+ # Generate HTML for the result
143
+ generate_html([relative_result], parsed, context)
144
+ end
145
+
146
+ def generate_html(results, parsed, context)
147
+ return "" if results.empty?
148
+
149
+ # Extract attributes and markup format from parsed data
150
+ attributes = extract_element_attributes(parsed)
151
+ markup_format = parsed[:markup_format] || "img"
152
+
153
+ # Get config from components
154
+ components = get_imgflow_components(context)
155
+ config = components[:config]
156
+
157
+ # Use unified HTML generator with config
158
+ JekyllImgFlow::HtmlGenerator.generate(results, attributes, markup_format, context, config)
159
+ end
160
+
161
+ # Extract element-specific attributes from parsed data
162
+ def extract_element_attributes(parsed)
163
+ # Start with basic HTML attributes
164
+ base_attrs = parsed[:html_attributes] || {}
165
+
166
+ # Build element-specific attribute structure
167
+ {
168
+ img: base_attrs.reject do |k, _v|
169
+ k.to_s.start_with?("picture-", "source-", "a-", "parent-")
170
+ end,
171
+ picture: extract_prefixed_attrs(base_attrs, "picture-"),
172
+ source: extract_prefixed_attrs(base_attrs, "source-"),
173
+ a: extract_prefixed_attrs(base_attrs, "a-"),
174
+ parent: extract_prefixed_attrs(base_attrs, "parent-"),
175
+ alt: base_attrs[:alt],
176
+ link: base_attrs[:link]
177
+ }
178
+ end
179
+
180
+ # Extract attributes with a specific prefix
181
+ def extract_prefixed_attrs(attrs, prefix)
182
+ attrs.select { |k, _v| k.to_s.start_with?(prefix) }
183
+ .transform_keys { |k| k.to_s.sub(prefix, "") }
184
+ end
185
+
186
+ def render_liquid_variable(variable, context)
187
+ # Simple liquid variable rendering
188
+ variable_name = variable.tr("{}", "").strip
189
+ context[variable_name] || variable
190
+ end
191
+
192
+ # Determine if params represent a default or specialized version
193
+ def determine_version_type(params, config)
194
+ config.determine_version_type(params)
195
+ end
196
+
197
+ def resolve_image_path(image_path, site, config)
198
+ # Use PathResolver for consistent path handling
199
+ path_resolver = JekyllImgFlow::PathResolver.new(config)
200
+
201
+ # Handle different path formats
202
+ if image_path.start_with?("/") || image_path.include?("/")
203
+ # Absolute or relative path
204
+ File.join(site.source, image_path)
205
+ else
206
+ # Use PathResolver to get CLI path (full filesystem path)
207
+ path_resolver.cli_path(image_path)
208
+ end
209
+ end
210
+
211
+ def get_imgflow_components(context)
212
+ # Get or create ImgFlow components
213
+ site = context.registers[:site]
214
+
215
+ # Cache components on site object
216
+ unless site.imgflow_components
217
+ config = JekyllImgFlow::Config.new(site)
218
+ manifest = JekyllImgFlow::ManifestManager.new(site)
219
+ path_resolver = JekyllImgFlow::PathResolver.new(config)
220
+ filename_generator = JekyllImgFlow::FilenameGenerator.new
221
+ registry = JekyllImgFlow::ProviderRegistry.new(config)
222
+ provider = registry.current_provider
223
+ operation_processor = JekyllImgFlow::OperationProcessor.new(provider, path_resolver,
224
+ manifest, config)
225
+ preset_manager = JekyllImgFlow::PresetManager.new(site, config)
226
+
227
+ site.imgflow_components = {
228
+ config: config,
229
+ manifest: manifest,
230
+ path_resolver: path_resolver,
231
+ filename_generator: filename_generator,
232
+ registry: registry,
233
+ provider: provider,
234
+ operation_processor: operation_processor,
235
+ preset_manager: preset_manager
236
+ }
237
+ end
238
+
239
+ # Manifest is shared with BuildTimeProcessor for page usage tracking
240
+
241
+ site.imgflow_components
242
+ end
243
+ end
244
+ end
245
+
246
+ # Register the tag with Liquid
247
+ Liquid::Template.register_tag("imgflow", Jekyll::ImgflowTag)
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "digest"
6
+
7
+ module JekyllImgFlow
8
+ # Manages the manifest of generated images
9
+ # Tracks default vs specialized versions and page usage for cleanup
10
+ class ManifestManager
11
+ VERSION_TYPES = %w[default specialized].freeze
12
+ attr_reader :manifest_path
13
+
14
+ def initialize(site)
15
+ @site = site
16
+ # Place manifest in _site - rebuilt on each Jekyll build
17
+ @manifest_path = File.join(@site.dest, "assets", "images", "imgflow-manifest.json")
18
+ @manifest = load_manifest
19
+ @current_provider = current_provider
20
+
21
+ # Check if provider changed and invalidate cache if needed
22
+ handle_provider_change if provider_changed?
23
+ end
24
+
25
+ # Load existing manifest or create new one
26
+ def load_manifest
27
+ if File.exist?(@manifest_path)
28
+ begin
29
+ data = JSON.parse(File.read(@manifest_path))
30
+
31
+ # Handle new format with provider at top level
32
+ if data.is_a?(Hash) && data.key?("images")
33
+ @cached_provider = data["provider"]
34
+ data["images"] || {}
35
+ else
36
+ # Old format - just image data
37
+ @cached_provider = nil
38
+ data
39
+ end
40
+ rescue JSON::ParserError => e
41
+ Jekyll.logger.warn "ImgFlow: Corrupt manifest file, starting fresh: #{e.message}"
42
+ @cached_provider = nil
43
+ {}
44
+ end
45
+ else
46
+ @cached_provider = nil
47
+ {}
48
+ end
49
+ end
50
+
51
+ # Save manifest to disk
52
+ def save
53
+ # Store current provider at top level of manifest
54
+ manifest_data = {
55
+ "provider" => @current_provider,
56
+ "images" => @manifest
57
+ }
58
+
59
+ FileUtils.mkdir_p(File.dirname(@manifest_path))
60
+ File.write(@manifest_path, JSON.pretty_generate(manifest_data))
61
+ end
62
+
63
+ # 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
69
+
70
+ # Clean up manifest entries for deleted original images
71
+ # @param current_originals [Array<String>] Array of current original image basenames
72
+ def cleanup_deleted_originals(current_originals)
73
+ current_set = current_originals.to_set
74
+
75
+ @manifest.each_key do |original_name|
76
+ # Extract basename without extension
77
+ basename = File.basename(original_name, ".*")
78
+
79
+ # If original no longer exists, remove from manifest
80
+ unless current_set.include?(basename)
81
+ @manifest.delete(original_name)
82
+ Jekyll.logger.debug "🗑️ Removed manifest entry for deleted original: #{original_name}"
83
+ end
84
+ end
85
+ end
86
+
87
+ # Check if a specific image version exists and is up-to-date
88
+ def version_exists?(original_name, operations, type = :specialized)
89
+ return false unless @manifest[original_name]
90
+
91
+ # Provider change is already handled in initialize, so just check operations
92
+ versions = @manifest.dig(original_name, "versions", type.to_s) || []
93
+ Jekyll.logger.debug "🔍 ManifestManager: Looking for operations: #{operations.inspect} in #{versions.length} #{type} versions"
94
+
95
+ result = versions.any? { |v| v["operations"] == operations }
96
+ Jekyll.logger.debug "🔍 ManifestManager: Final result: #{result}"
97
+ result
98
+ end
99
+
100
+ # Check if the provider has changed since the manifest was created
101
+ def provider_changed?
102
+ # If no cached provider, this is a new manifest
103
+ return false if @cached_provider.nil?
104
+
105
+ # Provider changed if current doesn't match cached
106
+ @cached_provider != @current_provider
107
+ end
108
+
109
+ # Handle provider change by invalidating cache and cleaning optimized directory
110
+ def handle_provider_change
111
+ Jekyll.logger.warn "ImgFlow:",
112
+ "Provider changed from '#{@cached_provider}' to '#{@current_provider}'"
113
+ Jekyll.logger.warn "ImgFlow:", "Invalidating cache and cleaning optimized directory..."
114
+
115
+ # Get optimized directory path
116
+ config = JekyllImgFlow::Config.new(@site)
117
+ optimized_dir = File.join(@site.dest, config.output)
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
134
+
135
+ # Clear manifest cache
136
+ @manifest.clear
137
+ Jekyll.logger.warn "ImgFlow:",
138
+ "Manifest cache cleared - all images will be regenerated with '#{@current_provider}'"
139
+
140
+ # Update cached provider to current
141
+ @cached_provider = @current_provider
142
+ end
143
+
144
+ # Get output path for a specific version
145
+ def get_version_output(original_name, operations, type = :specialized)
146
+ return unless @manifest[original_name]
147
+
148
+ versions = @manifest.dig(original_name, "versions", type.to_s) || []
149
+ version = versions.find { |v| v["operations"] == operations }
150
+ version&.dig("output")
151
+ end
152
+
153
+ # Update page usage for an existing version
154
+ # This is a convenience method that calls register_version
155
+ def update_page_usage(original_name, operations, type, page_path)
156
+ Jekyll.logger.debug "🔍 update_page_usage: original_name=#{original_name}, operations=#{operations.inspect}, type=#{type}, page_path=#{page_path}"
157
+
158
+ # Get the existing version's output path (already relative)
159
+ output_path = get_version_output(original_name, operations, type)
160
+ Jekyll.logger.debug "🔍 update_page_usage: output_path=#{output_path}"
161
+ return unless output_path
162
+
163
+ # Get the existing version to preserve provider
164
+ versions = @manifest.dig(original_name, "versions", type.to_s) || []
165
+ version = versions.find { |v| v["operations"] == operations }
166
+ provider = version&.dig("provider")
167
+ Jekyll.logger.debug "🔍 update_page_usage: provider=#{provider}, current used_on=#{version&.dig('used_on')&.inspect}"
168
+
169
+ # Call register_version to update page usage (output_path is already relative)
170
+ register_version(original_name, output_path, operations, type, page_path, nil, provider)
171
+ Jekyll.logger.debug "🔍 update_page_usage: completed register_version call"
172
+ end
173
+
174
+ # Register a new image version
175
+ def register_version(original_name, output_path, operations, type, page_path,
176
+ file_digest = nil, provider = nil)
177
+ @manifest[original_name] ||= {
178
+ "versions" => { "default" => [], "specialized" => [] },
179
+ "file_digest" => file_digest # Store SHA256 digest of original file
180
+ }
181
+
182
+ # Update file digest if provided
183
+ @manifest[original_name]["file_digest"] = file_digest if file_digest
184
+
185
+ versions = @manifest[original_name]["versions"][type.to_s] ||= []
186
+
187
+ # Find or create version entry
188
+ version = versions.find { |v| v["operations"] == operations }
189
+
190
+ # Normalize page_path to array
191
+ page_paths = if page_path.is_a?(Array)
192
+ page_path
193
+ else
194
+ (page_path ? [page_path] : [])
195
+ end
196
+
197
+ if version
198
+ # Add pages to used_on list if not already present
199
+ version["used_on"] ||= []
200
+ page_paths.each do |path|
201
+ version["used_on"] << path if path && !version["used_on"].include?(path)
202
+ end
203
+ # Update provider if provided
204
+ version["provider"] = provider if provider
205
+ else
206
+ # Create new version entry
207
+ versions << {
208
+ "output" => output_path,
209
+ "operations" => operations,
210
+ "type" => type.to_s,
211
+ "used_on" => page_paths,
212
+ "created_at" => Time.now.to_i,
213
+ "provider" => provider
214
+ }
215
+ end
216
+ end
217
+
218
+ # Check if image version is default type
219
+ def default_version?(original_name, operations)
220
+ return false unless @manifest[original_name]
221
+
222
+ versions = @manifest.dig(original_name, "versions", "default") || []
223
+ versions.any? { |v| v["operations"] == operations }
224
+ end
225
+
226
+ # Find orphaned specialized images (not used on any page)
227
+ def find_orphans
228
+ orphans = []
229
+
230
+ @manifest.each do |original_name, data|
231
+ specialized = data.dig("versions", "specialized") || []
232
+ specialized.each do |version|
233
+ next unless version["used_on"].nil? || version["used_on"].empty?
234
+
235
+ orphans << {
236
+ "original" => original_name,
237
+ "output" => version["output"],
238
+ "operations" => version["operations"]
239
+ }
240
+ end
241
+ end
242
+
243
+ orphans
244
+ end
245
+
246
+ # Remove page from all image version usage tracking
247
+ def remove_page_usage(page_path)
248
+ @manifest.each_value do |data|
249
+ VERSION_TYPES.each do |type|
250
+ versions = data.dig("versions", type) || []
251
+ versions.each do |version|
252
+ version["used_on"]&.delete(page_path)
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ # Cleanup orphaned specialized images
259
+ def cleanup_orphans
260
+ orphans = find_orphans
261
+ cleaned = []
262
+
263
+ orphans.each do |orphan|
264
+ output_file = if orphan["output"].start_with?("/")
265
+ orphan["output"]
266
+ else
267
+ File.join(@site.dest,
268
+ orphan["output"])
269
+ end
270
+ if File.exist?(output_file)
271
+ File.delete(output_file)
272
+ cleaned << orphan["output"]
273
+ Jekyll.logger.info "🗑️ Cleaned orphaned image: #{orphan['output']}"
274
+ end
275
+
276
+ # Remove from manifest
277
+ versions = @manifest.dig(orphan["original"], "versions", "specialized") || []
278
+ versions.reject! { |v| v["operations"] == orphan["operations"] }
279
+ end
280
+
281
+ cleaned
282
+ end
283
+
284
+ # Get all versions for an original image
285
+ def get_versions(original_name)
286
+ @manifest.dig(original_name, "versions") || { "default" => [], "specialized" => [] }
287
+ end
288
+
289
+ # Check if original image has any versions
290
+ def versions?(original_name)
291
+ return false unless @manifest[original_name]
292
+
293
+ versions = @manifest[original_name]["versions"]
294
+ (versions["default"]&.any? || false) || (versions["specialized"]&.any? || false)
295
+ end
296
+ end
297
+ end