datalog-theme 0.8.0 → 0.9.1

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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +4 -2
  5. data/_data/i18n/en.yml +321 -0
  6. data/_data/i18n/es.yml +321 -0
  7. data/_data/i18n/pt.yml +321 -0
  8. data/_data/js_manifest.json +16 -0
  9. data/_includes/components/academic-dashboard.html +4 -4
  10. data/_includes/components/author-bio.html +21 -11
  11. data/_includes/components/author-list.html +32 -0
  12. data/_includes/components/breadcrumbs.html +6 -4
  13. data/_includes/components/citation-tools.html +18 -58
  14. data/_includes/components/comments-thread.html +90 -0
  15. data/_includes/components/contact-form.html +120 -0
  16. data/_includes/components/correction-report.html +82 -0
  17. data/_includes/components/enhanced-toc.html +6 -7
  18. data/_includes/components/hero.html +1 -1
  19. data/_includes/components/license-link.html +13 -0
  20. data/_includes/components/license-notice.html +28 -0
  21. data/_includes/components/moderation-inbox.html +139 -0
  22. data/_includes/components/post-hero.html +2 -2
  23. data/_includes/components/reactions.html +49 -0
  24. data/_includes/components/reading-list.html +34 -0
  25. data/_includes/components/reading-mode-toggle.html +67 -0
  26. data/_includes/components/reading-state-bookmark.html +27 -0
  27. data/_includes/components/reading-state-panel.html +62 -0
  28. data/_includes/components/reading-state-resume.html +17 -0
  29. data/_includes/components/reproducibility.html +61 -0
  30. data/_includes/components/responsive-image.html +3 -3
  31. data/_includes/components/revision-history.html +39 -0
  32. data/_includes/components/revision-notice.html +35 -0
  33. data/_includes/components/series-nav.html +64 -0
  34. data/_includes/components/subscribe-form.html +88 -0
  35. data/_includes/components/subscription-manage.html +69 -0
  36. data/_includes/components/visualization-card.html +1 -1
  37. data/_includes/components/webmentions.html +48 -0
  38. data/_includes/csp-meta.html +21 -1
  39. data/_includes/footer/nav-column.html +1 -1
  40. data/_includes/footer.html +5 -2
  41. data/_includes/head.html +64 -52
  42. data/_includes/header/navigation.html +2 -2
  43. data/_includes/header.html +1 -1
  44. data/_includes/layouts/default/article.html +6 -8
  45. data/_includes/meta/dynamic-services-config.html +14 -0
  46. data/_includes/meta/math-config.html +12 -5
  47. data/_includes/meta/person-json.html +27 -0
  48. data/_includes/meta/publisher.html +45 -0
  49. data/_includes/meta/schema.html +84 -48
  50. data/_includes/meta/scholarly.html +122 -0
  51. data/_includes/meta/scripts-loader.html +2 -0
  52. data/_includes/meta/webmention-discovery.html +14 -0
  53. data/_includes/post/related-posts.html +1 -1
  54. data/_includes/scripts.html +57 -0
  55. data/_layouts/dataset.html +5 -4
  56. data/_layouts/default.html +1 -1
  57. data/_layouts/home.html +2 -2
  58. data/_layouts/notebook.html +1 -1
  59. data/_layouts/package.html +8 -7
  60. data/_layouts/page.html +15 -0
  61. data/_layouts/portfolio.html +1 -1
  62. data/_layouts/post.html +94 -25
  63. data/_layouts/project.html +5 -5
  64. data/_layouts/research.html +27 -75
  65. data/_plugins/authors.rb +151 -0
  66. data/_plugins/citation_exports.rb +93 -0
  67. data/_plugins/config_validator.rb +223 -20
  68. data/_plugins/critical_css_check.rb +42 -0
  69. data/_plugins/i18n.rb +6 -4
  70. data/_plugins/image_optimizer.rb +240 -186
  71. data/_plugins/licenses.rb +147 -0
  72. data/_plugins/math_preprocessor.rb +55 -11
  73. data/_plugins/references.rb +260 -0
  74. data/_plugins/reproducibility.rb +167 -0
  75. data/_plugins/responsive_content.rb +40 -0
  76. data/_plugins/revisions.rb +102 -0
  77. data/_plugins/scholarly.rb +55 -0
  78. data/_plugins/series.rb +104 -0
  79. data/_plugins/statements.rb +87 -0
  80. data/_sass/_base.scss +13 -1
  81. data/_sass/_comments-thread.scss +159 -0
  82. data/_sass/_layout.scss +402 -1
  83. data/_sass/_mathematical.scss +38 -1
  84. data/_sass/_moderation.scss +222 -0
  85. data/_sass/_post-components.scss +4 -2
  86. data/_sass/_print.scss +299 -0
  87. data/_sass/_reactions.scss +89 -0
  88. data/_sass/_reading-state.scss +290 -0
  89. data/_sass/_service-forms.scss +204 -0
  90. data/_sass/_subscriptions.scss +140 -0
  91. data/_sass/_syntax-highlighting.scss +3 -3
  92. data/_sass/_theme.scss +11 -0
  93. data/_sass/_typography.scss +116 -0
  94. data/_sass/_utilities.scss +5 -0
  95. data/_sass/_variables.scss +6 -0
  96. data/_sass/_webmentions.scss +125 -0
  97. data/assets/js/dist/academic.js +1 -1
  98. data/assets/js/dist/analytics-dashboard.js +1 -1
  99. data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
  100. data/assets/js/dist/chunks/chunk-VZ5WKQVA.js +1 -0
  101. data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +1 -0
  102. data/assets/js/dist/comments.js +2 -0
  103. data/assets/js/dist/contact.js +1 -0
  104. data/assets/js/dist/core.js +1 -1
  105. data/assets/js/dist/corrections.js +1 -0
  106. data/assets/js/dist/loader.js +1 -1
  107. data/assets/js/dist/math.js +1 -1
  108. data/assets/js/dist/moderation.js +1 -0
  109. data/assets/js/dist/notebook.js +1 -1
  110. data/assets/js/dist/reactions.js +1 -0
  111. data/assets/js/dist/reading-state.js +1 -0
  112. data/assets/js/dist/search.js +1 -1
  113. data/assets/js/dist/sources.json +52 -0
  114. data/assets/js/dist/subscriptions.js +1 -0
  115. data/assets/js/dist/visualizations.js +2 -2
  116. data/assets/js/dist/webmentions.js +1 -0
  117. data/assets/js/loader.js +34 -0
  118. data/datalog-theme.gemspec +1 -2
  119. data/lib/datalog/cli.rb +9 -1
  120. data/lib/datalog/critical_css.rb +168 -0
  121. data/lib/datalog/plugins/comments.rb +33 -3
  122. data/lib/datalog/theme/installed_files.rb +113 -0
  123. data/lib/datalog/theme/repository_checkout.rb +6 -2
  124. data/lib/datalog/theme/version.rb +5 -1
  125. data/lib/datalog-theme.rb +1 -0
  126. metadata +60 -23
  127. data/assets/js/dist/chunks/chunk-225H5YXE.js +0 -1
