datalog-theme 0.6.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 (180) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +162 -0
  3. data/CITATION.cff +29 -0
  4. data/LICENSE +21 -0
  5. data/README.md +257 -0
  6. data/_data/academic.yml +217 -0
  7. data/_data/cdn-integrity.yml +51 -0
  8. data/_data/config/author.yml +121 -0
  9. data/_data/config/features.yml +262 -0
  10. data/_data/config/site.yml +42 -0
  11. data/_data/config/theme.yml +181 -0
  12. data/_data/datasets.yml +28 -0
  13. data/_data/i18n/en.yml +438 -0
  14. data/_data/i18n/es.yml +438 -0
  15. data/_data/i18n/pt.yml +438 -0
  16. data/_data/js_manifest.json +22 -0
  17. data/_data/js_meta.json +371 -0
  18. data/_data/navigation.yml +145 -0
  19. data/_data/projects.yml +41 -0
  20. data/_data/publications.yml +28 -0
  21. data/_data/social.yml +73 -0
  22. data/_data/visualizations.yml +51 -0
  23. data/_includes/analytics/dashboard.html +149 -0
  24. data/_includes/analytics.html +9 -0
  25. data/_includes/components/academic-dashboard.html +347 -0
  26. data/_includes/components/advanced-search.html +682 -0
  27. data/_includes/components/api-function.html +94 -0
  28. data/_includes/components/author-bio.html +166 -0
  29. data/_includes/components/bookmark-system.html +96 -0
  30. data/_includes/components/breadcrumbs.html +89 -0
  31. data/_includes/components/citation-tools.html +94 -0
  32. data/_includes/components/comments.html +244 -0
  33. data/_includes/components/content-provenance.html +43 -0
  34. data/_includes/components/content-recommendations.html +228 -0
  35. data/_includes/components/difficulty-badge.html +84 -0
  36. data/_includes/components/email-preferences.html +200 -0
  37. data/_includes/components/enhanced-code-block.html +212 -0
  38. data/_includes/components/enhanced-metadata.html +228 -0
  39. data/_includes/components/enhanced-toc.html +209 -0
  40. data/_includes/components/hero.html +15 -0
  41. data/_includes/components/language-switcher.html +396 -0
  42. data/_includes/components/math-fallback.html +19 -0
  43. data/_includes/components/navigation-enhancements.html +454 -0
  44. data/_includes/components/newsletter-signup.html +178 -0
  45. data/_includes/components/open-science-badges.html +16 -0
  46. data/_includes/components/package-install.html +194 -0
  47. data/_includes/components/performance-monitor.html +170 -0
  48. data/_includes/components/popular-posts.html +233 -0
  49. data/_includes/components/post-hero.html +62 -0
  50. data/_includes/components/reading-progress.html +133 -0
  51. data/_includes/components/reading-time.html +121 -0
  52. data/_includes/components/responsive-image.html +63 -0
  53. data/_includes/components/search-facets.html +307 -0
  54. data/_includes/components/series-navigation.html +124 -0
  55. data/_includes/components/social-proof.html +34 -0
  56. data/_includes/components/social-share.html +119 -0
  57. data/_includes/components/user-preferences.html +566 -0
  58. data/_includes/components/visualization-card.html +24 -0
  59. data/_includes/components/viz-table-fallback.html +19 -0
  60. data/_includes/critical-css/default.html +0 -0
  61. data/_includes/critical-css/home.html +0 -0
  62. data/_includes/critical-css/post.html +0 -0
  63. data/_includes/csp-meta.html +17 -0
  64. data/_includes/footer/nav-column.html +31 -0
  65. data/_includes/footer.html +76 -0
  66. data/_includes/head.html +337 -0
  67. data/_includes/header/navigation.html +48 -0
  68. data/_includes/header.html +70 -0
  69. data/_includes/helpers/array-to-sentence.html +21 -0
  70. data/_includes/helpers/date-format.html +16 -0
  71. data/_includes/helpers/link-with-icon.html +26 -0
  72. data/_includes/layouts/default/article.html +67 -0
  73. data/_includes/meta/language-attributes.html +25 -0
  74. data/_includes/meta/math-config.html +21 -0
  75. data/_includes/meta/schema.html +153 -0
  76. data/_includes/meta/scripts-loader.html +52 -0
  77. data/_includes/post/related-posts.html +63 -0
  78. data/_includes/scripts.html +72 -0
  79. data/_includes/skip-link.html +1 -0
  80. data/_includes/toc.html +2 -0
  81. data/_layouts/archive.html +282 -0
  82. data/_layouts/dataset.html +52 -0
  83. data/_layouts/default.html +33 -0
  84. data/_layouts/home.html +48 -0
  85. data/_layouts/notebook.html +173 -0
  86. data/_layouts/package.html +238 -0
  87. data/_layouts/page.html +16 -0
  88. data/_layouts/portfolio.html +41 -0
  89. data/_layouts/post-sidebar.html +183 -0
  90. data/_layouts/post.html +338 -0
  91. data/_layouts/project.html +252 -0
  92. data/_layouts/research.html +504 -0
  93. data/_plugins/analytics_dashboard.rb +315 -0
  94. data/_plugins/config_validator.rb +397 -0
  95. data/_plugins/csp_generator.rb +115 -0
  96. data/_plugins/datalog_bibliography.rb +19 -0
  97. data/_plugins/datalog_comments.rb +18 -0
  98. data/_plugins/datalog_slides.rb +20 -0
  99. data/_plugins/front_matter_compat.rb +109 -0
  100. data/_plugins/i18n.rb +160 -0
  101. data/_plugins/image_optimizer.rb +445 -0
  102. data/_plugins/math_preprocessor.rb +185 -0
  103. data/_plugins/notebook_converter.rb +1107 -0
  104. data/_plugins/plugin_loader.rb +185 -0
  105. data/_plugins/publications_generator.rb +234 -0
  106. data/_plugins/reading_time.rb +15 -0
  107. data/_plugins/search_normalizer.rb +74 -0
  108. data/_plugins/sri_filter.rb +18 -0
  109. data/_plugins/toc_filter.rb +68 -0
  110. data/_plugins/warning_filter.rb +3 -0
  111. data/_sass/_base.scss +142 -0
  112. data/_sass/_components.scss +2380 -0
  113. data/_sass/_header.scss +470 -0
  114. data/_sass/_interactive.scss +344 -0
  115. data/_sass/_layout.scss +1760 -0
  116. data/_sass/_mathematical.scss +368 -0
  117. data/_sass/_mixins.scss +99 -0
  118. data/_sass/_package-docs.scss +715 -0
  119. data/_sass/_phase1-enhancements.scss +721 -0
  120. data/_sass/_phase3-enhancements.scss +874 -0
  121. data/_sass/_phase4-enhancements.scss +1214 -0
  122. data/_sass/_phase5-enhancements.scss +414 -0
  123. data/_sass/_search.scss +654 -0
  124. data/_sass/_syntax-highlighting.scss +128 -0
  125. data/_sass/_theme.scss +79 -0
  126. data/_sass/_typography.scss +148 -0
  127. data/_sass/_utilities.scss +334 -0
  128. data/_sass/_variables.scss +153 -0
  129. data/_sass/_visualizations.scss +242 -0
  130. data/assets/css/main.scss +4 -0
  131. data/assets/img/20220607123041_detail.001.png +0 -0
  132. data/assets/img/favicons/android-chrome-192x192.png +0 -0
  133. data/assets/img/favicons/android-chrome-512x512.png +0 -0
  134. data/assets/img/favicons/apple-touch-icon.png +0 -0
  135. data/assets/img/favicons/favicon-16x16.png +0 -0
  136. data/assets/img/favicons/favicon-32x32.png +0 -0
  137. data/assets/img/favicons/favicon.ico +0 -0
  138. data/assets/img/favicons/site.webmanifest +21 -0
  139. data/assets/img/portfolio-placeholder.svg +20 -0
  140. data/assets/js/academic.js +262 -0
  141. data/assets/js/analytics-dashboard.js +382 -0
  142. data/assets/js/core/dark-mode.js +79 -0
  143. data/assets/js/core/github-cards.js +123 -0
  144. data/assets/js/core/language-filter.js +69 -0
  145. data/assets/js/core/navigation.js +184 -0
  146. data/assets/js/core/scroll-progress.js +45 -0
  147. data/assets/js/core/search-hotkeys.js +62 -0
  148. data/assets/js/core/skip-links.js +62 -0
  149. data/assets/js/loader.js +163 -0
  150. data/assets/js/main.js +23 -0
  151. data/assets/js/math.js +818 -0
  152. data/assets/js/notebook.js +158 -0
  153. data/assets/js/search/analytics.js +91 -0
  154. data/assets/js/search/app.js +271 -0
  155. data/assets/js/search/autocomplete.js +120 -0
  156. data/assets/js/search/engine.js +260 -0
  157. data/assets/js/search/filters.js +38 -0
  158. data/assets/js/search/render.js +217 -0
  159. data/assets/js/search/utils.js +99 -0
  160. data/assets/js/search.js +354 -0
  161. data/assets/js/visualizations.js +816 -0
  162. data/assets/publications/datalog-publications.bib +8 -0
  163. data/assets/publications/datalog-publications.ris +9 -0
  164. data/assets/publications/publications.bib +30 -0
  165. data/assets/templates/diogo-ribeiro-cv.md +31 -0
  166. data/assets/templates/diogo-ribeiro-cv.tex +32 -0
  167. data/bin/datalog +26 -0
  168. data/datalog-theme.gemspec +72 -0
  169. data/lib/datalog/cli.rb +563 -0
  170. data/lib/datalog/plugin_system/dependency_resolver.rb +223 -0
  171. data/lib/datalog/plugin_system.rb +237 -0
  172. data/lib/datalog/plugins/citations.rb +182 -0
  173. data/lib/datalog/plugins/comments.rb +183 -0
  174. data/lib/datalog/plugins/search.rb +45 -0
  175. data/lib/datalog/plugins/slides.rb +121 -0
  176. data/lib/datalog/theme/theme.rb +18 -0
  177. data/lib/datalog/theme/version.rb +7 -0
  178. data/lib/datalog/warning_filter.rb +28 -0
  179. data/lib/datalog-theme.rb +17 -0
  180. metadata +555 -0
