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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -2
- data/_data/i18n/en.yml +297 -0
- data/_data/i18n/es.yml +297 -0
- data/_data/i18n/pt.yml +297 -0
- data/_data/js_manifest.json +16 -0
- data/_includes/components/author-bio.html +21 -11
- data/_includes/components/author-list.html +32 -0
- data/_includes/components/citation-tools.html +33 -25
- data/_includes/components/comments-thread.html +90 -0
- data/_includes/components/contact-form.html +120 -0
- data/_includes/components/correction-report.html +82 -0
- data/_includes/components/enhanced-toc.html +6 -7
- data/_includes/components/license-link.html +13 -0
- data/_includes/components/license-notice.html +28 -0
- data/_includes/components/moderation-inbox.html +139 -0
- data/_includes/components/reactions.html +49 -0
- data/_includes/components/reading-list.html +31 -0
- data/_includes/components/reading-mode-toggle.html +65 -0
- data/_includes/components/reading-state-bookmark.html +27 -0
- data/_includes/components/reading-state-panel.html +71 -0
- data/_includes/components/reproducibility.html +61 -0
- data/_includes/components/responsive-image.html +3 -3
- data/_includes/components/revision-history.html +39 -0
- data/_includes/components/revision-notice.html +35 -0
- data/_includes/components/series-nav.html +64 -0
- data/_includes/components/subscribe-form.html +88 -0
- data/_includes/components/subscription-manage.html +68 -0
- data/_includes/components/webmentions.html +48 -0
- data/_includes/csp-meta.html +21 -1
- data/_includes/footer.html +3 -0
- data/_includes/head.html +51 -40
- data/_includes/layouts/default/article.html +4 -6
- data/_includes/meta/dynamic-services-config.html +14 -0
- data/_includes/meta/math-config.html +9 -5
- data/_includes/meta/person-json.html +27 -0
- data/_includes/meta/publisher.html +45 -0
- data/_includes/meta/schema.html +62 -28
- data/_includes/meta/scholarly.html +121 -0
- data/_includes/meta/scripts-loader.html +2 -0
- data/_includes/meta/webmention-discovery.html +14 -0
- data/_includes/scripts.html +57 -0
- data/_layouts/dataset.html +4 -3
- data/_layouts/default.html +1 -1
- data/_layouts/package.html +3 -2
- data/_layouts/page.html +15 -0
- data/_layouts/post.html +82 -18
- data/_layouts/project.html +2 -2
- data/_layouts/research.html +20 -8
- data/_plugins/authors.rb +133 -0
- data/_plugins/config_validator.rb +223 -20
- data/_plugins/critical_css_check.rb +42 -0
- data/_plugins/i18n.rb +6 -4
- data/_plugins/image_optimizer.rb +224 -172
- data/_plugins/licenses.rb +135 -0
- data/_plugins/math_preprocessor.rb +32 -10
- data/_plugins/references.rb +238 -0
- data/_plugins/reproducibility.rb +150 -0
- data/_plugins/revisions.rb +101 -0
- data/_plugins/scholarly.rb +50 -0
- data/_plugins/series.rb +104 -0
- data/_plugins/statements.rb +87 -0
- data/_sass/_base.scss +8 -1
- data/_sass/_comments-thread.scss +159 -0
- data/_sass/_layout.scss +384 -0
- data/_sass/_mathematical.scss +27 -0
- data/_sass/_moderation.scss +222 -0
- data/_sass/_post-components.scss +4 -2
- data/_sass/_print.scss +291 -0
- data/_sass/_reactions.scss +89 -0
- data/_sass/_reading-state.scss +290 -0
- data/_sass/_service-forms.scss +204 -0
- data/_sass/_subscriptions.scss +140 -0
- data/_sass/_syntax-highlighting.scss +2 -2
- data/_sass/_theme.scss +11 -0
- data/_sass/_typography.scss +116 -0
- data/_sass/_utilities.scss +5 -0
- data/_sass/_variables.scss +6 -0
- data/_sass/_webmentions.scss +125 -0
- data/assets/js/dist/academic.js +1 -1
- data/assets/js/dist/analytics-dashboard.js +1 -1
- data/assets/js/dist/chunks/chunk-2DYDWUFX.js +1 -0
- data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
- data/assets/js/dist/chunks/chunk-V7734B2G.js +1 -0
- data/assets/js/dist/comments.js +2 -0
- data/assets/js/dist/contact.js +1 -0
- data/assets/js/dist/core.js +1 -1
- data/assets/js/dist/corrections.js +1 -0
- data/assets/js/dist/loader.js +1 -1
- data/assets/js/dist/math.js +1 -1
- data/assets/js/dist/moderation.js +1 -0
- data/assets/js/dist/notebook.js +1 -1
- data/assets/js/dist/reactions.js +1 -0
- data/assets/js/dist/reading-state.js +1 -0
- data/assets/js/dist/search.js +1 -1
- data/assets/js/dist/sources.json +52 -0
- data/assets/js/dist/subscriptions.js +1 -0
- data/assets/js/dist/webmentions.js +1 -0
- data/assets/js/loader.js +34 -0
- data/datalog-theme.gemspec +1 -2
- data/lib/datalog/cli.rb +9 -1
- data/lib/datalog/critical_css.rb +168 -0
- data/lib/datalog/plugins/comments.rb +33 -3
- data/lib/datalog/theme/installed_files.rb +113 -0
- data/lib/datalog/theme/repository_checkout.rb +6 -2
- data/lib/datalog/theme/version.rb +5 -1
- data/lib/datalog-theme.rb +1 -0
- metadata +57 -23
- data/assets/js/dist/chunks/chunk-225H5YXE.js +0 -1
data/_plugins/image_optimizer.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
164
|
-
variant = build_variant(
|
|
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)
|
|
172
|
-
variant["width"] >= width
|
|
173
|
-
end
|
|
212
|
+
fallback_variants << original_variant(normalized_src, width, height)
|
|
174
213
|
|
|
175
|
-
sources =
|
|
176
|
-
variants = source_variants[format].
|
|
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
|
|
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] ||
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
"
|
|
248
|
-
"
|
|
249
|
-
"
|
|
250
|
-
"format" => format
|
|
356
|
+
"imagemagick" => imagemagick,
|
|
357
|
+
"writable" => imagemagick ? writable_formats(imagemagick) : [],
|
|
358
|
+
"avifenc" => avifenc && [avifenc]
|
|
251
359
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
352
|
-
@
|
|
353
|
-
|
|
354
|
-
@
|
|
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
|
|
416
|
-
|
|
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)
|