@@ -1,17 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "fastimage"
4
- require "nokogiri"
5
- require "tempfile"
6
- require "English"
7
5
  require "fileutils"
8
6
  require "json"
9
-
10
- begin
11
- require "mini_magick"
12
- rescue LoadError
13
- MiniMagick = nil
14
- end
7
+ require "nokogiri"
8
+ require "open3"
9
+ require "tmpdir"
10
+ require "tempfile"
11
+ require "uri"
15
12
 
16
13
  module Jekyll
17
14
  module ImageOptimizer
@@ -33,6 +30,10 @@ module Jekyll
33
30
  "png" => "image/png"
34
31
  }.freeze
35
32
  RASTER_EXTENSIONS = %w[.jpg .jpeg .png].freeze
33
+ SOURCE_FORMATS = %w[avif webp].freeze
34
+ # ImageMagick's names for the formats it writes itself, as `-list format` prints them.
35
+ CODERS = { "jpeg" => "JPEG", "png" => "PNG", "webp" => "WEBP" }.freeze
36
+ CACHE_DIR = "datalog-images"
36
37
 
37
38
  # The one image a page fetches early: the first in its post or page content,
38
39
  # unless the author made it lazy or the page already preloads an image, as
@@ -51,12 +52,19 @@ module Jekyll
51
52
  return if document.output.nil? || document.output.empty?
