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.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +188 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +25 -17
  5. data/_data/cdn-integrity.yml +0 -30
  6. data/_data/i18n/en.yml +297 -0
  7. data/_data/i18n/es.yml +297 -0
  8. data/_data/i18n/pt.yml +297 -0
  9. data/_data/js_manifest.json +16 -0
  10. data/_includes/analytics/dashboard.html +3 -1
  11. data/_includes/components/api-function.html +20 -1
  12. data/_includes/components/author-bio.html +21 -11
  13. data/_includes/components/author-list.html +32 -0
  14. data/_includes/components/citation-tools.html +33 -25
  15. data/_includes/components/comments-thread.html +90 -0
  16. data/_includes/components/contact-form.html +120 -0
  17. data/_includes/components/correction-report.html +82 -0
  18. data/_includes/components/enhanced-code-block.html +1 -1
  19. data/_includes/components/enhanced-toc.html +6 -7
  20. data/_includes/components/license-link.html +13 -0
  21. data/_includes/components/license-notice.html +28 -0
  22. data/_includes/components/moderation-inbox.html +139 -0
  23. data/_includes/components/reactions.html +49 -0
  24. data/_includes/components/reading-list.html +31 -0
  25. data/_includes/components/reading-mode-toggle.html +65 -0
  26. data/_includes/components/reading-state-bookmark.html +27 -0
  27. data/_includes/components/reading-state-panel.html +71 -0
  28. data/_includes/components/reproducibility.html +61 -0
  29. data/_includes/components/responsive-image.html +3 -3
  30. data/_includes/components/revision-history.html +39 -0
  31. data/_includes/components/revision-notice.html +35 -0
  32. data/_includes/components/series-nav.html +64 -0
  33. data/_includes/components/subscribe-form.html +88 -0
  34. data/_includes/components/subscription-manage.html +68 -0
  35. data/_includes/components/webmentions.html +48 -0
  36. data/_includes/csp-meta.html +135 -11
  37. data/_includes/footer.html +22 -20
  38. data/_includes/head.html +111 -63
  39. data/_includes/header/navigation.html +12 -15
  40. data/_includes/header.html +28 -19
  41. data/_includes/layouts/default/article.html +9 -7
  42. data/_includes/meta/dynamic-services-config.html +14 -0
  43. data/_includes/meta/math-config.html +18 -10
  44. data/_includes/meta/person-json.html +27 -0
  45. data/_includes/meta/publisher.html +45 -0
  46. data/_includes/meta/schema.html +67 -30
  47. data/_includes/meta/scholarly.html +121 -0
  48. data/_includes/meta/scripts-loader.html +18 -32
  49. data/_includes/meta/webmention-discovery.html +14 -0
  50. data/_includes/post/related-posts.html +4 -7
  51. data/_includes/scripts.html +57 -0
  52. data/_includes/search/index-data.json +9 -34
  53. data/_layouts/dataset.html +5 -3
  54. data/_layouts/default.html +16 -8
  55. data/_layouts/notebook.html +1 -0
  56. data/_layouts/package.html +4 -2
  57. data/_layouts/page.html +15 -0
  58. data/_layouts/portfolio.html +1 -0
  59. data/_layouts/post.html +102 -23
  60. data/_layouts/project.html +4 -3
  61. data/_layouts/research.html +20 -8
  62. data/_plugins/analytics_dashboard.rb +9 -3
  63. data/_plugins/authors.rb +133 -0
  64. data/_plugins/config_validator.rb +231 -23
  65. data/_plugins/critical_css_check.rb +42 -0
  66. data/_plugins/csp_generator.rb +18 -28
  67. data/_plugins/datalog_bibliography.rb +9 -7
  68. data/_plugins/datalog_comments.rb +8 -5
  69. data/_plugins/datalog_slides.rb +9 -8
  70. data/_plugins/i18n.rb +13 -12
  71. data/_plugins/image_optimizer.rb +241 -178
  72. data/_plugins/licenses.rb +135 -0
  73. data/_plugins/math_preprocessor.rb +59 -7
  74. data/_plugins/notebook_converter.rb +23 -4
  75. data/_plugins/plugin_loader.rb +3 -1
  76. data/_plugins/publications_generator.rb +8 -2
  77. data/_plugins/references.rb +238 -0
  78. data/_plugins/reproducibility.rb +150 -0
  79. data/_plugins/revisions.rb +101 -0
  80. data/_plugins/rouge_highlight_filter.rb +42 -0
  81. data/_plugins/scholarly.rb +50 -0
  82. data/_plugins/search_code_blocks.rb +30 -0
  83. data/_plugins/search_normalizer.rb +15 -53
  84. data/_plugins/search_pages.rb +3 -4
  85. data/_plugins/series.rb +104 -0
  86. data/_plugins/statements.rb +87 -0
  87. data/_sass/_academic-dashboard.scss +262 -0
  88. data/_sass/_base.scss +20 -1
  89. data/_sass/_comments-thread.scss +159 -0
  90. data/_sass/_components.scss +64 -1153
  91. data/_sass/_features.scss +17 -0
  92. data/_sass/_layout.scss +385 -1
  93. data/_sass/_mathematical.scss +27 -0
  94. data/_sass/_moderation.scss +222 -0
  95. data/_sass/_notebooks.scss +322 -0
  96. data/_sass/_open-science-badges.scss +56 -0
  97. data/_sass/{_phase1-enhancements.scss → _post-components.scss} +5 -3
  98. data/_sass/_print.scss +291 -0
  99. data/_sass/_reactions.scss +89 -0
  100. data/_sass/_reading-state.scss +290 -0
  101. data/_sass/_search-page.scss +530 -0
  102. data/_sass/_search.scss +46 -0
  103. data/_sass/_service-forms.scss +204 -0
  104. data/_sass/_subscriptions.scss +140 -0
  105. data/_sass/_syntax-highlighting.scss +212 -97
  106. data/_sass/_theme.scss +40 -19
  107. data/_sass/_typography.scss +130 -0
  108. data/_sass/_utilities.scss +5 -0
  109. data/_sass/_variables.scss +6 -0
  110. data/_sass/_webmentions.scss +125 -0
  111. data/assets/css/main.scss +14 -0
  112. data/assets/js/dist/academic.js +1 -1
  113. data/assets/js/dist/analytics-dashboard.js +1 -1
  114. data/assets/js/dist/chunks/chunk-2DYDWUFX.js +1 -0
  115. data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
  116. data/assets/js/dist/chunks/chunk-V7734B2G.js +1 -0
  117. data/assets/js/dist/comments.js +2 -0
  118. data/assets/js/dist/contact.js +1 -0
  119. data/assets/js/dist/core.js +1 -1
  120. data/assets/js/dist/corrections.js +1 -0
  121. data/assets/js/dist/loader.js +1 -1
  122. data/assets/js/dist/math.js +1 -1
  123. data/assets/js/dist/moderation.js +1 -0
  124. data/assets/js/dist/notebook.js +1 -1
  125. data/assets/js/dist/reactions.js +1 -0
  126. data/assets/js/dist/reading-state.js +1 -0
  127. data/assets/js/dist/search.js +1 -1
  128. data/assets/js/dist/sources.json +52 -0
  129. data/assets/js/dist/subscriptions.js +1 -0
  130. data/assets/js/dist/visualizations.js +11 -2
  131. data/assets/js/dist/webmentions.js +1 -0
  132. data/assets/js/loader.js +37 -1
  133. data/datalog-theme.gemspec +35 -23
  134. data/lib/datalog/cli.rb +43 -15
  135. data/lib/datalog/critical_css.rb +168 -0
  136. data/lib/datalog/plugin_system/dependency_resolver.rb +0 -2
  137. data/lib/datalog/plugins/comments.rb +33 -3
  138. data/lib/datalog/theme/installed_files.rb +113 -0
  139. data/lib/datalog/theme/package.rb +57 -0
  140. data/lib/datalog/theme/repository_checkout.rb +94 -0
  141. data/lib/datalog/theme/version.rb +5 -1
  142. data/lib/datalog/warning_filter.rb +5 -11
  143. data/lib/datalog-theme.rb +6 -0
  144. metadata +96 -147
  145. data/_data/academic.yml +0 -217
  146. data/_data/config/author.yml +0 -121
  147. data/_data/datasets.yml +0 -28
  148. data/_data/js_meta.json +0 -371
  149. data/_data/navigation.yml +0 -145
  150. data/_data/projects.yml +0 -41
  151. data/_data/publications.yml +0 -28
  152. data/_data/social.yml +0 -73
  153. data/_data/visualizations.yml +0 -51
  154. data/_includes/components/advanced-search.html +0 -682
  155. data/_includes/components/bookmark-system.html +0 -96
  156. data/_includes/components/comments.html +0 -244
  157. data/_includes/components/content-recommendations.html +0 -228
  158. data/_includes/components/email-preferences.html +0 -200
  159. data/_includes/components/enhanced-metadata.html +0 -228
  160. data/_includes/components/language-switcher.html +0 -396
  161. data/_includes/components/navigation-enhancements.html +0 -454
  162. data/_includes/components/newsletter-signup.html +0 -178
  163. data/_includes/components/popular-posts.html +0 -233
  164. data/_includes/components/reading-progress.html +0 -133
  165. data/_includes/components/reading-time.html +0 -121
  166. data/_includes/components/series-navigation.html +0 -124
  167. data/_includes/components/social-proof.html +0 -34
  168. data/_includes/components/user-preferences.html +0 -566
  169. data/_includes/meta/syntax-config.html +0 -19
  170. data/_layouts/archive.html +0 -282
  171. data/_layouts/post-sidebar.html +0 -183
  172. data/_sass/_phase3-enhancements.scss +0 -874
  173. data/_sass/_phase4-enhancements.scss +0 -1214
  174. data/_sass/_phase5-enhancements.scss +0 -414
  175. data/assets/js/academic.js +0 -262
  176. data/assets/js/analytics-dashboard.js +0 -382
  177. data/assets/js/core/dark-mode.js +0 -79
  178. data/assets/js/core/github-cards.js +0 -123
  179. data/assets/js/core/language-filter.js +0 -69
  180. data/assets/js/core/navigation.js +0 -184
  181. data/assets/js/core/scroll-progress.js +0 -45
  182. data/assets/js/core/search-hotkeys.js +0 -62
  183. data/assets/js/core/skip-links.js +0 -62
  184. data/assets/js/dist/manifest.json +0 -22
  185. data/assets/js/dist/meta.json +0 -371
  186. data/assets/js/main.js +0 -23
  187. data/assets/js/math.js +0 -818
  188. data/assets/js/notebook.js +0 -158
  189. data/assets/js/search/analytics.js +0 -91
  190. data/assets/js/search/app.js +0 -271
  191. data/assets/js/search/autocomplete.js +0 -120
  192. data/assets/js/search/engine.js +0 -260
  193. data/assets/js/search/filters.js +0 -45
  194. data/assets/js/search/render.js +0 -217
  195. data/assets/js/search/utils.js +0 -99
  196. data/assets/js/search.js +0 -354
  197. data/assets/js/visualizations.js +0 -816
  198. data/assets/publications/datalog-publications.bib +0 -8
  199. data/assets/publications/datalog-publications.ris +0 -9
  200. data/assets/publications/publications.bib +0 -30
  201. data/assets/templates/diogo-ribeiro-cv.md +0 -31
  202. data/assets/templates/diogo-ribeiro-cv.tex +0 -32
  203. 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
