datalog-theme 0.8.0 → 0.9.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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +4 -2
  5. data/_data/i18n/en.yml +297 -0
  6. data/_data/i18n/es.yml +297 -0
  7. data/_data/i18n/pt.yml +297 -0
  8. data/_data/js_manifest.json +16 -0
  9. data/_includes/components/author-bio.html +21 -11
  10. data/_includes/components/author-list.html +32 -0
  11. data/_includes/components/citation-tools.html +33 -25
  12. data/_includes/components/comments-thread.html +90 -0
  13. data/_includes/components/contact-form.html +120 -0
  14. data/_includes/components/correction-report.html +82 -0
  15. data/_includes/components/enhanced-toc.html +6 -7
  16. data/_includes/components/license-link.html +13 -0
  17. data/_includes/components/license-notice.html +28 -0
  18. data/_includes/components/moderation-inbox.html +139 -0
  19. data/_includes/components/reactions.html +49 -0
  20. data/_includes/components/reading-list.html +31 -0
  21. data/_includes/components/reading-mode-toggle.html +65 -0
  22. data/_includes/components/reading-state-bookmark.html +27 -0
  23. data/_includes/components/reading-state-panel.html +71 -0
  24. data/_includes/components/reproducibility.html +61 -0
  25. data/_includes/components/responsive-image.html +3 -3
  26. data/_includes/components/revision-history.html +39 -0
  27. data/_includes/components/revision-notice.html +35 -0
  28. data/_includes/components/series-nav.html +64 -0
  29. data/_includes/components/subscribe-form.html +88 -0
  30. data/_includes/components/subscription-manage.html +68 -0
  31. data/_includes/components/webmentions.html +48 -0
  32. data/_includes/csp-meta.html +21 -1
  33. data/_includes/footer.html +3 -0
  34. data/_includes/head.html +51 -40
  35. data/_includes/layouts/default/article.html +4 -6
  36. data/_includes/meta/dynamic-services-config.html +14 -0
  37. data/_includes/meta/math-config.html +9 -5
  38. data/_includes/meta/person-json.html +27 -0
  39. data/_includes/meta/publisher.html +45 -0
  40. data/_includes/meta/schema.html +62 -28
  41. data/_includes/meta/scholarly.html +121 -0
  42. data/_includes/meta/scripts-loader.html +2 -0
  43. data/_includes/meta/webmention-discovery.html +14 -0
  44. data/_includes/scripts.html +57 -0
  45. data/_layouts/dataset.html +4 -3
  46. data/_layouts/default.html +1 -1
  47. data/_layouts/package.html +3 -2
  48. data/_layouts/page.html +15 -0
  49. data/_layouts/post.html +82 -18
  50. data/_layouts/project.html +2 -2
  51. data/_layouts/research.html +20 -8
  52. data/_plugins/authors.rb +133 -0
  53. data/_plugins/config_validator.rb +223 -20
  54. data/_plugins/critical_css_check.rb +42 -0
  55. data/_plugins/i18n.rb +6 -4
  56. data/_plugins/image_optimizer.rb +224 -172
  57. data/_plugins/licenses.rb +135 -0
  58. data/_plugins/math_preprocessor.rb +32 -10
  59. data/_plugins/references.rb +238 -0
  60. data/_plugins/reproducibility.rb +150 -0
  61. data/_plugins/revisions.rb +101 -0
  62. data/_plugins/scholarly.rb +50 -0
  63. data/_plugins/series.rb +104 -0
  64. data/_plugins/statements.rb +87 -0
  65. data/_sass/_base.scss +8 -1
  66. data/_sass/_comments-thread.scss +159 -0
  67. data/_sass/_layout.scss +384 -0
  68. data/_sass/_mathematical.scss +27 -0
  69. data/_sass/_moderation.scss +222 -0
  70. data/_sass/_post-components.scss +4 -2
  71. data/_sass/_print.scss +291 -0
  72. data/_sass/_reactions.scss +89 -0
  73. data/_sass/_reading-state.scss +290 -0
  74. data/_sass/_service-forms.scss +204 -0
  75. data/_sass/_subscriptions.scss +140 -0
  76. data/_sass/_syntax-highlighting.scss +2 -2
  77. data/_sass/_theme.scss +11 -0
  78. data/_sass/_typography.scss +116 -0
  79. data/_sass/_utilities.scss +5 -0
  80. data/_sass/_variables.scss +6 -0
  81. data/_sass/_webmentions.scss +125 -0
  82. data/assets/js/dist/academic.js +1 -1
  83. data/assets/js/dist/analytics-dashboard.js +1 -1
  84. data/assets/js/dist/chunks/chunk-2DYDWUFX.js +1 -0
  85. data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
  86. data/assets/js/dist/chunks/chunk-V7734B2G.js +1 -0
  87. data/assets/js/dist/comments.js +2 -0
  88. data/assets/js/dist/contact.js +1 -0
  89. data/assets/js/dist/core.js +1 -1
  90. data/assets/js/dist/corrections.js +1 -0
  91. data/assets/js/dist/loader.js +1 -1
  92. data/assets/js/dist/math.js +1 -1
  93. data/assets/js/dist/moderation.js +1 -0
  94. data/assets/js/dist/notebook.js +1 -1
  95. data/assets/js/dist/reactions.js +1 -0
  96. data/assets/js/dist/reading-state.js +1 -0
  97. data/assets/js/dist/search.js +1 -1
  98. data/assets/js/dist/sources.json +52 -0
  99. data/assets/js/dist/subscriptions.js +1 -0
  100. data/assets/js/dist/webmentions.js +1 -0
  101. data/assets/js/loader.js +34 -0
  102. data/datalog-theme.gemspec +1 -2
  103. data/lib/datalog/cli.rb +9 -1
  104. data/lib/datalog/critical_css.rb +168 -0
  105. data/lib/datalog/plugins/comments.rb +33 -3
  106. data/lib/datalog/theme/installed_files.rb +113 -0
  107. data/lib/datalog/theme/repository_checkout.rb +6 -2
  108. data/lib/datalog/theme/version.rb +5 -1
  109. data/lib/datalog-theme.rb +1 -0
  110. metadata +57 -23
  111. data/assets/js/dist/chunks/chunk-225H5YXE.js +0 -1
