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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -2
- data/_data/i18n/en.yml +321 -0
- data/_data/i18n/es.yml +321 -0
- data/_data/i18n/pt.yml +321 -0
- data/_data/js_manifest.json +16 -0
- data/_includes/components/academic-dashboard.html +4 -4
- data/_includes/components/author-bio.html +21 -11
- data/_includes/components/author-list.html +32 -0
- data/_includes/components/breadcrumbs.html +6 -4
- data/_includes/components/citation-tools.html +18 -58
- 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/hero.html +1 -1
- 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/post-hero.html +2 -2
- data/_includes/components/reactions.html +49 -0
- data/_includes/components/reading-list.html +34 -0
- data/_includes/components/reading-mode-toggle.html +67 -0
- data/_includes/components/reading-state-bookmark.html +27 -0
- data/_includes/components/reading-state-panel.html +62 -0
- data/_includes/components/reading-state-resume.html +17 -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 +69 -0
- data/_includes/components/visualization-card.html +1 -1
- data/_includes/components/webmentions.html +48 -0
- data/_includes/csp-meta.html +21 -1
- data/_includes/footer/nav-column.html +1 -1
- data/_includes/footer.html +5 -2
- data/_includes/head.html +64 -52
- data/_includes/header/navigation.html +2 -2
- data/_includes/header.html +1 -1
- data/_includes/layouts/default/article.html +6 -8
- data/_includes/meta/dynamic-services-config.html +14 -0
- data/_includes/meta/math-config.html +12 -5
- data/_includes/meta/person-json.html +27 -0
- data/_includes/meta/publisher.html +45 -0
- data/_includes/meta/schema.html +84 -48
- data/_includes/meta/scholarly.html +122 -0
- data/_includes/meta/scripts-loader.html +2 -0
- data/_includes/meta/webmention-discovery.html +14 -0
- data/_includes/post/related-posts.html +1 -1
- data/_includes/scripts.html +57 -0
- data/_layouts/dataset.html +5 -4
- data/_layouts/default.html +1 -1
- data/_layouts/home.html +2 -2
- data/_layouts/notebook.html +1 -1
- data/_layouts/package.html +8 -7
- data/_layouts/page.html +15 -0
- data/_layouts/portfolio.html +1 -1
- data/_layouts/post.html +94 -25
- data/_layouts/project.html +5 -5
- data/_layouts/research.html +27 -75
- data/_plugins/authors.rb +151 -0
- data/_plugins/citation_exports.rb +93 -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 +240 -186
- data/_plugins/licenses.rb +147 -0
- data/_plugins/math_preprocessor.rb +55 -11
- data/_plugins/references.rb +260 -0
- data/_plugins/reproducibility.rb +167 -0
- data/_plugins/responsive_content.rb +40 -0
- data/_plugins/revisions.rb +102 -0
- data/_plugins/scholarly.rb +55 -0
- data/_plugins/series.rb +104 -0
- data/_plugins/statements.rb +87 -0
- data/_sass/_base.scss +13 -1
- data/_sass/_comments-thread.scss +159 -0
- data/_sass/_layout.scss +402 -1
- data/_sass/_mathematical.scss +38 -1
- data/_sass/_moderation.scss +222 -0
- data/_sass/_post-components.scss +4 -2
- data/_sass/_print.scss +299 -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 +3 -3
- 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-PATLC23F.js +1 -0
- data/assets/js/dist/chunks/chunk-VZ5WKQVA.js +1 -0
- data/assets/js/dist/chunks/chunk-WIRUK3OZ.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/visualizations.js +2 -2
- 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 +60 -23
- data/assets/js/dist/chunks/chunk-225H5YXE.js +0 -1
data/_plugins/image_optimizer.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
img["
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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"]
|
|
90
|
+
next if img["width"] || img["height"]
|
|
84
91
|
|
|
85
|
-
source = image_source_path(site,
|
|
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(
|
|
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
|
-
|
|
164
|
-
variant = build_variant(
|
|
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)
|
|
172
|
-
variant["width"] >= width
|
|
173
|
-
end
|
|
214
|
+
fallback_variants << original_variant(normalized_src, width, height)
|
|
174
215
|
|
|
175
|
-
sources =
|
|
176
|
-
variants = source_variants[format].
|
|
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
|
|
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] ||
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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" =>
|
|
258
|
+
"url" => variant_file.url,
|
|
248
259
|
"width" => target_width,
|
|
249
|
-
"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.
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
normalized
|
|
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
|
|
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
|
|
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
|
|
390
|
-
|
|
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
|
|