- <<~HTML
10
- <div class="datalog-comments-placeholder">
11
- <p>Comments feature coming soon.</p>
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
@@ -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
- slides_data = page["datalog_slides"]
8
- return "" unless slides_data
12
+ return "" unless page["datalog_slides"]
9
13
 
10
- # Render a simple placeholder or embed
11
- <<~HTML
12
- <div class="datalog-slides-placeholder">
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
- page_lang = context["page"] && context["page"]["lang"]
9
- site = context.registers[:site]
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
- lang = page_lang || site_locale
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
- tokens = markup.strip.split(",").map(&:strip)
138
- tokens.each_with_object({}) do |token, memo|
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
@@ -1,17 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "fastimage"
4
- require "nokogiri"
5
- require "tempfile"
6
- require "English"
7
5
  require "fileutils"
8
6
  require "json"
9
-
10
- begin
11
- require "mini_magick"
12
- rescue LoadError
13
- MiniMagick = nil
14
- end
7
+ require "nokogiri"
8
+ require "open3"
9
+ require "tmpdir"
10
+ require "tempfile"
15
11
 
16
12
  module Jekyll
17
13
  module ImageOptimizer
@@ -33,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
- fragment = full_document ? Nokogiri::HTML5::Document.parse(document.output) : Nokogiri::HTML::DocumentFragment.parse(document.output)
54
+ # Fragments use the HTML5 parser too: the HTML4 serializer writes a
55
+ # </source> end tag after each void <source> that offer_variants adds.
56
+ fragment = if full_document
57
+ Nokogiri::HTML5::Document.parse(document.output)
58
+ else
59
+ Nokogiri::HTML5::DocumentFragment.parse(document.output)
60
+ end
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
- first_priority_assigned = false
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["loading"] ||= "lazy"
55
- img["decoding"] ||= "async"
56
-
57
- unless first_priority_assigned
73
+ if img == priority
58
74
  img["fetchpriority"] ||= "high"