52
53
 
53
54
  full_document = document.output.lstrip.match?(/\A(?:<!DOCTYPE|<html)/i)
54
- fragment = full_document ? Nokogiri::HTML5::Document.parse(document.output) : Nokogiri::HTML::DocumentFragment.parse(document.output)
55
+ # Fragments use the HTML5 parser too: the HTML4 serializer writes a
56
+ # </source> end tag after each void <source> that offer_variants adds.
57
+ fragment = if full_document
58
+ Nokogiri::HTML5::Document.parse(document.output)
59
+ else
60
+ Nokogiri::HTML5::DocumentFragment.parse(document.output)
61
+ end
55
62
  return if fragment.nil?
56
63
 
57
64
  site = document.respond_to?(:site) ? document.site : nil
58
65
  manifest = site&.data&.fetch("datalog_responsive_images", {}) || {}
59
66
  image_config = site&.config&.fetch("datalog_image_config", {}) || {}
67
+ baseurl = site.respond_to?(:baseurl) ? site.baseurl.to_s : ""
60
68
  optimized = false
61
69
  priority = priority_image(fragment)
62
70
 
@@ -69,20 +77,19 @@ module Jekyll
69
77
  img["loading"] ||= "lazy"
70
78
  end
71
79
  img["decoding"] ||= "async"
72
-
73
80
  normalized_src = normalize_src(img["src"], site)
74
81
  picture_entry = manifest[normalized_src]
75
82
 
76
83
  if picture_entry
77
- fallback_variants = picture_entry.dig("fallback", "variants") || []
78
- img["srcset"] = build_srcset(fallback_variants) unless img["srcset"]
79
- img["sizes"] ||= image_config.fetch("default_sizes", "100vw")
80
- img["width"] ||= picture_entry["width"].to_s if picture_entry["width"]
81
- img["height"] ||= picture_entry["height"].to_s if picture_entry["height"]
84
+ offer_variants(img, picture_entry, image_config, baseurl)
85
+ if !img["width"] && !img["height"] && picture_entry["width"] && picture_entry["height"]
86
+ img["width"] = picture_entry["width"].to_s
87
+ img["height"] = picture_entry["height"].to_s
88
+ end
82
89
  else
83
- next if img["width"] && img["height"]
90
+ next if img["width"] || img["height"]
84
91
 
85
- source = image_source_path(site, img["src"])
92
+ source = image_source_path(site, normalized_src)
86
93
  next unless source && File.exist?(source)
87
94
 
88
95
  width, height = FastImage.size(source)
@@ -101,12 +108,42 @@ module Jekyll
101
108
  document.output = fragment.to_html
102
109
  end
103
110
 
111
+ # Offers an image's generated variants: the resized copies as the <img>'s
112
+ # srcset, and each modern format as a <source> in a <picture> around it.
113
+ # Markup the author chose, a srcset of their own or a <picture>, is left
114
+ # alone, and so is an image with nothing generated besides the original.
115
+ def offer_variants(img, entry, image_config, baseurl)
116
+ return if img["srcset"] || img.parent&.name == "picture"
117
+
118
+ fallback = entry.dig("fallback", "variants") || []
119
+ sources = entry.fetch("sources", []).select { |source| source["type"] && source["variants"]&.any? }
120
+ return if fallback.size < 2 && sources.empty?
121
+
122
+ sizes = img["sizes"] || image_config.fetch("default_sizes", "100vw")
123
+ img["srcset"] = build_srcset(fallback, baseurl) if fallback.size > 1
124
+ img["sizes"] = sizes
125
+ return if sources.empty?
126
+
127
+ picture = Nokogiri::XML::Node.new("picture", img.document)
128
+ sources.each do |source|
129
+ element = Nokogiri::XML::Node.new("source", img.document)
130
+ element["type"] = source["type"]
131
+ element["srcset"] = build_srcset(source["variants"], baseurl)
132
+ element["sizes"] = sizes
133
+ picture.add_child(element)
134
+ end
135
+ img.add_previous_sibling(picture)
136
+ picture.add_child(img)
137
+ end
138
+
104
139
  def prepare_site(site)
