datalog-theme 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +127 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -2
- data/_data/i18n/en.yml +55 -3
- data/_data/i18n/es.yml +55 -3
- data/_data/i18n/pt.yml +55 -3
- data/_includes/analytics/dashboard.html +24 -9
- data/_includes/components/academic-dashboard.html +168 -133
- data/_includes/components/author-bio.html +7 -5
- data/_includes/components/author-list.html +6 -5
- data/_includes/components/citation-tools.html +2 -1
- data/_includes/components/content-provenance.html +19 -1
- data/_includes/components/correction-report.html +15 -8
- data/_includes/components/hero.html +22 -15
- data/_includes/components/package-index.html +121 -0
- data/_includes/components/package-install.html +27 -50
- data/_includes/components/post-list.html +14 -0
- data/_includes/components/responsive-image.html +15 -21
- data/_includes/footer.html +1 -1
- data/_includes/head.html +70 -11
- data/_includes/layouts/default/article.html +76 -46
- data/_includes/meta/dataset-json.html +171 -0
- data/_includes/meta/math-config.html +15 -1
- data/_includes/meta/package-json.html +69 -0
- data/_includes/meta/person-json.html +36 -13
- data/_includes/meta/publisher.html +15 -0
- data/_includes/meta/schema.html +108 -23
- data/_includes/meta/scholarly.html +10 -5
- data/_includes/post/related-posts.html +17 -43
- data/_includes/scripts.html +2 -2
- data/_includes/search/index-data.json +10 -1
- data/_includes/search/page.html +11 -6
- data/_layouts/archive.html +67 -0
- data/_layouts/dataset.html +2 -1
- data/_layouts/default.html +13 -11
- data/_layouts/docs.html +53 -0
- data/_layouts/home.html +30 -1
- data/_layouts/notebook.html +5 -1
- data/_layouts/package.html +38 -21
- data/_layouts/portfolio.html +1 -0
- data/_layouts/post.html +21 -62
- data/_layouts/project.html +2 -1
- data/_layouts/research.html +14 -2
- data/_plugins/analytics_dashboard.rb +4 -1
- data/_plugins/archive.rb +114 -0
- data/_plugins/authors.rb +50 -1
- data/_plugins/citation_exports.rb +12 -1
- data/_plugins/config_validator.rb +77 -2
- data/_plugins/correction_fallback.rb +110 -0
- data/_plugins/image_optimizer.rb +129 -22
- data/_plugins/math_preprocessor.rb +7 -48
- data/_plugins/notebook_converter.rb +44 -5
- data/_plugins/packages.rb +84 -0
- data/_plugins/page_dates.rb +37 -0
- data/_plugins/references.rb +16 -2
- data/_plugins/related_posts.rb +149 -0
- data/_plugins/search_sections.rb +110 -0
- data/_plugins/site_identity.rb +42 -0
- data/_plugins/social_cards.rb +17 -0
- data/_sass/_academic-dashboard.scss +18 -30
- data/_sass/_base.scss +2 -0
- data/_sass/_components.scss +21 -23
- data/_sass/_docs.scss +143 -0
- data/_sass/_features.scss +6 -0
- data/_sass/_layout.scss +65 -6
- data/_sass/_mathematical.scss +61 -19
- data/_sass/_notebooks.scss +1 -32
- data/_sass/_package-docs.scss +6 -9
- data/_sass/_post-components.scss +23 -23
- data/_sass/_print.scss +1 -1
- data/_sass/_search-page.scss +75 -0
- data/_sass/_search.scss +17 -26
- data/_sass/_syntax-highlighting.scss +23 -1
- data/_sass/_theme.scss +1 -0
- data/_sass/_utilities.scss +87 -40
- data/_sass/_variables.scss +31 -29
- data/assets/css/main.scss +2 -1
- data/assets/js/dist/academic.js +1 -1
- data/assets/js/dist/analytics-dashboard.js +1 -1
- data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
- data/assets/js/dist/comments.js +1 -1
- data/assets/js/dist/contact.js +1 -1
- data/assets/js/dist/core.js +1 -1
- data/assets/js/dist/corrections.js +5 -1
- data/assets/js/dist/loader.js +1 -1
- data/assets/js/dist/math.js +2 -1
- data/assets/js/dist/moderation.js +1 -1
- data/assets/js/dist/reactions.js +1 -1
- data/assets/js/dist/search.js +1 -1
- data/assets/js/dist/sources.json +20 -16
- data/assets/js/dist/subscriptions.js +1 -1
- data/assets/js/loader.js +6 -2
- data/lib/datalog/audit/checks.rb +269 -0
- data/lib/datalog/audit/known_keys.rb +61 -0
- data/lib/datalog/audit/site_reader.rb +118 -0
- data/lib/datalog/audit/source_file.rb +85 -0
- data/lib/datalog/audit.rb +145 -0
- data/lib/datalog/citations/bibtex.rb +200 -0
- data/lib/datalog/citations/entry.rb +333 -0
- data/lib/datalog/citations/markup.rb +79 -0
- data/lib/datalog/cli.rb +164 -89
- data/lib/datalog/critical_css.rb +126 -21
- data/lib/datalog/latex_speech/words.json +88 -0
- data/lib/datalog/latex_speech.rb +355 -0
- data/lib/datalog/packages/command.rb +51 -0
- data/lib/datalog/packages/refresh.rb +187 -0
- data/lib/datalog/packages.rb +177 -0
- data/lib/datalog/plugin_system.rb +5 -0
- data/lib/datalog/plugins/citations.rb +325 -109
- data/lib/datalog/site_config.rb +26 -0
- data/lib/datalog/social_cards/font.rb +286 -0
- data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
- data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
- data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
- data/lib/datalog/social_cards/geometry.rb +51 -0
- data/lib/datalog/social_cards/outline.rb +54 -0
- data/lib/datalog/social_cards/plain_text.rb +258 -0
- data/lib/datalog/social_cards/template.rb +322 -0
- data/lib/datalog/social_cards/template.svg +17 -0
- data/lib/datalog/social_cards/typesetter.rb +178 -0
- data/lib/datalog/social_cards.rb +369 -0
- data/lib/datalog/theme/updater.rb +307 -0
- data/lib/datalog/theme/version.rb +1 -1
- metadata +44 -7
- data/_includes/components/enhanced-code-block.html +0 -212
- data/_includes/components/performance-monitor.html +0 -170
- data/_includes/components/viz-table-fallback.html +0 -19
- data/_plugins/datalog_bibliography.rb +0 -21
- data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +0 -1
|
@@ -73,7 +73,21 @@ module Datalog
|
|
|
73
73
|
required: true,
|
|
74
74
|
schema: {
|
|
75
75
|
name: { type: :string, required: true },
|
|
76
|
-
email: { type: :string, format: :email }
|
|
76
|
+
email: { type: :string, format: :email },
|
|
77
|
+
# The `rel` of the author's profile links, `me noopener noreferrer` unless set.
|
|
78
|
+
profile_rel: { type: :string },
|
|
79
|
+
# What the JSON-LD Person says about them: one value or a list of them.
|
|
80
|
+
alternate_name: { type: %i[string array] }, alternate_names: { type: %i[array string] },
|
|
81
|
+
job_title: { type: %i[string array] }, roles: { type: %i[array string] },
|
|
82
|
+
knows_about: { type: %i[array string] }, same_as: { type: %i[array string] }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
# The homepage's WebSite node (meta/schema.html): true, or a map naming the site.
|
|
86
|
+
site_identity: {
|
|
87
|
+
type: %i[boolean hash],
|
|
88
|
+
schema: {
|
|
89
|
+
enabled: { type: :boolean }, name: { type: :string },
|
|
90
|
+
alternate_names: { type: %i[array string] }, alternate_name: { type: :string }
|
|
77
91
|
}
|
|
78
92
|
},
|
|
79
93
|
# Who publishes the site in structured data and citations. Without it, the author does.
|
|
@@ -145,6 +159,23 @@ module Datalog
|
|
|
145
159
|
timezone: { type: :string },
|
|
146
160
|
collections: { type: :hash },
|
|
147
161
|
plugins: { type: :array },
|
|
162
|
+
# `datalog audit`: front matter keys a site's own templates read, internal
|
|
163
|
+
# links it builds outside Jekyll, and when an edit calls for a revision.
|
|
164
|
+
audit: {
|
|
165
|
+
type: :hash,
|
|
166
|
+
schema: {
|
|
167
|
+
known_keys: { type: :array }, ignore_links: { type: :array }, revision_after_days: { type: :integer }
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
# In-text citations (the datalog-citations plugin): the style, and the
|
|
171
|
+
# bibliography a page uses when it names none of its own.
|
|
172
|
+
citations: {
|
|
173
|
+
type: :hash,
|
|
174
|
+
schema: {
|
|
175
|
+
style: { type: :string, enum: %w[numeric author-year] },
|
|
176
|
+
bibliography: { type: %i[string array] }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
148
179
|
features: {
|
|
149
180
|
type: :hash,
|
|
150
181
|
schema: {
|
|
@@ -177,14 +208,34 @@ module Datalog
|
|
|
177
208
|
style: { type: :string, enum: %w[compressed expanded] }
|
|
178
209
|
}
|
|
179
210
|
},
|
|
211
|
+
# `datalog critical-css` (lib/datalog/critical_css.rb).
|
|
212
|
+
critical_css: {
|
|
213
|
+
type: :hash,
|
|
214
|
+
schema: {
|
|
215
|
+
enabled: { type: :boolean },
|
|
216
|
+
engine: { type: :string, enum: %w[render static] },
|
|
217
|
+
dimensions: { type: :array },
|
|
218
|
+
pages: { type: :hash }
|
|
219
|
+
}
|
|
220
|
+
},
|
|
180
221
|
theme_options: {
|
|
181
222
|
type: :hash,
|
|
182
223
|
schema: {
|
|
224
|
+
color_scheme: {
|
|
225
|
+
type: :hash,
|
|
226
|
+
schema: {
|
|
227
|
+
default: { type: :string, enum: %w[dark light system] }
|
|
228
|
+
}
|
|
229
|
+
},
|
|
183
230
|
math: {
|
|
184
231
|
type: :hash,
|
|
185
232
|
schema: {
|
|
186
233
|
engine: { type: :string, enum: %w[mathjax katex] },
|
|
187
|
-
enabled: { type: :boolean }
|
|
234
|
+
enabled: { type: :boolean },
|
|
235
|
+
# A display equation set plain on its line, or in a framed card.
|
|
236
|
+
display_style: { type: :string, enum: %w[plain card] },
|
|
237
|
+
# Which display equations MathJax numbers (its tex.tags).
|
|
238
|
+
numbering: { type: :string, enum: %w[ams all none] }
|
|
188
239
|
}
|
|
189
240
|
},
|
|
190
241
|
# The "Reading mode" control on posts, and whether a reader's choice is kept.
|
|
@@ -205,6 +256,25 @@ module Datalog
|
|
|
205
256
|
highlights: { type: :boolean },
|
|
206
257
|
list_url: { type: :string }
|
|
207
258
|
}
|
|
259
|
+
},
|
|
260
|
+
# Where a post's author and editorial note goes: after the article, or before it.
|
|
261
|
+
provenance: {
|
|
262
|
+
type: :hash,
|
|
263
|
+
schema: {
|
|
264
|
+
position: { type: :string, enum: %w[end start] }
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
# A share card drawn for each page without an image of its own (lib/datalog/social_cards.rb).
|
|
268
|
+
social_cards: {
|
|
269
|
+
type: :hash,
|
|
270
|
+
schema: {
|
|
271
|
+
enabled: { type: :boolean },
|
|
272
|
+
scheme: { type: :string, enum: %w[dark light] },
|
|
273
|
+
background: { type: :string },
|
|
274
|
+
logo: { type: %i[string boolean] },
|
|
275
|
+
template: { type: :string },
|
|
276
|
+
collections: { type: :array }
|
|
277
|
+
}
|
|
208
278
|
}
|
|
209
279
|
}
|
|
210
280
|
}
|
|
@@ -224,6 +294,11 @@ module Datalog
|
|
|
224
294
|
"theme_options.math.enabled" => {
|
|
225
295
|
message: "Nothing reads it: theme_options.math.render_on_load decides which pages load the math engine, " \
|
|
226
296
|
"and a page's `math` front matter overrides that. Remove it."
|
|
297
|
+
},
|
|
298
|
+
# critical 9 dropped penthouse, and with it every option passed to it.
|
|
299
|
+
"critical_css.penthouse_options" => {
|
|
300
|
+
message: "`datalog critical-css` runs critical 9, which has no penthouse options, so these have no effect. " \
|
|
301
|
+
"Remove them."
|
|
227
302
|
}
|
|
228
303
|
}.freeze
|
|
229
304
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Datalog
|
|
6
|
+
# A plain link is useful even on a static site, before JavaScript enriches
|
|
7
|
+
# the report with the section and selected passage.
|
|
8
|
+
module CorrectionFallback
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
MAX_BODY = 900
|
|
12
|
+
MAX_TITLE = 180
|
|
13
|
+
MAX_URL = 1800
|
|
14
|
+
HOSTS = %w[github.com gitlab.com].freeze
|
|
15
|
+
EMAIL = /\A[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+\z/
|
|
16
|
+
|
|
17
|
+
def resolve(page, site, article_url, context)
|
|
18
|
+
settings = Authors.value(site, "corrections")
|
|
19
|
+
settings = {} unless settings.respond_to?(:[])
|
|
20
|
+
repository = repository_url(Authors.value(site, "repository"))
|
|
21
|
+
email = recipient(site)
|
|
22
|
+
mode = fallback_mode(settings, repository, email)
|
|
23
|
+
return unless (mode == "issue" && repository) || (mode == "email" && email)
|
|
24
|
+
|
|
25
|
+
title = Authors.value(page, "title").to_s.strip[0, MAX_TITLE]
|
|
26
|
+
subject = I18n.translate(context, "corrections.fallback.subject", "title" => title)
|
|
27
|
+
body_lines = [
|
|
28
|
+
"#{I18n.translate(context, 'corrections.fallback.article')}: #{title}",
|
|
29
|
+
"#{I18n.translate(context, 'corrections.fallback.url')}: #{article_url}"
|
|
30
|
+
]
|
|
31
|
+
minimum = body_lines.join("\n").length
|
|
32
|
+
body = (body_lines + ["", I18n.translate(context, "corrections.fallback.prompt")]).join("\n")
|
|
33
|
+
body = body[0, [MAX_BODY, minimum].max]
|
|
34
|
+
|
|
35
|
+
if mode == "email"
|
|
36
|
+
{ "kind" => mode, "body_param" => "body",
|
|
37
|
+
"href" => bounded_link("mailto:#{email}", { subject: subject, body: body }, :body, minimum) }
|
|
38
|
+
else
|
|
39
|
+
issue_link(repository, subject, body, minimum, settings)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def recipient(site)
|
|
44
|
+
email = Authors.value(site, "contact_email")
|
|
45
|
+
email = Authors.value(Authors.value(site, "author"), "email") if email.to_s.strip.empty?
|
|
46
|
+
email.to_s.strip if email.to_s.strip.match?(EMAIL)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fallback_mode(settings, repository, email)
|
|
50
|
+
requested = Authors.value(settings, "fallback").to_s
|
|
51
|
+
return requested unless requested.empty?
|
|
52
|
+
return "issue" if repository
|
|
53
|
+
return "email" if email
|
|
54
|
+
|
|
55
|
+
"none"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def issue_link(repository, subject, body, minimum, settings)
|
|
59
|
+
github = URI.parse(repository).host == "github.com"
|
|
60
|
+
title_key, body_key = github ? %w[title body] : ["issue[title]", "issue[description]"]
|
|
61
|
+
params = { title_key => subject, body_key => body }
|
|
62
|
+
labels = Authors.value(settings, "issue_labels") || ["correction"]
|
|
63
|
+
labels = Array(labels).map(&:to_s).map(&:strip).reject(&:empty?)
|
|
64
|
+
params[github ? "labels" : "issue[label_names]"] = labels.join(",") unless labels.empty?
|
|
65
|
+
path = github ? "/issues/new" : "/-/issues/new"
|
|
66
|
+
{ "kind" => "issue", "body_param" => body_key,
|
|
67
|
+
"href" => bounded_link("#{repository}#{path}", params, body_key, minimum) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def bounded_link(destination, params, body_key, minimum)
|
|
71
|
+
address = "#{destination}?#{URI.encode_www_form(params)}"
|
|
72
|
+
while address.bytesize > MAX_URL && params[body_key].length > minimum
|
|
73
|
+
params[body_key] = params[body_key][0, [params[body_key].length - 20, minimum].max]
|
|
74
|
+
address = "#{destination}?#{URI.encode_www_form(params)}"
|
|
75
|
+
end
|
|
76
|
+
address
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def repository_url(value)
|
|
80
|
+
uri = URI.parse(value.to_s.strip)
|
|
81
|
+
return unless valid_repository_uri?(uri)
|
|
82
|
+
|
|
83
|
+
parts = uri.path.to_s.sub(%r{/$}, "").split("/").reject(&:empty?)
|
|
84
|
+
return unless valid_repository_parts?(parts, uri.host)
|
|
85
|
+
|
|
86
|
+
"https://#{uri.host.downcase}/#{parts.join('/').sub(/\.git\z/, '')}"
|
|
87
|
+
rescue URI::InvalidURIError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def valid_repository_uri?(uri)
|
|
92
|
+
uri.scheme == "https" && HOSTS.include?(uri.host&.downcase) && uri.port == 443 &&
|
|
93
|
+
!uri.userinfo && !uri.query && !uri.fragment
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def valid_repository_parts?(parts, host)
|
|
97
|
+
return false unless parts.length >= 2 && (host.downcase == "gitlab.com" || parts.length == 2)
|
|
98
|
+
|
|
99
|
+
parts.all? { |part| part.match?(/\A[a-zA-Z0-9_.-]+\z/) && !%w[. ..].include?(part) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
module CorrectionFallbackFilters
|
|
104
|
+
def correction_fallback(page, article_url)
|
|
105
|
+
CorrectionFallback.resolve(page, @context["site"], article_url, @context)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Liquid::Template.register_filter(Datalog::CorrectionFallbackFilters)
|
data/_plugins/image_optimizer.rb
CHANGED
|
@@ -35,6 +35,12 @@ module Jekyll
|
|
|
35
35
|
CODERS = { "jpeg" => "JPEG", "png" => "PNG", "webp" => "WEBP" }.freeze
|
|
36
36
|
CACHE_DIR = "datalog-images"
|
|
37
37
|
|
|
38
|
+
# The file-name suffix that marks a dark twin, and the query the markup
|
|
39
|
+
# asks the browser. The site's script narrows this when a reader has used
|
|
40
|
+
# the theme toggle, whose choice the operating system does not know about.
|
|
41
|
+
DEFAULT_DARK_SUFFIX = "-dark"
|
|
42
|
+
DARK_MEDIA = "(prefers-color-scheme: dark)"
|
|
43
|
+
|
|
38
44
|
# The one image a page fetches early: the first in its post or page content,
|
|
39
45
|
# unless the author made it lazy or the page already preloads an image, as
|
|
40
46
|
# the hero does. The first <img> anywhere used to be marked both lazy and
|
|
@@ -80,23 +86,18 @@ module Jekyll
|
|
|
80
86
|
normalized_src = normalize_src(img["src"], site)
|
|
81
87
|
picture_entry = manifest[normalized_src]
|
|
82
88
|
|
|
89
|
+
dark = dark_twin(img, picture_entry, manifest, site)
|
|
90
|
+
offer_variants(img, picture_entry, dark, image_config, baseurl) if picture_entry || dark
|
|
91
|
+
|
|
83
92
|
if picture_entry
|
|
84
|
-
offer_variants(img, picture_entry, image_config, baseurl)
|
|
85
93
|
if !img["width"] && !img["height"] && picture_entry["width"] && picture_entry["height"]
|
|
86
94
|
img["width"] = picture_entry["width"].to_s
|
|
87
95
|
img["height"] = picture_entry["height"].to_s
|
|
88
96
|
end
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
next unless source && File.exist?(source)
|
|
94
|
-
|
|
95
|
-
width, height = FastImage.size(source)
|
|
96
|
-
next unless width && height
|
|
97
|
-
|
|
98
|
-
img["width"] ||= width.to_s
|
|
99
|
-
img["height"] ||= height.to_s
|
|
97
|
+
elsif (size = unlisted_size(img, site, normalized_src))
|
|
98
|
+
img["width"], img["height"] = size.map(&:to_s)
|
|
99
|
+
elsif dark.nil?
|
|
100
|
+
next
|
|
100
101
|
end
|
|
101
102
|
optimized = true
|
|
102
103
|
rescue StandardError => e
|
|
@@ -110,21 +111,31 @@ module Jekyll
|
|
|
110
111
|
|
|
111
112
|
# Offers an image's generated variants: the resized copies as the <img>'s
|
|
112
113
|
# srcset, and each modern format as a <source> in a <picture> around it.
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
114
|
+
# An image with a dark companion is wrapped for that alone, even when the
|
|
115
|
+
# pipeline generated nothing else for it. Markup the author chose, a srcset
|
|
116
|
+
# of their own or a <picture>, is left alone, and so is an image with
|
|
117
|
+
# nothing generated besides the original.
|
|
118
|
+
def offer_variants(img, entry, dark, image_config, baseurl)
|
|
116
119
|
return if img["srcset"] || img.parent&.name == "picture"
|
|
117
120
|
|
|
118
|
-
fallback = entry
|
|
119
|
-
sources = entry.fetch("sources", []).select { |source| source["type"] && source["variants"]&.any? }
|
|
120
|
-
return if fallback.size < 2 && sources.empty?
|
|
121
|
+
fallback = entry&.dig("fallback", "variants") || []
|
|
122
|
+
sources = (entry || {}).fetch("sources", []).select { |source| source["type"] && source["variants"]&.any? }
|
|
123
|
+
return if fallback.size < 2 && sources.empty? && dark.nil?
|
|
121
124
|
|
|
122
125
|
sizes = img["sizes"] || image_config.fetch("default_sizes", "100vw")
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
+
# `sizes` without a `srcset` on the <img> says nothing; the <source>
|
|
127
|
+
# elements carry their own.
|
|
128
|
+
if fallback.size > 1
|
|
129
|
+
img["srcset"] = build_srcset(fallback, baseurl)
|
|
130
|
+
img["sizes"] = sizes
|
|
131
|
+
end
|
|
132
|
+
dark_first = dark_elements(img, dark, sizes, baseurl)
|
|
133
|
+
return if sources.empty? && dark_first.empty?
|
|
126
134
|
|
|
127
135
|
picture = Nokogiri::XML::Node.new("picture", img.document)
|
|
136
|
+
# A <picture> takes the first source whose media and type both match, so
|
|
137
|
+
# the dark ones come first or they would never be reached.
|
|
138
|
+
dark_first.each { |element| picture.add_child(element) }
|
|
128
139
|
sources.each do |source|
|
|
129
140
|
element = Nokogiri::XML::Node.new("source", img.document)
|
|
130
141
|
element["type"] = source["type"]
|
|
@@ -136,6 +147,59 @@ module Jekyll
|
|
|
136
147
|
picture.add_child(img)
|
|
137
148
|
end
|
|
138
149
|
|
|
150
|
+
# The dark companion of an image: the one the author named with `dark_src`,
|
|
151
|
+
# or the twin the build found beside it. A named companion is looked up in
|
|
152
|
+
# the manifest, so it is served with the same responsive variants as any
|
|
153
|
+
# other image; one the pipeline never saw, an SVG or an image on another
|
|
154
|
+
# host, is carried as the single file it is.
|
|
155
|
+
def dark_twin(img, entry, manifest, site)
|
|
156
|
+
named = img["data-dark-src"]
|
|
157
|
+
return entry && entry["dark"] unless named
|
|
158
|
+
|
|
159
|
+
img.remove_attribute("data-dark-src")
|
|
160
|
+
manifest[normalize_src(named, site)] || { "url" => named }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The companion's <source> elements: its modern formats, then its own, so a
|
|
164
|
+
# browser that supports neither AVIF nor WebP still gets the dark image
|
|
165
|
+
# rather than the light one.
|
|
166
|
+
def dark_elements(img, dark, sizes, baseurl)
|
|
167
|
+
return [] unless dark
|
|
168
|
+
return [dark_element(img, nil, dark["url"], sizes)] if dark["url"]
|
|
169
|
+
|
|
170
|
+
sources = dark.fetch("sources", []).select { |source| source["type"] && source["variants"]&.any? }
|
|
171
|
+
fallback = dark["fallback"]
|
|
172
|
+
sources += [fallback] if fallback && fallback["variants"]&.any?
|
|
173
|
+
sources.map do |source|
|
|
174
|
+
dark_element(img, source["type"], build_srcset(source["variants"], baseurl), sizes)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# `data-dark-source` marks it for the toggle: a reader whose system is
|
|
179
|
+
# light and who has switched the site to dark needs the media query
|
|
180
|
+
# overruled, and the script has to know which sources to overrule.
|
|
181
|
+
def dark_element(img, type, srcset, sizes)
|
|
182
|
+
element = Nokogiri::XML::Node.new("source", img.document)
|
|
183
|
+
element["media"] = DARK_MEDIA
|
|
184
|
+
element["type"] = type if type
|
|
185
|
+
element["srcset"] = srcset
|
|
186
|
+
element["sizes"] = sizes
|
|
187
|
+
element["data-dark-source"] = ""
|
|
188
|
+
element
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The real size of an image the variant pipeline did not touch, read from
|
|
192
|
+
# the file, so the page does not shift while it loads. An image the author
|
|
193
|
+
# gave a width or a height has a deliberate aspect ratio and keeps it.
|
|
194
|
+
def unlisted_size(img, site, src)
|
|
195
|
+
return if img["width"] || img["height"]
|
|
196
|
+
|
|
197
|
+
source = image_source_path(site, src)
|
|
198
|
+
return unless source && File.exist?(source)
|
|
199
|
+
|
|
200
|
+
FastImage.size(source)
|
|
201
|
+
end
|
|
202
|
+
|
|
139
203
|
def prepare_site(site)
|
|
140
204
|
config = image_config(site)
|
|
141
205
|
encoders = config["variants"] ? tools : {}
|
|
@@ -149,11 +213,37 @@ module Jekyll
|
|
|
149
213
|
manifest[entry.delete("source")] = entry
|
|
150
214
|
end
|
|
151
215
|
|
|
216
|
+
link_dark_variants(manifest, config)
|
|
152
217
|
site.data["datalog_responsive_images"] = manifest
|
|
153
218
|
site.config["datalog_image_config"] = config
|
|
154
219
|
report(encoders, counts)
|
|
155
220
|
end
|
|
156
221
|
|
|
222
|
+
# A plot exported for a white page is unreadable on a dark one. An author
|
|
223
|
+
# exports a second file beside the first — power.png and power-dark.png —
|
|
224
|
+
# and every image that has such a twin carries it in the manifest, so the
|
|
225
|
+
# markup can offer both and the browser can choose.
|
|
226
|
+
#
|
|
227
|
+
# The twin is an image in its own right, so it already has its own entry
|
|
228
|
+
# with its own variants; this only records which entry belongs to which.
|
|
229
|
+
def link_dark_variants(manifest, config)
|
|
230
|
+
suffix = config.fetch("dark_suffix", DEFAULT_DARK_SUFFIX).to_s
|
|
231
|
+
return if suffix.empty?
|
|
232
|
+
|
|
233
|
+
manifest.each do |src, entry|
|
|
234
|
+
extension = File.extname(src)
|
|
235
|
+
next if extension.empty?
|
|
236
|
+
# The twin of a twin is not a thing.
|
|
237
|
+
next if File.basename(src, extension).end_with?(suffix)
|
|
238
|
+
|
|
239
|
+
twin = "#{src.delete_suffix(extension)}#{suffix}#{extension}"
|
|
240
|
+
dark = manifest[twin]
|
|
241
|
+
next unless dark
|
|
242
|
+
|
|
243
|
+
entry["dark"] = { "source" => twin, "sources" => dark["sources"], "fallback" => dark["fallback"] }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
157
247
|
def report(encoders, counts)
|
|
158
248
|
if counts[:created].positive? || counts[:cached].positive?
|
|
159
249
|
Jekyll.logger.info "Images:", "#{counts[:created]} variants created, #{counts[:cached]} reused from the cache"
|
|
@@ -469,7 +559,10 @@ module Jekyll
|
|
|
469
559
|
"sizes" => options["sizes"] || options["breakpoints"] || DEFAULT_SIZES,
|
|
470
560
|
"quality" => qualities,
|
|
471
561
|
"lazy_loading" => options["lazy_loading"] || "lazy",
|
|
472
|
-
"default_sizes" => options["default_sizes"] || "100vw"
|
|
562
|
+
"default_sizes" => options["default_sizes"] || "100vw",
|
|
563
|
+
# An empty suffix turns the convention off, for a site whose file names
|
|
564
|
+
# end in "-dark" for some other reason.
|
|
565
|
+
"dark_suffix" => options.fetch("dark_suffix", DEFAULT_DARK_SUFFIX).to_s
|
|
473
566
|
}
|
|
474
567
|
end
|
|
475
568
|
end
|
|
@@ -491,6 +584,20 @@ class Jekyll::ResponsiveImageStaticFile < Jekyll::StaticFile
|
|
|
491
584
|
end
|
|
492
585
|
end
|
|
493
586
|
|
|
587
|
+
# The srcset of a list of manifest variants, for markup built in Liquid.
|
|
588
|
+
# `responsive-image.html` wrote its own, three times over, and it had to keep
|
|
589
|
+
# in step with the one the optimizer writes for every other image.
|
|
590
|
+
module Jekyll
|
|
591
|
+
module ImageSrcsetFilter
|
|
592
|
+
def image_srcset(variants)
|
|
593
|
+
site = @context.registers[:site]
|
|
594
|
+
Jekyll::ImageOptimizer.build_srcset(Array(variants), site&.config&.fetch("baseurl", "").to_s)
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
Liquid::Template.register_filter(Jekyll::ImageSrcsetFilter)
|
|
600
|
+
|
|
494
601
|
class Jekyll::ImageOptimizerGenerator < Jekyll::Generator
|
|
495
602
|
safe true
|
|
496
603
|
priority :low
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "cgi"
|
|
4
4
|
require "kramdown"
|
|
5
5
|
require "kramdown-parser-gfm"
|
|
6
|
+
require_relative "../lib/datalog/latex_speech"
|
|
6
7
|
|
|
7
8
|
module MathPreprocessor
|
|
8
9
|
DISPLAY_PATTERNS = [
|
|
@@ -215,55 +216,13 @@ module MathPreprocessor
|
|
|
215
216
|
latex.to_s.strip
|
|
216
217
|
end
|
|
217
218
|
|
|
219
|
+
# The words the expression reads as (lib/datalog/latex_speech.rb), as the
|
|
220
|
+
# browser reads one the build didn't wrap. Every command is read, or left out
|
|
221
|
+
# when it isn't part of what the expression says; this used to drop each command it had no rule
|
|
222
|
+
# for, so "c \in (a, b)" was labelled "c (a, b)" (#417).
|
|
218
223
|
def auto_alt_text(latex)
|
|
219
|
-
text = latex
|
|
220
|
-
|
|
221
|
-
text.gsub!(/\\frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/) do
|
|
222
|
-
numerator = sanitize_segment(Regexp.last_match[1])
|
|
223
|
-
denominator = sanitize_segment(Regexp.last_match[2])
|
|
224
|
-
"#{numerator} over #{denominator}"
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
text.gsub!(/\\int(?:_\{([^}]*)\}|_([^\s^{}]+))?(?:\^\{([^}]*)\}|\^([^\s_{}]+))?/) do
|
|
228
|
-
lower = Regexp.last_match[1] || Regexp.last_match[2]
|
|
229
|
-
upper = Regexp.last_match[3] || Regexp.last_match[4]
|
|
230
|
-
phrase = "integral"
|
|
231
|
-
phrase += " from #{sanitize_segment(lower)}" if lower && !lower.empty?
|
|
232
|
-
phrase += " to #{sanitize_segment(upper)}" if upper && !upper.empty?
|
|
233
|
-
phrase
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
text.gsub!(/\\sum(?:_\{([^}]*)\}|_([^\s^{}]+))?(?:\^\{([^}]*)\}|\^([^\s_{}]+))?/) do
|
|
237
|
-
lower = Regexp.last_match[1] || Regexp.last_match[2]
|
|
238
|
-
upper = Regexp.last_match[3] || Regexp.last_match[4]
|
|
239
|
-
phrase = "summation"
|
|
240
|
-
phrase += " from #{sanitize_segment(lower)}" if lower && !lower.empty?
|
|
241
|
-
phrase += " to #{sanitize_segment(upper)}" if upper && !upper.empty?
|
|
242
|
-
phrase
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
text.gsub!(/\\sqrt\s*\{([^{}]+)\}/) do
|
|
246
|
-
"square root of #{sanitize_segment(Regexp.last_match[1])}"
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
text.gsub!(/\\mathrm\s*\{([^{}]+)\}/) { sanitize_segment(Regexp.last_match[1]) }
|
|
250
|
-
text.gsub!(/\\operatorname\*?\s*\{([^{}]+)\}/) { sanitize_segment(Regexp.last_match[1]) }
|
|
251
|
-
text.gsub!(/\\[a-zA-Z]+\s*/m, " ")
|
|
252
|
-
text.gsub!(/[{}]/, " ")
|
|
253
|
-
text.gsub!(/\s+/, " ")
|
|
254
|
-
text = text.strip
|
|
255
|
-
|
|
256
|
-
return "Mathematical expression" if text.empty?
|
|
257
|
-
|
|
258
|
-
text
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def sanitize_segment(segment)
|
|
262
|
-
return "" unless segment
|
|
263
|
-
|
|
264
|
-
cleaned = segment.gsub(/\\[a-zA-Z]+/, " ")
|
|
265
|
-
cleaned = cleaned.gsub(/[{}]/, " ")
|
|
266
|
-
cleaned.gsub(/\s+/, " ").strip
|
|
224
|
+
text = Datalog::LatexSpeech.speak(latex)
|
|
225
|
+
text.empty? ? "Mathematical expression" : text
|
|
267
226
|
end
|
|
268
227
|
end
|
|
269
228
|
|
|
@@ -59,8 +59,10 @@ module Datalog
|
|
|
59
59
|
cells = Array(notebook["cells"])
|
|
60
60
|
return if cells.empty?
|
|
61
61
|
|
|
62
|
+
opening = cells.index { |cell| cell["cell_type"] == "markdown" && !Array(cell["source"]).join.strip.empty? }
|
|
62
63
|
fragments = cells.each_with_index.filter_map do |cell, index|
|
|
63
64
|
source = Array(cell["source"]).join
|
|
65
|
+
source = without_title_heading(source, metadata) if index == opening
|
|
64
66
|
next if source.strip.empty?
|
|
65
67
|
|
|
66
68
|
case cell["cell_type"]
|
|
@@ -98,10 +100,45 @@ module Datalog
|
|
|
98
100
|
%(<section class="notebook-cell notebook-cell--markdown">\n#{demote_headings(sanitized.to_s)}\n</section>)
|
|
99
101
|
end
|
|
100
102
|
|
|
101
|
-
# The notebook layout
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
103
|
+
# The notebook layout writes the page's title as its <h1>, and a notebook
|
|
104
|
+
# usually opens with the same title as a heading, which showed straight
|
|
105
|
+
# under it a second time (#416). The first markdown cell loses a heading it
|
|
106
|
+
# opens with when the heading's text is the title. A heading that says
|
|
107
|
+
# something else stays, and so does the rest of the cell.
|
|
108
|
+
def without_title_heading(source, metadata)
|
|
109
|
+
title = metadata.is_a?(Hash) ? metadata[:title] || metadata["title"] : nil
|
|
110
|
+
wanted = title.to_s.split.join(" ")
|
|
111
|
+
return source if wanted.empty?
|
|
112
|
+
|
|
113
|
+
lines = source.lines
|
|
114
|
+
first = lines.index { |line| !line.strip.empty? }
|
|
115
|
+
return source unless first
|
|
116
|
+
|
|
117
|
+
texts, taken = heading_at(lines[first].chomp, lines[first + 1].to_s.chomp)
|
|
118
|
+
return source unless texts&.any? { |text| text.casecmp?(wanted) }
|
|
119
|
+
|
|
120
|
+
lines.drop(first + taken).join
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The texts a line can be read as a heading with, as the site's Markdown
|
|
124
|
+
# (kramdown, GFM) reads it, and how many lines the heading takes. An ATX
|
|
125
|
+
# heading starts the line and may end with #s, which kramdown leaves out
|
|
126
|
+
# and a title taken from the heading keeps. A setext heading's text may be
|
|
127
|
+
# indented up to three spaces, its underline starts the line, and its #s
|
|
128
|
+
# are text. Anything indented further is code, and is never a heading.
|
|
129
|
+
def heading_at(line, underline)
|
|
130
|
+
if line.match?(/\A\#{1,6}(?:[ \t]|\z)/)
|
|
131
|
+
words = line.sub(/\A#+/, "").split
|
|
132
|
+
texts = [words.join(" ")]
|
|
133
|
+
texts << words[0...-1].join(" ") if words.last&.match?(/\A#+\z/)
|
|
134
|
+
[texts, 1]
|
|
135
|
+
elsif line.match?(/\A {0,3}\S/) && underline.match?(/\A(?:=+|-+)[ \t]*\z/)
|
|
136
|
+
[[line.split.join(" ")], 2]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Each heading left in a cell moves down a level (h1 to h2, and so on to
|
|
141
|
+
# h6), which keeps one <h1> on the page and the cells' own outline under it.
|
|
105
142
|
def demote_headings(html)
|
|
106
143
|
html.gsub(%r{<(/?)h([1-5])(?=[\s>])}i) { "<#{Regexp.last_match(1)}h#{Regexp.last_match(2).to_i + 1}" }
|
|
107
144
|
end
|
|
@@ -782,8 +819,10 @@ module Jekyll
|
|
|
782
819
|
language = kernelspec["language"] || language_info["name"]
|
|
783
820
|
|
|
784
821
|
execution_meta = meta["execution_info"] || meta["execution"] || meta.dig("datalog", "execution") || {}
|
|
822
|
+
# The time the notebook records, or none. The file's own time is the
|
|
823
|
+
# checkout's: a fresh clone executed and published every notebook at
|
|
824
|
+
# the moment it was cloned (#411).
|
|
785
825
|
executed_at = parse_time(execution_meta["finished_at"] || execution_meta["timestamp"] || meta["modified"])
|
|
786
|
-
executed_at ||= File.mtime(absolute_path)
|
|
787
826
|
duration = format_duration(execution_meta["duration"] || meta.dig("datalog", "duration"))
|
|
788
827
|
|
|
789
828
|
counts = count_cells(cells)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../lib/datalog/packages"
|
|
4
|
+
require_relative "licenses"
|
|
5
|
+
|
|
6
|
+
module Datalog
|
|
7
|
+
# Liquid's view of a package page: the registries its front matter names
|
|
8
|
+
# (lib/datalog/packages.rb), and its latest release as
|
|
9
|
+
# `datalog packages refresh` wrote it to _data/package_releases.yml, which
|
|
10
|
+
# wins over the `version` and `license` typed in front matter.
|
|
11
|
+
module PackageFilters
|
|
12
|
+
# What each registry's language requirement is called on the page.
|
|
13
|
+
REQUIREMENTS = {
|
|
14
|
+
"requires_python" => "Python", "rust_version" => "Rust", "required_ruby_version" => "Ruby",
|
|
15
|
+
"r_version" => "R", "node_version" => "Node.js"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# [{"registry", "label", "url"}]: the registry pages, docs.rs for a crate.
|
|
19
|
+
def package_links(page)
|
|
20
|
+
Packages.links(PackageFilters.data(page))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The install panel's tabs. The options are the include's parameters,
|
|
24
|
+
# which win over the front matter: language, name, git_url, and pip,
|
|
25
|
+
# conda and cran to replace that tab's command.
|
|
26
|
+
def package_install_tabs(page, options = {})
|
|
27
|
+
data = PackageFilters.data(page)
|
|
28
|
+
options = options.is_a?(Hash) ? options.transform_keys(&:to_s) : {}
|
|
29
|
+
Packages.install_tabs(data, language: options["language"] || data["language"],
|
|
30
|
+
git_url: options["git_url"] || data["github_url"],
|
|
31
|
+
name: options["name"] || data["title"],
|
|
32
|
+
overrides: options.slice("pip", "conda", "cran"))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# {"version", "released", "license", "license_url", "requirement",
|
|
36
|
+
# "prerelease", "yanked", "registry", "status"}: from the data file, for
|
|
37
|
+
# the first of the page's registries it has, else from the front matter.
|
|
38
|
+
def package_release(page)
|
|
39
|
+
data = PackageFilters.data(page)
|
|
40
|
+
named = Packages.registries(data)
|
|
41
|
+
releases = @context["site"]["data"]["package_releases"]
|
|
42
|
+
releases = {} unless releases.is_a?(Hash)
|
|
43
|
+
registry, name = named.find { |key, value| releases[key].is_a?(Hash) && releases[key][value].is_a?(Hash) }
|
|
44
|
+
release = registry ? releases[registry][name].transform_keys(&:to_s) : {}
|
|
45
|
+
release["registry"] = registry
|
|
46
|
+
release["version"] = (release["version"] || data["version"])&.to_s
|
|
47
|
+
unless release.key?("prerelease")
|
|
48
|
+
release["prerelease"] = Packages.prerelease?(release["version"], registry || named.keys.first)
|
|
49
|
+
end
|
|
50
|
+
license(release, data)
|
|
51
|
+
release["requirement"] = requirement(release)
|
|
52
|
+
release["status"] = data["status"]&.to_s
|
|
53
|
+
release.compact.reject { |_key, value| value == "" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.data(page)
|
|
57
|
+
page.respond_to?(:[]) && !page.is_a?(String) ? page : {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# The registry's licence, else the front matter's; and the licence's
|
|
63
|
+
# address when the front matter resolves the same one, for the JSON-LD.
|
|
64
|
+
# A `license:` map shows its name, and `license: false` shows none.
|
|
65
|
+
def license(release, data)
|
|
66
|
+
given = Licenses.content(data, @context["site"])
|
|
67
|
+
own = data["license"].is_a?(String) ? data["license"].strip : given&.dig("name")
|
|
68
|
+
release["license"] = (release["license"] || own)&.to_s
|
|
69
|
+
same = given && [given["id"], given["name"], own].compact.include?(release["license"])
|
|
70
|
+
release["license_url"] = given["url"] if same
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def requirement(release)
|
|
74
|
+
key = REQUIREMENTS.keys.find { |name| release[name] }
|
|
75
|
+
return nil unless key
|
|
76
|
+
|
|
77
|
+
value = release[key].to_s
|
|
78
|
+
value = ">= #{value}" if value.match?(/\A\d/)
|
|
79
|
+
"#{REQUIREMENTS[key]} #{value}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Liquid::Template.register_filter(Datalog::PackageFilters)
|