59
- first_priority_assigned = true
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
- fallback_variants = picture_entry.dig("fallback", "variants") || []
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(site, static_file, target_width, target_height, fallback_format, config,
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
- %w[avif webp].each do |format|
153
- variant = build_variant(site, static_file, target_width, target_height, format, config, fallback_format,
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) unless fallback_variants.any? do |variant|
161
- variant["width"] >= width
162
- end
212
+ fallback_variants << original_variant(normalized_src, width, height)
163
213
 
164
- sources = %w[avif webp].map do |format|
165
- variants = source_variants[format].compact.sort_by { |variant| variant["width"] }
214
+ sources = SOURCE_FORMATS.filter_map do |format|
215
+ variants = source_variants[format].sort_by { |variant| variant["width"] }
166
216
  next if variants.empty?
167
217
 
168
218
  { "type" => MIME_TYPES[format], "format" => format, "variants" => variants }
169
- end.compact
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] || MIME_TYPES[normalize_format(File.extname(source_path))] || "image/jpeg",
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
- def build_variant(site, static_file, target_width, target_height, format, config, original_format, original_width)
185
- return unless format
234
+ # Encodes one variant into the cache, unless an earlier build already did,
235
+ # and adds it to the files the site writes. Returns nil when this machine
236
+ # cannot encode the format or the encoder fails, so the manifest only names
237
+ # files that exist.
238
+ def build_variant(image, target_width, format)
239
+ return unless can_encode?(format, image[:encoders])
186
240
 
