datalog-theme 0.7.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 +188 -0
- data/CITATION.cff +2 -2
- data/README.md +25 -17
- data/_data/cdn-integrity.yml +0 -30
- 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/analytics/dashboard.html +3 -1
- data/_includes/components/api-function.html +20 -1
- 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-code-block.html +1 -1
- 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 +135 -11
- data/_includes/footer.html +22 -20
- data/_includes/head.html +111 -63
- data/_includes/header/navigation.html +12 -15
- data/_includes/header.html +28 -19
- data/_includes/layouts/default/article.html +9 -7
- data/_includes/meta/dynamic-services-config.html +14 -0
- data/_includes/meta/math-config.html +18 -10
- data/_includes/meta/person-json.html +27 -0
- data/_includes/meta/publisher.html +45 -0
- data/_includes/meta/schema.html +67 -30
- data/_includes/meta/scholarly.html +121 -0
- data/_includes/meta/scripts-loader.html +18 -32
- data/_includes/meta/webmention-discovery.html +14 -0
- data/_includes/post/related-posts.html +4 -7
- data/_includes/scripts.html +57 -0
- data/_includes/search/index-data.json +9 -34
- data/_layouts/dataset.html +5 -3
- data/_layouts/default.html +16 -8
- data/_layouts/notebook.html +1 -0
- data/_layouts/package.html +4 -2
- data/_layouts/page.html +15 -0
- data/_layouts/portfolio.html +1 -0
- data/_layouts/post.html +102 -23
- data/_layouts/project.html +4 -3
- data/_layouts/research.html +20 -8
- data/_plugins/analytics_dashboard.rb +9 -3
- data/_plugins/authors.rb +133 -0
- data/_plugins/config_validator.rb +231 -23
- data/_plugins/critical_css_check.rb +42 -0
- data/_plugins/csp_generator.rb +18 -28
- data/_plugins/datalog_bibliography.rb +9 -7
- data/_plugins/datalog_comments.rb +8 -5
- data/_plugins/datalog_slides.rb +9 -8
- data/_plugins/i18n.rb +13 -12
- data/_plugins/image_optimizer.rb +241 -178
- data/_plugins/licenses.rb +135 -0
- data/_plugins/math_preprocessor.rb +59 -7
- data/_plugins/notebook_converter.rb +23 -4
- data/_plugins/plugin_loader.rb +3 -1
- data/_plugins/publications_generator.rb +8 -2
- data/_plugins/references.rb +238 -0
- data/_plugins/reproducibility.rb +150 -0
- data/_plugins/revisions.rb +101 -0
- data/_plugins/rouge_highlight_filter.rb +42 -0
- data/_plugins/scholarly.rb +50 -0
- data/_plugins/search_code_blocks.rb +30 -0
- data/_plugins/search_normalizer.rb +15 -53
- data/_plugins/search_pages.rb +3 -4
- data/_plugins/series.rb +104 -0
- data/_plugins/statements.rb +87 -0
- data/_sass/_academic-dashboard.scss +262 -0
- data/_sass/_base.scss +20 -1
- data/_sass/_comments-thread.scss +159 -0
- data/_sass/_components.scss +64 -1153
- data/_sass/_features.scss +17 -0
- data/_sass/_layout.scss +385 -1
- data/_sass/_mathematical.scss +27 -0
- data/_sass/_moderation.scss +222 -0
- data/_sass/_notebooks.scss +322 -0
- data/_sass/_open-science-badges.scss +56 -0
- data/_sass/{_phase1-enhancements.scss → _post-components.scss} +5 -3
- data/_sass/_print.scss +291 -0
- data/_sass/_reactions.scss +89 -0
- data/_sass/_reading-state.scss +290 -0
- data/_sass/_search-page.scss +530 -0
- data/_sass/_search.scss +46 -0
- data/_sass/_service-forms.scss +204 -0
- data/_sass/_subscriptions.scss +140 -0
- data/_sass/_syntax-highlighting.scss +212 -97
- data/_sass/_theme.scss +40 -19
- data/_sass/_typography.scss +130 -0
- data/_sass/_utilities.scss +5 -0
- data/_sass/_variables.scss +6 -0
- data/_sass/_webmentions.scss +125 -0
- data/assets/css/main.scss +14 -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/visualizations.js +11 -2
- data/assets/js/dist/webmentions.js +1 -0
- data/assets/js/loader.js +37 -1
- data/datalog-theme.gemspec +35 -23
- data/lib/datalog/cli.rb +43 -15
- data/lib/datalog/critical_css.rb +168 -0
- data/lib/datalog/plugin_system/dependency_resolver.rb +0 -2
- data/lib/datalog/plugins/comments.rb +33 -3
- data/lib/datalog/theme/installed_files.rb +113 -0
- data/lib/datalog/theme/package.rb +57 -0
- data/lib/datalog/theme/repository_checkout.rb +94 -0
- data/lib/datalog/theme/version.rb +5 -1
- data/lib/datalog/warning_filter.rb +5 -11
- data/lib/datalog-theme.rb +6 -0
- metadata +96 -147
- data/_data/academic.yml +0 -217
- data/_data/config/author.yml +0 -121
- data/_data/datasets.yml +0 -28
- data/_data/js_meta.json +0 -371
- data/_data/navigation.yml +0 -145
- data/_data/projects.yml +0 -41
- data/_data/publications.yml +0 -28
- data/_data/social.yml +0 -73
- data/_data/visualizations.yml +0 -51
- data/_includes/components/advanced-search.html +0 -682
- data/_includes/components/bookmark-system.html +0 -96
- data/_includes/components/comments.html +0 -244
- data/_includes/components/content-recommendations.html +0 -228
- data/_includes/components/email-preferences.html +0 -200
- data/_includes/components/enhanced-metadata.html +0 -228
- data/_includes/components/language-switcher.html +0 -396
- data/_includes/components/navigation-enhancements.html +0 -454
- data/_includes/components/newsletter-signup.html +0 -178
- data/_includes/components/popular-posts.html +0 -233
- data/_includes/components/reading-progress.html +0 -133
- data/_includes/components/reading-time.html +0 -121
- data/_includes/components/series-navigation.html +0 -124
- data/_includes/components/social-proof.html +0 -34
- data/_includes/components/user-preferences.html +0 -566
- data/_includes/meta/syntax-config.html +0 -19
- data/_layouts/archive.html +0 -282
- data/_layouts/post-sidebar.html +0 -183
- data/_sass/_phase3-enhancements.scss +0 -874
- data/_sass/_phase4-enhancements.scss +0 -1214
- data/_sass/_phase5-enhancements.scss +0 -414
- data/assets/js/academic.js +0 -262
- data/assets/js/analytics-dashboard.js +0 -382
- data/assets/js/core/dark-mode.js +0 -79
- data/assets/js/core/github-cards.js +0 -123
- data/assets/js/core/language-filter.js +0 -69
- data/assets/js/core/navigation.js +0 -184
- data/assets/js/core/scroll-progress.js +0 -45
- data/assets/js/core/search-hotkeys.js +0 -62
- data/assets/js/core/skip-links.js +0 -62
- data/assets/js/dist/manifest.json +0 -22
- data/assets/js/dist/meta.json +0 -371
- data/assets/js/main.js +0 -23
- data/assets/js/math.js +0 -818
- data/assets/js/notebook.js +0 -158
- data/assets/js/search/analytics.js +0 -91
- data/assets/js/search/app.js +0 -271
- data/assets/js/search/autocomplete.js +0 -120
- data/assets/js/search/engine.js +0 -260
- data/assets/js/search/filters.js +0 -45
- data/assets/js/search/render.js +0 -217
- data/assets/js/search/utils.js +0 -99
- data/assets/js/search.js +0 -354
- data/assets/js/visualizations.js +0 -816
- data/assets/publications/datalog-publications.bib +0 -8
- data/assets/publications/datalog-publications.ris +0 -9
- data/assets/publications/publications.bib +0 -30
- data/assets/templates/diogo-ribeiro-cv.md +0 -31
- data/assets/templates/diogo-ribeiro-cv.tex +0 -32
- data/lib/datalog/theme/theme.rb +0 -18
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Jekyll
|
|
4
|
+
# Stands in for the `datalog_comments` tag when the datalog-comments plugin is
|
|
5
|
+
# not enabled, so a layout that uses the tag still builds. The plugin registers
|
|
6
|
+
# the real tag and sets `datalog_comments` on the pages it handles; a page that
|
|
7
|
+
# sets the key by hand without the plugin gets nothing and a build warning,
|
|
8
|
+
# where it used to get a "coming soon" notice.
|
|
4
9
|
class DatalogCommentsTag < Liquid::Tag
|
|
5
10
|
def render(context)
|
|
6
11
|
page = context.registers[:page]
|
|
7
12
|
return "" unless page["datalog_comments"]
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
</div>
|
|
13
|
-
HTML
|
|
14
|
+
Jekyll.logger.warn("datalog-comments", "#{page['path']} sets datalog_comments, " \
|
|
15
|
+
"but datalog_plugins.enabled does not list datalog-comments")
|
|
16
|
+
""
|
|
14
17
|
end
|
|
15
18
|
end
|
|
16
19
|
end
|
data/_plugins/datalog_slides.rb
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Jekyll
|
|
4
|
+
# Stands in for the `datalog_slides` tag when the datalog-slides plugin is not
|
|
5
|
+
# enabled, so a layout that uses the tag still builds. The plugin registers the
|
|
6
|
+
# real tag and sets `datalog_slides` on the pages it handles; a page that sets
|
|
7
|
+
# the key by hand without the plugin gets nothing and a build warning, where it
|
|
8
|
+
# used to get a "coming soon" notice showing its raw configuration.
|
|
4
9
|
class DatalogSlidesTag < Liquid::Tag
|
|
5
10
|
def render(context)
|
|
6
11
|
page = context.registers[:page]
|
|
7
|
-
|
|
8
|
-
return "" unless slides_data
|
|
12
|
+
return "" unless page["datalog_slides"]
|
|
9
13
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<p>Slides feature coming soon. Configured: #{slides_data}</p>
|
|
14
|
-
</div>
|
|
15
|
-
HTML
|
|
14
|
+
Jekyll.logger.warn("datalog-slides", "#{page['path']} sets datalog_slides, " \
|
|
15
|
+
"but datalog_plugins.enabled does not list datalog-slides")
|
|
16
|
+
""
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
data/_plugins/i18n.rb
CHANGED
|
@@ -5,11 +5,13 @@ module Datalog
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def locale(context)
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
locale_code(context.registers[:site], context["page"] && context["page"]["lang"])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# A page's language, or the site's, as a code such as "pt".
|
|
12
|
+
def locale_code(site, page_lang)
|
|
10
13
|
site_locale = site.config.dig("theme_options", "localization", "default_locale") || site.config["locale"] || "en"
|
|
11
|
-
|
|
12
|
-
lang.to_s.split(/[-_]/).first
|
|
14
|
+
(page_lang || site_locale).to_s.split(/[-_]/).first
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def data_for(site)
|
|
@@ -116,6 +118,11 @@ class TranslateTag < Liquid::Tag
|
|
|
116
118
|
# quote into the first option name, or interpolation silently fails.
|
|
117
119
|
SYNTAX = /\A\s*(['"]?)(\w[\w.-]*)\1(.*)?\z/m
|
|
118
120
|
|
|
121
|
+
# `name: value` pairs, separated by commas or spaces. A quoted value may
|
|
122
|
+
# contain commas: splitting the markup on every comma cut `name: "Doe, Jane"`
|
|
123
|
+
# in two.
|
|
124
|
+
OPTION = /(\w+)\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s,]+)/
|
|
125
|
+
|
|
119
126
|
def initialize(tag_name, markup, tokens)
|
|
120
127
|
super
|
|
121
128
|
raise Liquid::SyntaxError, "Syntax Error in 't' - Valid syntax: t key [arg: value]" unless markup.strip =~ SYNTAX
|
|
@@ -134,14 +141,8 @@ class TranslateTag < Liquid::Tag
|
|
|
134
141
|
def parse_options(markup, context)
|
|
135
142
|
return {} unless markup && !markup.strip.empty?
|
|
136
143
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
next if token.empty?
|
|
140
|
-
|
|
141
|
-
if token.include?(":")
|
|
142
|
-
key, value = token.split(":", 2)
|
|
143
|
-
memo[key.strip.to_sym] = context.evaluate(Liquid::Expression.parse(value.strip))
|
|
144
|
-
end
|
|
144
|
+
markup.scan(OPTION).to_h do |key, value|
|
|
145
|
+
[key.to_sym, context.evaluate(Liquid::Expression.parse(value))]
|
|
145
146
|
end
|
|
146
147
|
end
|
|
147
148
|
end
|
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,39 +29,59 @@ 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
|
+
|
|
37
|
+
# The one image a page fetches early: the first in its post or page content,
|
|
38
|
+
# unless the author made it lazy or the page already preloads an image, as
|
|
39
|
+
# the hero does. The first <img> anywhere used to be marked both lazy and
|
|
40
|
+
# high priority; on the home page that was a post card below the hero, and
|
|
41
|
+
# on tutorials the thumbnail of a related post at the bottom.
|
|
42
|
+
def priority_image(fragment)
|
|
43
|
+
return if fragment.at_css('link[rel="preload"][as="image"]')
|
|
44
|
+
|
|
45
|
+
image = fragment.css(".post-content img, .page-content img").find { |img| img["data-no-optimize"] != "true" }
|
|
46
|
+
image unless image.nil? || image["loading"] == "lazy"
|
|
47
|
+
end
|
|
36
48
|
|
|
37
49
|
def process(document)
|
|
38
50
|
return unless document.output_ext == ".html"
|
|
39
51
|
return if document.output.nil? || document.output.empty?
|
|
40
52
|
|
|
41
53
|
full_document = document.output.lstrip.match?(/\A(?:<!DOCTYPE|<html)/i)
|
|
42
|
-
|
|
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
|
|
43
61
|
return if fragment.nil?
|
|
44
62
|
|
|
45
63
|
site = document.respond_to?(:site) ? document.site : nil
|
|
46
64
|
manifest = site&.data&.fetch("datalog_responsive_images", {}) || {}
|
|
47
65
|
image_config = site&.config&.fetch("datalog_image_config", {}) || {}
|
|
66
|
+
baseurl = site.respond_to?(:baseurl) ? site.baseurl.to_s : ""
|
|
48
67
|
optimized = false
|
|
49
|
-
|
|
68
|
+
priority = priority_image(fragment)
|
|
50
69
|
|
|
51
70
|
fragment.css("img").each do |img|
|
|
52
71
|
next if img["data-no-optimize"] == "true"
|
|
53
72
|
|
|
54
|
-
img
|
|
55
|
-
img["decoding"] ||= "async"
|
|
56
|
-
|
|
57
|
-
unless first_priority_assigned
|
|
73
|
+
if img == priority
|
|
58
74
|
img["fetchpriority"] ||= "high"
|
|
59
|
-
|
|
75
|
+
else
|
|
76
|
+
img["loading"] ||= "lazy"
|
|
60
77
|
end
|
|
78
|
+
img["decoding"] ||= "async"
|
|
61
79
|
|
|
62
80
|
normalized_src = normalize_src(img["src"], site)
|
|
63
81
|
picture_entry = manifest[normalized_src]
|
|
64
82
|
|
|
65
83
|
if picture_entry
|
|
66
|
-
|
|
67
|
-
img["srcset"] = build_srcset(fallback_variants) unless img["srcset"]
|
|
68
|
-
img["sizes"] ||= image_config.fetch("default_sizes", "100vw")
|
|
84
|
+
offer_variants(img, picture_entry, image_config, baseurl)
|
|
69
85
|
img["width"] ||= picture_entry["width"].to_s if picture_entry["width"]
|
|
70
86
|
img["height"] ||= picture_entry["height"].to_s if picture_entry["height"]
|
|
71
87
|
else
|
|
@@ -90,12 +106,42 @@ module Jekyll
|
|
|
90
106
|
document.output = fragment.to_html
|
|
91
107
|
end
|
|
92
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
|
+
|
|
93
137
|
def prepare_site(site)
|
|
94
138
|
config = image_config(site)
|
|
139
|
+
encoders = config["variants"] ? tools : {}
|
|
140
|
+
counts = Hash.new(0)
|
|
95
141
|
manifest = {}
|
|
96
142
|
|
|
97
143
|
responsive_candidates(site).each do |static_file|
|
|
98
|
-
entry = manifest_entry_for(site, static_file, config)
|
|
144
|
+
entry = manifest_entry_for(site, static_file, config, encoders, counts)
|
|
99
145
|
next unless entry
|
|
100
146
|
|
|
101
147
|
manifest[entry.delete("source")] = entry
|
|
@@ -103,6 +149,15 @@ module Jekyll
|
|
|
103
149
|
|
|
104
150
|
site.data["datalog_responsive_images"] = manifest
|
|
105
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
|
|
106
161
|
end
|
|
107
162
|
|
|
108
163
|
def image_source_path(site, src)
|
|
@@ -129,44 +184,39 @@ module Jekyll
|
|
|
129
184
|
end
|
|
130
185
|
end
|
|
131
186
|
|
|
132
|
-
def manifest_entry_for(site, static_file, config)
|
|
187
|
+
def manifest_entry_for(site, static_file, config, encoders, counts)
|
|
133
188
|
source_path = static_file.path
|
|
134
189
|
width, height = FastImage.size(source_path)
|
|
135
190
|
return unless width && height
|
|
136
191
|
|
|
137
192
|
normalized_src = normalize_src(static_file.relative_path, site)
|
|
138
|
-
sizes = normalized_sizes(config["sizes"], width)
|
|
139
193
|
fallback_format = normalize_format(File.extname(source_path))
|
|
140
194
|
fallback_variants = []
|
|
141
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 }
|
|
142
198
|
|
|
143
|
-
sizes.each do |target_width|
|
|
144
|
-
target_height = scaled_height(width, height, target_width)
|
|
145
|
-
|
|
199
|
+
normalized_sizes(config["sizes"], width).each do |target_width|
|
|
146
200
|
if fallback_format && target_width < width
|
|
147
|
-
variant = build_variant(
|
|
148
|
-
fallback_format, width)
|
|
201
|
+
variant = build_variant(image, target_width, fallback_format)
|
|
149
202
|
fallback_variants << variant if variant
|
|
150
203
|
end
|
|
151
204
|
|
|
152
|
-
|
|
153
|
-
variant = build_variant(
|
|
154
|
-
width)
|
|
205
|
+
SOURCE_FORMATS.each do |format|
|
|
206
|
+
variant = build_variant(image, target_width, format)
|
|
155
207
|
source_variants[format] << variant if variant
|
|
156
208
|
end
|
|
157
209
|
end
|
|
158
210
|
|
|
159
211
|
fallback_variants.sort_by! { |variant| variant["width"] }
|
|
160
|
-
fallback_variants << original_variant(normalized_src, width, height)
|
|
161
|
-
variant["width"] >= width
|
|
162
|
-
end
|
|
212
|
+
fallback_variants << original_variant(normalized_src, width, height)
|
|
163
213
|
|
|
164
|
-
sources =
|
|
165
|
-
variants = source_variants[format].
|
|
214
|
+
sources = SOURCE_FORMATS.filter_map do |format|
|
|
215
|
+
variants = source_variants[format].sort_by { |variant| variant["width"] }
|
|
166
216
|
next if variants.empty?
|
|
167
217
|
|
|
168
218
|
{ "type" => MIME_TYPES[format], "format" => format, "variants" => variants }
|
|
169
|
-
end
|
|
219
|
+
end
|
|
170
220
|
|
|
171
221
|
{
|
|
172
222
|
"source" => normalized_src,
|
|
@@ -174,73 +224,162 @@ module Jekyll
|
|
|
174
224
|
"height" => height,
|
|
175
225
|
"sources" => sources,
|
|
176
226
|
"fallback" => {
|
|
177
|
-
"type" => MIME_TYPES[fallback_format] ||
|
|
227
|
+
"type" => MIME_TYPES[fallback_format] || "image/jpeg",
|
|
178
228
|
"format" => fallback_format,
|
|
179
229
|
"variants" => fallback_variants
|
|
180
230
|
}
|
|
181
231
|
}
|
|
182
232
|
end
|
|
183
233
|
|
|
184
|
-
|
|
185
|
-
|
|
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])
|
|
186
240
|
|
|
187
|
-
|
|
188
|
-
if original_format != format
|
|
189
|
-
Jekyll.logger.debug "ImageOptimizer",
|
|
190
|
-
"Skipping #{format} variant for #{static_file.relative_path} because MiniMagick is unavailable"
|
|
191
|
-
return
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
if original_width && target_width < original_width
|
|
195
|
-
Jekyll.logger.debug "ImageOptimizer",
|
|
196
|
-
"Skipping #{format} #{target_width}w variant for #{static_file.relative_path} because MiniMagick is unavailable"
|
|
197
|
-
return
|
|
198
|
-
end
|
|
241
|
+
static_file = image[:file]
|
|
199
242
|
|
|
200
|
-
|
|
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
|
|
201
250
|
end
|
|
202
251
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
211
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)
|
|
212
270
|
relative_path = static_file.relative_path.sub(%r{^/}, "")
|
|
213
271
|
dir = File.dirname(relative_path)
|
|
214
|
-
|
|
215
|
-
responsive_dir = File.join(dir, "responsive")
|
|
216
|
-
|
|
272
|
+
responsive_dir = dir == "." ? "responsive" : File.join(dir, "responsive")
|
|
217
273
|
base = File.basename(relative_path, File.extname(relative_path))
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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")
|
|
234
354
|
|
|
235
355
|
{
|
|
236
|
-
"
|
|
237
|
-
"
|
|
238
|
-
"
|
|
239
|
-
"format" => format
|
|
356
|
+
"imagemagick" => imagemagick,
|
|
357
|
+
"writable" => imagemagick ? writable_formats(imagemagick) : [],
|
|
358
|
+
"avifenc" => avifenc && [avifenc]
|
|
240
359
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
244
383
|
nil
|
|
245
384
|
end
|
|
246
385
|
|
|
@@ -276,14 +415,7 @@ module Jekyll
|
|
|
276
415
|
end
|
|
277
416
|
|
|
278
417
|
def extension_for(format)
|
|
279
|
-
|
|
280
|
-
when "jpeg"
|
|
281
|
-
"jpg"
|
|
282
|
-
when "jpg"
|
|
283
|
-
"jpg"
|
|
284
|
-
else
|
|
285
|
-
format
|
|
286
|
-
end
|
|
418
|
+
format == "jpeg" ? "jpg" : format
|
|
287
419
|
end
|
|
288
420
|
|
|
289
421
|
def original_variant(src, width, height)
|
|
@@ -295,11 +427,15 @@ module Jekyll
|
|
|
295
427
|
}
|
|
296
428
|
end
|
|
297
429
|
|
|
298
|
-
|
|
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 = "")
|
|
299
433
|
variants.map do |variant|
|
|
300
434
|
next unless variant["url"] && variant["width"]
|
|
301
435
|
|
|
302
|
-
|
|
436
|
+
url = variant["url"]
|
|
437
|
+
url = "#{baseurl}#{url}" if url.start_with?("/") && !url.start_with?("//")
|
|
438
|
+
"#{url} #{variant['width']}w"
|
|
303
439
|
end.compact.join(", ")
|
|
304
440
|
end
|
|
305
441
|
|
|
@@ -321,102 +457,29 @@ module Jekyll
|
|
|
321
457
|
qualities = DEFAULT_QUALITY.merge((options["quality"] || {}).transform_keys { |key| key.to_s.downcase })
|
|
322
458
|
|
|
323
459
|
{
|
|
460
|
+
"variants" => options.fetch("variants", true) != false,
|
|
324
461
|
"sizes" => options["sizes"] || options["breakpoints"] || DEFAULT_SIZES,
|
|
325
462
|
"quality" => qualities,
|
|
326
463
|
"lazy_loading" => options["lazy_loading"] || "lazy",
|
|
327
464
|
"default_sizes" => options["default_sizes"] || "100vw"
|
|
328
465
|
}
|
|
329
466
|
end
|
|
330
|
-
|
|
331
|
-
def avif_encoder
|
|
332
|
-
@avif_encoder ||= (Jekyll::Utils.which("avifenc") if defined?(Jekyll::Utils))
|
|
333
|
-
end
|
|
334
467
|
end
|
|
335
468
|
end
|
|
336
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.
|
|
337
472
|
class Jekyll::ResponsiveImageStaticFile < Jekyll::StaticFile
|
|
338
473
|
attr_reader :url
|
|
339
474
|
|
|
340
|
-
def initialize(site
|
|
341
|
-
@
|
|
342
|
-
|
|
343
|
-
@
|
|
344
|
-
@target_height = height
|
|
345
|
-
@format = format
|
|
346
|
-
@quality = quality
|
|
347
|
-
@avif_encoder = avif_encoder
|
|
348
|
-
|
|
349
|
-
sanitized_dir = relative_dir.to_s.sub(%r{^/}, "")
|
|
350
|
-
super(site, site.source, sanitized_dir, name)
|
|
351
|
-
|
|
352
|
-
relative = File.join(sanitized_dir, name).gsub(File::SEPARATOR, "/")
|
|
353
|
-
@url = relative.empty? ? "/#{name}" : "/#{relative}"
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def write(dest)
|
|
357
|
-
dest_path = destination(dest)
|
|
358
|
-
return false unless needs_write?(dest_path)
|
|
359
|
-
|
|
360
|
-
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
361
|
-
generate_variant(dest_path)
|
|
362
|
-
true
|
|
363
|
-
rescue StandardError => e
|
|
364
|
-
Jekyll.logger.warn "ImageOptimizer", "Failed to generate #{relative_path}: #{e.message}"
|
|
365
|
-
false
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
private
|
|
369
|
-
|
|
370
|
-
def needs_write?(dest_path)
|
|
371
|
-
return true unless File.exist?(dest_path)
|
|
372
|
-
|
|
373
|
-
File.mtime(dest_path) < File.mtime(@original_path)
|
|
374
|
-
rescue Errno::ENOENT
|
|
375
|
-
true
|
|
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}"
|
|
376
479
|
end
|
|
377
480
|
|
|
378
|
-
def
|
|
379
|
-
|
|
380
|
-
FileUtils.cp(@original_path, dest_path)
|
|
381
|
-
return
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
case @format
|
|
385
|
-
when "avif"
|
|
386
|
-
generate_avif(dest_path)
|
|
387
|
-
else
|
|
388
|
-
generate_with_mini_magick(dest_path, @format)
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
def generate_with_mini_magick(dest_path, format)
|
|
393
|
-
image = MiniMagick::Image.open(@original_path)
|
|
394
|
-
image.auto_orient
|
|
395
|
-
image.resize "#{@target_width}x#{@target_height}>" if @target_width.positive? && @target_width < image.width
|
|
396
|
-
image.strip
|
|
397
|
-
image.format(format)
|
|
398
|
-
image.quality(@quality.to_i) if @quality
|
|
399
|
-
image.write(dest_path)
|
|
400
|
-
ensure
|
|
401
|
-
image&.destroy!
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
def generate_avif(dest_path)
|
|
405
|
-
raise "avifenc encoder not available" unless @avif_encoder
|
|
406
|
-
|
|
407
|
-
Tempfile.create(["responsive", ".png"]) do |tempfile|
|
|
408
|
-
generate_with_mini_magick(tempfile.path, "png")
|
|
409
|
-
args = [@avif_encoder, "-q", avif_quality.to_s, "-s", "6", tempfile.path, dest_path]
|
|
410
|
-
system(*args)
|
|
411
|
-
raise "avifenc failed" unless $CHILD_STATUS&.success?
|
|
412
|
-
end
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
def avif_quality
|
|
416
|
-
quality = @quality.to_i
|
|
417
|
-
return 50 if quality <= 0
|
|
418
|
-
|
|
419
|
-
[[quality, 100].min, 0].max
|
|
481
|
+
def path
|
|
482
|
+
@cached_path
|
|
420
483
|
end
|
|
421
484
|
end
|
|
422
485
|
|