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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filename_generator"
4
+
5
+ module JekyllImgFlow
6
+ # OperationProcessor - processes image operations using providers
7
+ # Handles both single operations and batch operations
8
+ class OperationProcessor
9
+ def initialize(provider, path_resolver, manifest = nil, config = nil)
10
+ @provider = provider
11
+ @path_resolver = path_resolver
12
+ @filename_generator = FilenameGenerator.new
13
+ @manifest = manifest
14
+ @config = config
15
+ end
16
+
17
+ # Process a single operation on an image
18
+ # @param operation_type [Symbol] Type of operation (:resize, :crop, :quality, etc.)
19
+ # @param input_path [String] Path to input image
20
+ # @param output_path [String] Path to output image
21
+ # @param params [Hash] Operation parameters
22
+ # @return [String] Path to processed image
23
+ def process_single_operation(operation_type, input_path, output_path, params)
24
+ # Get the appropriate tag class for validation
25
+ tag_class = JekyllImgFlow::Tags::TagRegistry.get_tag(operation_type)
26
+ raise "Unknown operation: #{operation_type}" unless tag_class
27
+
28
+ # Create tag instance and process
29
+ tag = tag_class.new(@provider)
30
+ tag.process(input_path, output_path, params)
31
+
32
+ output_path
33
+ end
34
+
35
+ # Process an operation and return the output path
36
+ # @param original_name [String] Original image filename
37
+ # @param operation [Hash] Operation structure { type: :resize, params: { width: 800 } }
38
+ # @param input_path [String] Path to input image
39
+ # @param page_path [String] Optional page path for manifest tracking
40
+ # @return [String] Path to processed image
41
+ def process_operation(original_name, operation, input_path, page_path = nil)
42
+ type = operation[:type]
43
+ params = operation[:params]
44
+
45
+ # Generate filename using FilenameGenerator (JPT compatible)
46
+ filename = @filename_generator.generate_filename(input_path, params)
47
+ # Write to _site during build (after Jekyll copies assets)
48
+ actual_output_path = @path_resolver.resolve_output_path(filename)
49
+
50
+ # Determine version type
51
+ version_type = determine_version_type(params)
52
+
53
+ # Ensure output directory exists before processing
54
+ FileUtils.mkdir_p(File.dirname(actual_output_path))
55
+
56
+ # Process the operation (create the image)
57
+ process_single_operation(type, input_path, actual_output_path, params)
58
+
59
+ # Register in manifest
60
+ if @manifest
61
+ # Convert absolute path to relative for manifest storage
62
+ relative_path = actual_output_path.sub(@path_resolver.site_dest, "")
63
+
64
+ provider_name = @provider.class.provider_name
65
+ @manifest.register_version(
66
+ original_name,
67
+ relative_path,
68
+ params,
69
+ version_type,
70
+ page_path,
71
+ nil, # file_digest
72
+ provider_name
73
+ )
74
+ end
75
+
76
+ actual_output_path
77
+ end
78
+
79
+ # Process multiple operations on an image in sequence (batch)
80
+ # @param operations [Array<Hash>] Array of operations to process
81
+ # @param input_path [String] Path to input image
82
+ # @param final_output_path [String] Path to final output image
83
+ # @return [String] Path to final processed image
84
+ def process_batch_operations(operations, input_path, final_output_path)
85
+ return input_path if operations.empty?
86
+
87
+ current_input = input_path
88
+ temp_files = []
89
+
90
+ begin
91
+ # Process each operation in sequence
92
+ operations.each_with_index do |operation, index|
93
+ operation_type = operation[:type]
94
+ params = operation[:params] || {}
95
+
96
+ # Determine output path
97
+ if index == operations.length - 1
98
+ # Last operation - use final output path
99
+ output_path = final_output_path
100
+ else
101
+ # Intermediate operation - use temp file
102
+ format = params[:format] || File.extname(input_path).delete(".")
103
+ output_path = @path_resolver.temp_output_path(format)
104
+ temp_files << output_path
105
+ end
106
+
107
+ # Process the operation
108
+ current_input = process_single_operation(operation_type, current_input,
109
+ output_path, params)
110
+ end
111
+
112
+ current_input
113
+ ensure
114
+ # Cleanup temp files even on failure
115
+ temp_files.each { |f| FileUtils.rm_f(f) }
116
+ end
117
+ end
118
+
119
+ # Check if operation needs to be processed (cache check)
120
+ # @param input_path [String] Path to input image
121
+ # @param output_path [String] Path to output image
122
+ # @param operations [Hash] Operations to apply
123
+ # @return [Boolean] True if processing needed
124
+ def needs_processing?(input_path, output_path, operations)
125
+ # If output doesn't exist, needs processing
126
+ return true unless File.exist?(output_path)
127
+
128
+ # If input is newer than output, needs processing
129
+ return true if File.mtime(input_path) > File.mtime(output_path)
130
+
131
+ # Check if operations changed by comparing cache key
132
+ # stored alongside the output file
133
+ cache_key = @filename_generator.generate_cache_key(operations)
134
+ cache_file = "#{output_path}.cache_key"
135
+
136
+ return true unless File.exist?(cache_file)
137
+
138
+ stored_key = File.read(cache_file).strip
139
+ return true if stored_key != cache_key
140
+
141
+ # No cache key file - needs processing to create it
142
+
143
+ false
144
+ end
145
+
146
+ # Build operation structure from params hash
147
+ # Combines all params into a single operation with flat params
148
+ # @param params [Hash] Parameters hash
149
+ # @return [Hash] Operation structure { type: :resize, params: { ... } }
150
+ def build_operation_from_params(params)
151
+ # Determine primary operation type
152
+ type = if params[:width] || params[:height]
153
+ :resize
154
+ elsif params[:crop]
155
+ :crop
156
+ elsif params[:watermark]
157
+ :watermark
158
+ else
159
+ :format
160
+ end
161
+
162
+ # Return unified operation structure with all params flattened
163
+ {
164
+ type: type,
165
+ params: params.compact
166
+ }
167
+ end
168
+
169
+ # Build operations array from tag parameters
170
+ # @param tag_params [Hash] Parameters from tag
171
+ # @return [Array<Hash>] Array of operation hashes
172
+ def build_operations_from_params(tag_params)
173
+ [build_operation_from_params(tag_params)]
174
+ end
175
+
176
+ # Determine if operations represent default or specialized version
177
+ # @param params [Hash] Operation parameters
178
+ # @return [Symbol] :default or :specialized
179
+ def determine_version_type(params)
180
+ @config&.determine_version_type(params) || :specialized
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module JekyllImgFlow
6
+ # Central parser for all ImgFlow markup (tags, presets, etc.)
7
+ # Optimized version with reduced duplication and complexity
8
+ class Parser
9
+ # Define valid operation parameters and attribute types
10
+ OPERATION_PARAMS = %i[width height ratio aspect_ratio quality format formats
11
+ optimize level watermark opacity position keep preset].freeze
12
+ HTML_ATTRIBUTES = %i[alt class title loading].freeze
13
+
14
+ # Simple operation mappings for single-parameter operations
15
+ SIMPLE_OPERATIONS = {
16
+ quality: ->(val) { { type: :quality, params: { quality: val } } },
17
+ watermark: ->(val) { { type: :watermark, params: { text: val } } },
18
+ opacity: ->(val) { { type: :alpha_opacity, params: { opacity: val } } }
19
+ }.freeze
20
+
21
+ def self.parse(markup, _context = nil)
22
+ # For KISS architecture: expect structured data from TagScanner
23
+ return validate_structured_data(markup) if markup.is_a?(Hash) && markup.key?(:image_path)
24
+
25
+ # Parse simple markup (backward compatibility)
26
+ raw_options = parse_markup_to_hash(markup)
27
+ operations = detect_operations(raw_options)
28
+ html_attributes = extract_html_attributes(raw_options)
29
+ image_path = extract_image_path(raw_options, markup)
30
+
31
+ # Early return if no image path
32
+ return build_error_response(raw_options, "No image path found in markup") unless image_path
33
+
34
+ # File validation handled by PathResolver when resolving paths
35
+ build_success_response(image_path, operations, html_attributes, raw_options)
36
+ end
37
+
38
+ def self.parse_markup_to_hash(markup)
39
+ options = {}
40
+
41
+ # Handle different markup formats
42
+ if markup.include?("=")
43
+ parse_key_value_format(markup, options)
44
+ else
45
+ parse_positional_format(markup, options)
46
+ end
47
+
48
+ options
49
+ end
50
+
51
+ def self.parse_key_value_format(markup, options)
52
+ parse_key_value_pairs(markup.split, "=", result: options, handle_arrays: false)
53
+ end
54
+
55
+ def self.parse_positional_format(markup, options)
56
+ # Use Shellwords for proper quote handling
57
+ parts = Shellwords.split(markup)
58
+
59
+ parse_key_value_pairs(
60
+ parts,
61
+ ":",
62
+ result: options,
63
+ handle_arrays: true,
64
+ image_path_handler: lambda { |part, result|
65
+ result[:image_path] = part unless result[:image_path]
66
+ }
67
+ )
68
+ end
69
+
70
+ def self.parse_key_value_pairs(pairs, separator, options = {})
71
+ pairs.each do |pair|
72
+ if pair.include?(separator)
73
+ key, value = pair.split(separator, 2)
74
+ options[:result][key.to_sym] = clean_and_convert_value(value, options)
75
+ elsif options[:image_path_handler]
76
+ options[:image_path_handler].call(pair, options[:result])
77
+ end
78
+ end
79
+ end
80
+
81
+ def self.clean_and_convert_value(value, options = {})
82
+ value = value.tr("'\"", "")
83
+ value = convert_numeric_value(value)
84
+
85
+ # Handle comma-separated arrays
86
+ value = value.split(",").map(&:strip) if options[:handle_arrays] && value.is_a?(String) && value.include?(",")
87
+
88
+ value
89
+ end
90
+
91
+ def self.convert_numeric_value(value)
92
+ return value.to_i if value.match?(/^\d+$/)
93
+ return value.to_f if value.match?(/^\d+\.\d+$/)
94
+
95
+ value
96
+ end
97
+
98
+ def self.validate_structured_data(data)
99
+ image_path = data[:image_path]
100
+ operations = data[:operations] || {}
101
+
102
+ return build_error_response(data, "No image path found in structured data") unless image_path
103
+
104
+ # Operations validation handled by Tags - Parser only parses structure
105
+ # File validation handled by PathResolver when resolving paths
106
+ build_success_response(image_path, operations, data[:html_attributes] || {}, data)
107
+ end
108
+
109
+ def self.detect_operations(options)
110
+ operations = []
111
+ operation_options = options.slice(*OPERATION_PARAMS)
112
+
113
+ # Handle crop+resize combination (needs sequential processing)
114
+ has_crop = operation_options[:ratio] || operation_options[:aspect_ratio]
115
+ has_resize = operation_options[:width] || operation_options[:height]
116
+
117
+ if has_crop && has_resize
118
+ crop_params = operation_options.slice(:ratio, :aspect_ratio, :keep, :position)
119
+ resize_params = operation_options.except(:ratio, :aspect_ratio, :keep, :position)
120
+ operations << { type: :crop, params: crop_params }
121
+ operations << { type: :resize, params: resize_params }
122
+ elsif has_crop
123
+ crop_params = operation_options.slice(:ratio, :aspect_ratio, :keep, :position)
124
+ operations << { type: :crop, params: crop_params }
125
+ elsif has_resize
126
+ operations << { type: :resize, params: operation_options }
127
+ end
128
+
129
+ # Add simple operations using mappings
130
+ SIMPLE_OPERATIONS.each do |key, builder|
131
+ operations << builder.call(operation_options[key]) if operation_options[key]
132
+ end
133
+
134
+ # Handle format operation (can be array or single value)
135
+ if operation_options[:format] || operation_options[:formats]
136
+ format_value = operation_options[:format] || operation_options[:formats]
137
+ operations << if format_value.is_a?(Array)
138
+ { type: :format, params: { formats: format_value } }
139
+ else
140
+ { type: :format, params: { format: format_value } }
141
+ end
142
+ end
143
+
144
+ # Handle optimize operation
145
+ if operation_options[:optimize] || operation_options[:level]
146
+ operations << { type: :optimize,
147
+ params: { level: operation_options[:level] || :medium } }
148
+ end
149
+
150
+ operations
151
+ end
152
+
153
+ def self.extract_html_attributes(options)
154
+ options.slice(*HTML_ATTRIBUTES)
155
+ end
156
+
157
+ def self.extract_image_path(options, markup)
158
+ return options[:image_path] if options[:image_path]
159
+ return if markup.strip.empty?
160
+
161
+ # Extract first word (before space or =)
162
+ markup.split(/[\s=]/, 2).first&.strip
163
+ end
164
+
165
+ # Convert operations array from TagScanner to structured hash
166
+ def self.convert_operations_array(operations_array)
167
+ operations_array.each_with_object({}) do |op_str, hash|
168
+ next unless op_str.include?(":")
169
+
170
+ key, value = op_str.split(":", 2)
171
+ hash[key.strip.to_sym] = clean_and_convert_value(value.strip, handle_arrays: true)
172
+ end
173
+ end
174
+
175
+ def self.build_error_response(raw_options, error_message)
176
+ {
177
+ image_path: nil,
178
+ operations: [],
179
+ html_attributes: {},
180
+ raw_options: raw_options,
181
+ preset: nil,
182
+ error: error_message
183
+ }
184
+ end
185
+
186
+ def self.build_success_response(image_path, operations, html_attributes, raw_options)
187
+ {
188
+ image_path: image_path,
189
+ operations: operations,
190
+ html_attributes: html_attributes,
191
+ raw_options: raw_options,
192
+ preset: raw_options[:preset]
193
+ }
194
+ end
195
+
196
+ # File validation removed - handled by PathResolver when resolving paths
197
+ # Operations validation removed - handled by Tags
198
+ end
199
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "tmpdir"
5
+
6
+ module JekyllImgFlow
7
+ # PathResolver - centralized path management for all image operations
8
+ class PathResolver
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ # Get path information for an image
14
+ # @param image_name [String] Name of the image file
15
+ # @return [Hash] Path information for different use cases
16
+ def paths_for(image_name)
17
+ {
18
+ cli: File.join(@config.site.source, @config.originals, image_name), # Full filesystem path
19
+ http: File.join(@config.originals, image_name), # Relative path for URLs
20
+ name: image_name # Just the filename
21
+ }
22
+ end
23
+
24
+ # Get CLI path (convenience method)
25
+ def cli_path(image_name)
26
+ paths_for(image_name)[:cli]
27
+ end
28
+
29
+ # Get HTTP path (convenience method)
30
+ def http_path(image_name)
31
+ paths_for(image_name)[:http]
32
+ end
33
+
34
+ # Resolve original path with validation
35
+ # @param image_name [String] Name of the image file
36
+ # @return [String] Full path to original image file
37
+ # @raise [ArgumentError] If image cannot be resolved or doesn't exist
38
+ def resolve_original_path(image_name)
39
+ full_path = cli_path(image_name)
40
+
41
+ # Validate path exists and is readable
42
+ raise ArgumentError, "Cannot resolve image path: #{image_name} (not found: #{full_path})" unless File.exist?(full_path)
43
+
44
+ raise ArgumentError, "Image path is not a file: #{image_name} (path: #{full_path})" unless File.file?(full_path)
45
+
46
+ raise ArgumentError, "Image path not readable: #{image_name} (path: #{full_path})" unless File.readable?(full_path)
47
+
48
+ # Validate input file format against config
49
+ file_extension = File.extname(full_path).delete(".").downcase
50
+ input_formats = @config.input_formats
51
+
52
+ raise ArgumentError, "No input_formats configured in unified config" unless input_formats
53
+
54
+ unless input_formats.include?(file_extension)
55
+ raise ArgumentError,
56
+ "Invalid input format: '#{file_extension}'. Must be one of: #{input_formats.join(', ')}."
57
+ end
58
+
59
+ full_path
60
+ end
61
+
62
+ # Resolve output path for generated filename
63
+ # @param filename [String] Generated filename
64
+ # @return [String] Full path to output file in _site (for specialized versions during rendering)
65
+ def resolve_output_path(filename)
66
+ # Resolve output path relative to site destination
67
+ File.join(@config.site.dest, @config.output, filename)
68
+ end
69
+
70
+ # Resolve output path to source directory for default images
71
+ # @param filename [String] Generated filename
72
+ # @return [String] Full path to output file in source (for default versions during pre_render)
73
+ def resolve_source_output_path(filename)
74
+ # Default images go to source directory so Jekyll can copy them to _site
75
+ File.join(@config.site.source, @config.output, filename)
76
+ end
77
+
78
+ # Resolve relative output path for manifest storage
79
+ # @param filename [String] Generated filename
80
+ # @return [String] Relative path from site root
81
+ def resolve_relative_output_path(filename)
82
+ # Return path relative to site root (without leading /)
83
+ File.join(@config.output, filename)
84
+ end
85
+
86
+ # Get site destination directory
87
+ # @return [String] Site destination path
88
+ def site_dest
89
+ @config.site.dest
90
+ end
91
+
92
+ # Generate temporary output path
93
+ # @param format [String] File format extension
94
+ # @return [String] Path to temporary file
95
+ def temp_output_path(format)
96
+ File.join(Dir.tmpdir, "imgflow-out-#{SecureRandom.hex}.#{format}")
97
+ end
98
+
99
+ # Generate temporary input path
100
+ # @param extension [String] File extension
101
+ # @return [String] Path to temporary file
102
+ def temp_input_path(extension = "tmp")
103
+ File.join(Dir.tmpdir, "imgflow-in-#{SecureRandom.hex}.#{extension}")
104
+ end
105
+
106
+ # Build public URL for image
107
+ # @param relative_path [String] Relative path from site root
108
+ # @param site [Jekyll::Site] Jekyll site object
109
+ # @return [String] Public URL
110
+ def build_url(relative_path, site)
111
+ base_url = site.config["url"] || "http://localhost:4000"
112
+ baseurl = site.config["baseurl"] || ""
113
+ "#{base_url}#{baseurl}/#{relative_path}"
114
+ end
115
+
116
+ # Get relative path from site destination
117
+ # @param full_path [String] Full filesystem path
118
+ # @param site [Jekyll::Site] Jekyll site object
119
+ # @return [String] Relative path
120
+ def relative_from_dest(full_path, site)
121
+ # Simple path manipulation - remove site.dest prefix and leading slash
122
+ if full_path.start_with?(site.dest)
123
+ relative = full_path[site.dest.length..]
124
+ relative.start_with?("/") ? relative[1..] : relative
125
+ else
126
+ full_path
127
+ end
128
+ end
129
+ end
130
+ end