187
- if MiniMagick.nil?
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
- return
243
+ quality = image[:config]["quality"][format]
244
+ cached = cached_variant_path(image, target_width, format, quality)
245
+ if File.exist?(cached)
246
+ image[:counts][:cached] += 1
247
+ else
248
+ encode_into_cache(static_file.path, cached, target_width, format, quality, image[:encoders])
249
+ image[:counts][:created] += 1
201
250
  end
202
251
 
203
- quality_map = config["quality"] || {}
204
- quality = quality_map[format] || quality_map[format&.downcase] || quality_map[format&.upcase]
205
- encoder_path = avif_encoder
206
- if format == "avif" && encoder_path.nil?
207
- Jekyll.logger.debug "ImageOptimizer",
208
- "Skipping AVIF variant for #{static_file.relative_path} because avifenc is unavailable"
209
- return
210
- end
252
+ variant_file = variant_static_file(image[:site], static_file, target_width, format, cached)
253
+ image[:site].static_files << variant_file
254
+
255
+ {
256
+ "url" => variant_file.url,
257
+ "width" => target_width,
258
+ "height" => scaled_height(image[:width], image[:height], target_width),
259
+ "format" => format
260
+ }
261
+ rescue StandardError => e
262
+ Jekyll.logger.warn "ImageOptimizer:",
263
+ "Could not create a #{target_width}px #{format} of #{image[:file].relative_path}: #{e.message}"
264
+ nil
265
+ end
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
- dir = "" if dir == "."
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
- ext = extension_for(format)
219
- filename = "#{base}-#{target_width}w.#{ext}"
220
-
221
- responsive_file = ResponsiveImageStaticFile.new(
222
- site: site,
223
- relative_dir: responsive_dir,
224
- name: filename,
225
- original_path: static_file.path,
226
- width: target_width,
227
- height: target_height,
228
- format: format,
229
- quality: quality,
230
- avif_encoder: encoder_path
231
- )
232
-
233
- site.static_files << responsive_file
274
+ Jekyll::ResponsiveImageStaticFile.new(site, responsive_dir, "#{base}-#{target_width}w.#{extension_for(format)}",
275
+ cached)
276
+ end
277
+
278
+ # Keyed by the image's content rather than its file time, which a fresh
279
+ # checkout resets.
280
+ def cached_variant_path(image, target_width, format, quality)
281
+ image[:digest] ||= Digest::SHA256.file(image[:file].path).hexdigest
282
+ key = Digest::SHA256.hexdigest([image[:digest], target_width, format, quality].join("|"))
283
+ File.join(cache_root(image[:site]), "#{key}.#{extension_for(format)}")
284
+ end
285
+
286
+ # Jekyll's cache directory. A site that sets disable_disk_cache gets a
287
+ # temporary directory instead, which `jekyll serve` reuses between builds
288
+ # and which is removed when Jekyll exits.
289
+ def cache_root(site)
290
+ return site.in_cache_dir(CACHE_DIR) unless site.config["disable_disk_cache"]
291
+
292
+ @cache_root ||= Dir.mktmpdir(CACHE_DIR).tap { |dir| at_exit { FileUtils.rm_rf(dir) } }
293
+ end
294
+
295
+ # Encodes beside the cache entry and renames it into place, so a failed or
296
+ # interrupted encode never leaves a file that a later build takes for done.
297
+ def encode_into_cache(source, cached, target_width, format, quality, encoders)
298
+ partial = cached.sub(/(\.\w+)\z/, '.partial\1')
299
+ FileUtils.mkdir_p(File.dirname(cached))
300
+ if format == "avif"
301
+ Tempfile.create(["datalog-image", ".png"]) do |png|
302
+ png.close
303
+ resize(source, png.path, target_width, nil, encoders)
304
+ run!(*encoders["avifenc"], "-q", avif_quality(quality).to_s, "-s", "6", png.path, partial)
305
+ end
306
+ else
307
+ resize(source, partial, target_width, quality, encoders)
308
+ end
309
+ File.rename(partial, cached)
310
+ ensure
311
+ FileUtils.rm_f(partial)
312
+ end
313
+
314
+ def resize(source, destination, target_width, quality, encoders)
315
+ command = [*encoders["imagemagick"], source, "-auto-orient", "-resize", "#{target_width}x>", "-strip"]
316
+ command += ["-quality", quality.to_s] if quality
317
+ run!(*command, destination)
318
+ end
319
+
320
+ def run!(*command)
321
+ output, status = Open3.capture2e(*command)
322
+ return if status.success?
323
+
324
+ raise "#{File.basename(command.first)} exited with #{status.exitstatus}: #{output.strip.lines.last(3).join.strip}"
325
+ end
326
+
327
+ def avif_quality(quality)
328
+ value = quality.to_i
329
+ value.positive? ? value.clamp(1, 100) : 50
330
+ end
331
+
332
+ def can_encode?(format, encoders)
333
+ return false unless encoders["imagemagick"]
334
+ return encoders["writable"].include?("PNG") && !encoders["avifenc"].nil? if format == "avif"
335
+
336
+ encoders["writable"].include?(CODERS.fetch(format, format.upcase))
337
+ end
338
+
339
+ # The encoders on this machine, looked up once per process. ImageMagick 7
340
+ # is `magick`. ImageMagick 6 is `convert`, looked for only off Windows,
341
+ # where C:\Windows\System32\convert.exe converts file systems. AVIF also
342
+ # needs avifenc, from libavif.
343
+ def tools
344
+ @tools ||= detect_tools
345
+ end
346
+
347
+ def detect_tools(windows: Gem.win_platform?)
348
+ magick = which("magick")
349
+ convert = which("convert") unless magick || windows
350
+ imagemagick = [magick || convert].compact
351
+ imagemagick = nil if imagemagick.empty?
352
+
353
+ avifenc = which("avifenc")
234
354
 