105
140
  config = image_config(site)
141
+ encoders = config["variants"] ? tools : {}
142
+ counts = Hash.new(0)
106
143
  manifest = {}
107
144
 
108
145
  responsive_candidates(site).each do |static_file|
109
- entry = manifest_entry_for(site, static_file, config)
146
+ entry = manifest_entry_for(site, static_file, config, encoders, counts)
110
147
  next unless entry
111
148
 
112
149
  manifest[entry.delete("source")] = entry
@@ -114,6 +151,15 @@ module Jekyll
114
151
 
115
152
  site.data["datalog_responsive_images"] = manifest
116
153
  site.config["datalog_image_config"] = config
154
+ report(encoders, counts)
155
+ end
156
+
157
+ def report(encoders, counts)
158
+ if counts[:created].positive? || counts[:cached].positive?
159
+ Jekyll.logger.info "Images:", "#{counts[:created]} variants created, #{counts[:cached]} reused from the cache"
160
+ elsif encoders.any? && encoders["imagemagick"].nil?
161
+ Jekyll.logger.debug "ImageOptimizer", "ImageMagick was not found, so no image variants were created"
162
+ end
117
163
  end
118
164
 
119
165
  def image_source_path(site, src)
@@ -140,44 +186,39 @@ module Jekyll
140
186
  end
141
187
  end
142
188
 
143
- def manifest_entry_for(site, static_file, config)
189
+ def manifest_entry_for(site, static_file, config, encoders, counts)
144
190
  source_path = static_file.path
145
191
  width, height = FastImage.size(source_path)
146
192
  return unless width && height
147
193
 
148
194
  normalized_src = normalize_src(static_file.relative_path, site)
149
- sizes = normalized_sizes(config["sizes"], width)
150
195
  fallback_format = normalize_format(File.extname(source_path))
151
196
  fallback_variants = []
152
197
  source_variants = Hash.new { |hash, key| hash[key] = [] }
198
+ image = { site: site, file: static_file, width: width, height: height, config: config,
199
+ encoders: encoders, counts: counts }
153
200
 
154
- sizes.each do |target_width|
155
- target_height = scaled_height(width, height, target_width)
156
-
201
+ normalized_sizes(config["sizes"], width).each do |target_width|
157
202
  if fallback_format && target_width < width
158
- variant = build_variant(site, static_file, target_width, target_height, fallback_format, config,
159
- fallback_format, width)
203
+ variant = build_variant(image, target_width, fallback_format)
160
204
  fallback_variants << variant if variant
161
205
  end
162
206
 
163
- %w[avif webp].each do |format|
164
- variant = build_variant(site, static_file, target_width, target_height, format, config, fallback_format,
165
- width)
207
+ SOURCE_FORMATS.each do |format|
208
+ variant = build_variant(image, target_width, format)
166
209
  source_variants[format] << variant if variant
167
210
  end
168
211
  end
169
212
 
170
213
  fallback_variants.sort_by! { |variant| variant["width"] }
171
- fallback_variants << original_variant(normalized_src, width, height) unless fallback_variants.any? do |variant|
172
- variant["width"] >= width
173
- end
214
+ fallback_variants << original_variant(normalized_src, width, height)
174
215
 
175
- sources = %w[avif webp].map do |format|
176
- variants = source_variants[format].compact.sort_by { |variant| variant["width"] }
216
+ sources = SOURCE_FORMATS.filter_map do |format|
217
+ variants = source_variants[format].sort_by { |variant| variant["width"] }
177
218
  next if variants.empty?
178
219
 
179
220
  { "type" => MIME_TYPES[format], "format" => format, "variants" => variants }
180
- end.compact
221
+ end
181
222
 