@@ -1,17 +1,13 @@
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"
15
11
 
16
12
  module Jekyll
17
13
  module ImageOptimizer
@@ -33,6 +29,10 @@ module Jekyll
33
29
  "png" => "image/png"
34
30
  }.freeze
35
31
  RASTER_EXTENSIONS = %w[.jpg .jpeg .png].freeze
32
+ SOURCE_FORMATS = %w[avif webp].freeze
33
+ # ImageMagick's names for the formats it writes itself, as `-list format` prints them.
34
+ CODERS = { "jpeg" => "JPEG", "png" => "PNG", "webp" => "WEBP" }.freeze
35
+ CACHE_DIR = "datalog-images"
36
36
 
37
37
  # The one image a page fetches early: the first in its post or page content,
38
38
  # unless the author made it lazy or the page already preloads an image, as
@@ -51,12 +51,19 @@ module Jekyll
51
51
  return if document.output.nil? || document.output.empty?
52
52
 
53
53
  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)
54
+ # Fragments use the HTML5 parser too: the HTML4 serializer writes a
55
+ # </source> end tag after each void <source> that offer_variants adds.
56
+ fragment = if full_document
57
+ Nokogiri::HTML5::Document.parse(document.output)
58
+ else
59
+ Nokogiri::HTML5::DocumentFragment.parse(document.output)
60
+ end
55
61
  return if fragment.nil?
56
62
 
57
63
  site = document.respond_to?(:site) ? document.site : nil
58
64
  manifest = site&.data&.fetch("datalog_responsive_images", {}) || {}
59
65
  image_config = site&.config&.fetch("datalog_image_config", {}) || {}
66
+ baseurl = site.respond_to?(:baseurl) ? site.baseurl.to_s : ""
60
67
  optimized = false
