jekyll-skyhook 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b7b5eb02cafd3faa4b686413d6f77ded1e247786c09fb23ac6c77cb23252f818
4
+ data.tar.gz: c8b4fdf65dd016a38b102eb447b4408dacbabb1de74ea1b1326007e19d293d2b
5
+ SHA512:
6
+ metadata.gz: d5ebe3144da494e72596d464143bab8d8f11b3d19ce91293093e077ad8df9bdc737085af6b8a23310ec246f7f6b0c3331e69ca32fb3198e217f546cb4aa92f9e
7
+ data.tar.gz: ec8abf0e67ef4b9dbdfc8b2e302911da71a99bb3730254ef031ebeba95bef0d1690e54e59d43a43bcb57ecee8f10467d23f2f439cf055cb238771571e012da43
@@ -0,0 +1,167 @@
1
+ module Jekyll
2
+ module Tags
3
+ class AssetTag < Liquid::Tag
4
+ def initialize(tag_name, asset_name, tokens)
5
+ super
6
+ @asset_name = asset_name
7
+ end
8
+
9
+ def render(context)
10
+ site = context.registers[:site]
11
+ manifest_path = File.join(site.cache_dir, 'skyhook', 'assets-manifest.json')
12
+
13
+ # Render any variables in the asset name
14
+ asset_name = Liquid::Template.parse(@asset_name).render(context).strip
15
+
16
+ manifest = JSON.parse(File.read(manifest_path))
17
+ digested = manifest.fetch(asset_name)
18
+ "/#{digested}"
19
+ end
20
+ end
21
+
22
+ class SrcsetTag < Liquid::Tag
23
+ def initialize(tag_name, markup, tokens)
24
+ super
25
+ @markup = markup.strip
26
+ end
27
+
28
+ def render(context)
29
+ site = context.registers[:site]
30
+ skyhook = Jekyll::Skyhook.instance(site)
31
+
32
+ asset_path, format, widths = parse_srcset_markup(@markup)
33
+
34
+ # Render any variables in the path
35
+ asset_path = Liquid::Template.parse(asset_path).render(context).strip
36
+
37
+ # Find the original file
38
+ original_file_path = File.join(site.source, asset_path)
39
+ unless File.exist?(original_file_path)
40
+ raise "Image file not found: #{asset_path}"
41
+ end
42
+
43
+ srcset = widths.map do |width|
44
+ transformations = { width: width.to_s }
45
+ transformations[:format] = format if format
46
+ digested_path = skyhook.process_image_transformation(original_file_path, transformations)
47
+ "/#{digested_path} #{width}w"
48
+ end.join(', ')
49
+
50
+ "srcset=\"#{srcset}\""
51
+ end
52
+
53
+ private
54
+
55
+ def parse_srcset_markup(markup)
56
+ # Parse: path[format="webp"] 400 800 1200
57
+ # Or: path 400 800 1200
58
+
59
+ if markup.include?('[format="')
60
+ match = markup.match(/^([^\[]+)\[format="([^"]+)"\](.*)$/)
61
+ if match
62
+ asset_path = match[1].strip
63
+ format = match[2]
64
+ widths_part = match[3].strip
65
+ if widths_part.empty?
66
+ # No widths provided - fall back to simple parsing
67
+ return [asset_path, nil, []]
68
+ else
69
+ widths = widths_part.split.map(&:to_i)
70
+ return [asset_path, format, widths]
71
+ end
72
+ end
73
+ end
74
+
75
+ # Fallback to original parsing - be careful with liquid variables
76
+ # Look for numeric parts from the end
77
+ parts = markup.split
78
+ numeric_parts = []
79
+ path_parts = []
80
+
81
+ parts.reverse.each do |part|
82
+ if part.match?(/^\d+$/)
83
+ numeric_parts.unshift(part.to_i)
84
+ else
85
+ path_parts = parts[0...(parts.length - numeric_parts.length)]
86
+ break
87
+ end
88
+ end
89
+
90
+ asset_path = path_parts.join(' ')
91
+ widths = numeric_parts
92
+ [asset_path, nil, widths]
93
+ end
94
+ end
95
+
96
+ class ImageTransformTag < Liquid::Tag
97
+ def initialize(tag_name, markup, tokens)
98
+ super
99
+ @markup = markup.strip
100
+ end
101
+
102
+ def render(context)
103
+ site = context.registers[:site]
104
+ skyhook = Jekyll::Skyhook.instance(site)
105
+
106
+ asset_path, transformations = parse_markup(@markup)
107
+
108
+ # Render any variables in the asset path
109
+ asset_path = Liquid::Template.parse(asset_path).render(context).strip
110
+
111
+ # Convert transformation values using context
112
+ parsed_transformations = {}
113
+ transformations.each do |key, value|
114
+ parsed_value = Liquid::Template.parse(value).render(context).strip
115
+ parsed_transformations[key.to_sym] = parsed_value
116
+ end
117
+
118
+ # Find the original file
119
+ original_file_path = File.join(site.source, asset_path)
120
+ unless File.exist?(original_file_path)
121
+ raise "Image file not found: #{asset_path}"
122
+ end
123
+
124
+ # Process the transformation or get original digested path
125
+ if parsed_transformations.any?
126
+ digested_path = skyhook.process_image_transformation(original_file_path, parsed_transformations)
127
+ return "" unless digested_path
128
+ "/#{digested_path}"
129
+ else
130
+ # No transformations - return original digested asset path
131
+ manifest_path = File.join(site.cache_dir, 'skyhook', 'assets-manifest.json')
132
+ if File.exist?(manifest_path)
133
+ manifest = JSON.parse(File.read(manifest_path))
134
+ digested_path = manifest[asset_path]
135
+ digested_path ? "/#{digested_path}" : ""
136
+ else
137
+ ""
138
+ end
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def parse_markup(markup)
145
+ # Parse syntax like: foo/bar/baz.jpg[format="png"][width="400"]
146
+ match = markup.match(/^([^\[]+)(.*)$/)
147
+ return [markup, {}] unless match
148
+
149
+ asset_path = match[1].strip
150
+ brackets_part = match[2]
151
+
152
+ transformations = {}
153
+
154
+ # Extract all [key="value"] pairs
155
+ brackets_part.scan(/\[([^=]+)="([^"]+)"\]/) do |key, value|
156
+ transformations[key.strip] = value
157
+ end
158
+
159
+ [asset_path, transformations]
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ Liquid::Template.register_tag('asset', Jekyll::Tags::AssetTag)
166
+ Liquid::Template.register_tag('srcset', Jekyll::Tags::SrcsetTag)
167
+ Liquid::Template.register_tag('image_transform', Jekyll::Tags::ImageTransformTag)
@@ -0,0 +1,354 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+ require 'json'
4
+ require 'listen'
5
+ require 'css_parser'
6
+ require 'jekyll-skyhook/tags'
7
+
8
+ module Jekyll
9
+ class Skyhook
10
+ class << self
11
+ def instance(site)
12
+ @instances ||= {}
13
+ @instances[site] ||= new(site)
14
+ end
15
+ end
16
+ private_class_method :new
17
+
18
+ def initialize(site)
19
+ @site = site
20
+ skyhook_config = site.config['skyhook'] || {}
21
+ @asset_dirs = Array(skyhook_config['asset_dirs'] || ['assets'])
22
+ @digest_dir = skyhook_config['digest_dir'] || '_digested'
23
+ @digested_path = File.join(site.source, @digest_dir)
24
+ FileUtils.mkdir_p(@digested_path)
25
+
26
+ # Configure image processing library
27
+ @image_processor_library = skyhook_config['image_processor'] || 'mini_magick'
28
+ load_image_processor
29
+
30
+ # Load existing manifest if it exists
31
+ manifest_dir = File.join(site.cache_dir, 'skyhook')
32
+ manifest_path = File.join(manifest_dir, 'assets-manifest.json')
33
+ @manifest = if File.exist?(manifest_path)
34
+ JSON.parse(File.read(manifest_path))
35
+ else
36
+ {}
37
+ end
38
+ end
39
+
40
+ def should_digest?
41
+ skyhook_config = @site.config['skyhook'] || {}
42
+ digest_assets = skyhook_config.fetch('digest_assets', true)
43
+ env = @site.config['environment'] || 'development'
44
+
45
+ case digest_assets
46
+ when true
47
+ true
48
+ when false
49
+ false
50
+ when Array
51
+ digest_assets.map(&:to_s).map(&:downcase).include?(env.downcase)
52
+ else
53
+ digest_assets.to_s.downcase == env.downcase
54
+ end
55
+ end
56
+
57
+ def generate_digest(file_path)
58
+ content = File.read(file_path)
59
+ Digest::MD5.hexdigest(content)
60
+ end
61
+
62
+ def process_assets
63
+ return unless should_digest?
64
+
65
+ css_files = []
66
+
67
+ @asset_dirs.each do |asset_dir|
68
+ assets_dir = File.join(@site.source, asset_dir)
69
+ next unless Dir.exist?(assets_dir)
70
+
71
+ files = Dir.glob(File.join(assets_dir, '**', '*'))
72
+
73
+ files.each do |file|
74
+ next if File.directory?(file)
75
+
76
+ if File.extname(file) == '.css'
77
+ css_files << file
78
+ else
79
+ process_file(file)
80
+ end
81
+ end
82
+ end
83
+
84
+ # Process CSS files last so their URL rewriting can reference already-processed assets
85
+ css_files.each do |file|
86
+ process_file(file)
87
+ end
88
+
89
+ write_manifest
90
+ end
91
+
92
+ def process_file(file)
93
+ digest = generate_digest(file)
94
+ ext = File.extname(file)
95
+ basename = File.basename(file, ext)
96
+ original_relative_dir = File.dirname(file).sub(@site.source, '').sub(%r{^/}, '')
97
+
98
+ digested_dir = File.join(@digested_path, original_relative_dir)
99
+ FileUtils.mkdir_p(digested_dir)
100
+
101
+ new_filename = "#{basename}-#{digest}#{ext}"
102
+ new_filepath = File.join(digested_dir, new_filename)
103
+
104
+ FileUtils.cp(file, new_filepath)
105
+
106
+ static_file_dir = File.dirname(new_filepath).sub(@site.source, '').sub(%r{^/}, '')
107
+ @site.static_files << Jekyll::StaticFile.new(
108
+ @site,
109
+ @site.source,
110
+ static_file_dir,
111
+ new_filename
112
+ )
113
+
114
+ original_relative_path = file.sub("#{@site.source}/", '')
115
+ @manifest[original_relative_path] = File.join(@digest_dir, original_relative_dir, new_filename)
116
+
117
+ rewrite_css_urls(new_filepath) if ext == '.css'
118
+ end
119
+
120
+ def process_image_transformation(file_path, transformations = {})
121
+ return nil unless File.exist?(file_path)
122
+ return nil unless transformations.any?
123
+
124
+ original_relative_path = file_path.sub("#{@site.source}/", '')
125
+ transform_key = build_transform_key(original_relative_path, transformations)
126
+
127
+ return @manifest[transform_key] if @manifest.key?(transform_key)
128
+
129
+ processed_image = apply_transformations(file_path, transformations)
130
+ return nil unless processed_image
131
+
132
+ result = store_transformed_image(original_relative_path, transformations, processed_image)
133
+
134
+ # Write manifest after new transformation
135
+ write_manifest
136
+
137
+ result
138
+ end
139
+
140
+ def store_version(original_path, transformations, processed_image_path)
141
+ original_relative_path = original_path.sub("#{@site.source}/", '')
142
+ transform_key = build_transform_key(original_relative_path, transformations)
143
+
144
+ content = File.read(processed_image_path)
145
+ digest = generate_digest_from_content(content)
146
+
147
+ ext = File.extname(processed_image_path)
148
+ basename = File.basename(original_relative_path, File.extname(original_relative_path))
149
+ transform_suffix = transformations.map { |k, v| "#{k}#{v}" }.sort.join('-')
150
+
151
+ original_relative_dir = File.dirname(original_relative_path)
152
+ digested_dir = File.join(@digested_path, original_relative_dir)
153
+ FileUtils.mkdir_p(digested_dir)
154
+
155
+ new_filename = "#{basename}-#{transform_suffix}-#{digest}#{ext}"
156
+ new_filepath = File.join(digested_dir, new_filename)
157
+
158
+ FileUtils.cp(processed_image_path, new_filepath)
159
+
160
+ static_file_dir = File.dirname(new_filepath).sub(@site.source, '').sub(%r{^/}, '')
161
+ @site.static_files << Jekyll::StaticFile.new(
162
+ @site,
163
+ @site.source,
164
+ static_file_dir,
165
+ new_filename
166
+ )
167
+
168
+ digested_relative_path = File.join(@digest_dir, original_relative_dir, new_filename)
169
+ @manifest[transform_key] = digested_relative_path
170
+
171
+ digested_relative_path
172
+ end
173
+
174
+ def write_manifest
175
+ manifest_dir = File.join(@site.cache_dir, 'skyhook')
176
+ FileUtils.mkdir_p(manifest_dir)
177
+ manifest_path = File.join(manifest_dir, 'assets-manifest.json')
178
+ File.write(manifest_path, JSON.pretty_generate(@manifest))
179
+ end
180
+
181
+ private
182
+
183
+ def build_transform_key(original_path, transformations)
184
+ params = transformations.map { |k, v| "#{k}=#{v}" }.sort.join('&')
185
+ "#{original_path}##{params}"
186
+ end
187
+
188
+ def load_image_processor
189
+ case @image_processor_library.to_s.downcase
190
+ when 'vips'
191
+ require 'image_processing/vips'
192
+ @image_processor = ImageProcessing::Vips
193
+ when 'mini_magick'
194
+ require 'image_processing/mini_magick'
195
+ @image_processor = ImageProcessing::MiniMagick
196
+ else
197
+ Jekyll.logger.warn "Skyhook:", "Unknown image_processor '#{@image_processor_library}'. Using mini_magick as fallback."
198
+ require 'image_processing/mini_magick'
199
+ @image_processor = ImageProcessing::MiniMagick
200
+ end
201
+ end
202
+
203
+ def apply_transformations(file_path, transformations)
204
+ processor = @image_processor.source(file_path)
205
+
206
+ processor = processor.resize_to_limit(transformations[:width].to_i, nil) if transformations[:width]
207
+ processor = processor.resize_to_limit(nil, transformations[:height].to_i) if transformations[:height]
208
+ processor = processor.convert(transformations[:format]) if transformations[:format]
209
+
210
+ processor
211
+ end
212
+
213
+ def store_transformed_image(original_relative_path, transformations, processed_image)
214
+ ext = transformations[:format] ? ".#{transformations[:format]}" : File.extname(original_relative_path)
215
+ basename = File.basename(original_relative_path, File.extname(original_relative_path))
216
+ transform_suffix = transformations.map { |k, v| "#{k}#{v}" }.sort.join('-')
217
+
218
+ original_relative_dir = File.dirname(original_relative_path)
219
+ digested_dir = File.join(@digested_path, original_relative_dir)
220
+ FileUtils.mkdir_p(digested_dir)
221
+
222
+ # Process image and save to temporary location
223
+ temp_result = processed_image.call
224
+
225
+ # Calculate digest from processed content
226
+ digest = generate_digest(temp_result.path)
227
+
228
+ # Create final filename with digest
229
+ new_filename = "#{basename}-#{transform_suffix}-#{digest}#{ext}"
230
+ new_filepath = File.join(digested_dir, new_filename)
231
+
232
+ # Move processed image to final location
233
+ FileUtils.mv(temp_result.path, new_filepath)
234
+
235
+ static_file_dir = File.dirname(new_filepath).sub(@site.source, '').sub(%r{^/}, '')
236
+ @site.static_files << Jekyll::StaticFile.new(
237
+ @site,
238
+ @site.source,
239
+ static_file_dir,
240
+ new_filename
241
+ )
242
+
243
+ transform_key = build_transform_key(original_relative_path, transformations)
244
+ digested_relative_path = File.join(@digest_dir, original_relative_dir, new_filename)
245
+ @manifest[transform_key] = digested_relative_path
246
+
247
+ digested_relative_path
248
+ end
249
+
250
+ def generate_digest_from_content(content)
251
+ Digest::MD5.hexdigest(content)
252
+ end
253
+
254
+ def rewrite_css_urls(css_file)
255
+ # Find the original CSS file path from the manifest
256
+ original_css_path = nil
257
+ @manifest.each do |key, value|
258
+ if value == css_file.sub("#{@site.source}/", '')
259
+ original_css_path = key
260
+ break
261
+ end
262
+ end
263
+
264
+ return unless original_css_path
265
+
266
+ parser = CssParser::Parser.new
267
+ parser.load_file!(css_file)
268
+
269
+ parser.each_rule_set do |rule_set|
270
+ rule_set.each_declaration do |property, value, _|
271
+ next unless value.include?('url(')
272
+
273
+ updated_value = value.gsub(/url\(['"]?(.*?)['"]?\)/) do |match|
274
+ asset_path = $1
275
+ next match if asset_path.start_with?('/') || asset_path.include?('://') || asset_path.start_with?('//')
276
+
277
+ # Resolve relative path from original CSS location
278
+ original_css_dir = File.dirname(original_css_path)
279
+ resolved_asset_path = File.join(original_css_dir, asset_path)
280
+
281
+ # Normalize path (remove ../ and ./)
282
+ resolved_asset_path = File.expand_path(resolved_asset_path, '/').sub('/', '')
283
+
284
+
285
+ if @manifest.key?(resolved_asset_path)
286
+ "url(/#{@manifest[resolved_asset_path]})"
287
+ else
288
+ match
289
+ end
290
+ end
291
+ rule_set[property] = updated_value
292
+ end
293
+ end
294
+
295
+ File.write(css_file, parser.to_s)
296
+ end
297
+ end
298
+
299
+ class SkyhookWatcher
300
+ def initialize(site)
301
+ @site = site
302
+ @skyhook = Skyhook.instance(site)
303
+ skyhook_config = site.config['skyhook'] || {}
304
+ @asset_dirs = Array(skyhook_config['asset_dirs'] || ['assets'])
305
+ end
306
+
307
+ def start
308
+ return if @listener&.listening?
309
+ return unless @skyhook.should_digest?
310
+
311
+ @asset_dirs.each do |asset_dir|
312
+ assets_dir = File.join(@site.source, asset_dir)
313
+ next unless Dir.exist?(assets_dir)
314
+
315
+ digested_path = @skyhook.instance_variable_get(:@digested_path)
316
+
317
+ @listener = Listen.to(assets_dir,
318
+ ignore: %r{#{Regexp.escape(digested_path)}}) do |modified, added, removed|
319
+ process_changes(added, modified, removed)
320
+ end
321
+ @listener.start
322
+ end
323
+ end
324
+
325
+ private
326
+
327
+ def process_changes(added, modified, removed)
328
+ return if added.empty? && modified.empty? && removed.empty?
329
+
330
+ added.each { |f| @skyhook.process_file(f) }
331
+ modified.each { |f| @skyhook.process_file(f) }
332
+ removed.each { |f| remove_from_manifest(f) }
333
+
334
+ @skyhook.write_manifest
335
+ puts "Processed #{added.size} new, #{modified.size} modified, #{removed.size} removed assets"
336
+ end
337
+
338
+ def remove_from_manifest(file)
339
+ original_relative_path = file.sub("#{@site.source}/", '')
340
+ @skyhook.instance_variable_get(:@manifest).delete(original_relative_path)
341
+ end
342
+ end
343
+ end
344
+
345
+ Jekyll::Hooks.register :site, :after_init do |site|
346
+ skyhook = Jekyll::Skyhook.instance(site)
347
+ skyhook.process_assets if skyhook.should_digest?
348
+ end
349
+
350
+ Jekyll::Hooks.register :site, :after_reset do |site|
351
+ if site.config['serving']
352
+ @skyhook_watcher ||= Jekyll::SkyhookWatcher.new(site).start
353
+ end
354
+ end
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-skyhook
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Arclight Automata
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-07-24 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jekyll
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.7'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.7'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: listen
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '3.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '3.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: css_parser
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '1.11'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '1.11'
60
+ - !ruby/object:Gem::Dependency
61
+ name: image_processing
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '1.0'
67
+ type: :runtime
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '1.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: bundler
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '2.0'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '2.0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: rspec
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '3.0'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '3.0'
102
+ email:
103
+ - root@arclight.run
104
+ executables: []
105
+ extensions: []
106
+ extra_rdoc_files: []
107
+ files:
108
+ - lib/jekyll-skyhook.rb
109
+ - lib/jekyll-skyhook/tags.rb
110
+ homepage: https://github.com/arclight0/jekyll-skyhook
111
+ licenses:
112
+ - MIT
113
+ metadata: {}
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.6.2
129
+ specification_version: 4
130
+ summary: Modern asset processing for Jekyll with image transformations
131
+ test_files: []