@@ -0,0 +1,445 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fastimage"
4
+ require "nokogiri"
5
+ require "tempfile"
6
+ require "English"
7
+ require "fileutils"
8
+ require "json"
9
+
10
+ begin
11
+ require "mini_magick"
12
+ rescue LoadError
13
+ MiniMagick = nil
14
+ end
15
+
16
+ module Jekyll
17
+ module ImageOptimizer
18
+ module_function
19
+
20
+ DEFAULT_SIZES = [320, 640, 960, 1_280, 1_920].freeze
21
+ DEFAULT_QUALITY = {
22
+ "avif" => 45,
23
+ "webp" => 75,
24
+ "jpeg" => 85,
25
+ "jpg" => 85,
26
+ "png" => 85
27
+ }.freeze
28
+ MIME_TYPES = {
29
+ "avif" => "image/avif",
30
+ "webp" => "image/webp",
31
+ "jpeg" => "image/jpeg",
32
+ "jpg" => "image/jpeg",
33
+ "png" => "image/png"
34
+ }.freeze
35
+ RASTER_EXTENSIONS = %w[.jpg .jpeg .png].freeze
36
+
37
+ def process(document)
38
+ return unless document.output_ext == ".html"
39
+ return if document.output.nil? || document.output.empty?
40
+
41
+ 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)
43
+ return if fragment.nil?
44
+
45
+ site = document.respond_to?(:site) ? document.site : nil
46
+ manifest = site&.data&.fetch("datalog_responsive_images", {}) || {}
47
+ image_config = site&.config&.fetch("datalog_image_config", {}) || {}
48
+ optimized = false
49
+ first_priority_assigned = false
50
+
51
+ fragment.css("img").each do |img|
52
+ next if img["data-no-optimize"] == "true"
53
+
54
+ img["loading"] ||= "lazy"
55
+ img["decoding"] ||= "async"
56
+
57
+ unless first_priority_assigned
58
+ img["fetchpriority"] ||= "high"
59
+ first_priority_assigned = true
60
+ end
61
+
62
+ normalized_src = normalize_src(img["src"], site)
63
+ picture_entry = manifest[normalized_src]
64
+
65
+ 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")
69
+ img["width"] ||= picture_entry["width"].to_s if picture_entry["width"]
70
+ img["height"] ||= picture_entry["height"].to_s if picture_entry["height"]
71
+ else
72
+ next if img["width"] && img["height"]
73
+
74
+ source = image_source_path(site, img["src"])
75
+ next unless source && File.exist?(source)
76
+
77
+ width, height = FastImage.size(source)
78
+ next unless width && height
79
+
80
+ img["width"] ||= width.to_s
81
+ img["height"] ||= height.to_s
82
+ end
83
+ optimized = true
84
+ rescue StandardError => e
85
+ Jekyll.logger.debug "ImageOptimizer", "Skipping #{img['src']}: #{e.message}"
86
+ end
87
+
88
+ return unless optimized
89
+
90
+ document.output = fragment.to_html
91
+ end
92
+
93
+ def prepare_site(site)
94
+ config = image_config(site)
95
+ manifest = {}
96
+
97
+ responsive_candidates(site).each do |static_file|
98
+ entry = manifest_entry_for(site, static_file, config)
99
+ next unless entry
100
+
101
+ manifest[entry.delete("source")] = entry
102
+ end
103
+
104
+ site.data["datalog_responsive_images"] = manifest
105
+ site.config["datalog_image_config"] = config
106
+ end
107
+
108
+ def image_source_path(site, src)
109
+ return unless site && src
110
+
111
+ normalized = src.gsub(%r{^/}, "")
112
+ return if normalized.start_with?("http://", "https://", "data:")
113
+
114
+ Jekyll.sanitized_path(site.source, normalized)
115
+ end
116
+
117
+ def responsive_candidates(site)
118
+ return [] unless site.respond_to?(:static_files)
119
+
120
+ site.static_files.select do |static_file|
121
+ path = static_file.path
122
+ next false unless path
123
+
124
+ ext = File.extname(path).downcase
125
+ next false unless RASTER_EXTENSIONS.include?(ext)
126
+
127
+ relative_dir = File.dirname(static_file.relative_path.to_s)
128
+ !relative_dir.to_s.split(File::SEPARATOR).include?("responsive")
129
+ end
130
+ end
131
+
132
+ def manifest_entry_for(site, static_file, config)
133
+ source_path = static_file.path
134
+ width, height = FastImage.size(source_path)
135
+ return unless width && height
136
+
137
+ normalized_src = normalize_src(static_file.relative_path, site)
138
+ sizes = normalized_sizes(config["sizes"], width)
139
+ fallback_format = normalize_format(File.extname(source_path))
140
+ fallback_variants = []
141
+ source_variants = Hash.new { |hash, key| hash[key] = [] }
142
+
143
+ sizes.each do |target_width|
144
+ target_height = scaled_height(width, height, target_width)
145
+
146
+ if fallback_format && target_width < width
147
+ variant = build_variant(site, static_file, target_width, target_height, fallback_format, config,
148
+ fallback_format, width)
149
+ fallback_variants << variant if variant
150
+ end
151
+
152
+ %w[avif webp].each do |format|
153
+ variant = build_variant(site, static_file, target_width, target_height, format, config, fallback_format,
154
+ width)
155
+ source_variants[format] << variant if variant
156
+ end
157
+ end
158
+
159
+ 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
163
+
164
+ sources = %w[avif webp].map do |format|
165
+ variants = source_variants[format].compact.sort_by { |variant| variant["width"] }
166
+ next if variants.empty?
167
+
168
+ { "type" => MIME_TYPES[format], "format" => format, "variants" => variants }
169
+ end.compact
170
+
171
+ {
172
+ "source" => normalized_src,
173
+ "width" => width,
174
+ "height" => height,
175
+ "sources" => sources,
176
+ "fallback" => {
177
+ "type" => MIME_TYPES[fallback_format] || MIME_TYPES[normalize_format(File.extname(source_path))] || "image/jpeg",
178
+ "format" => fallback_format,
179
+ "variants" => fallback_variants
180
+ }
181
+ }
182
+ end
183
+
184
+ def build_variant(site, static_file, target_width, target_height, format, config, original_format, original_width)
185
+ return unless format
186
+
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
199
+
200
+ return
201
+ end
202
+
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
211
+
212
+ relative_path = static_file.relative_path.sub(%r{^/}, "")
213
+ dir = File.dirname(relative_path)
214
+ dir = "" if dir == "."
215
+ responsive_dir = File.join(dir, "responsive")
216
+
217
+ 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
234
+
235
+ {
236
+ "url" => responsive_file.url,
237
+ "width" => target_width,
238
+ "height" => target_height,
239
+ "format" => format
240
+ }
241
+ rescue StandardError => e
242
+ Jekyll.logger.debug "ImageOptimizer",
243
+ "Unable to generate variant for #{static_file.relative_path}: #{e.message}"
244
+ nil
245
+ end
246
+
247
+ def normalized_sizes(configured_sizes, original_width)
248
+ sizes = Array(configured_sizes || DEFAULT_SIZES).map do |value|
249
+ Integer(value)
250
+ rescue ArgumentError, TypeError
251
+ nil
252
+ end.compact
253
+
254
+ sizes = DEFAULT_SIZES if sizes.empty?
255
+ sizes = sizes.select { |size| size.positive? && size < original_width }
256
+ sizes.sort.uniq.tap do |collection|
257
+ collection << original_width
258
+ collection.uniq!
259
+ end
260
+ end
261
+
262
+ def scaled_height(original_width, original_height, target_width)
263
+ return original_height if original_width.zero?
264
+
265
+ scaled = (original_height.to_f * target_width.to_f / original_width.to_f).round
266
+ scaled.positive? ? scaled : 1
267
+ end
268
+
269
+ def normalize_format(ext)
270
+ return unless ext
271
+
272
+ normalized = ext.delete(".").downcase
273
+ return "jpeg" if normalized == "jpg"
274
+
275
+ normalized if %w[jpeg png].include?(normalized)
276
+ end
277
+
278
+ def extension_for(format)
279
+ case format
280
+ when "jpeg"
281
+ "jpg"
282
+ when "jpg"
283
+ "jpg"
284
+ else
285
+ format
286
+ end
287
+ end
288
+
289
+ def original_variant(src, width, height)
290
+ {
291
+ "url" => src,
292
+ "width" => width,
293
+ "height" => height,
294
+ "format" => normalize_format(File.extname(src))
295
+ }
296
+ end
297
+
298
+ def build_srcset(variants)
299
+ variants.map do |variant|
300
+ next unless variant["url"] && variant["width"]
301
+
302
+ "#{variant['url']} #{variant['width']}w"
303
+ end.compact.join(", ")
304
+ end
305
+
306
+ def normalize_src(src, site)
307
+ return src if src.nil? || src.empty?
308
+ return src if src.start_with?("http://", "https://", "data:")
309
+
310
+ baseurl = site&.baseurl.to_s
311
+ normalized = src.dup
312
+ normalized = normalized.delete_prefix(baseurl) if baseurl && !baseurl.empty? && normalized.start_with?(baseurl)
313
+ site_url = site&.config&.fetch("url", "").to_s
314
+ normalized = normalized.delete_prefix(site_url) if !site_url.empty? && normalized.start_with?(site_url)
315
+ normalized = normalized.gsub(%r{^/+}, "")
316
+ "/#{normalized}"
317
+ end
318
+
319
+ def image_config(site)
320
+ options = site.config.dig("theme_options", "images") || {}
321
+ qualities = DEFAULT_QUALITY.merge((options["quality"] || {}).transform_keys { |key| key.to_s.downcase })
322
+
323
+ {
324
+ "sizes" => options["sizes"] || options["breakpoints"] || DEFAULT_SIZES,
325
+ "quality" => qualities,
326
+ "lazy_loading" => options["lazy_loading"] || "lazy",
327
+ "default_sizes" => options["default_sizes"] || "100vw"
328
+ }
329
+ end
330
+
331
+ def avif_encoder
332
+ @avif_encoder ||= (Jekyll::Utils.which("avifenc") if defined?(Jekyll::Utils))
333
+ end
334
+ end
335
+ end
336
+
337
+ class Jekyll::ResponsiveImageStaticFile < Jekyll::StaticFile
338
+ attr_reader :url
339
+
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
376
+ end
377
+
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
420
+ end
421
+ end
422
+
423
+ class Jekyll::ImageOptimizerGenerator < Jekyll::Generator
424
+ safe true
425
+ priority :low
426
+
427
+ def generate(site)
428
+ Jekyll::ImageOptimizer.prepare_site(site)
429
+ end
430
+ end
431
+
432
+ Jekyll::Hooks.register(%i[pages documents], :post_render) do |document|
433
+ Jekyll::ImageOptimizer.process(document)
434
+ end
435
+
436
+ Jekyll::Hooks.register(:site, :post_write) do |site|
437
+ manifest = site.data.fetch("datalog_responsive_images", {})
438
+ next if manifest.empty?
439
+
440
+ destination = File.join(site.dest, "assets", "img", "responsive")
441
+ FileUtils.mkdir_p(destination)
442
+
443
+ manifest_path = File.join(destination, "manifest.json")
444
+ File.write(manifest_path, JSON.pretty_generate(manifest))
445
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module MathPreprocessor
6
+ DISPLAY_PATTERNS = [
7
+ {
8
+ regex: /(?<open>\$\$)(?<body>.+?)(?<close>\$\$)/m,
9
+ tag: "div"
10
+ },
11
+ {
12
+ regex: /(?<open>\\\[)(?<body>.+?)(?<close>\\\])/m,
13
+ tag: "div"
14
+ },
15
+ {
16
+ regex: /(?<open>\\begin\{(?<env>equation\*?|align\*?|gather\*?|multline\*?)\})(?<body>.+?)(?<close>\\end\{\k<env>\})/m,
17
+ tag: "div"
18
+ }
19
+ ].freeze
20
+
21
+ INLINE_PATTERNS = [
22
+ {
23
+ regex: /(?<![\\$])(?<open>\$)(?!\$)(?<body>[^$]+?)(?<close>\$)(?!\$)/m,
24
+ tag: "span"
25
+ },
26
+ {
27
+ regex: /(?<open>\\\()(?<body>.+?)(?<close>\\\))/m,
28
+ tag: "span"
29
+ }
30
+ ].freeze
31
+
32
+ class Processor
33
+ attr_reader :expressions
34
+
35
+ def initialize(content)
36
+ @content = content
37
+ @expressions = []
38
+ end
39
+
40
+ def process
41
+ return @content unless @content&.match?(/\$|\\\(|\\\[|\\begin\{/)
42
+
43
+ processed = @content.dup
44
+ processed = apply_patterns(processed, DISPLAY_PATTERNS, display: true)
45
+ apply_patterns(processed, INLINE_PATTERNS, display: false)
46
+ end
47
+
48
+ private
49
+
50
+ def apply_patterns(text, patterns, display: false)
51
+ patterns.reduce(text) do |result, pattern|
52
+ result.gsub(pattern[:regex]) do |match|
53
+ body = Regexp.last_match[:body]
54
+ open = Regexp.last_match[:open]
55
+ close = Regexp.last_match[:close]
56
+
57
+ next match if body.nil? || body.strip.empty?
58
+
59
+ wrapper_for(match, body, open, close, pattern[:tag], display: display)
60
+ end
61
+ end
62
+ end
63
+
64
+ def wrapper_for(_original, latex, open, close, tag, display: false)
65
+ alt_text = derive_alt_text(latex)
66
+ cleaned_source = cleanup_source(latex)
67
+ record_expression(cleaned_source, alt_text)
68
+
69
+ attributes = {
70
+ "class" => display ? "math-expression math-expression--source" : "math-expression-inline math-expression--source",
71
+ "data-math-alt" => alt_text,
72
+ "data-math-source" => cleaned_source,
73
+ "aria-label" => alt_text,
74
+ "tabindex" => "0"
75
+ }
76
+
77
+ attribute_string = attributes.map do |key, value|
78
+ next if value.nil? || value.strip.empty?
79
+
80
+ %(#{key}="#{CGI.escapeHTML(value)}")
81
+ end.compact.join(" ")
82
+
83
+ inner = "#{open}#{latex}#{close}"
84
+ "<#{tag} #{attribute_string}>#{inner}</#{tag}>"
85
+ end
86
+
87
+ def record_expression(latex, alt)
88
+ @expressions << { "latex" => latex, "alt" => alt } if latex && !latex.empty?
89
+ end
90
+
91
+ def derive_alt_text(latex)
92
+ comment_alt = extract_comment_alt(latex)
93
+ return comment_alt if comment_alt
94
+
95
+ auto_alt_text(latex)
96
+ end
97
+
98
+ def extract_comment_alt(latex)
99
+ latex.to_s.lines.each do |line|
100
+ if (match = line.match(/%\s*alt:\s*(.+)$/i))
101
+ return match[1].strip
102
+ end
103
+ end
104
+ nil
105
+ end
106
+
107
+ def cleanup_source(latex)
108
+ latex.to_s.strip
109
+ end
110
+
111
+ def auto_alt_text(latex)
112
+ text = latex.to_s.dup
113
+
114
+ text.gsub!(/\\frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/) do
115
+ numerator = sanitize_segment(Regexp.last_match[1])
116
+ denominator = sanitize_segment(Regexp.last_match[2])
117
+ "#{numerator} over #{denominator}"
118
+ end
119
+
120
+ text.gsub!(/\\int(?:_\{([^}]*)\}|_([^\s^{}]+))?(?:\^\{([^}]*)\}|\^([^\s_{}]+))?/) do
121
+ lower = Regexp.last_match[1] || Regexp.last_match[2]
122
+ upper = Regexp.last_match[3] || Regexp.last_match[4]
123
+ phrase = "integral"
124
+ phrase += " from #{sanitize_segment(lower)}" if lower && !lower.empty?
125
+ phrase += " to #{sanitize_segment(upper)}" if upper && !upper.empty?
126
+ phrase
127
+ end
128
+
129
+ text.gsub!(/\\sum(?:_\{([^}]*)\}|_([^\s^{}]+))?(?:\^\{([^}]*)\}|\^([^\s_{}]+))?/) do
130
+ lower = Regexp.last_match[1] || Regexp.last_match[2]
131
+ upper = Regexp.last_match[3] || Regexp.last_match[4]
132
+ phrase = "summation"
133
+ phrase += " from #{sanitize_segment(lower)}" if lower && !lower.empty?
134
+ phrase += " to #{sanitize_segment(upper)}" if upper && !upper.empty?
135
+ phrase
136
+ end
137
+
138
+ text.gsub!(/\\sqrt\s*\{([^{}]+)\}/) do
139
+ "square root of #{sanitize_segment(Regexp.last_match[1])}"
140
+ end
141
+
142
+ text.gsub!(/\\mathrm\s*\{([^{}]+)\}/) { sanitize_segment(Regexp.last_match[1]) }
143
+ text.gsub!(/\\operatorname\*?\s*\{([^{}]+)\}/) { sanitize_segment(Regexp.last_match[1]) }
144
+ text.gsub!(/\\[a-zA-Z]+\s*/m, " ")
145
+ text.gsub!(/[{}]/, " ")
146
+ text.gsub!(/\s+/, " ")
147
+ text = text.strip
148
+
149
+ return "Mathematical expression" if text.empty?
150
+
151
+ text
152
+ end
153
+
154
+ def sanitize_segment(segment)
155
+ return "" unless segment
156
+
157
+ cleaned = segment.gsub(/\\[a-zA-Z]+/, " ")
158
+ cleaned = cleaned.gsub(/[{}]/, " ")
159
+ cleaned.gsub(/\s+/, " ").strip
160
+ end
161
+ end
162
+
163
+ module_function
164
+
165
+ def apply(document)
166
+ return unless document.respond_to?(:content)
167
+ return unless document.respond_to?(:output_ext) && document.output_ext == ".html"
168
+
169
+ content = document.content
170
+ return unless content&.match?(/\$|\\\(|\\\[|\\begin\{/)
171
+
172
+ processor = Processor.new(content)
173
+ updated_content = processor.process
174
+ document.content = updated_content
175
+ document.data["math_expressions"] = processor.expressions if processor.expressions.any?
176
+ end
177
+ end
178
+
179
+ # Posts are documents, so registering them separately ran the preprocessor
180
+ # twice on every post and nested each expression inside its own wrapper.
181
+ %w[documents pages].each do |scope|
182
+ Jekyll::Hooks.register(scope.to_sym, :pre_render) do |document|
183
+ MathPreprocessor.apply(document)
184
+ end
185
+ end