61
68
  priority = priority_image(fragment)
62
69
 
@@ -74,9 +81,7 @@ module Jekyll
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")
84
+ offer_variants(img, picture_entry, image_config, baseurl)
80
85
  img["width"] ||= picture_entry["width"].to_s if picture_entry["width"]
81
86
  img["height"] ||= picture_entry["height"].to_s if picture_entry["height"]
82
87
  else
@@ -101,12 +106,42 @@ module Jekyll
101
106
  document.output = fragment.to_html
102
107
  end
103
108
 
109
+ # Offers an image's generated variants: the resized copies as the <img>'s
110
+ # srcset, and each modern format as a <source> in a <picture> around it.
111
+ # Markup the author chose, a srcset of their own or a <picture>, is left
112
+ # alone, and so is an image with nothing generated besides the original.
113
+ def offer_variants(img, entry, image_config, baseurl)
114
+ return if img["srcset"] || img.parent&.name == "picture"
115
+
116
+ fallback = entry.dig("fallback", "variants") || []
117
+ sources = entry.fetch("sources", []).select { |source| source["type"] && source["variants"]&.any? }
118
+ return if fallback.size < 2 && sources.empty?
119
+
120
+ sizes = img["sizes"] || image_config.fetch("default_sizes", "100vw")
121
+ img["srcset"] = build_srcset(fallback, baseurl) if fallback.size > 1
122
+ img["sizes"] = sizes
123
+ return if sources.empty?
124
+
125
+ picture = Nokogiri::XML::Node.new("picture", img.document)
126
+ sources.each do |source|
127
+ element = Nokogiri::XML::Node.new("source", img.document)
128
+ element["type"] = source["type"]
129
+ element["srcset"] = build_srcset(source["variants"], baseurl)
130
+ element["sizes"] = sizes
131
+ picture.add_child(element)
132
+ end
133
+ img.add_previous_sibling(picture)
134
+ picture.add_child(img)
135
+ end
136
+
104
137
  def prepare_site(site)
105
138
  config = image_config(site)
139
+ encoders = config["variants"] ? tools : {}
140
+ counts = Hash.new(0)
106
141
  manifest = {}
107
142
 
108
143
  responsive_candidates(site).each do |static_file|
109
- entry = manifest_entry_for(site, static_file, config)
144
+ entry = manifest_entry_for(site, static_file, config, encoders, counts)
110
145
  next unless entry
111
146
 
112
147
  manifest[entry.delete("source")] = entry
@@ -114,6 +149,15 @@ module Jekyll
114
149
 
115
150
  site.data["datalog_responsive_images"] = manifest
116
151
  site.config["datalog_image_config"] = config
152
+ report(encoders, counts)
153
+ end
154
+
155
+ def report(encoders, counts)
156
+ if counts[:created].positive? || counts[:cached].positive?
157
+ Jekyll.logger.info "Images:", "#{counts[:created]} variants created, #{counts[:cached]} reused from the cache"
158
+ elsif encoders.any? && encoders["imagemagick"].nil?
159
+ Jekyll.logger.debug "ImageOptimizer", "ImageMagick was not found, so no image variants were created"
160
+ end
117
161
  end
118
162
 
119
163
  def image_source_path(site, src)
@@ -140,44 +184,39 @@ module Jekyll
140
184
  end
141
185
  end
142
186
 
143
- def manifest_entry_for(site, static_file, config)
187
+ def manifest_entry_for(site, static_file, config, encoders, counts)
144
188
  source_path = static_file.path
145
189
  width, height = FastImage.size(source_path)
146
190
  return unless width && height
147
191
 
148
192
  normalized_src = normalize_src(static_file.relative_path, site)
149
- sizes = normalized_sizes(config["sizes"], width)
150
193
  fallback_format = normalize_format(File.extname(source_path))
151
194
  fallback_variants = []
152
195
  source_variants = Hash.new { |hash, key| hash[key] = [] }
196
+ image = { site: site, file: static_file, width: width, height: height, config: config,
197
+ encoders: encoders, counts: counts }
153
198
 
