jekyll-compress-images 1.3 → 1.4

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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -3
  3. data/lib/jekyll-compress-images.rb +252 -19
  4. metadata +6 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0acb0ae2e739816700bc6fb491d44d272ffb7dad3bd0f725b8b90e61f9589709
4
- data.tar.gz: e2d6ff51708865f6ba43a21f9c6a2f93ff5f1867a9b0aac31253999e31cdd97b
3
+ metadata.gz: 704a828325e4c1d9396206750da62503785ac90033f86d8d36429d3a5ab04a59
4
+ data.tar.gz: 657104fb5242f0a3c5948278b4718762d41cc2d73c5423414205ade8e3d7eb2a
5
5
  SHA512:
6
- metadata.gz: d1cbccb9e8ba180bada845b8b8fcbaf6dc5e5be4ce4d092ff7d7d024d06822f7c44135be321b9bc76d063d864aafded65ebae9289b16ee770fe22b68731ac53a
7
- data.tar.gz: 4a5536237ae2205c4ca9372a1bea7e907d831943d47dd0c3cf8bcc59f53e11a6ccb492ae8908d2438bd60c62f2f8ef41f90352c256f4198cbb63b41641b8fc12
6
+ metadata.gz: 86ae385b510546688f9e72709c9f8f10b1f60c0e4b09bad40cc1fca06c5b3a39efc95262f704b2c0a98880225444a10a049a24228c76360766d0acf56099f65a
7
+ data.tar.gz: 29e08242a343da1a4476324336700ab579a20e039de62db5241a39740c04636c282d4988e0e11d8c860ded80709c3b9900aa864987f9cf88f6cb8f83310264e6
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # jekyll-compress-images
5
5
 
6
- Plugin for compressing/optimizing images (jpg, png, gif, svg).
6
+ Plugin for compressing/optimizing images (jpg, png, gif, svg), and optionally creating WebP and AVIF versions.
7
7
 
8
8
  # Installation
9
9
 
@@ -40,8 +40,60 @@ compress_images:
40
40
  images_path: "assets/img/**/*.{gif,png,jpg,jpeg,svg}" # relative to your site source
41
41
  cache_file: "_compress_images_cache.yml" # remembers which images are already optimized
42
42
  threads: 8 # defaults to the number of CPU cores
43
+ overwrite_originals: true # false = only the published images in _site are compressed
44
+ webp: false # create .webp versions of jpg/png images
45
+ webp_quality: 80
46
+ avif: false # create .avif versions of jpg/png images
47
+ avif_quality: 60
43
48
  ```
44
49
 
50
+ ## Keep your original images
51
+
52
+ By default, images are compressed **in place**, in your source folder. If you'd rather keep high-quality originals in your repo and only publish compressed ones, set:
53
+
54
+ ```yaml
55
+ compress_images:
56
+ overwrite_originals: false
57
+ ```
58
+
59
+ Compressed copies are kept in `.jekyll-cache/compress-images/`, so only the first build is slow. Your source images are never touched, and `_compress_images_cache.yml` isn't needed.
60
+
61
+ ## WebP and AVIF
62
+
63
+ WebP and AVIF are usually much smaller than JPG/PNG. Turn them on with:
64
+
65
+ ```yaml
66
+ compress_images:
67
+ webp: true
68
+ avif: true
69
+ ```
70
+
71
+ This needs `cwebp` and `avifenc` (version 1.0 or newer) installed:
72
+
73
+ - macOS: `brew install webp libavif`
74
+ - Ubuntu 24.04+/Debian 13+ (and GitHub Actions `ubuntu-latest`): `sudo apt-get install webp libavif-bin`
75
+
76
+ For every jpg/png, a `.webp` and `.avif` file is added next to it in `_site`, e.g. `hero.jpg.webp`. If one comes out bigger than the original, it's skipped. Your source folder isn't touched.
77
+
78
+ Browsers only use these files if your HTML asks for them, so use the `picture` tag instead of `<img>`:
79
+
80
+ ```liquid
81
+ {% picture assets/img/hero.jpg alt="Hero" class="cover" loading="lazy" %}
82
+ {% picture {{ page.image }} alt="{{ page.title }}" %}
83
+ ```
84
+
85
+ which becomes:
86
+
87
+ ```html
88
+ <picture>
89
+ <source srcset="/assets/img/hero.jpg.avif" type="image/avif">
90
+ <source srcset="/assets/img/hero.jpg.webp" type="image/webp">
91
+ <img src="/assets/img/hero.jpg" alt="Hero" class="cover" loading="lazy">
92
+ </picture>
93
+ ```
94
+
95
+ Anything after the image path is added to the `<img>`. Browsers that don't support AVIF/WebP use the original image. If you already use another plugin with a `picture` tag (like jekyll_picture_tag), that one is kept.
96
+
45
97
  ## image_optim options
46
98
 
47
99
  You can pass [image_optim](https://github.com/toy/image_optim) options by using:
@@ -53,7 +105,7 @@ imageoptim:
53
105
  verbose: false
54
106
  ```