182
223
  {
183
224
  "source" => normalized_src,
@@ -185,73 +226,162 @@ module Jekyll
185
226
  "height" => height,
186
227
  "sources" => sources,
187
228
  "fallback" => {
188
- "type" => MIME_TYPES[fallback_format] || MIME_TYPES[normalize_format(File.extname(source_path))] || "image/jpeg",
229
+ "type" => MIME_TYPES[fallback_format] || "image/jpeg",
189
230
  "format" => fallback_format,
190
231
  "variants" => fallback_variants
191
232
  }
192
233
  }
193
234
  end
194
235
 
195
- def build_variant(site, static_file, target_width, target_height, format, config, original_format, original_width)
196
- return unless format
236
+ # Encodes one variant into the cache, unless an earlier build already did,
237
+ # and adds it to the files the site writes. Returns nil when this machine
238
+ # cannot encode the format or the encoder fails, so the manifest only names
239
+ # files that exist.
240
+ def build_variant(image, target_width, format)
241
+ return unless can_encode?(format, image[:encoders])
197
242
 
198
- if MiniMagick.nil?
199
- if original_format != format
200
- Jekyll.logger.debug "ImageOptimizer",
201
- "Skipping #{format} variant for #{static_file.relative_path} because MiniMagick is unavailable"
202
- return
203
- end
243
+ static_file = image[:file]
204
244
 
205
- if original_width && target_width < original_width
206
- Jekyll.logger.debug "ImageOptimizer",
207
- "Skipping #{format} #{target_width}w variant for #{static_file.relative_path} because MiniMagick is unavailable"
208
- return
209
- end
210
-
211
- return
212
- end
213
-
214
- quality_map = config["quality"] || {}
215
- quality = quality_map[format] || quality_map[format&.downcase] || quality_map[format&.upcase]
216
- encoder_path = avif_encoder
217
- if format == "avif" && encoder_path.nil?
218
- Jekyll.logger.debug "ImageOptimizer",
219
- "Skipping AVIF variant for #{static_file.relative_path} because avifenc is unavailable"
220
- return
245
+ quality = image[:config]["quality"][format]
246
+ cached = cached_variant_path(image, target_width, format, quality)
247
+ if File.exist?(cached)
248
+ image[:counts][:cached] += 1
249
+ else
250
+ encode_into_cache(static_file.path, cached, target_width, format, quality, image[:encoders])
251
+ image[:counts][:created] += 1
221
252
  end
222
253
 
223
- relative_path = static_file.relative_path.sub(%r{^/}, "")
224
- dir = File.dirname(relative_path)
225
- dir = "" if dir == "."
226
- responsive_dir = File.join(dir, "responsive")
227
-
228
- base = File.basename(relative_path, File.extname(relative_path))
229
- ext = extension_for(format)
230
- filename = "#{base}-#{target_width}w.#{ext}"
231
-
232
- responsive_file = ResponsiveImageStaticFile.new(
233
- site: site,
234
- relative_dir: responsive_dir,
235
- name: filename,
236
- original_path: static_file.path,
237
- width: target_width,
238
- height: target_height,
239
- format: format,
240
- quality: quality,
241
- avif_encoder: encoder_path
242
- )
243
-
244
- site.static_files << responsive_file
254
+ variant_file = variant_static_file(image[:site], static_file, target_width, format, cached)
255
+ image[:site].static_files << variant_file
245
256
 
246
257
  {
247
- "url" => responsive_file.url,
258
+ "url" => variant_file.url,
248
259
  "width" => target_width,
249
- "height" => target_height,
260
+ "height" => scaled_height(image[:width], image[:height], target_width),
250
261
  "format" => format
251
262
  }
252
263
  rescue StandardError => e