154
- sizes.each do |target_width|
155
- target_height = scaled_height(width, height, target_width)
156
-
199
+ normalized_sizes(config["sizes"], width).each do |target_width|
157
200
  if fallback_format && target_width < width
158
- variant = build_variant(site, static_file, target_width, target_height, fallback_format, config,
159
- fallback_format, width)
201
+ variant = build_variant(image, target_width, fallback_format)
160
202
  fallback_variants << variant if variant
161
203
  end
162
204
 
163
- %w[avif webp].each do |format|
164
- variant = build_variant(site, static_file, target_width, target_height, format, config, fallback_format,
165
- width)
205
+ SOURCE_FORMATS.each do |format|
206
+ variant = build_variant(image, target_width, format)
166
207
  source_variants[format] << variant if variant
167
208
  end
168
209
  end
169
210
 
170
211
  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
212
+ fallback_variants << original_variant(normalized_src, width, height)
174
213
 
175
- sources = %w[avif webp].map do |format|
176
- variants = source_variants[format].compact.sort_by { |variant| variant["width"] }
214
+ sources = SOURCE_FORMATS.filter_map do |format|
215
+ variants = source_variants[format].sort_by { |variant| variant["width"] }
177
216
  next if variants.empty?
178
217
 
179
218
  { "type" => MIME_TYPES[format], "format" => format, "variants" => variants }
180
- end.compact
219
+ end
181
220
 
182
221
  {
183
222
  "source" => normalized_src,
@@ -185,73 +224,162 @@ module Jekyll
185
224
  "height" => height,
186
225
  "sources" => sources,
187
226
  "fallback" => {
188
- "type" => MIME_TYPES[fallback_format] || MIME_TYPES[normalize_format(File.extname(source_path))] || "image/jpeg",
227
+ "type" => MIME_TYPES[fallback_format] || "image/jpeg",
189
228
  "format" => fallback_format,
190
229
  "variants" => fallback_variants
191
230
  }
192
231
  }
193
232
  end
194
233
 
195
- def build_variant(site, static_file, target_width, target_height, format, config, original_format, original_width)
196
- return unless format
234
+ # Encodes one variant into the cache, unless an earlier build already did,
235
+ # and adds it to the files the site writes. Returns nil when this machine
236
+ # cannot encode the format or the encoder fails, so the manifest only names
237
+ # files that exist.
238
+ def build_variant(image, target_width, format)
239
+ return unless can_encode?(format, image[:encoders])
197
240
 
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
241
+ static_file = image[:file]
204
242
 
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
243
+ quality = image[:config]["quality"][format]
244
+ cached = cached_variant_path(image, target_width, format, quality)
245
+ if File.exist?(cached)
246
+ image[:counts][:cached] += 1
247
+ else
248
+ encode_into_cache(static_file.path, cached, target_width, format, quality, image[:encoders])
249
+ image[:counts][:created] += 1
212
250
  end
213
251
 
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
221
- end
252
+ variant_file = variant_static_file(image[:site], static_file, target_width, format, cached)
253
+ image[:site].static_files << variant_file
254
+
255
+ {
256
+ "url" => variant_file.url,
257
+ "width" => target_width,
258
+ "height" => scaled_height(image[:width], image[:height], target_width),
259
+ "format" => format
260
+ }
261
+ rescue StandardError => e
262
+ Jekyll.logger.warn "ImageOptimizer:",
263
+ "Could not create a #{target_width}px #{format} of #{image[:file].relative_path}: #{e.message}"
264
+ nil
265
+ end
222
266
 
267
+ # Published next to the original, in a responsive/ folder:
268
+ # /assets/img/plot.png gives /assets/img/responsive/plot-640w.webp.
269
+ def variant_static_file(site, static_file, target_width, format, cached)
223
270
  relative_path = static_file.relative_path.sub(%r{^/}, "")
224
271
  dir = File.dirname(relative_path)
225
- dir = "" if dir == "."
226
- responsive_dir = File.join(dir, "responsive")
227
-
272
+ responsive_dir = dir == "." ? "responsive" : File.join(dir, "responsive")
228
273
  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