55
107
 
56
- SVG optimization uses [svgo](https://github.com/svg/svgo), which isn't bundled. Install it with `npm install -g svgo`, or set `svgo: false` to hide the warning.
108
+ SVG optimization uses [svgo](https://github.com/svg/svgo), which isn't bundled. It's used automatically when it's installed (`npm install -g svgo`).
57
109
 
58
110
  # Usage
59
111
 
@@ -61,8 +113,9 @@ On `jekyll serve` or `jekyll build`, compression will run. If your images are al
61
113
 
62
114
  Good to know:
63
115
 
64
- - Images are optimized **in place**, in your source folder. Commit them afterwards.
116
+ - Images are optimized **in place**, in your source folder (unless you set `overwrite_originals: false`). Commit them afterwards.
65
117
  - Commit `_compress_images_cache.yml` too, so other machines (and CI) know which images are already done.
118
+ - Each build prints a summary like `Optimized 12 images, saved 3.4 MB`. Run with `--verbose` to see every image.
66
119
  - If you replace an image with a new file, it will be optimized again automatically.
67
120
 
68
121
  # Development
@@ -1,6 +1,10 @@
1
+ require "cgi"
1
2
  require "digest"
2
3
  require "etc"
4
+ require "fileutils"
5
+ require "open3"
3
6
  require "pathname"
7
+ require "set"
4
8
  require "yaml"
5
9
  require "image_optim"
6
10
  require "image_optim_pack"
@@ -13,52 +17,248 @@ module Jekyll
13
17
  LOG_TOPIC = "CompressImages:".freeze
14
18
 
15
19
  DEFAULT_OPTIONS = {
16
- "cache_file" => "_compress_images_cache.yml",
17
- "images_path" => "assets/img/**/*.{gif,png,jpg,jpeg,svg}",
18
- "threads" => Etc.nprocessors
20
+ "cache_file" => "_compress_images_cache.yml",
21
+ "images_path" => "assets/img/**/*.{gif,png,jpg,jpeg,svg}",
22
+ "threads" => Etc.nprocessors,
23
+ "overwrite_originals" => true,
24
+ "webp" => false,
25
+ "webp_quality" => 80,
26
+ "avif" => false,
27
+ "avif_quality" => 60
19
28
  }.freeze
20
29
 
21
30
  DEFAULT_IMAGEOPTIM_OPTIONS = {
22
31
  "pngout" => false,
23
- "svgo" => true,
24
32
  "verbose" => false
25
33
  }.freeze
26
34
 
35
+ # Extra formats we can create, the tool that makes them and how to install it
36
+ VARIANTS = {
37
+ "webp" => { "tool" => "cwebp", "install" => "brew install webp / apt install webp" },
38
+ "avif" => { "tool" => "avifenc", "install" => "brew install libavif / apt install libavif-bin" }
39
+ }.freeze
40
+
41
+ # Formats cwebp and avifenc can read
42
+ CONVERTIBLE = %w[.jpg .jpeg .png].freeze
43
+
27
44
  def generate(site)
28
45
  @site = site
29
46
  @config = DEFAULT_OPTIONS.merge(site.config["compress_images"] || {})
30
- @cache_path = File.expand_path(@config["cache_file"], site.source)
47
+ @mutex = Mutex.new
48
+ @stats = { "optimized" => 0, "saved" => 0, "created" => 0 }
49
+ @used_cache_files = Set.new
50
+ @digests = {}
31
51
 
32
52
  images = Dir.glob(File.expand_path(@config["images_path"], site.source)).select { |path| File.file?(path) }.sort
53
+
54
+ # Look these up before optimize_copies points them at the cache folder
55
+ files = published_files(images)
56
+
57
+ if @config["overwrite_originals"]
58
+ optimize_originals(images)
59
+ else
60
+ optimize_copies(files)
61
+ end
62
+ create_variants(files)
63
+ clean_cache_dir
64
+ log_summary
65
+ end
66
+
67
+ private
68
+
69
+ # Optimizes images in the source folder and remembers them in the cache file
70
+ def optimize_originals(images)
71
+ @cache_path = File.expand_path(@config["cache_file"], site.source)
33
72
  @original_cache = load_cache
34
73
  # Only keep entries for images that still exist, so deleted images drop out of the cache
35
74
  @cache = @original_cache.select { |key, _| images.any? { |path| relative(path) == key } }
36
- @mutex = Mutex.new
37
75
 
38
76
  pending = images.reject { |path| @cache[relative(path)] == digest(path) }
39
- pending.in_threads([@config["threads"].to_i, 1].max).each { |path| optimize(path) } if pending.any?
77
+ in_parallel(pending) do |path|
78
+ safely("optimize", path) do
79
+ optimize(path)
80
+ # Store the digest of the optimized file, so we only run again when the image is replaced
81
+ new_digest = digest(path)
82
+ @mutex.synchronize { @cache[relative(path)] = new_digest }
83
+ end
84
+ end
40
85
  ensure
41
86
  save_cache if @cache
42
87
  end
43
88
 
44
- private
89
+ # Leaves the originals untouched. Optimized copies live in the cache folder
90
+ # and Jekyll publishes them instead of the originals.
91
+ def optimize_copies(files)
92
+ pending = files.keys.reject { |path| File.file?(cached_path(path, File.extname(path))) }
93
+ pending = pending.uniq { |path| cached_path(path, File.extname(path)) }
45
94
 
46
- def image_optim
47
- @image_optim ||= ImageOptim.new(DEFAULT_IMAGEOPTIM_OPTIONS.merge(@site.config["imageoptim"] || {}))
95
+ in_parallel(pending) do |path|
96
+ safely("optimize", path) do
97
+ write_atomically(cached_path(path, File.extname(path))) do |tmp|
98
+ FileUtils.cp(path, tmp)
99
+ optimize(tmp, path)
100
+ end
101
+ end
102
+ end
103
+
104
+ files.each do |path, file|
105
+ copy = cached_path(path, File.extname(path))
106
+ file.define_singleton_method(:path) { copy } if File.file?(copy)
107
+ end
108
+ end
109
+
110
+ # Creates .webp/.avif files next to the images, e.g. hero.jpg -> hero.jpg.webp
111
+ def create_variants(files)
112
+ formats = VARIANTS.keys.select { |format| @config[format] && tool_installed?(format) }
113
+ return if formats.empty?
114
+
115
+ images = files.keys.select { |path| CONVERTIBLE.include?(File.extname(path).downcase) }
116
+ jobs = images.product(formats)
117
+ pending = jobs.reject { |path, format| File.file?(cached_path(path, ".#{format}")) || File.file?(skip_marker(path, format)) }
118
+ pending = pending.uniq { |path, format| cached_path(path, ".#{format}") }
119
+
120
+ in_parallel(pending) do |path, format|
121
+ safely("create #{format} for", path) { convert(path, format) }
122
+ end
123
+
124
+ jobs.each do |path, format|
125
+ variant = cached_path(path, ".#{format}")
126
+ next unless File.file?(variant)
127
+
128
+ file = StaticFile.new(site, site.source, File.dirname("/#{relative(path)}"), "#{File.basename(path)}.#{format}")
129
+ file.define_singleton_method(:path) { variant }
130
+ site.static_files << file
131
+ end
132
+ end
133
+
134
+ def convert(path, format)
135
+ target = cached_path(path, ".#{format}")
136
+ write_atomically(target) do |tmp|
137
+ output, status = Open3.capture2e(*convert_command(format, path, tmp))
138
+ raise output.strip unless status.success?
139
+
140
+ # Not worth publishing if it's bigger than the original, the picture tag then falls back to it
141
+ if File.size(tmp) >= File.size(path)
142
+ FileUtils.touch(skip_marker(path, format))
143
+ FileUtils.rm_f(tmp)
144
+ else
145
+ Jekyll.logger.debug LOG_TOPIC, "Created #{relative(path)}.#{format}"
146
+ @mutex.synchronize { @stats["created"] += 1 }
147
+ end
148
+ end
149
+ end
150
+
151
+ def convert_command(format, input, output)
152
+ quality = @config["#{format}_quality"].to_s
153
+ case format
154
+ when "webp" then ["cwebp", "-quiet", "-q", quality, "-metadata", "icc", input, "-o", output]
155
+ when "avif" then ["avifenc", "-q", quality, "-s", "6", "-j", "all", input, output]
156
+ end
48
157
  end
49
158
 
50
- def optimize(path)
159
+ def optimize(path, original = path)
51
160
  size_before = File.size(path)
52
161
  image_optim.optimize_image!(path)
53
- size_after = File.size(path)
54
- saved = size_before.zero? ? 0 : ((size_before - size_after) * 100.0 / size_before).round(1)
55
- Jekyll.logger.info LOG_TOPIC, "Optimized #{relative(path)} (-#{saved}%)"
162
+ saved = size_before - File.size(path)
163
+ Jekyll.logger.debug LOG_TOPIC, "Optimized #{relative(original)} (-#{percent(saved, size_before)}%)"
164
+ @mutex.synchronize do
165
+ @stats["optimized"] += 1
166
+ @stats["saved"] += saved
167
+ end
168
+ end
169
+
170
+ def site
171
+ @site
172
+ end
173
+
174
+ def image_optim
175
+ @image_optim ||= ImageOptim.new(imageoptim_options)
176
+ end
177
+
178
+ # svgo isn't bundled, so only turn it on when it's installed (unless the site config says otherwise)
179
+ def imageoptim_options
180
+ DEFAULT_IMAGEOPTIM_OPTIONS.merge("svgo" => executable?("svgo") || ENV.key?("SVGO_BIN"))
181
+ .merge(site.config["imageoptim"] || {})
182
+ end
183
+
184
+ # Static files Jekyll will publish at their usual URL, by source path
185
+ def published_files(images)
186
+ by_path = images.to_h { |path| [path, nil] }
187
+ site.static_files.each_with_object({}) do |file, found|
188
+ path = File.expand_path(file.path)
189
+ found[path] = file if by_path.key?(path) && file.url == "/#{relative(path)}"
190
+ end
191
+ end
192
+
193
+ def in_parallel(items, &block)
194
+ items.in_threads([@config["threads"].to_i, 1].max).each(&block)
195
+ end
56
196
 
57
- # Store the digest of the optimized file, so we only run again when the image is replaced
58
- new_digest = digest(path)
59
- @mutex.synchronize { @cache[relative(path)] = new_digest }
197
+ def safely(action, path)
198
+ yield
60
199
  rescue StandardError => e
61
- Jekyll.logger.warn LOG_TOPIC, "Could not optimize #{relative(path)}: #{e.message}"
200
+ Jekyll.logger.warn LOG_TOPIC, "Could not #{action} #{relative(path)}: #{e.message}"
201
+ end
202
+
203
+ # Writes to a temporary file first, so an interrupted build never leaves a half-done file in the cache
204
+ def write_atomically(target)
205
+ FileUtils.mkdir_p(File.dirname(target))
206
+ tmp = "#{target}.tmp#{File.extname(target)}"
207
+ yield tmp
208
+ File.rename(tmp, target) if File.file?(tmp)
209
+ ensure
210
+ FileUtils.rm_f(tmp) if tmp
211
+ end
212
+
213
+ def cache_dir
214
+ @cache_dir ||= File.join(File.expand_path(site.config["cache_dir"] || ".jekyll-cache", site.source), "compress-images")
215
+ end
216
+
217
+ def cached_path(path, extension)
218
+ @digests[path] ||= digest(path)
219
+ File.join(cache_dir, "#{@digests[path]}#{extension}").tap { |file| @used_cache_files << file }
220
+ end
221
+
222
+ def skip_marker(path, format)
223
+ "#{cached_path(path, ".#{format}")}.skip".tap { |file| @used_cache_files << file }
224
+ end
225
+
226
+ def clean_cache_dir
227
+ return unless File.directory?(cache_dir)
228
+
229
+ (Dir.glob(File.join(cache_dir, "*")).to_set - @used_cache_files).each { |file| FileUtils.rm_f(file) }
230
+ end
231
+
232
+ def tool_installed?(format)
233
+ tool = VARIANTS[format]["tool"]
234
+ return true if executable?(tool)
235
+
236
+ Jekyll.logger.warn LOG_TOPIC, "#{format} is enabled but `#{tool}` isn't installed (#{VARIANTS[format]["install"]})"
237
+ false
238
+ end
239
+
240
+ def executable?(name)
241
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
242
+ end
243
+
244
+ def log_summary
245
+ if @stats["optimized"].positive?
246
+ Jekyll.logger.info LOG_TOPIC, "Optimized #{@stats["optimized"]} #{@stats["optimized"] == 1 ? "image" : "images"}, saved #{human_size(@stats["saved"])}"
247
+ end
248
+ return unless @stats["created"].positive?
249
+
250
+ Jekyll.logger.info LOG_TOPIC, "Created #{@stats["created"]} WebP/AVIF #{@stats["created"] == 1 ? "file" : "files"}"
251
+ end
252
+
253
+ def human_size(bytes)
254
+ return "#{bytes} B" if bytes < 1024
255
+ return "#{(bytes / 1024.0).round(1)} KB" if bytes < 1024 * 1024
256
+
257
+ "#{(bytes / 1024.0 / 1024).round(1)} MB"
258
+ end
259
+
260
+ def percent(part, whole)
261
+ whole.zero? ? 0 : (part * 100.0 / whole).round(1)
62
262
  end
63
263
 
64
264
  def digest(path)
@@ -66,7 +266,7 @@ module Jekyll
66
266
  end
67
267
 
68
268
  def relative(path)
69
- Pathname.new(path).relative_path_from(Pathname.new(@site.source)).to_s
269
+ Pathname.new(path).relative_path_from(Pathname.new(site.source)).to_s
70
270
  end
71
271
 
72
272
  def load_cache
@@ -86,4 +286,37 @@ module Jekyll
86
286
  File.write(@cache_path, @cache.sort.to_h.to_yaml)
87
287
  end
88
288
  end
289
+
290
+ # {% picture assets/img/hero.jpg alt="Hero" class="cover" %}
291
+ # Uses the AVIF/WebP versions when they exist, and falls back to the original image
292
+ class CompressImagesPictureTag < Liquid::Tag
293
+ def initialize(tag_name, markup, tokens)
294
+ super
295
+ @markup = markup
296
+ end
297
+
298
+ def render(context)
299
+ site = context.registers[:site]
300
+ src, attributes = Liquid::Template.parse(@markup).render(context).strip.split(/\s+/, 2)
301
+ raise ArgumentError, "{% picture %} needs an image path, e.g. {% picture assets/img/hero.jpg alt=\"Hero\" %}" if src.to_s.empty?
302
+
303
+ url = "/#{src.sub(%r!\A/+!, "")}"
304
+ published = context.registers[:compress_images_urls] ||= site.static_files.map(&:url).to_set
305
+ sources = %w[avif webp].select { |format| published.include?("#{url}.#{format}") }.map do |format|
306
+ %(<source srcset="#{html_url(site, "#{url}.#{format}")}" type="image/#{format}">)
307
+ end
308
+ img = %(<img src="#{html_url(site, url)}"#{" #{attributes}" if attributes}>)
309
+
310
+ sources.empty? ? img : "<picture>#{sources.join}#{img}</picture>"
311
+ end
312
+
313
+ private
314
+
315
+ def html_url(site, url)
316
+ CGI.escapeHTML("#{site.config["baseurl"].to_s.chomp("/")}#{url}".gsub(" ", "%20"))
317
+ end
318
+ end
89
319
  end
320
+
321
+ # Don't take over the tag if another plugin (like jekyll_picture_tag) already registered it
322
+ Liquid::Template.register_tag("picture", Jekyll::CompressImagesPictureTag) unless Liquid::Template.tags["picture"]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-compress-images
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.3'
4
+ version: '1.4'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Valerija Spasojevic
@@ -76,14 +76,14 @@ dependencies:
76
76
  name: minitest
77
77
  requirement: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - "~>"
79
+ - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '5.0'
82
82
  type: :development
83
83
  prerelease: false
84
84
  version_requirements: !ruby/object:Gem::Requirement
85
85
  requirements:
86
- - - "~>"
86
+ - - ">="
87
87
  - !ruby/object:Gem::Version
88
88
  version: '5.0'
89
89
  - !ruby/object:Gem::Dependency
@@ -116,6 +116,7 @@ licenses:
116
116
  metadata:
117
117
  source_code_uri: https://github.com/valerijaspasojevic/jekyll-compress-images
118
118
  changelog_uri: https://github.com/valerijaspasojevic/jekyll-compress-images/blob/master/CHANGELOG.md
119
+ rubygems_mfa_required: 'true'
119
120
  post_install_message:
120
121
  rdoc_options: []
121
122
  require_paths:
@@ -134,5 +135,6 @@ requirements: []
134
135
  rubygems_version: 3.4.15
135
136
  signing_key:
136
137
  specification_version: 4
137
- summary: Jekyll plugin for compress/optimize images (jpg, png, gif, svg)
138
+ summary: Jekyll plugin for compressing images (jpg, png, gif, svg), with optional
139
+ WebP and AVIF versions
138
140
  test_files: []