253
- Jekyll.logger.debug "ImageOptimizer",
254
- "Unable to generate variant for #{static_file.relative_path}: #{e.message}"
264
+ Jekyll.logger.warn "ImageOptimizer:",
265
+ "Could not create a #{target_width}px #{format} of #{image[:file].relative_path}: #{e.message}"
266
+ nil
267
+ end
268
+
269
+ # Published next to the original, in a responsive/ folder:
270
+ # /assets/img/plot.png gives /assets/img/responsive/plot-640w.webp.
271
+ def variant_static_file(site, static_file, target_width, format, cached)
272
+ relative_path = static_file.relative_path.sub(%r{^/}, "")
273
+ dir = File.dirname(relative_path)
274
+ responsive_dir = dir == "." ? "responsive" : File.join(dir, "responsive")
275
+ base = File.basename(relative_path)
276
+ Jekyll::ResponsiveImageStaticFile.new(site, responsive_dir, "#{base}-#{target_width}w.#{extension_for(format)}",
277
+ cached)
278
+ end
279
+
280
+ # Keyed by the image's content rather than its file time, which a fresh
281
+ # checkout resets.
282
+ def cached_variant_path(image, target_width, format, quality)
283
+ image[:digest] ||= Digest::SHA256.file(image[:file].path).hexdigest
284
+ key = Digest::SHA256.hexdigest([image[:digest], target_width, format, quality].join("|"))
285
+ File.join(cache_root(image[:site]), "#{key}.#{extension_for(format)}")
286
+ end
287
+
288
+ # Jekyll's cache directory. A site that sets disable_disk_cache gets a
289
+ # temporary directory instead, which `jekyll serve` reuses between builds
290
+ # and which is removed when Jekyll exits.
291
+ def cache_root(site)
292
+ return site.in_cache_dir(CACHE_DIR) unless site.config["disable_disk_cache"]
293
+
294
+ @cache_root ||= Dir.mktmpdir(CACHE_DIR).tap { |dir| at_exit { FileUtils.rm_rf(dir) } }
295
+ end
296
+
297
+ # Encodes beside the cache entry and renames it into place, so a failed or
298
+ # interrupted encode never leaves a file that a later build takes for done.
299
+ def encode_into_cache(source, cached, target_width, format, quality, encoders)
300
+ partial = cached.sub(/(\.\w+)\z/, '.partial\1')
301
+ FileUtils.mkdir_p(File.dirname(cached))
302
+ if format == "avif"
303
+ Tempfile.create(["datalog-image", ".png"]) do |png|
304
+ png.close
305
+ resize(source, png.path, target_width, nil, encoders)
306
+ run!(*encoders["avifenc"], "-q", avif_quality(quality).to_s, "-s", "6", png.path, partial)
307
+ end
308
+ else
309
+ resize(source, partial, target_width, quality, encoders)
310
+ end
311
+ File.rename(partial, cached)
312
+ ensure
313
+ FileUtils.rm_f(partial)
314
+ end
315
+
316
+ def resize(source, destination, target_width, quality, encoders)
317
+ command = [*encoders["imagemagick"], source, "-auto-orient", "-resize", "#{target_width}x>", "-strip"]
318
+ command += ["-quality", quality.to_s] if quality
319
+ run!(*command, destination)
320
+ end
321
+
322
+ def run!(*command)
323
+ output, status = Open3.capture2e(*command)
324
+ return if status.success?
325
+
326
+ raise "#{File.basename(command.first)} exited with #{status.exitstatus}: #{output.strip.lines.last(3).join.strip}"
327
+ end
328
+
329
+ def avif_quality(quality)
330
+ value = quality.to_i
331
+ value.positive? ? value.clamp(1, 100) : 50
332
+ end
333
+
334
+ def can_encode?(format, encoders)
335
+ return false unless encoders["imagemagick"]
336
+ return encoders["writable"].include?("PNG") && !encoders["avifenc"].nil? if format == "avif"
337
+
338
+ encoders["writable"].include?(CODERS.fetch(format, format.upcase))
339
+ end
340
+
341
+ # The encoders on this machine, looked up once per process. ImageMagick 7
342
+ # is `magick`. ImageMagick 6 is `convert`, looked for only off Windows,
343
+ # where C:\Windows\System32\convert.exe converts file systems. AVIF also
344
+ # needs avifenc, from libavif.
345
+ def tools
346
+ @tools ||= detect_tools
347
+ end
348
+
349
+ def detect_tools(windows: Gem.win_platform?)
350
+ magick = which("magick")
351
+ convert = which("convert") unless magick || windows
352
+ imagemagick = [magick || convert].compact
353
+ imagemagick = nil if imagemagick.empty?
354
+
355
+ avifenc = which("avifenc")
356
+
357
+ {
358
+ "imagemagick" => imagemagick,
359
+ "writable" => imagemagick ? writable_formats(imagemagick) : [],
360
+ "avifenc" => avifenc && [avifenc]
361
+ }
362
+ end
363
+
364
+ def writable_formats(command)
365
+ output, status = Open3.capture2e(*command, "-list", "format")
366
+ status.success? ? parse_writable_formats(output) : []
367
+ rescue SystemCallError
368
+ []
369
+ end
370
+
371
+ # A line of `-list format` reads " WEBP* rw+ WebP Image Format" in
372
+ # ImageMagick 7. ImageMagick 6 adds the module: " WEBP* WEBP rw+".
373
+ def parse_writable_formats(listing)
374
+ listing.each_line.filter_map { |line| line[/\A\s*([A-Z0-9-]+)\*?\s+(?:[A-Z0-9-]+\s+)?[r-]w[+-]\s/, 1] }
375
+ end
376
+
377
+ def which(command)
378
+ extensions = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
379
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
380
+ extensions.each do |extension|
381
+ path = File.join(dir, "#{command}#{extension}")
382
+ return path if File.file?(path) && File.executable?(path)
383
+ end
384
+ end
255
385
  nil