274
+ Jekyll::ResponsiveImageStaticFile.new(site, responsive_dir, "#{base}-#{target_width}w.#{extension_for(format)}",
275
+ cached)
276
+ end
277
+
278
+ # Keyed by the image's content rather than its file time, which a fresh
279
+ # checkout resets.
280
+ def cached_variant_path(image, target_width, format, quality)
281
+ image[:digest] ||= Digest::SHA256.file(image[:file].path).hexdigest
282
+ key = Digest::SHA256.hexdigest([image[:digest], target_width, format, quality].join("|"))
283
+ File.join(cache_root(image[:site]), "#{key}.#{extension_for(format)}")
284
+ end
285
+
286
+ # Jekyll's cache directory. A site that sets disable_disk_cache gets a
287
+ # temporary directory instead, which `jekyll serve` reuses between builds
288
+ # and which is removed when Jekyll exits.
289
+ def cache_root(site)
290
+ return site.in_cache_dir(CACHE_DIR) unless site.config["disable_disk_cache"]
291
+
292
+ @cache_root ||= Dir.mktmpdir(CACHE_DIR).tap { |dir| at_exit { FileUtils.rm_rf(dir) } }
293
+ end
294
+
295
+ # Encodes beside the cache entry and renames it into place, so a failed or
296
+ # interrupted encode never leaves a file that a later build takes for done.
297
+ def encode_into_cache(source, cached, target_width, format, quality, encoders)
298
+ partial = cached.sub(/(\.\w+)\z/, '.partial\1')
299
+ FileUtils.mkdir_p(File.dirname(cached))
300
+ if format == "avif"
301
+ Tempfile.create(["datalog-image", ".png"]) do |png|
302
+ png.close
303
+ resize(source, png.path, target_width, nil, encoders)
304
+ run!(*encoders["avifenc"], "-q", avif_quality(quality).to_s, "-s", "6", png.path, partial)
305
+ end
306
+ else
307
+ resize(source, partial, target_width, quality, encoders)
308
+ end
309
+ File.rename(partial, cached)
310
+ ensure
311
+ FileUtils.rm_f(partial)
312
+ end
313
+
314
+ def resize(source, destination, target_width, quality, encoders)
315
+ command = [*encoders["imagemagick"], source, "-auto-orient", "-resize", "#{target_width}x>", "-strip"]
316
+ command += ["-quality", quality.to_s] if quality
317
+ run!(*command, destination)
318
+ end
319
+
320
+ def run!(*command)
321
+ output, status = Open3.capture2e(*command)
322
+ return if status.success?
323
+
324
+ raise "#{File.basename(command.first)} exited with #{status.exitstatus}: #{output.strip.lines.last(3).join.strip}"
325
+ end
326
+
327
+ def avif_quality(quality)
328
+ value = quality.to_i
329
+ value.positive? ? value.clamp(1, 100) : 50
330
+ end
331
+
332
+ def can_encode?(format, encoders)
333
+ return false unless encoders["imagemagick"]
334
+ return encoders["writable"].include?("PNG") && !encoders["avifenc"].nil? if format == "avif"
335
+
336
+ encoders["writable"].include?(CODERS.fetch(format, format.upcase))
337
+ end
338
+
339
+ # The encoders on this machine, looked up once per process. ImageMagick 7
340
+ # is `magick`. ImageMagick 6 is `convert`, looked for only off Windows,
341
+ # where C:\Windows\System32\convert.exe converts file systems. AVIF also
342
+ # needs avifenc, from libavif.
343
+ def tools
344
+ @tools ||= detect_tools
345
+ end
346
+
347
+ def detect_tools(windows: Gem.win_platform?)
348
+ magick = which("magick")
349
+ convert = which("convert") unless magick || windows
350
+ imagemagick = [magick || convert].compact
351
+ imagemagick = nil if imagemagick.empty?
352
+
353
+ avifenc = which("avifenc")
245
354
 