235
355
  {
236
- "url" => responsive_file.url,
237
- "width" => target_width,
238
- "height" => target_height,
239
- "format" => format
356
+ "imagemagick" => imagemagick,
357
+ "writable" => imagemagick ? writable_formats(imagemagick) : [],
358
+ "avifenc" => avifenc && [avifenc]
240
359
  }
241
- rescue StandardError => e
242
- Jekyll.logger.debug "ImageOptimizer",
243
- "Unable to generate variant for #{static_file.relative_path}: #{e.message}"
360
+ end
361
+
362
+ def writable_formats(command)
363
+ output, status = Open3.capture2e(*command, "-list", "format")
364
+ status.success? ? parse_writable_formats(output) : []
365
+ rescue SystemCallError
366
+ []
367
+ end
368
+
369
+ # A line of `-list format` reads " WEBP* rw+ WebP Image Format" in
370
+ # ImageMagick 7. ImageMagick 6 adds the module: " WEBP* WEBP rw+".
371
+ def parse_writable_formats(listing)
372
+ listing.each_line.filter_map { |line| line[/\A\s*([A-Z0-9-]+)\*?\s+(?:[A-Z0-9-]+\s+)?[r-]w[+-]\s/, 1] }
373
+ end
374
+
375
+ def which(command)
376
+ extensions = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
377
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
378
+ extensions.each do |extension|
379
+ path = File.join(dir, "#{command}#{extension}")
380
+ return path if File.file?(path) && File.executable?(path)
381
+ end
382
+ end
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
- case format
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
- def build_srcset(variants)
430
+ # Variant URLs are site paths. A site served below a baseurl needs it in
431
+ # front of them, as relative_url adds it in templates.
432
+ def build_srcset(variants, baseurl = "")
299
433
  variants.map do |variant|
300
434
  next unless variant["url"] && variant["width"]
301
435
 
302
- "#{variant['url']} #{variant['width']}w"
436
+ url = variant["url"]
437
+ url = "#{baseurl}#{url}" if url.start_with?("/") && !url.start_with?("//")
438
+ "#{url} #{variant['width']}w"
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:, relative_dir:, name:, original_path:, width:, height:, format:, quality:, avif_encoder: nil)
341
- @site = site
342
- @original_path = original_path
343
- @target_width = width
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 generate_variant(dest_path)
379
- if MiniMagick.nil?
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