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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +4 -2
  5. data/_data/i18n/en.yml +55 -3
  6. data/_data/i18n/es.yml +55 -3
  7. data/_data/i18n/pt.yml +55 -3
  8. data/_includes/analytics/dashboard.html +24 -9
  9. data/_includes/components/academic-dashboard.html +168 -133
  10. data/_includes/components/author-bio.html +7 -5
  11. data/_includes/components/author-list.html +6 -5
  12. data/_includes/components/citation-tools.html +2 -1
  13. data/_includes/components/content-provenance.html +19 -1
  14. data/_includes/components/correction-report.html +15 -8
  15. data/_includes/components/hero.html +22 -15
  16. data/_includes/components/package-index.html +121 -0
  17. data/_includes/components/package-install.html +27 -50
  18. data/_includes/components/post-list.html +14 -0
  19. data/_includes/components/responsive-image.html +15 -21
  20. data/_includes/footer.html +1 -1
  21. data/_includes/head.html +70 -11
  22. data/_includes/layouts/default/article.html +76 -46
  23. data/_includes/meta/dataset-json.html +171 -0
  24. data/_includes/meta/math-config.html +15 -1
  25. data/_includes/meta/package-json.html +69 -0
  26. data/_includes/meta/person-json.html +36 -13
  27. data/_includes/meta/publisher.html +15 -0
  28. data/_includes/meta/schema.html +108 -23
  29. data/_includes/meta/scholarly.html +10 -5
  30. data/_includes/post/related-posts.html +17 -43
  31. data/_includes/scripts.html +2 -2
  32. data/_includes/search/index-data.json +10 -1
  33. data/_includes/search/page.html +11 -6
  34. data/_layouts/archive.html +67 -0
  35. data/_layouts/dataset.html +2 -1
  36. data/_layouts/default.html +13 -11
  37. data/_layouts/docs.html +53 -0
  38. data/_layouts/home.html +30 -1
  39. data/_layouts/notebook.html +5 -1
  40. data/_layouts/package.html +38 -21
  41. data/_layouts/portfolio.html +1 -0
  42. data/_layouts/post.html +21 -62
  43. data/_layouts/project.html +2 -1
  44. data/_layouts/research.html +14 -2
  45. data/_plugins/analytics_dashboard.rb +4 -1
  46. data/_plugins/archive.rb +114 -0
  47. data/_plugins/authors.rb +50 -1
  48. data/_plugins/citation_exports.rb +12 -1
  49. data/_plugins/config_validator.rb +77 -2
  50. data/_plugins/correction_fallback.rb +110 -0
  51. data/_plugins/image_optimizer.rb +129 -22
  52. data/_plugins/math_preprocessor.rb +7 -48
  53. data/_plugins/notebook_converter.rb +44 -5
  54. data/_plugins/packages.rb +84 -0
  55. data/_plugins/page_dates.rb +37 -0
  56. data/_plugins/references.rb +16 -2
  57. data/_plugins/related_posts.rb +149 -0
  58. data/_plugins/search_sections.rb +110 -0
  59. data/_plugins/site_identity.rb +42 -0
  60. data/_plugins/social_cards.rb +17 -0
  61. data/_sass/_academic-dashboard.scss +18 -30
  62. data/_sass/_base.scss +2 -0
  63. data/_sass/_components.scss +21 -23
  64. data/_sass/_docs.scss +143 -0
  65. data/_sass/_features.scss +6 -0
  66. data/_sass/_layout.scss +65 -6
  67. data/_sass/_mathematical.scss +61 -19
  68. data/_sass/_notebooks.scss +1 -32
  69. data/_sass/_package-docs.scss +6 -9
  70. data/_sass/_post-components.scss +23 -23
  71. data/_sass/_print.scss +1 -1
  72. data/_sass/_search-page.scss +75 -0
  73. data/_sass/_search.scss +17 -26
  74. data/_sass/_syntax-highlighting.scss +23 -1
  75. data/_sass/_theme.scss +1 -0
  76. data/_sass/_utilities.scss +87 -40
  77. data/_sass/_variables.scss +31 -29
  78. data/assets/css/main.scss +2 -1
  79. data/assets/js/dist/academic.js +1 -1
  80. data/assets/js/dist/analytics-dashboard.js +1 -1
  81. data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
  82. data/assets/js/dist/comments.js +1 -1
  83. data/assets/js/dist/contact.js +1 -1
  84. data/assets/js/dist/core.js +1 -1
  85. data/assets/js/dist/corrections.js +5 -1
  86. data/assets/js/dist/loader.js +1 -1
  87. data/assets/js/dist/math.js +2 -1
  88. data/assets/js/dist/moderation.js +1 -1
  89. data/assets/js/dist/reactions.js +1 -1
  90. data/assets/js/dist/search.js +1 -1
  91. data/assets/js/dist/sources.json +20 -16
  92. data/assets/js/dist/subscriptions.js +1 -1
  93. data/assets/js/loader.js +6 -2
  94. data/lib/datalog/audit/checks.rb +269 -0
  95. data/lib/datalog/audit/known_keys.rb +61 -0
  96. data/lib/datalog/audit/site_reader.rb +118 -0
  97. data/lib/datalog/audit/source_file.rb +85 -0
  98. data/lib/datalog/audit.rb +145 -0
  99. data/lib/datalog/citations/bibtex.rb +200 -0
  100. data/lib/datalog/citations/entry.rb +333 -0
  101. data/lib/datalog/citations/markup.rb +79 -0
  102. data/lib/datalog/cli.rb +164 -89
  103. data/lib/datalog/critical_css.rb +126 -21
  104. data/lib/datalog/latex_speech/words.json +88 -0
  105. data/lib/datalog/latex_speech.rb +355 -0
  106. data/lib/datalog/packages/command.rb +51 -0
  107. data/lib/datalog/packages/refresh.rb +187 -0
  108. data/lib/datalog/packages.rb +177 -0
  109. data/lib/datalog/plugin_system.rb +5 -0
  110. data/lib/datalog/plugins/citations.rb +325 -109
  111. data/lib/datalog/site_config.rb +26 -0
  112. data/lib/datalog/social_cards/font.rb +286 -0
  113. data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
  114. data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
  115. data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
  116. data/lib/datalog/social_cards/geometry.rb +51 -0
  117. data/lib/datalog/social_cards/outline.rb +54 -0
  118. data/lib/datalog/social_cards/plain_text.rb +258 -0
  119. data/lib/datalog/social_cards/template.rb +322 -0
  120. data/lib/datalog/social_cards/template.svg +17 -0
  121. data/lib/datalog/social_cards/typesetter.rb +178 -0
  122. data/lib/datalog/social_cards.rb +369 -0
  123. data/lib/datalog/theme/updater.rb +307 -0
  124. data/lib/datalog/theme/version.rb +1 -1
  125. metadata +44 -7
  126. data/_includes/components/enhanced-code-block.html +0 -212
  127. data/_includes/components/performance-monitor.html +0 -170
  128. data/_includes/components/viz-table-fallback.html +0 -19
  129. data/_plugins/datalog_bibliography.rb +0 -21
  130. 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)
@@ -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
- else
90
- next if img["width"] || img["height"]
91
-
92
- source = image_source_path(site, normalized_src)
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
- # Markup the author chose, a srcset of their own or a <picture>, is left
114
- # alone, and so is an image with nothing generated besides the original.
115
- def offer_variants(img, entry, image_config, baseurl)
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.dig("fallback", "variants") || []
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
- img["srcset"] = build_srcset(fallback, baseurl) if fallback.size > 1
124
- img["sizes"] = sizes
125
- return if sources.empty?
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.to_s.dup
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 gives the page its <h1>, and a notebook's first
102
- # markdown cell usually repeats the title as `# Title`. Each heading moves
103
- # down a level (h1 to h2, and so on to h6), which keeps one <h1> on the
104
- # page and the cells' own outline under it.
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)