246
355
  {
247
- "url" => responsive_file.url,
248
- "width" => target_width,
249
- "height" => target_height,
250
- "format" => format
356
+ "imagemagick" => imagemagick,
357
+ "writable" => imagemagick ? writable_formats(imagemagick) : [],
358
+ "avifenc" => avifenc && [avifenc]
251
359
  }
252
- rescue StandardError => e
253
- Jekyll.logger.debug "ImageOptimizer",
254
- "Unable to generate variant for #{static_file.relative_path}: #{e.message}"
360
+ end
361
+
362
+ def writable_formats(command)
363
+ output, status = Open3.capture2e(*command, "-list", "format")
364
+ status.success? ? parse_writable_formats(output) : []
365
+ rescue SystemCallError
366
+ []
367
+ end
368
+
369
+ # A line of `-list format` reads " WEBP* rw+ WebP Image Format" in
370
+ # ImageMagick 7. ImageMagick 6 adds the module: " WEBP* WEBP rw+".
371
+ def parse_writable_formats(listing)
372
+ listing.each_line.filter_map { |line| line[/\A\s*([A-Z0-9-]+)\*?\s+(?:[A-Z0-9-]+\s+)?[r-]w[+-]\s/, 1] }
373
+ end
374
+
375
+ def which(command)
376
+ extensions = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
377
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
378
+ extensions.each do |extension|
379
+ path = File.join(dir, "#{command}#{extension}")
380
+ return path if File.file?(path) && File.executable?(path)
381
+ end
382
+ end
255
383
  nil
256
384
  end
257
385
 
@@ -287,14 +415,7 @@ module Jekyll
287
415
  end
288
416
 
289
417
  def extension_for(format)
290
- case format
291
- when "jpeg"
292
- "jpg"
293
- when "jpg"
294
- "jpg"
295
- else
296
- format
297
- end
418
+ format == "jpeg" ? "jpg" : format
298
419
  end
299
420
 
300
421
  def original_variant(src, width, height)
@@ -306,11 +427,15 @@ module Jekyll
306
427
  }
307
428
  end
308
429
 
309
- def build_srcset(variants)
430
+ # Variant URLs are site paths. A site served below a baseurl needs it in
431
+ # front of them, as relative_url adds it in templates.
432
+ def build_srcset(variants, baseurl = "")
310
433
  variants.map do |variant|
311
434
  next unless variant["url"] && variant["width"]
312
435
 
313
- "#{variant['url']} #{variant['width']}w"
436
+ url = variant["url"]
437
+ url = "#{baseurl}#{url}" if url.start_with?("/") && !url.start_with?("//")
438
+ "#{url} #{variant['width']}w"
314
439
  end.compact.join(", ")
315
440
  end
316
441
 
@@ -332,102 +457,29 @@ module Jekyll
332
457
  qualities = DEFAULT_QUALITY.merge((options["quality"] || {}).transform_keys { |key| key.to_s.downcase })
333
458
 
334
459
  {
460
+ "variants" => options.fetch("variants", true) != false,
335
461
  "sizes" => options["sizes"] || options["breakpoints"] || DEFAULT_SIZES,
336
462
  "quality" => qualities,
337
463
  "lazy_loading" => options["lazy_loading"] || "lazy",
338
464
  "default_sizes" => options["default_sizes"] || "100vw"
339
465
  }
340
466
  end
341
-
342
- def avif_encoder
343
- @avif_encoder ||= (Jekyll::Utils.which("avifenc") if defined?(Jekyll::Utils))
344
- end
345
467
  end
346
468
  end
347
469
 
470
+ # A variant kept in the image cache. Jekyll copies it into the site from there,
471
+ # as it copies any static file from the source.
348
472
  class Jekyll::ResponsiveImageStaticFile < Jekyll::StaticFile
349
473
  attr_reader :url
350
474
 
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
387
- end
388
-
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!
475
+ def initialize(site, relative_dir, name, cached_path)
476
+ @cached_path = cached_path
477
+ super(site, site.source, "/#{relative_dir}", name)
478
+ @url = "/#{relative_dir}/#{name}"
413
479
  end
