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,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JekyllImgFlow
|
|
4
|
+
# Adaptor that translates Jekyll Picture Tag syntax to ImgFlow syntax
|
|
5
|
+
# Simple tag-to-tag translation with values
|
|
6
|
+
class PictureTagAdaptor
|
|
7
|
+
def initialize(site, config)
|
|
8
|
+
@site = site
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Convert Picture Tag to ImgFlow tag markup that can be used in templates
|
|
13
|
+
# @param picture_markup [String] Picture Tag markup
|
|
14
|
+
# @return [String] ImgFlow tag markup with attributes
|
|
15
|
+
def to_imgflow_tag(picture_markup)
|
|
16
|
+
result = translate_to_imgflow(picture_markup)
|
|
17
|
+
return "" if result[:markup].empty?
|
|
18
|
+
|
|
19
|
+
# Build ImgFlow tag with attributes
|
|
20
|
+
tag_parts = ["{% imgflow", result[:markup]]
|
|
21
|
+
|
|
22
|
+
# Add HTML attributes
|
|
23
|
+
tag_parts << "alt:\"#{result[:attributes][:alt]}\"" if result[:attributes][:alt]
|
|
24
|
+
|
|
25
|
+
if result[:attributes][:img]&.any?
|
|
26
|
+
result[:attributes][:img].each do |name, value|
|
|
27
|
+
tag_parts << "img-#{name}:\"#{value}\""
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if result[:attributes][:picture]&.any?
|
|
32
|
+
result[:attributes][:picture].each do |name, value|
|
|
33
|
+
tag_parts << "picture-#{name}:\"#{value}\""
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
tag_parts << "%}"
|
|
38
|
+
tag_parts.join(" ")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Convert Picture Tag markup to ImgFlow markup with HTML attributes
|
|
42
|
+
# @param picture_markup [String] Picture Tag markup like "{% picture hero image.jpg 16:9 --alt Text %}"
|
|
43
|
+
# @return [Hash] Translation result with markup and attributes
|
|
44
|
+
def translate_to_imgflow(picture_markup)
|
|
45
|
+
# Extract content between {% picture ... %}
|
|
46
|
+
match = picture_markup.match(/\{%\s*picture\s+(.+?)\s*%}/)
|
|
47
|
+
return { markup: "", attributes: {} } unless match
|
|
48
|
+
|
|
49
|
+
content = match[1].strip
|
|
50
|
+
return { markup: "", attributes: {} } if content.empty?
|
|
51
|
+
|
|
52
|
+
# Parse arguments
|
|
53
|
+
args = parse_arguments(content)
|
|
54
|
+
return { markup: "", attributes: {} } if args.empty?
|
|
55
|
+
|
|
56
|
+
# Step 1: Categorize arguments by type
|
|
57
|
+
categorized = categorize_arguments(args)
|
|
58
|
+
return { markup: "", attributes: {} } unless categorized[:image]
|
|
59
|
+
|
|
60
|
+
# Step 2: Translate each category to ImgFlow syntax
|
|
61
|
+
imgflow_parts = []
|
|
62
|
+
imgflow_parts << categorized[:image]
|
|
63
|
+
imgflow_parts += translate_media_queries(categorized[:media_queries])
|
|
64
|
+
imgflow_parts += translate_operations(categorized[:operations])
|
|
65
|
+
imgflow_parts << "formats:webp,jpg" unless formats?(imgflow_parts)
|
|
66
|
+
imgflow_parts << "markup:#{categorized[:markup_format]}" if categorized[:markup_format] && categorized[:markup_format] != "auto"
|
|
67
|
+
|
|
68
|
+
# Step 3: Assemble result
|
|
69
|
+
{
|
|
70
|
+
markup: imgflow_parts.compact.join(" "),
|
|
71
|
+
attributes: categorized[:html_attributes],
|
|
72
|
+
markup_format: categorized[:markup_format]
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Parse arguments handling quoted paths and attributes
|
|
79
|
+
# @param content [String] Raw content
|
|
80
|
+
# @return [Array] Parsed arguments
|
|
81
|
+
def parse_arguments(content)
|
|
82
|
+
args = []
|
|
83
|
+
current = +"" # Create unfrozen string
|
|
84
|
+
in_quotes = false
|
|
85
|
+
quote_char = nil
|
|
86
|
+
|
|
87
|
+
content.dup.each_char do |char|
|
|
88
|
+
case char
|
|
89
|
+
when '"', "'"
|
|
90
|
+
if !in_quotes
|
|
91
|
+
in_quotes = true
|
|
92
|
+
quote_char = char
|
|
93
|
+
elsif char == quote_char
|
|
94
|
+
in_quotes = false
|
|
95
|
+
quote_char = nil
|
|
96
|
+
else
|
|
97
|
+
current << char
|
|
98
|
+
end
|
|
99
|
+
when " "
|
|
100
|
+
if in_quotes
|
|
101
|
+
current << char
|
|
102
|
+
elsif !current.empty?
|
|
103
|
+
args << current.dup
|
|
104
|
+
current = +""
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
current << char
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
args << current.dup unless current.empty?
|
|
112
|
+
args
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Categorize arguments into types
|
|
116
|
+
# @param args [Array] All parsed arguments
|
|
117
|
+
# @return [Hash] Categorized arguments
|
|
118
|
+
def categorize_arguments(args)
|
|
119
|
+
result = {
|
|
120
|
+
image: nil,
|
|
121
|
+
media_queries: {},
|
|
122
|
+
operations: [],
|
|
123
|
+
html_attributes: default_html_attributes,
|
|
124
|
+
markup_format: nil
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
i = 0
|
|
128
|
+
while i < args.length
|
|
129
|
+
arg = args[i]
|
|
130
|
+
|
|
131
|
+
case arg
|
|
132
|
+
when /^--alt$/
|
|
133
|
+
# Collect alt text until next --
|
|
134
|
+
i += 1
|
|
135
|
+
alt_parts = []
|
|
136
|
+
while i < args.length && !args[i].start_with?("--")
|
|
137
|
+
alt_parts << args[i]
|
|
138
|
+
i += 1
|
|
139
|
+
end
|
|
140
|
+
result[:html_attributes][:alt] = strip_quotes(alt_parts.join(" "))
|
|
141
|
+
next
|
|
142
|
+
when /^--link$/
|
|
143
|
+
result[:html_attributes][:link] = strip_quotes(args[i + 1]) if i + 1 < args.length
|
|
144
|
+
i += 2
|
|
145
|
+
next
|
|
146
|
+
when /^--(img|picture|source|a|parent)$/
|
|
147
|
+
element = ::Regexp.last_match(1).to_sym
|
|
148
|
+
i += 1
|
|
149
|
+
while i < args.length && !args[i].start_with?("--")
|
|
150
|
+
parse_element_attribute(args[i], result[:html_attributes][element])
|
|
151
|
+
i += 1
|
|
152
|
+
end
|
|
153
|
+
next
|
|
154
|
+
when /(mobile|tablet|desktop):/
|
|
155
|
+
device = ::Regexp.last_match(1)
|
|
156
|
+
i += 1
|
|
157
|
+
if i < args.length && args[i].include?(".")
|
|
158
|
+
image = args[i]
|
|
159
|
+
i += 1
|
|
160
|
+
crop = parse_crop_from_arg(args[i]) if i < args.length && args[i] =~ /^\d+:\d+/
|
|
161
|
+
i += 1 if crop
|
|
162
|
+
result[:media_queries][device] = { image: image, crop: crop }
|
|
163
|
+
end
|
|
164
|
+
next
|
|
165
|
+
when /^(auto|data_auto|picture|img)$/
|
|
166
|
+
result[:markup_format] = arg
|
|
167
|
+
when /\./
|
|
168
|
+
# Image path (has extension, no colon)
|
|
169
|
+
result[:image] ||= arg unless arg.include?(":")
|
|
170
|
+
else
|
|
171
|
+
# Operation argument
|
|
172
|
+
result[:operations] << arg unless arg.start_with?("--")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
i += 1
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
result
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Translate media queries to ImgFlow crop syntax
|
|
182
|
+
# @param media_queries [Hash] Media queries by device
|
|
183
|
+
# @return [Array<String>] Translated crop operations
|
|
184
|
+
def translate_media_queries(media_queries)
|
|
185
|
+
return [] if media_queries.empty?
|
|
186
|
+
|
|
187
|
+
# Use mobile crop as primary (most important for responsive)
|
|
188
|
+
primary = media_queries["mobile"] || media_queries.values.first
|
|
189
|
+
return [] unless primary && primary[:crop]
|
|
190
|
+
|
|
191
|
+
parts = []
|
|
192
|
+
parts << "ratio:#{primary[:crop][:ratio]}" if primary[:crop][:ratio]
|
|
193
|
+
parts << "keep:#{primary[:crop][:keep]}" if primary[:crop][:keep]
|
|
194
|
+
parts
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Default HTML attributes structure
|
|
198
|
+
def default_html_attributes
|
|
199
|
+
{
|
|
200
|
+
img: {},
|
|
201
|
+
picture: {},
|
|
202
|
+
source: {},
|
|
203
|
+
a: {},
|
|
204
|
+
parent: {},
|
|
205
|
+
alt: nil,
|
|
206
|
+
link: nil
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Parse element attribute (class=value or boolean)
|
|
211
|
+
# @param attr_string [String] Attribute string
|
|
212
|
+
# @param attrs_hash [Hash] Hash to store parsed attributes
|
|
213
|
+
def parse_element_attribute(attr_string, attrs_hash)
|
|
214
|
+
case attr_string
|
|
215
|
+
when /^(\w+(?:-\w+)*)=(["'])(.+?)\2$/
|
|
216
|
+
attrs_hash[::Regexp.last_match(1)] = ::Regexp.last_match(3)
|
|
217
|
+
when /^(\w+(?:-\w+)*)=([^"\s]+)$/
|
|
218
|
+
attrs_hash[::Regexp.last_match(1)] = ::Regexp.last_match(2)
|
|
219
|
+
when /^(\w+(?:-\w+)*)$/
|
|
220
|
+
attrs_hash[::Regexp.last_match(1)] = true
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Translate operation arguments to ImgFlow syntax
|
|
225
|
+
# @param operations [Array<String>] Operation arguments
|
|
226
|
+
# @return [Array<String>] Translated operations
|
|
227
|
+
def translate_operations(operations)
|
|
228
|
+
operations.map do |arg|
|
|
229
|
+
case arg
|
|
230
|
+
when /^\d+:\d+\s+(center|entropy|top|bottom|left|right|smart)$/
|
|
231
|
+
parts = arg.split
|
|
232
|
+
["ratio:#{parts[0]}", "keep:#{parts[1]}"]
|
|
233
|
+
when /^\d+:\d+$/
|
|
234
|
+
"ratio:#{arg}"
|
|
235
|
+
when /^(center|entropy|top|bottom|left|right|smart)$/
|
|
236
|
+
"keep:#{arg}"
|
|
237
|
+
when /^[a-zA-Z][a-zA-Z0-9_-]*$/
|
|
238
|
+
"preset:#{arg}"
|
|
239
|
+
when /^\d+$/
|
|
240
|
+
"width:#{arg}"
|
|
241
|
+
end
|
|
242
|
+
end.flatten.compact
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Parse crop from argument string
|
|
246
|
+
# @param arg [String] Argument like "16:9" or "16:9 center" or "16:9 attention"
|
|
247
|
+
# @return [Hash, nil] Crop info with ratio and keep parameter
|
|
248
|
+
def parse_crop_from_arg(arg)
|
|
249
|
+
return unless arg
|
|
250
|
+
|
|
251
|
+
parts = arg.split
|
|
252
|
+
crop = {}
|
|
253
|
+
|
|
254
|
+
if /^\d+:\d+$/.match?(parts[0])
|
|
255
|
+
crop[:ratio] = parts[0]
|
|
256
|
+
# Default keep to center if not specified
|
|
257
|
+
crop[:keep] = parts[1] || "center"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
crop.empty? ? nil : crop
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Parse crop information from crop string (for tests)
|
|
264
|
+
# @param crop_string [String] Crop string like "16:9" or "16:9 center"
|
|
265
|
+
# @return [Hash] Parsed crop info with ratio and keep
|
|
266
|
+
def parse_crop(crop_string)
|
|
267
|
+
parse_crop_from_arg(crop_string) || {}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Strip quotes from string
|
|
271
|
+
# @param str [String] String that may have quotes
|
|
272
|
+
# @return [String] String without surrounding quotes
|
|
273
|
+
def strip_quotes(str)
|
|
274
|
+
return "" if str.nil? || str.empty?
|
|
275
|
+
|
|
276
|
+
str = str[1..-2] if str.start_with?('"') && str.end_with?('"')
|
|
277
|
+
str = str[1..-2] if str.start_with?("'") && str.end_with?("'")
|
|
278
|
+
str
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Check if formats are already specified
|
|
282
|
+
# @param parts [Array<String>] ImgFlow parts
|
|
283
|
+
# @return [Boolean] True if formats already specified
|
|
284
|
+
def formats?(parts)
|
|
285
|
+
parts.any? { |part| part&.include?("formats:") }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Determine primary crop from multiple crop options (for tests)
|
|
289
|
+
# @param crops [Hash] Hash of crops by breakpoint
|
|
290
|
+
# @return [Hash] Primary crop info
|
|
291
|
+
def determine_primary_crop(crops)
|
|
292
|
+
return crops["mobile"] if crops["mobile"]
|
|
293
|
+
return crops["tablet"] if crops["tablet"]
|
|
294
|
+
return crops["default"] if crops["default"]
|
|
295
|
+
|
|
296
|
+
crops.values.first
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module JekyllImgFlow
|
|
7
|
+
# Migrates Jekyll Picture Tag presets to ImgFlow YAML preset format
|
|
8
|
+
class PictureTagPresetMigrator
|
|
9
|
+
PICTURE_TAG_CONFIG = "_data/picture.yml"
|
|
10
|
+
IMGFLOW_PRESETS_DIR = "_data/imgflow/presets"
|
|
11
|
+
|
|
12
|
+
def initialize(site_source)
|
|
13
|
+
@site_source = site_source
|
|
14
|
+
@picture_config_path = File.join(site_source, PICTURE_TAG_CONFIG)
|
|
15
|
+
@imgflow_presets_dir = File.join(site_source, IMGFLOW_PRESETS_DIR)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Migrate all Picture Tag presets to ImgFlow format
|
|
19
|
+
# @return [Hash] Migration results
|
|
20
|
+
def migrate_presets
|
|
21
|
+
results = {
|
|
22
|
+
migrated: [],
|
|
23
|
+
skipped: [],
|
|
24
|
+
errors: []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return results unless File.exist?(@picture_config_path)
|
|
28
|
+
|
|
29
|
+
picture_config = load_picture_config
|
|
30
|
+
return results unless picture_config["presets"]
|
|
31
|
+
|
|
32
|
+
# Ensure ImgFlow presets directory exists
|
|
33
|
+
FileUtils.mkdir_p(@imgflow_presets_dir)
|
|
34
|
+
|
|
35
|
+
picture_config["presets"].each do |preset_name, preset_data|
|
|
36
|
+
imgflow_preset = convert_preset_to_imgflow(preset_name, preset_data)
|
|
37
|
+
|
|
38
|
+
if imgflow_preset
|
|
39
|
+
save_imgflow_preset(preset_name, imgflow_preset)
|
|
40
|
+
results[:migrated] << preset_name
|
|
41
|
+
else
|
|
42
|
+
results[:skipped] << { name: preset_name, reason: "No convertible operations" }
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
results[:errors] << { name: preset_name, error: e.message }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
results
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get migration summary without actually migrating
|
|
52
|
+
# @return [Hash] Preview of what would be migrated
|
|
53
|
+
def preview_migration
|
|
54
|
+
results = {
|
|
55
|
+
found: [],
|
|
56
|
+
convertible: [],
|
|
57
|
+
non_convertible: []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return results unless File.exist?(@picture_config_path)
|
|
61
|
+
|
|
62
|
+
picture_config = load_picture_config
|
|
63
|
+
return results unless picture_config["presets"]
|
|
64
|
+
|
|
65
|
+
picture_config["presets"].each do |preset_name, preset_data|
|
|
66
|
+
results[:found] << preset_name
|
|
67
|
+
|
|
68
|
+
if convertible_operations?(preset_data)
|
|
69
|
+
imgflow_preset = convert_preset_to_imgflow(preset_name, preset_data)
|
|
70
|
+
results[:convertible] << { name: preset_name, preview: imgflow_preset }
|
|
71
|
+
else
|
|
72
|
+
results[:non_convertible] << { name: preset_name,
|
|
73
|
+
reason: "No convertible operations" }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
results
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Load Picture Tag configuration
|
|
83
|
+
# @return [Hash] Picture Tag config
|
|
84
|
+
def load_picture_config
|
|
85
|
+
YAML.safe_load_file(@picture_config_path) || {}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check if preset has operations that can be converted
|
|
89
|
+
# @param preset_data [Hash] Picture Tag preset data
|
|
90
|
+
# @return [Boolean] True if convertible
|
|
91
|
+
def convertible_operations?(preset_data)
|
|
92
|
+
convertible_keys = %w[widths base_width width height quality formats crop gravity]
|
|
93
|
+
convertible_keys.any? { |key| preset_data[key] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Convert Picture Tag preset to ImgFlow YAML format
|
|
97
|
+
# @param preset_name [String] Preset name
|
|
98
|
+
# @param preset_data [Hash] Picture Tag preset data
|
|
99
|
+
# @return [Hash, nil] ImgFlow preset data or nil if not convertible
|
|
100
|
+
def convert_preset_to_imgflow(preset_name, preset_data)
|
|
101
|
+
operations = []
|
|
102
|
+
|
|
103
|
+
# Handle multi-width presets - use the largest width as primary
|
|
104
|
+
if preset_data["widths"]
|
|
105
|
+
max_width = Array(preset_data["widths"]).max
|
|
106
|
+
operations << { "resize" => { "width" => max_width } }
|
|
107
|
+
elsif preset_data["base_width"]
|
|
108
|
+
# For pixel-ratio presets, use base_width
|
|
109
|
+
operations << { "resize" => { "width" => preset_data["base_width"] } }
|
|
110
|
+
elsif preset_data["width"]
|
|
111
|
+
# Direct width setting
|
|
112
|
+
operations << { "resize" => { "width" => preset_data["width"] } }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Handle height (less common in Picture Tag)
|
|
116
|
+
if preset_data["height"]
|
|
117
|
+
if operations.any? && operations.last["resize"]
|
|
118
|
+
# Add height to existing resize operation
|
|
119
|
+
operations.last["resize"]["height"] = preset_data["height"]
|
|
120
|
+
else
|
|
121
|
+
operations << { "resize" => { "height" => preset_data["height"] } }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Convert quality (if specified)
|
|
126
|
+
operations << { "quality" => { "quality" => preset_data["quality"] } } if preset_data["quality"]
|
|
127
|
+
|
|
128
|
+
# Convert formats - Picture Tag uses 'original', ImgFlow uses actual format
|
|
129
|
+
if preset_data["formats"]
|
|
130
|
+
formats = Array(preset_data["formats"]).map do |format|
|
|
131
|
+
case format
|
|
132
|
+
when "original"
|
|
133
|
+
"jpg" # Default to jpg for 'original'
|
|
134
|
+
else
|
|
135
|
+
format
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
operations << { "format" => { "formats" => formats } }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Convert crop (aspect ratio) - handle both ratio and aspect_ratio with precedence
|
|
142
|
+
ratio_value = preset_data["ratio"] || preset_data["aspect_ratio"] || preset_data["crop"]
|
|
143
|
+
operations << { "crop" => { "ratio" => ratio_value } } if ratio_value
|
|
144
|
+
|
|
145
|
+
# Convert gravity/position - handle both position and gravity with precedence
|
|
146
|
+
position_value = preset_data["position"] || preset_data["gravity"]
|
|
147
|
+
if position_value
|
|
148
|
+
if operations.any? { |op| op["crop"] }
|
|
149
|
+
# Add to existing crop operation
|
|
150
|
+
crop_op = operations.find { |op| op["crop"] }
|
|
151
|
+
crop_op["crop"]["position"] = position_value
|
|
152
|
+
else
|
|
153
|
+
# Create new crop operation
|
|
154
|
+
operations << { "crop" => { "position" => position_value } }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
return if operations.empty?
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
"name" => preset_name,
|
|
162
|
+
"description" => generate_description(preset_name, preset_data),
|
|
163
|
+
"operations" => operations,
|
|
164
|
+
"picture_tag_metadata" => {
|
|
165
|
+
"original_widths" => preset_data["widths"],
|
|
166
|
+
"original_base_width" => preset_data["base_width"],
|
|
167
|
+
"original_pixel_ratios" => preset_data["pixel_ratios"],
|
|
168
|
+
"original_sizes" => preset_data["sizes"],
|
|
169
|
+
"dimension_attributes" => preset_data["dimension_attributes"]
|
|
170
|
+
}.compact
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Generate description for ImgFlow preset
|
|
175
|
+
# @param preset_name [String] Preset name
|
|
176
|
+
# @param preset_data [Hash] Original preset data
|
|
177
|
+
# @return [String] Description
|
|
178
|
+
def generate_description(preset_name, preset_data)
|
|
179
|
+
operations = []
|
|
180
|
+
|
|
181
|
+
if preset_data["widths"]
|
|
182
|
+
operations << "widths: #{Array(preset_data['widths']).join(',')}"
|
|
183
|
+
operations << "using max width: #{Array(preset_data['widths']).max}"
|
|
184
|
+
elsif preset_data["base_width"]
|
|
185
|
+
operations << "base_width: #{preset_data['base_width']}"
|
|
186
|
+
operations << "pixel_ratios: #{Array(preset_data['pixel_ratios']).join(',')}"
|
|
187
|
+
elsif preset_data["width"]
|
|
188
|
+
operations << "width: #{preset_data['width']}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
operations << "height: #{preset_data['height']}" if preset_data["height"]
|
|
192
|
+
operations << "quality: #{preset_data['quality']}" if preset_data["quality"]
|
|
193
|
+
operations << "formats: #{Array(preset_data['formats']).join(',')}" if preset_data["formats"]
|
|
194
|
+
operations << "crop: #{preset_data['crop']}" if preset_data["crop"]
|
|
195
|
+
operations << "gravity: #{preset_data['gravity']}" if preset_data["gravity"]
|
|
196
|
+
|
|
197
|
+
"Migrated from Picture Tag preset '#{preset_name}'. #{operations.join(', ')}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Save ImgFlow preset to YAML file
|
|
201
|
+
# @param preset_name [String] Preset name
|
|
202
|
+
# @param preset_data [Hash] ImgFlow preset data
|
|
203
|
+
def save_imgflow_preset(preset_name, preset_data)
|
|
204
|
+
filename = "#{preset_name}.yml"
|
|
205
|
+
filepath = File.join(@imgflow_presets_dir, filename)
|
|
206
|
+
|
|
207
|
+
File.write(filepath, YAML.dump(preset_data))
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module JekyllImgFlow
|
|
6
|
+
# PresetManager - translates YAML presets to tags:value format
|
|
7
|
+
# Handles tag overrides (user tags override preset tags)
|
|
8
|
+
# Passes combined markup to Parser for uniform validation flow
|
|
9
|
+
class PresetManager
|
|
10
|
+
def initialize(site, config)
|
|
11
|
+
@site = site
|
|
12
|
+
@config = config
|
|
13
|
+
@presets = load_presets
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Load presets from _data/imgflow/presets/
|
|
17
|
+
def load_presets
|
|
18
|
+
presets = {}
|
|
19
|
+
presets_dir = File.join(@site.source, "_data", "imgflow", "presets")
|
|
20
|
+
|
|
21
|
+
return presets unless Dir.exist?(presets_dir)
|
|
22
|
+
|
|
23
|
+
Dir.glob(File.join(presets_dir, "*.yml")).each do |preset_file|
|
|
24
|
+
preset_name = File.basename(preset_file, ".yml")
|
|
25
|
+
begin
|
|
26
|
+
preset_data = YAML.safe_load_file(preset_file)
|
|
27
|
+
presets[preset_name] = preset_data
|
|
28
|
+
rescue Psych::SyntaxError => e
|
|
29
|
+
Jekyll.logger.warn "ImgFlow: Invalid YAML in preset file #{preset_file}: #{e.message}"
|
|
30
|
+
# Skip invalid preset files
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
presets
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get a preset by name
|
|
38
|
+
# @param name [String] Preset name
|
|
39
|
+
# @return [Hash, nil] Preset data or nil if not found
|
|
40
|
+
def get_preset(name)
|
|
41
|
+
@presets[name.to_s]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check if a preset exists
|
|
45
|
+
# @param name [String] Preset name
|
|
46
|
+
# @return [Boolean] True if preset exists
|
|
47
|
+
def preset_exists?(name)
|
|
48
|
+
@presets.key?(name.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get all available preset names
|
|
52
|
+
# @return [Array<String>] Array of preset names
|
|
53
|
+
def available_presets
|
|
54
|
+
@presets.keys
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Build markup from preset (tags:value format)
|
|
58
|
+
# @param preset_name [String] Name of preset
|
|
59
|
+
# @param user_options [Hash] User-provided options that override preset values
|
|
60
|
+
# @return [String] Markup string in tags:value format
|
|
61
|
+
def build_markup_from_preset(preset_name, user_options = {})
|
|
62
|
+
preset = get_preset(preset_name)
|
|
63
|
+
return "" unless preset
|
|
64
|
+
|
|
65
|
+
# Convert YAML operations to tags:value format
|
|
66
|
+
preset_tags = yaml_to_tags(preset)
|
|
67
|
+
|
|
68
|
+
# Merge with user options (user options override preset)
|
|
69
|
+
merged_tags = merge_tags(preset_tags, user_options)
|
|
70
|
+
|
|
71
|
+
# Convert to markup string
|
|
72
|
+
tags_to_markup(merged_tags)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Convert YAML operations to tags hash
|
|
78
|
+
# Note: Multiple operations of same type will have last one's values
|
|
79
|
+
# This matches the override behavior where user tags override preset tags
|
|
80
|
+
# @param preset [Hash] Preset data from YAML
|
|
81
|
+
# @return [Hash] Tags in key => value format
|
|
82
|
+
def yaml_to_tags(preset)
|
|
83
|
+
tags = {}
|
|
84
|
+
preset_operations = preset["operations"] || []
|
|
85
|
+
|
|
86
|
+
preset_operations.each do |op|
|
|
87
|
+
next unless op.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
op.each do |op_type, params|
|
|
90
|
+
next unless params.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
# Store operation type for operations that don't have specific params
|
|
93
|
+
# This allows Parser to detect the operation type
|
|
94
|
+
case op_type.to_s
|
|
95
|
+
when "resize"
|
|
96
|
+
# Resize params: width, height (both optional, at least one required)
|
|
97
|
+
tags[:width] = params["width"] if params["width"]
|
|
98
|
+
tags[:height] = params["height"] if params["height"]
|
|
99
|
+
when "crop"
|
|
100
|
+
# Crop params: ratio or width/height
|
|
101
|
+
tags[:ratio] = params["ratio"] if params["ratio"]
|
|
102
|
+
tags[:aspect_ratio] = params["aspect_ratio"] if params["aspect_ratio"]
|
|
103
|
+
tags[:width] = params["width"] if params["width"] && !tags[:width]
|
|
104
|
+
tags[:height] = params["height"] if params["height"] && !tags[:height]
|
|
105
|
+
when "format"
|
|
106
|
+
# Format params: format or formats
|
|
107
|
+
if params["formats"]
|
|
108
|
+
tag_value = params["formats"].is_a?(Array) ? params["formats"].join(",") : params["formats"]
|
|
109
|
+
tags[:formats] = tag_value
|
|
110
|
+
elsif params["format"]
|
|
111
|
+
tags[:format] = params["format"]
|
|
112
|
+
end
|
|
113
|
+
when "quality"
|
|
114
|
+
tags[:quality] = params["quality"] if params["quality"]
|
|
115
|
+
when "optimize"
|
|
116
|
+
tags[:optimize] = true
|
|
117
|
+
tags[:level] = params["level"] if params["level"]
|
|
118
|
+
when "opacity"
|
|
119
|
+
tags[:opacity] = params["opacity"] if params["opacity"]
|
|
120
|
+
else
|
|
121
|
+
# Generic handling for unknown operations
|
|
122
|
+
params.each do |key, value|
|
|
123
|
+
tag_value = value.is_a?(Array) ? value.join(",") : value
|
|
124
|
+
tags[key.to_sym] = tag_value
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
tags
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Merge preset tags with user options (user options override)
|
|
134
|
+
# @param preset_tags [Hash] Tags from preset
|
|
135
|
+
# @param user_options [Hash] User-provided options
|
|
136
|
+
# @return [Hash] Merged tags
|
|
137
|
+
def merge_tags(preset_tags, user_options)
|
|
138
|
+
# User options override preset tags
|
|
139
|
+
preset_tags.merge(user_options)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Convert tags hash to markup string
|
|
143
|
+
# @param tags [Hash] Tags in key => value format
|
|
144
|
+
# @return [String] Markup string
|
|
145
|
+
def tags_to_markup(tags)
|
|
146
|
+
tags.map { |key, value| "#{key}:#{value}" }.join(" ")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|