256
386
  end
257
387
 
@@ -287,14 +417,7 @@ module Jekyll
287
417
  end
288
418
 
289
419
  def extension_for(format)
290
- case format
291
- when "jpeg"
292
- "jpg"
293
- when "jpg"
294
- "jpg"
295
- else
296
- format
297
- end
420
+ format == "jpeg" ? "jpg" : format
298
421
  end
299
422
 
300
423
  def original_variant(src, width, height)
@@ -306,25 +429,29 @@ module Jekyll
306
429
  }
307
430
  end
308
431
 
309
- def build_srcset(variants)
432
+ # Variant URLs are site paths. A site served below a baseurl needs it in
433
+ # front of them, as relative_url adds it in templates.
434
+ def build_srcset(variants, baseurl = "")
310
435
  variants.map do |variant|
311
436
  next unless variant["url"] && variant["width"]
312
437
 
313
- "#{variant['url']} #{variant['width']}w"
438
+ url = variant["url"]
439
+ url = "#{baseurl}#{url}" if url.start_with?("/") && !url.start_with?("//")
440
+ "#{url} #{variant['width']}w"
314
441
  end.compact.join(", ")
315
442
  end
316
443
 
317
444
  def normalize_src(src, site)
318
- return src if src.nil? || src.empty?
319
- return src if src.start_with?("http://", "https://", "data:")
445
+ # Relative image URLs belong to the page, not to the source root. Leave
446
+ # them alone rather than offer another image's dimensions or variants.
447
+ return unless src&.start_with?("/") && !src.start_with?("//")
320
448
 
321
449
  baseurl = site&.baseurl.to_s