414
480
 
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
481
+ def path
482
+ @cached_path
431
483
  end
432
484
  end
433
485
 
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Datalog
6
+ # The licence of a page's text and figures, and of its code samples, for
7
+ # the reuse notice, the JSON-LD and the head's rel="license" link:
8
+ #
9
+ # content_license: CC-BY-4.0 # _config.yml: the default for every article
10
+ #
11
+ # license: CC-BY-SA-4.0 # front matter: this page's own
12
+ # license: false # front matter: none for this page
13
+ # license: # front matter: a licence the theme does not know
14
+ # name: Open Government Licence v3.0
15
+ # url: https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/
16
+ # holder: The Lab
17
+ # year: 2026
18
+ # code_license: MIT # the code samples, when their licence differs
19
+ #
20
+ # The repository's LICENSE covers the theme's software; these settings are
21
+ # for what a site publishes. Datasets and packages carry their own
22
+ # `license` and never take the site's default.
23
+ module Licenses
24
+ module_function
25
+
26
+ # SPDX identifiers, with the name readers see and the licence text.
27
+ KNOWN = {
28
+ "CC-BY-4.0" => ["CC BY 4.0", "https://creativecommons.org/licenses/by/4.0/"],
29
+ "CC-BY-SA-4.0" => ["CC BY-SA 4.0", "https://creativecommons.org/licenses/by-sa/4.0/"],
30
+ "CC-BY-ND-4.0" => ["CC BY-ND 4.0", "https://creativecommons.org/licenses/by-nd/4.0/"],
31
+ "CC-BY-NC-4.0" => ["CC BY-NC 4.0", "https://creativecommons.org/licenses/by-nc/4.0/"],
32
+ "CC-BY-NC-SA-4.0" => ["CC BY-NC-SA 4.0", "https://creativecommons.org/licenses/by-nc-sa/4.0/"],
33
+ "CC-BY-NC-ND-4.0" => ["CC BY-NC-ND 4.0", "https://creativecommons.org/licenses/by-nc-nd/4.0/"],
34
+ "CC0-1.0" => ["CC0 1.0", "https://creativecommons.org/publicdomain/zero/1.0/"],
35
+ "MIT" => ["MIT", "https://spdx.org/licenses/MIT.html"],
36
+ "Apache-2.0" => ["Apache 2.0", "https://spdx.org/licenses/Apache-2.0.html"],
37
+ "BSD-2-Clause" => ["BSD 2-Clause", "https://spdx.org/licenses/BSD-2-Clause.html"],
38
+ "BSD-3-Clause" => ["BSD 3-Clause", "https://spdx.org/licenses/BSD-3-Clause.html"],
39
+ "GPL-3.0-only" => ["GPL 3.0", "https://spdx.org/licenses/GPL-3.0-only.html"],
40
+ "GPL-3.0-or-later" => ["GPL 3.0 or later", "https://spdx.org/licenses/GPL-3.0-or-later.html"],
41
+ "LGPL-3.0-only" => ["LGPL 3.0", "https://spdx.org/licenses/LGPL-3.0-only.html"],
42
+ "AGPL-3.0-only" => ["AGPL 3.0", "https://spdx.org/licenses/AGPL-3.0-only.html"],
43
+ "MPL-2.0" => ["MPL 2.0", "https://spdx.org/licenses/MPL-2.0.html"],
44
+ "ISC" => ["ISC", "https://spdx.org/licenses/ISC.html"],
45
+ "Unlicense" => ["The Unlicense", "https://spdx.org/licenses/Unlicense.html"],
46
+ "all-rights-reserved" => ["All rights reserved", nil]
47
+ }.freeze
48
+
49
+ # Other spellings: "CC BY" and "cc0" take the current version; the GPL
50
+ # family without a suffix means "only", as SPDX reads it.
51
+ ALIASES = {
52
+ "CC-BY" => "CC-BY-4.0", "CC-BY-SA" => "CC-BY-SA-4.0", "CC-BY-ND" => "CC-BY-ND-4.0",
53
+ "CC-BY-NC" => "CC-BY-NC-4.0", "CC-BY-NC-SA" => "CC-BY-NC-SA-4.0", "CC-BY-NC-ND" => "CC-BY-NC-ND-4.0",
54
+ "CC0" => "CC0-1.0", "GPL-3.0" => "GPL-3.0-only", "LGPL-3.0" => "LGPL-3.0-only", "AGPL-3.0" => "AGPL-3.0-only"
55
+ }.freeze
56
+
57
+ # Collections whose pages carry their own licence and never the site's.
58
+ OWN_LICENSE = %w[datasets packages].freeze
59
+
60
+ def key(value)
61
+ value.to_s.strip.upcase.gsub(/[\s_]+/, "-")
62
+ end
63
+
64
+ LOOKUP = KNOWN.keys.to_h { |id| [key(id), id] }.merge(ALIASES.to_h { |from, to| [key(from), to] }).freeze
65
+
66
+ # The licence of the page's text and figures, or nil.
67
+ def content(page, site)
68
+ resolve(setting(page, "license", site, "content_license"), page, site)
69
+ end
70
+
71
+ # The licence of the page's code samples, or nil.
72
+ def code(page, site)
73
+ resolve(setting(page, "code_license", site, "code_license"), page, site)
74
+ end
75
+
76
+ # The page's own value, else the site's; `false` declines the site's.
77
+ def setting(page, page_key, site, site_key)
78
+ value = Authors.value(page, page_key)
79
+ return value unless value.nil?
80
+ return if OWN_LICENSE.include?(Authors.value(page, "collection").to_s)
81
+
82
+ Authors.value(site, site_key)
83
+ end
84
+
85
+ def resolve(value, page, site)
86
+ return if value.nil? || value == false || (value.is_a?(String) && value.strip.empty?)
87
+
88
+ given = value.is_a?(Hash) ? Authors.present(value) : { "name" => value.to_s.strip }
89
+ id = LOOKUP[key(given["id"] || given["name"])]
90
+ name, url = KNOWN[id] if id
91
+ # A name that is not itself an identifier is the label the page chose.
92
+ name = given["name"] if given["name"] && LOOKUP[key(given["name"])].nil?
93
+ licence = { "id" => id, "name" => name, "url" => given["url"] || url, "reserved" => id == "all-rights-reserved" }
94
+ licence.merge(holder(given, page, site)).compact
95
+ end
96
+
97
+ # The copyright holder and year: the page's, else the site default's,
98
+ # else the page's authors and its date.
99
+ def holder(given, page, site)
100
+ site_given = Authors.value(site, "content_license")
101
+ site_given = site_given.is_a?(Hash) ? Authors.present(site_given) : {}
102
+ names = Array(given["holder"] || site_given["holder"]).map(&:to_s).reject(&:empty?)
103
+ authors = Authors.authors(page, site).map { |author| author["name"] }
104
+ people = names.empty? || (names - authors).empty?
105
+ names = authors if names.empty?
106
+ year = year(given["year"] || site_given["year"] || Authors.value(page, "date"))
107
+ { "holders" => names, "holder" => (names.join(", ") unless names.empty?), "people" => people, "year" => year }
108
+ end
109
+
110
+ # A year, a date or a string naming either.
111
+ def year(value)
112
+ return value if value.is_a?(Integer)
113
+ return value.year if value.respond_to?(:year)
114
+
115
+ text = value.to_s.strip
116
+ return text.to_i if text.match?(/\A\d{4}\z/)
117
+
118
+ Time.parse(text).year unless text.empty?
119
+ rescue ArgumentError
120
+ nil
121
+ end
122
+ end
123
+
124
+ module LicenseFilters
125
+ def page_license(page)
126
+ Licenses.content(page, @context["site"])
127
+ end
128
+
129
+ def page_code_license(page)
130
+ Licenses.code(page, @context["site"])
131
+ end
132
+ end
133
+ end
134
+
135
+ Liquid::Template.register_filter(Datalog::LicenseFilters)