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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +162 -0
- data/CITATION.cff +29 -0
- data/LICENSE +21 -0
- data/README.md +257 -0
- data/_data/academic.yml +217 -0
- data/_data/cdn-integrity.yml +51 -0
- data/_data/config/author.yml +121 -0
- data/_data/config/features.yml +262 -0
- data/_data/config/site.yml +42 -0
- data/_data/config/theme.yml +181 -0
- data/_data/datasets.yml +28 -0
- data/_data/i18n/en.yml +438 -0
- data/_data/i18n/es.yml +438 -0
- data/_data/i18n/pt.yml +438 -0
- data/_data/js_manifest.json +22 -0
- data/_data/js_meta.json +371 -0
- data/_data/navigation.yml +145 -0
- data/_data/projects.yml +41 -0
- data/_data/publications.yml +28 -0
- data/_data/social.yml +73 -0
- data/_data/visualizations.yml +51 -0
- data/_includes/analytics/dashboard.html +149 -0
- data/_includes/analytics.html +9 -0
- data/_includes/components/academic-dashboard.html +347 -0
- data/_includes/components/advanced-search.html +682 -0
- data/_includes/components/api-function.html +94 -0
- data/_includes/components/author-bio.html +166 -0
- data/_includes/components/bookmark-system.html +96 -0
- data/_includes/components/breadcrumbs.html +89 -0
- data/_includes/components/citation-tools.html +94 -0
- data/_includes/components/comments.html +244 -0
- data/_includes/components/content-provenance.html +43 -0
- data/_includes/components/content-recommendations.html +228 -0
- data/_includes/components/difficulty-badge.html +84 -0
- data/_includes/components/email-preferences.html +200 -0
- data/_includes/components/enhanced-code-block.html +212 -0
- data/_includes/components/enhanced-metadata.html +228 -0
- data/_includes/components/enhanced-toc.html +209 -0
- data/_includes/components/hero.html +15 -0
- data/_includes/components/language-switcher.html +396 -0
- data/_includes/components/math-fallback.html +19 -0
- data/_includes/components/navigation-enhancements.html +454 -0
- data/_includes/components/newsletter-signup.html +178 -0
- data/_includes/components/open-science-badges.html +16 -0
- data/_includes/components/package-install.html +194 -0
- data/_includes/components/performance-monitor.html +170 -0
- data/_includes/components/popular-posts.html +233 -0
- data/_includes/components/post-hero.html +62 -0
- data/_includes/components/reading-progress.html +133 -0
- data/_includes/components/reading-time.html +121 -0
- data/_includes/components/responsive-image.html +63 -0
- data/_includes/components/search-facets.html +307 -0
- data/_includes/components/series-navigation.html +124 -0
- data/_includes/components/social-proof.html +34 -0
- data/_includes/components/social-share.html +119 -0
- data/_includes/components/user-preferences.html +566 -0
- data/_includes/components/visualization-card.html +24 -0
- data/_includes/components/viz-table-fallback.html +19 -0
- data/_includes/critical-css/default.html +0 -0
- data/_includes/critical-css/home.html +0 -0
- data/_includes/critical-css/post.html +0 -0
- data/_includes/csp-meta.html +17 -0
- data/_includes/footer/nav-column.html +31 -0
- data/_includes/footer.html +76 -0
- data/_includes/head.html +337 -0
- data/_includes/header/navigation.html +48 -0
- data/_includes/header.html +70 -0
- data/_includes/helpers/array-to-sentence.html +21 -0
- data/_includes/helpers/date-format.html +16 -0
- data/_includes/helpers/link-with-icon.html +26 -0
- data/_includes/layouts/default/article.html +67 -0
- data/_includes/meta/language-attributes.html +25 -0
- data/_includes/meta/math-config.html +21 -0
- data/_includes/meta/schema.html +153 -0
- data/_includes/meta/scripts-loader.html +52 -0
- data/_includes/post/related-posts.html +63 -0
- data/_includes/scripts.html +72 -0
- data/_includes/skip-link.html +1 -0
- data/_includes/toc.html +2 -0
- data/_layouts/archive.html +282 -0
- data/_layouts/dataset.html +52 -0
- data/_layouts/default.html +33 -0
- data/_layouts/home.html +48 -0
- data/_layouts/notebook.html +173 -0
- data/_layouts/package.html +238 -0
- data/_layouts/page.html +16 -0
- data/_layouts/portfolio.html +41 -0
- data/_layouts/post-sidebar.html +183 -0
- data/_layouts/post.html +338 -0
- data/_layouts/project.html +252 -0
- data/_layouts/research.html +504 -0
- data/_plugins/analytics_dashboard.rb +315 -0
- data/_plugins/config_validator.rb +397 -0
- data/_plugins/csp_generator.rb +115 -0
- data/_plugins/datalog_bibliography.rb +19 -0
- data/_plugins/datalog_comments.rb +18 -0
- data/_plugins/datalog_slides.rb +20 -0
- data/_plugins/front_matter_compat.rb +109 -0
- data/_plugins/i18n.rb +160 -0
- data/_plugins/image_optimizer.rb +445 -0
- data/_plugins/math_preprocessor.rb +185 -0
- data/_plugins/notebook_converter.rb +1107 -0
- data/_plugins/plugin_loader.rb +185 -0
- data/_plugins/publications_generator.rb +234 -0
- data/_plugins/reading_time.rb +15 -0
- data/_plugins/search_normalizer.rb +74 -0
- data/_plugins/sri_filter.rb +18 -0
- data/_plugins/toc_filter.rb +68 -0
- data/_plugins/warning_filter.rb +3 -0
- data/_sass/_base.scss +142 -0
- data/_sass/_components.scss +2380 -0
- data/_sass/_header.scss +470 -0
- data/_sass/_interactive.scss +344 -0
- data/_sass/_layout.scss +1760 -0
- data/_sass/_mathematical.scss +368 -0
- data/_sass/_mixins.scss +99 -0
- data/_sass/_package-docs.scss +715 -0
- data/_sass/_phase1-enhancements.scss +721 -0
- data/_sass/_phase3-enhancements.scss +874 -0
- data/_sass/_phase4-enhancements.scss +1214 -0
- data/_sass/_phase5-enhancements.scss +414 -0
- data/_sass/_search.scss +654 -0
- data/_sass/_syntax-highlighting.scss +128 -0
- data/_sass/_theme.scss +79 -0
- data/_sass/_typography.scss +148 -0
- data/_sass/_utilities.scss +334 -0
- data/_sass/_variables.scss +153 -0
- data/_sass/_visualizations.scss +242 -0
- data/assets/css/main.scss +4 -0
- data/assets/img/20220607123041_detail.001.png +0 -0
- data/assets/img/favicons/android-chrome-192x192.png +0 -0
- data/assets/img/favicons/android-chrome-512x512.png +0 -0
- data/assets/img/favicons/apple-touch-icon.png +0 -0
- data/assets/img/favicons/favicon-16x16.png +0 -0
- data/assets/img/favicons/favicon-32x32.png +0 -0
- data/assets/img/favicons/favicon.ico +0 -0
- data/assets/img/favicons/site.webmanifest +21 -0
- data/assets/img/portfolio-placeholder.svg +20 -0
- data/assets/js/academic.js +262 -0
- data/assets/js/analytics-dashboard.js +382 -0
- data/assets/js/core/dark-mode.js +79 -0
- data/assets/js/core/github-cards.js +123 -0
- data/assets/js/core/language-filter.js +69 -0
- data/assets/js/core/navigation.js +184 -0
- data/assets/js/core/scroll-progress.js +45 -0
- data/assets/js/core/search-hotkeys.js +62 -0
- data/assets/js/core/skip-links.js +62 -0
- data/assets/js/loader.js +163 -0
- data/assets/js/main.js +23 -0
- data/assets/js/math.js +818 -0
- data/assets/js/notebook.js +158 -0
- data/assets/js/search/analytics.js +91 -0
- data/assets/js/search/app.js +271 -0
- data/assets/js/search/autocomplete.js +120 -0
- data/assets/js/search/engine.js +260 -0
- data/assets/js/search/filters.js +38 -0
- data/assets/js/search/render.js +217 -0
- data/assets/js/search/utils.js +99 -0
- data/assets/js/search.js +354 -0
- data/assets/js/visualizations.js +816 -0
- data/assets/publications/datalog-publications.bib +8 -0
- data/assets/publications/datalog-publications.ris +9 -0
- data/assets/publications/publications.bib +30 -0
- data/assets/templates/diogo-ribeiro-cv.md +31 -0
- data/assets/templates/diogo-ribeiro-cv.tex +32 -0
- data/bin/datalog +26 -0
- data/datalog-theme.gemspec +72 -0
- data/lib/datalog/cli.rb +563 -0
- data/lib/datalog/plugin_system/dependency_resolver.rb +223 -0
- data/lib/datalog/plugin_system.rb +237 -0
- data/lib/datalog/plugins/citations.rb +182 -0
- data/lib/datalog/plugins/comments.rb +183 -0
- data/lib/datalog/plugins/search.rb +45 -0
- data/lib/datalog/plugins/slides.rb +121 -0
- data/lib/datalog/theme/theme.rb +18 -0
- data/lib/datalog/theme/version.rb +7 -0
- data/lib/datalog/warning_filter.rb +28 -0
- data/lib/datalog-theme.rb +17 -0
- 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
|