322
- normalized = src.dup
323
- normalized = normalized.delete_prefix(baseurl) if baseurl && !baseurl.empty? && normalized.start_with?(baseurl)
324
- site_url = site&.config&.fetch("url", "").to_s
325
- normalized = normalized.delete_prefix(site_url) if !site_url.empty? && normalized.start_with?(site_url)
326
- normalized = normalized.gsub(%r{^/+}, "")
327
- "/#{normalized}"
450
+ normalized = URI::DEFAULT_PARSER.unescape(src.split(/[?#]/, 2).first)
451
+ if !baseurl.empty? && (normalized == baseurl || normalized.start_with?("#{baseurl.chomp('/')}/"))
452
+ normalized = normalized.delete_prefix(baseurl.chomp("/"))
453
+ end
454
+ normalized
328
455
  end
329
456
 
330
457
  def image_config(site)
@@ -332,102 +459,29 @@ module Jekyll
332
459
  qualities = DEFAULT_QUALITY.merge((options["quality"] || {}).transform_keys { |key| key.to_s.downcase })
333
460
 
334
461
  {
462
+ "variants" => options.fetch("variants", true) != false,
335
463
  "sizes" => options["sizes"] || options["breakpoints"] || DEFAULT_SIZES,
336
464
  "quality" => qualities,
337
465
  "lazy_loading" => options["lazy_loading"] || "lazy",
338
466
  "default_sizes" => options["default_sizes"] || "100vw"
339
467
  }
340
468
  end
341
-
342
- def avif_encoder
343
- @avif_encoder ||= (Jekyll::Utils.which("avifenc") if defined?(Jekyll::Utils))
344
- end
345
469
  end
346
470
  end
347
471
 
472
+ # A variant kept in the image cache. Jekyll copies it into the site from there,
473
+ # as it copies any static file from the source.
348
474
  class Jekyll::ResponsiveImageStaticFile < Jekyll::StaticFile
349
475
  attr_reader :url
350
476
 
351
- def initialize(site:, relative_dir:, name:, original_path:, width:, height:, format:, quality:, avif_encoder: nil)
352
- @site = site
353
- @original_path = original_path
354
- @target_width = width
355
- @target_height = height
356
- @format = format
357
- @quality = quality
358
- @avif_encoder = avif_encoder
359
-
360
- sanitized_dir = relative_dir.to_s.sub(%r{^/}, "")
361
- super(site, site.source, sanitized_dir, name)
362
-
363
- relative = File.join(sanitized_dir, name).gsub(File::SEPARATOR, "/")
364
- @url = relative.empty? ? "/#{name}" : "/#{relative}"
365
- end
366
-
367
- def write(dest)
368
- dest_path = destination(dest)
369
- return false unless needs_write?(dest_path)
370
-
371
- FileUtils.mkdir_p(File.dirname(dest_path))
372
- generate_variant(dest_path)
373
- true
374
- rescue StandardError => e
375
- Jekyll.logger.warn "ImageOptimizer", "Failed to generate #{relative_path}: #{e.message}"
376
- false
377
- end
378
-
379
- private
380
-
381
- def needs_write?(dest_path)
382
- return true unless File.exist?(dest_path)
383
-
384
- File.mtime(dest_path) < File.mtime(@original_path)
385
- rescue Errno::ENOENT
386
- true
477
+ def initialize(site, relative_dir, name, cached_path)
478
+ @cached_path = cached_path
479
+ super(site, site.source, "/#{relative_dir}", name)
480
+ @url = "/#{relative_dir}/#{name}"
387
481
  end
388
482
 
389
- def generate_variant(dest_path)
390
- if MiniMagick.nil?
391
- FileUtils.cp(@original_path, dest_path)
392
- return
393
- end
394
-
395
- case @format
396
- when "avif"
397
- generate_avif(dest_path)
398
- else
399
- generate_with_mini_magick(dest_path, @format)
400
- end
401
- end
402
-
403
- def generate_with_mini_magick(dest_path, format)
404
- image = MiniMagick::Image.open(@original_path)
405
- image.auto_orient
406
- image.resize "#{@target_width}x#{@target_height}>" if @target_width.positive? && @target_width < image.width
407
- image.strip
408
- image.format(format)
409
- image.quality(@quality.to_i) if @quality
410
- image.write(dest_path)
411
- ensure
412
- image&.destroy!
413
- end
414
-
415
- def generate_avif(dest_path)
416
- raise "avifenc encoder not available" unless @avif_encoder
417
-
418
- Tempfile.create(["responsive", ".png"]) do |tempfile|
419
- generate_with_mini_magick(tempfile.path, "png")
420
- args = [@avif_encoder, "-q", avif_quality.to_s, "-s", "6", tempfile.path, dest_path]
421
- system(*args)
422
- raise "avifenc failed" unless $CHILD_STATUS&.success?
423
- end
424
- end
425
-
426
- def avif_quality
427
- quality = @quality.to_i
428
- return 50 if quality <= 0
429
-
430
- [[quality, 100].min, 0].max
483
+ def path
484
+ @cached_path
431
485
  end
432
486
  end
433
487