datalog-theme 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +188 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +25 -17
  5. data/_data/cdn-integrity.yml +0 -30
  6. data/_data/i18n/en.yml +297 -0
  7. data/_data/i18n/es.yml +297 -0
  8. data/_data/i18n/pt.yml +297 -0
  9. data/_data/js_manifest.json +16 -0
  10. data/_includes/analytics/dashboard.html +3 -1
  11. data/_includes/components/api-function.html +20 -1
  12. data/_includes/components/author-bio.html +21 -11
  13. data/_includes/components/author-list.html +32 -0
  14. data/_includes/components/citation-tools.html +33 -25
  15. data/_includes/components/comments-thread.html +90 -0
  16. data/_includes/components/contact-form.html +120 -0
  17. data/_includes/components/correction-report.html +82 -0
  18. data/_includes/components/enhanced-code-block.html +1 -1
  19. data/_includes/components/enhanced-toc.html +6 -7
  20. data/_includes/components/license-link.html +13 -0
  21. data/_includes/components/license-notice.html +28 -0
  22. data/_includes/components/moderation-inbox.html +139 -0
  23. data/_includes/components/reactions.html +49 -0
  24. data/_includes/components/reading-list.html +31 -0
  25. data/_includes/components/reading-mode-toggle.html +65 -0
  26. data/_includes/components/reading-state-bookmark.html +27 -0
  27. data/_includes/components/reading-state-panel.html +71 -0
  28. data/_includes/components/reproducibility.html +61 -0
  29. data/_includes/components/responsive-image.html +3 -3
  30. data/_includes/components/revision-history.html +39 -0
  31. data/_includes/components/revision-notice.html +35 -0
  32. data/_includes/components/series-nav.html +64 -0
  33. data/_includes/components/subscribe-form.html +88 -0
  34. data/_includes/components/subscription-manage.html +68 -0
  35. data/_includes/components/webmentions.html +48 -0
  36. data/_includes/csp-meta.html +135 -11
  37. data/_includes/footer.html +22 -20
  38. data/_includes/head.html +111 -63
  39. data/_includes/header/navigation.html +12 -15
  40. data/_includes/header.html +28 -19
  41. data/_includes/layouts/default/article.html +9 -7
  42. data/_includes/meta/dynamic-services-config.html +14 -0
  43. data/_includes/meta/math-config.html +18 -10
  44. data/_includes/meta/person-json.html +27 -0
  45. data/_includes/meta/publisher.html +45 -0
  46. data/_includes/meta/schema.html +67 -30
  47. data/_includes/meta/scholarly.html +121 -0
  48. data/_includes/meta/scripts-loader.html +18 -32
  49. data/_includes/meta/webmention-discovery.html +14 -0
  50. data/_includes/post/related-posts.html +4 -7
  51. data/_includes/scripts.html +57 -0
  52. data/_includes/search/index-data.json +9 -34
  53. data/_layouts/dataset.html +5 -3
  54. data/_layouts/default.html +16 -8
  55. data/_layouts/notebook.html +1 -0
  56. data/_layouts/package.html +4 -2
  57. data/_layouts/page.html +15 -0
  58. data/_layouts/portfolio.html +1 -0
  59. data/_layouts/post.html +102 -23
  60. data/_layouts/project.html +4 -3
  61. data/_layouts/research.html +20 -8
  62. data/_plugins/analytics_dashboard.rb +9 -3
  63. data/_plugins/authors.rb +133 -0
  64. data/_plugins/config_validator.rb +231 -23
  65. data/_plugins/critical_css_check.rb +42 -0
  66. data/_plugins/csp_generator.rb +18 -28
  67. data/_plugins/datalog_bibliography.rb +9 -7
  68. data/_plugins/datalog_comments.rb +8 -5
  69. data/_plugins/datalog_slides.rb +9 -8
  70. data/_plugins/i18n.rb +13 -12
  71. data/_plugins/image_optimizer.rb +241 -178
  72. data/_plugins/licenses.rb +135 -0
  73. data/_plugins/math_preprocessor.rb +59 -7
  74. data/_plugins/notebook_converter.rb +23 -4
  75. data/_plugins/plugin_loader.rb +3 -1
  76. data/_plugins/publications_generator.rb +8 -2
  77. data/_plugins/references.rb +238 -0
  78. data/_plugins/reproducibility.rb +150 -0
  79. data/_plugins/revisions.rb +101 -0
  80. data/_plugins/rouge_highlight_filter.rb +42 -0
  81. data/_plugins/scholarly.rb +50 -0
  82. data/_plugins/search_code_blocks.rb +30 -0
  83. data/_plugins/search_normalizer.rb +15 -53
  84. data/_plugins/search_pages.rb +3 -4
  85. data/_plugins/series.rb +104 -0
  86. data/_plugins/statements.rb +87 -0
  87. data/_sass/_academic-dashboard.scss +262 -0
  88. data/_sass/_base.scss +20 -1
  89. data/_sass/_comments-thread.scss +159 -0
  90. data/_sass/_components.scss +64 -1153
  91. data/_sass/_features.scss +17 -0
  92. data/_sass/_layout.scss +385 -1
  93. data/_sass/_mathematical.scss +27 -0
  94. data/_sass/_moderation.scss +222 -0
  95. data/_sass/_notebooks.scss +322 -0
  96. data/_sass/_open-science-badges.scss +56 -0
  97. data/_sass/{_phase1-enhancements.scss → _post-components.scss} +5 -3
  98. data/_sass/_print.scss +291 -0
  99. data/_sass/_reactions.scss +89 -0
  100. data/_sass/_reading-state.scss +290 -0
  101. data/_sass/_search-page.scss +530 -0
  102. data/_sass/_search.scss +46 -0
  103. data/_sass/_service-forms.scss +204 -0
  104. data/_sass/_subscriptions.scss +140 -0
  105. data/_sass/_syntax-highlighting.scss +212 -97
  106. data/_sass/_theme.scss +40 -19
  107. data/_sass/_typography.scss +130 -0
  108. data/_sass/_utilities.scss +5 -0
  109. data/_sass/_variables.scss +6 -0
  110. data/_sass/_webmentions.scss +125 -0
  111. data/assets/css/main.scss +14 -0
  112. data/assets/js/dist/academic.js +1 -1
  113. data/assets/js/dist/analytics-dashboard.js +1 -1
  114. data/assets/js/dist/chunks/chunk-2DYDWUFX.js +1 -0
  115. data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
  116. data/assets/js/dist/chunks/chunk-V7734B2G.js +1 -0
  117. data/assets/js/dist/comments.js +2 -0
  118. data/assets/js/dist/contact.js +1 -0
  119. data/assets/js/dist/core.js +1 -1
  120. data/assets/js/dist/corrections.js +1 -0
  121. data/assets/js/dist/loader.js +1 -1
  122. data/assets/js/dist/math.js +1 -1
  123. data/assets/js/dist/moderation.js +1 -0
  124. data/assets/js/dist/notebook.js +1 -1
  125. data/assets/js/dist/reactions.js +1 -0
  126. data/assets/js/dist/reading-state.js +1 -0
  127. data/assets/js/dist/search.js +1 -1
  128. data/assets/js/dist/sources.json +52 -0
  129. data/assets/js/dist/subscriptions.js +1 -0
  130. data/assets/js/dist/visualizations.js +11 -2
  131. data/assets/js/dist/webmentions.js +1 -0
  132. data/assets/js/loader.js +37 -1
  133. data/datalog-theme.gemspec +35 -23
  134. data/lib/datalog/cli.rb +43 -15
  135. data/lib/datalog/critical_css.rb +168 -0
  136. data/lib/datalog/plugin_system/dependency_resolver.rb +0 -2
  137. data/lib/datalog/plugins/comments.rb +33 -3
  138. data/lib/datalog/theme/installed_files.rb +113 -0
  139. data/lib/datalog/theme/package.rb +57 -0
  140. data/lib/datalog/theme/repository_checkout.rb +94 -0
  141. data/lib/datalog/theme/version.rb +5 -1
  142. data/lib/datalog/warning_filter.rb +5 -11
  143. data/lib/datalog-theme.rb +6 -0
  144. metadata +96 -147
  145. data/_data/academic.yml +0 -217
  146. data/_data/config/author.yml +0 -121
  147. data/_data/datasets.yml +0 -28
  148. data/_data/js_meta.json +0 -371
  149. data/_data/navigation.yml +0 -145
  150. data/_data/projects.yml +0 -41
  151. data/_data/publications.yml +0 -28
  152. data/_data/social.yml +0 -73
  153. data/_data/visualizations.yml +0 -51
  154. data/_includes/components/advanced-search.html +0 -682
  155. data/_includes/components/bookmark-system.html +0 -96
  156. data/_includes/components/comments.html +0 -244
  157. data/_includes/components/content-recommendations.html +0 -228
  158. data/_includes/components/email-preferences.html +0 -200
  159. data/_includes/components/enhanced-metadata.html +0 -228
  160. data/_includes/components/language-switcher.html +0 -396
  161. data/_includes/components/navigation-enhancements.html +0 -454
  162. data/_includes/components/newsletter-signup.html +0 -178
  163. data/_includes/components/popular-posts.html +0 -233
  164. data/_includes/components/reading-progress.html +0 -133
  165. data/_includes/components/reading-time.html +0 -121
  166. data/_includes/components/series-navigation.html +0 -124
  167. data/_includes/components/social-proof.html +0 -34
  168. data/_includes/components/user-preferences.html +0 -566
  169. data/_includes/meta/syntax-config.html +0 -19
  170. data/_layouts/archive.html +0 -282
  171. data/_layouts/post-sidebar.html +0 -183
  172. data/_sass/_phase3-enhancements.scss +0 -874
  173. data/_sass/_phase4-enhancements.scss +0 -1214
  174. data/_sass/_phase5-enhancements.scss +0 -414
  175. data/assets/js/academic.js +0 -262
  176. data/assets/js/analytics-dashboard.js +0 -382
  177. data/assets/js/core/dark-mode.js +0 -79
  178. data/assets/js/core/github-cards.js +0 -123
  179. data/assets/js/core/language-filter.js +0 -69
  180. data/assets/js/core/navigation.js +0 -184
  181. data/assets/js/core/scroll-progress.js +0 -45
  182. data/assets/js/core/search-hotkeys.js +0 -62
  183. data/assets/js/core/skip-links.js +0 -62
  184. data/assets/js/dist/manifest.json +0 -22
  185. data/assets/js/dist/meta.json +0 -371
  186. data/assets/js/main.js +0 -23
  187. data/assets/js/math.js +0 -818
  188. data/assets/js/notebook.js +0 -158
  189. data/assets/js/search/analytics.js +0 -91
  190. data/assets/js/search/app.js +0 -271
  191. data/assets/js/search/autocomplete.js +0 -120
  192. data/assets/js/search/engine.js +0 -260
  193. data/assets/js/search/filters.js +0 -45
  194. data/assets/js/search/render.js +0 -217
  195. data/assets/js/search/utils.js +0 -99
  196. data/assets/js/search.js +0 -354
  197. data/assets/js/visualizations.js +0 -816
  198. data/assets/publications/datalog-publications.bib +0 -8
  199. data/assets/publications/datalog-publications.ris +0 -9
  200. data/assets/publications/publications.bib +0 -30
  201. data/assets/templates/diogo-ribeiro-cv.md +0 -31
  202. data/assets/templates/diogo-ribeiro-cv.tex +0 -32
  203. data/lib/datalog/theme/theme.rb +0 -18
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "lib/datalog/theme/version"
4
+ require_relative "lib/datalog/theme/package"
4
5
 
5
6
  Gem::Specification.new do |spec|
6
7
  spec.name = "datalog-theme"
@@ -23,7 +24,9 @@ Gem::Specification.new do |spec|
23
24
  spec.metadata["plugin_type"] = "theme"
24
25
  spec.metadata["orcid"] = "https://orcid.org/0009-0001-2022-7072"
25
26
 
26
- spec.required_ruby_version = ">= 3.0"
27
+ # The sass-embedded and nokogiri releases the theme is tested with need Ruby
28
+ # 3.2; the gemspec said 3.0.
29
+ spec.required_ruby_version = Datalog::Theme::RUBY_REQUIREMENT
27
30
  spec.require_paths = ["lib"]
28
31
  spec.bindir = "bin"
29
32
  spec.executables = ["datalog"]
@@ -31,10 +34,13 @@ Gem::Specification.new do |spec|
31
34
  # Ship only theme infrastructure — not demo content (_posts, _pages, _portfolio,
32
35
  # _datasets, _packages, _notebooks, tests, docs, scripts, CI configs, frontend
33
36
  # tooling, Dockerfiles, etc.). Theme consumers get layouts/includes/sass/assets/
34
- # plugins/data/lib/bin and the licensing/changelog metadata.
37
+ # plugins/data/lib/bin and the licensing/changelog metadata. Within _data and
38
+ # assets, the demo site's own files (its navigation, social profiles, author
39
+ # profile, CV and publication exports) stay out: see Datalog::Theme::Package.
35
40
  spec.files = Dir.chdir(__dir__) do
36
41
  tracked = `git ls-files -z`.split("\x0").select do |f|
37
- f.match?(%r{\A(?:_layouts|_includes|_sass|_plugins|_data|assets|lib|bin)/}) ||
42
+ (f.match?(%r{\A(?:_layouts|_includes|_sass|_plugins|_data|assets|lib|bin)/}) &&
43
+ Datalog::Theme::Package.theme_file?(f)) ||
38
44
  %w[
39
45
  LICENSE
40
46
  README.md
@@ -47,32 +53,38 @@ Gem::Specification.new do |spec|
47
53
  # The browser bundles are build output, so git does not track them, but
48
54
  # _data/js_manifest.json points every page at them: a gem without them
49
55
  # gives consumers a site whose scripts all 404. Run `npm run build:js`
50
- # before packaging; scripts/verify_gem_package.rb checks the result.
51
- built = Dir.glob("assets/js/dist/**/*").select { |f| File.file?(f) }
56
+ # before packaging; scripts/verify_gem_package.rb checks the result. The
57
+ # esbuild metafile and the copy of the manifest written next to the bundles
58
+ # are records of the build that no page loads, so they stay out.
59
+ built = Dir.glob("assets/js/dist/**/*").select { |f| File.file?(f) } -
60
+ %w[assets/js/dist/manifest.json assets/js/dist/meta.json]
52
61
 
53
62
  (tracked + built).uniq.sort
54
63
  end
55
64
 
65
+ # Each dependency stops below its next major version, so a breaking release
66
+ # reaches sites through a pull request that updates this file rather than an
67
+ # untested `bundle update`.
56
68
  spec.add_runtime_dependency "jekyll", "~> 4.3"
57
69
  spec.add_runtime_dependency "jekyll-sass-converter", "~> 3.0"
58
- spec.add_runtime_dependency "jekyll-feed", ">= 0.16"
59
- spec.add_runtime_dependency "jekyll-seo-tag", ">= 2.8"
60
- spec.add_runtime_dependency "jekyll-sitemap", ">= 1.4"
61
- spec.add_runtime_dependency "sass-embedded", ">= 1.71"
62
- spec.add_runtime_dependency "jekyll-paginate", ">= 1.1"
63
- spec.add_runtime_dependency "jekyll-include-cache", ">= 0.2"
64
- spec.add_runtime_dependency "jekyll-archives", ">= 2.2"
65
- spec.add_runtime_dependency "jekyll-remote-theme", ">= 0.4"
66
- spec.add_runtime_dependency "jekyll-redirect-from", ">= 0.16"
67
- spec.add_runtime_dependency "kramdown-parser-gfm", ">= 1.1"
68
- spec.add_runtime_dependency "webrick", ">= 1.8"
69
- spec.add_runtime_dependency "fastimage", ">= 2.2"
70
- spec.add_runtime_dependency "loofah", ">= 2.19"
71
- spec.add_runtime_dependency "nokogiri", ">= 1.15"
72
- spec.add_runtime_dependency "mini_magick", ">= 4.12"
73
- spec.add_runtime_dependency "logger", ">= 1.6"
74
- spec.add_runtime_dependency "thor", ">= 1.3"
75
- spec.add_runtime_dependency "googleauth", ">= 1.9"
70
+ spec.add_runtime_dependency "jekyll-feed", "~> 0.16"
71
+ spec.add_runtime_dependency "jekyll-seo-tag", "~> 2.8"
72
+ spec.add_runtime_dependency "jekyll-sitemap", "~> 1.4"
73
+ spec.add_runtime_dependency "sass-embedded", "~> 1.71"
74
+ spec.add_runtime_dependency "jekyll-paginate", "~> 1.1"
75
+ spec.add_runtime_dependency "jekyll-include-cache", "~> 0.2"
76
+ spec.add_runtime_dependency "jekyll-redirect-from", "~> 0.16"
77
+ spec.add_runtime_dependency "kramdown-parser-gfm", "~> 1.1"
78
+ spec.add_runtime_dependency "webrick", "~> 1.8"
79
+ spec.add_runtime_dependency "fastimage", "~> 2.2"
80
+ spec.add_runtime_dependency "loofah", "~> 2.19"
81
+ spec.add_runtime_dependency "nokogiri", "~> 1.15"
82
+ spec.add_runtime_dependency "logger", "~> 1.6"
83
+ spec.add_runtime_dependency "thor", "~> 1.3"
84
+ # googleauth, with the Google Cloud gems it brings, is needed only by the
85
+ # analytics dashboard with a GA4 property configured, and such a site adds it
86
+ # to its own Gemfile. jekyll-archives and jekyll-remote-theme were listed here
87
+ # but used by nothing in the theme.
76
88
 
77
89
  spec.add_development_dependency "bundler", ">= 2.4"
78
90
  spec.add_development_dependency "rake", ">= 13.0"
data/lib/datalog/cli.rb CHANGED
@@ -83,19 +83,27 @@ module Datalog
83
83
  branch = options[:branch]
84
84
  message = options[:message] || "Publish DataLog site on #{Time.now.utc.strftime('%Y-%m-%d %H:%M UTC')}"
85
85
 
86
- say_status :build, "bundle exec jekyll build", :blue
87
- unless system({ "BUNDLE_GEMFILE" => gemfile_path(root) }, "bundle exec jekyll build", chdir: root)
86
+ # A deployment is a production build: without JEKYLL_ENV the published
87
+ # site left out the analytics tag and shipped the development CSP logger.
88
+ build_env = { "BUNDLE_GEMFILE" => gemfile_path(root), "JEKYLL_ENV" => "production" }
89
+ say_status :build, "JEKYLL_ENV=production bundle exec jekyll build", :blue
90
+ unless system(build_env, "bundle exec jekyll build", chdir: root)
88
91
  say_error "Jekyll build failed. Resolve the error above and try again."
89
92
  exit 1
90
93
  end
91
94
 
95
+ published = false
92
96
  Dir.mktmpdir("datalog-publish") do |tmp|
93
97
  worktree_path = File.join(tmp, "deploy")
94
98
  prepare_worktree(root, branch, worktree_path)
95
- copy_site_output(root, worktree_path)
96
- commit_and_push(worktree_path, branch, message)
97
- cleanup_worktree(root, worktree_path)
99
+ begin
100
+ copy_site_output(root, worktree_path)
101
+ published = commit_and_push(worktree_path, branch, message)
102
+ ensure
103
+ cleanup_worktree(root, worktree_path)
104
+ end
98
105
  end
106
+ exit 1 unless published
99
107
  end
100
108
 
101
109
  desc "update", "Update the DataLog theme and related assets to the latest version"
@@ -182,23 +190,26 @@ module Datalog
182
190
  exit 1
183
191
  end
184
192
 
193
+ # Values go in as JSON strings, which YAML reads as double-quoted scalars.
194
+ # Interpolated between plain quotes, a title containing a double quote
195
+ # or a tag containing a colon produced front matter that did not parse.
185
196
  tags_yaml = if tags.empty?
186
197
  "[]"
187
198
  else
188
- "\n" + tags.map { |tag| " - #{tag}" }.join("\n")
199
+ "\n" + tags.map { |tag| " - #{tag.to_json}" }.join("\n")
189
200
  end
190
201
  front_matter = <<~YAML
191
202
  ---
192
203
  layout: post
193
- title: "#{title}"
194
- description: "#{summary}"
195
- author: "#{author}"
204
+ title: #{title.to_json}
205
+ description: #{summary.to_json}
206
+ author: #{author.to_json}
196
207
  date: #{date.strftime('%Y-%m-%d')}
197
208
  tags:#{tags_yaml}
198
- difficulty: "#{difficulty}"
209
+ difficulty: #{difficulty.to_json}
199
210
  hero:
200
211
  image: /assets/images/#{slug}.jpg
201
- alt: "Hero image for #{title}"
212
+ alt: #{"Hero image for #{title}".to_json}
202
213
  callouts:
203
214
  - label: Key takeaway
204
215
  content: Highlight the most important insight from the post.
@@ -411,6 +422,14 @@ module Datalog
411
422
  end
412
423
  end
413
424
 
425
+ desc "critical-css", "Write the critical CSS a production build inlines, into _includes/critical-css"
426
+ method_option :critical, type: :array,
427
+ desc: "Command that runs critical (default: node_modules/.bin/critical or npx critical@8)"
428
+ def critical_css
429
+ require_relative "critical_css"
430
+ CriticalCss.command(site_root, options[:critical], ->(*line) { say_status(*line) })
431
+ end
432
+
414
433
  register(New, "new", "new COMMAND", "Scaffold posts, notebooks, and portfolio projects")
415
434
 
416
435
  private
@@ -428,7 +447,7 @@ module Datalog
428
447
  end
429
448
 
430
449
  def ruby_supported?
431
- Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0")
450
+ Gem::Requirement.new(Datalog::Theme::RUBY_REQUIREMENT).satisfied_by?(Gem::Version.new(RUBY_VERSION))
432
451
  end
433
452
 
434
453
  def gemfile_path(root)
@@ -553,17 +572,26 @@ module Datalog
553
572
  end
554
573
  end
555
574
 
575
+ # Returns whether the branch now holds the site. The exit status of commit
576
+ # and push used to be ignored, so a rejected push still ended as a publish.
556
577
  def commit_and_push(worktree_path, branch, message)
557
578
  Dir.chdir(worktree_path) do
558
579
  system("git", "add", "--all")
559
580
  if system("git", "diff", "--cached", "--quiet")
560
581
  say_status :skip, "No changes to publish", :yellow
561
- return
582
+ return true
583
+ end
584
+
585
+ unless system("git", "commit", "-m", message)
586
+ say_error "git commit failed, so nothing was published."
587
+ return false
562
588
  end
563
589
 
564
- system("git", "commit", "-m", message)
565
590
  say_status :git, "git push origin #{branch}", :blue
566
- system("git", "push", "origin", branch)
591
+ return true if system("git", "push", "origin", branch)
592
+
593
+ say_error "git push to #{branch} failed, so the site was not published."
594
+ false
567
595
  end
568
596
  end
569
597
 
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "fileutils"
5
+ require "jekyll"
6
+ require "open3"
7
+ require "tmpdir"
8
+ require "yaml"
9
+
10
+ module Datalog
11
+ # Writes the critical CSS a production build inlines. head.html inlines
12
+ # _includes/critical-css/<target>.html for a page's layout (home, post, or
13
+ # default for every other layout) and then loads main.css without blocking
14
+ # rendering. Critical CSS depends on a site's own pages and styles, so the
15
+ # theme ships those files empty and each site writes its own with
16
+ # `datalog critical-css`.
17
+ class CriticalCss
18
+ TARGETS = %w[home post default].freeze
19
+ DEFAULT_DIMENSIONS = [{ "width" => 1920, "height" => 1080 }, { "width" => 375, "height" => 667 }].freeze
20
+ PACKAGE = "critical@8"
21
+
22
+ class Error < StandardError; end
23
+
24
+ attr_reader :root, :settings
25
+
26
+ # `datalog critical-css`. status reports a line as the command's shell
27
+ # does; an error ends the command with exit status 1.
28
+ def self.command(root, critical, status)
29
+ config = YAML.safe_load_file(File.join(root, "_config.yml"), permitted_classes: [Date, Time]) || {}
30
+ log = ->(message) { status.call(:critical, message, :blue) }
31
+ written = new(root, config["critical_css"], critical: critical, log: log).run
32
+ written.each_value { |path| status.call(:write, path, :green) }
33
+ rescue Error, Jekyll::Errors::FatalException, SystemCallError => e
34
+ status.call(:error, e.message, :red)
35
+ exit 1
36
+ end
37
+
38
+ # critical: the command that runs critical, as a list of words. By default
39
+ # the site's own node_modules/.bin/critical, or `npx --yes critical@8`.
40
+ def initialize(root, settings, critical: nil, log: ->(_message) {})
41
+ @root = File.expand_path(root)
42
+ @settings = settings || {}
43
+ @critical = critical
44
+ @log = log
45
+ end
46
+
47
+ # Returns the files written, by target. A target with no page to extract
48
+ # from, such as post on a site without posts, is left as it is.
49
+ def run
50
+ unless settings["enabled"] == true
51
+ raise Error, "critical_css.enabled is not true in _config.yml, so no page would inline critical CSS"
52
+ end
53
+
54
+ Dir.mktmpdir("datalog-critical") do |site_dir|
55
+ @log.call("Building the site for production into a temporary directory")
56
+ build(site_dir)
57
+ pages_for(site_dir).each_with_object({}) do |(target, page), written|
58
+ if page.nil?
59
+ @log.call("No page uses the #{target} layout; _includes/critical-css/#{target}.html is left as it is")
60
+ next
61
+ end
62
+
63
+ @log.call("Extracting critical CSS for #{target} from #{page}")
64
+ written[target] = write(target, extract(site_dir, page))
65
+ end
66
+ end
67
+ end
68
+
69
+ # A production build, with critical CSS turned off for it: pages then link
70
+ # main.css as usual, and CSS inlined by an earlier run cannot skew the result.
71
+ def build(destination)
72
+ previous = ENV.fetch("JEKYLL_ENV", nil)
73
+ ENV["JEKYLL_ENV"] = "production"
74
+ config = Jekyll.configuration(
75
+ "source" => root, "destination" => destination, "quiet" => true,
76
+ "critical_css" => settings.merge("enabled" => false)
77
+ )
78
+ Jekyll::Site.new(config).process
79
+ ensure
80
+ ENV["JEKYLL_ENV"] = previous
81
+ end
82
+
83
+ # The page each target extracts from: the one critical_css.pages names, or
84
+ # the first built page with that layout, nearest the site root first.
85
+ def pages_for(site_dir)
86
+ configured = settings["pages"] || {}
87
+ found = TARGETS.to_h { |target| [target, configured[target] && page_file(site_dir, configured[target])] }
88
+ missing = TARGETS.reject { |target| found[target] }
89
+
90
+ html_files(site_dir).each do |page|
91
+ break if missing.empty?
92
+
93
+ target = target_of(File.read(File.join(site_dir, page), encoding: "utf-8"))
94
+ next unless missing.delete(target)
95
+
96
+ found[target] = page
97
+ end
98
+ found
99
+ end
100
+
101
+ # Mirrors head.html: the home and post layouts have their own file, and
102
+ # every other page the theme's default layout renders uses default. Pages
103
+ # kept out of search engines, such as the search page, are not typical.
104
+ def target_of(html)
105
+ classes = html[/<body\b[^>]*\bclass="([^"]*)"/, 1].to_s.split
106
+ return unless classes.include?("site-body")
107
+ return "home" if classes.include?("layout-home")
108
+ return "post" if classes.include?("layout-post")
109
+
110
+ "default" unless html.match?(/<meta name="robots" content="noindex/)
111
+ end
112
+
113
+ # Only the site's stylesheet: the Google Fonts stylesheet would inline
114
+ # @font-face rules for the font files headless Chrome is served (TrueType),
115
+ # and fetching it made each page take over a minute.
116
+ def extract(site_dir, page)
117
+ arguments = [page, "--base", site_dir, "--css", File.join(site_dir, "assets", "css", "main.css"),
118
+ *dimension_arguments, *penthouse_arguments]
119
+ output, errors, status = Open3.capture3(*critical_command, *arguments, chdir: site_dir)
120
+ unless status.success?
121
+ raise Error, "critical could not extract CSS from #{page}: #{errors.strip.lines.last(5).join.strip}"
122
+ end
123
+
124
+ css = output.strip
125
+ raise Error, "critical found no critical CSS in #{page}" if css.empty?
126
+
127
+ css
128
+ end
129
+
130
+ def write(target, css)
131
+ path = File.join(root, "_includes", "critical-css", "#{target}.html")
132
+ FileUtils.mkdir_p(File.dirname(path))
133
+ # The include is rendered as Liquid; raw keeps a "{{" in CSS from being read as a tag.
134
+ File.write(path, "{% raw %}\n#{css}\n{% endraw %}\n")
135
+ path
136
+ end
137
+
138
+ def critical_command
139
+ return @critical if @critical
140
+
141
+ local = File.join(root, "node_modules", ".bin", Gem.win_platform? ? "critical.cmd" : "critical")
142
+ File.file?(local) ? [local] : ["npx", "--yes", PACKAGE]
143
+ end
144
+
145
+ def dimension_arguments
146
+ dimensions = Array(settings["dimensions"]).grep(Hash)
147
+ dimensions = DEFAULT_DIMENSIONS if dimensions.empty?
148
+ dimensions.flat_map { |entry| ["--dimensions", "#{entry['width']}x#{entry['height']}"] }
149
+ end
150
+
151
+ def penthouse_arguments
152
+ options = settings["penthouse_options"]
153
+ return [] unless options.is_a?(Hash)
154
+
155
+ options.flat_map { |key, value| ["--penthouse-#{key}", value.to_s] }
156
+ end
157
+
158
+ def page_file(site_dir, url)
159
+ path = url.to_s.sub(%r{\A/+}, "")
160
+ path = File.join(path, "index.html") if path.empty? || path.end_with?("/")
161
+ path if File.file?(File.join(site_dir, path))
162
+ end
163
+
164
+ def html_files(site_dir)
165
+ Dir.glob("**/*.html", base: site_dir).sort_by { |page| [page.count("/"), page] }
166
+ end
167
+ end
168
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  module Datalog
6
4
  module PluginSystem
7
5
  class DependencyResolver
@@ -19,16 +19,23 @@ module Datalog
19
19
  REQUIRED_FIELDS = {
20
20
  "giscus" => %w[repo repo_id category category_id],
21
21
  "utterances" => %w[repo],
22
- "disqus" => %w[shortname]
22
+ "disqus" => %w[shortname],
23
+ "api" => []
23
24
  }.freeze
24
25
 
26
+ # The api provider's thread (components/comments-thread.html) is a Liquid
27
+ # include, so it shares the service forms' labels and states.
28
+ API_INCLUDE = "{% include components/comments-thread.html %}"
29
+
25
30
  def initialize(site, config = {})
26
31
  super
27
32
  @defaults = {
28
33
  "provider" => "giscus",
29
34
  "mapping" => "pathname",
30
35
  "theme" => "light",
31
- "enabled_by_default" => false
36
+ "enabled_by_default" => false,
37
+ "replies" => true,
38
+ "moderation" => true
32
39
  }.merge(@config)
33
40
  @memo = {}
34
41
  end
@@ -67,9 +74,12 @@ module Datalog
67
74
  settings = @memo[key]
68
75
  return "" unless settings
69
76
 
77
+ embed = settings["embed"]
78
+ embed = render_api(context) if settings["provider"] == "api" && embed.to_s.empty?
79
+
70
80
  <<~HTML
71
81
  <div class="datalog-comments" aria-label="Comments">
72
- #{settings['embed']}
82
+ #{embed}
73
83
  </div>
74
84
  HTML
75
85
  end
@@ -109,6 +119,8 @@ module Datalog
109
119
  render_utterances(settings)
110
120
  when "disqus"
111
121
  render_disqus(document, settings)
122
+ when "api"
123
+ "" # the thread is rendered by the tag, in its own context
112
124
  else
113
125
  %(<p class="datalog-comments-unsupported">Comments provider "#{settings['provider']}" is not supported.</p>)
114
126
  end
@@ -140,6 +152,12 @@ module Datalog
140
152
  async></script>)
141
153
  end
142
154
 
155
+ # The thread of the site's own backend (#253), rendered in the tag's own
156
+ # context so the include sees the page and the site.
157
+ def render_api(context)
158
+ Liquid::Template.parse(API_INCLUDE).render(context)
159
+ end
160
+
143
161
  def render_disqus(document, settings)
144
162
  shortname = settings["shortname"] || ""
145
163
  nonce = nonce_attribute(document)
@@ -171,10 +189,22 @@ module Datalog
171
189
  end
172
190
 
173
191
  def missing_required_fields(provider, settings)
192
+ return api_missing(settings) if provider.to_s == "api"
193
+
174
194
  required = REQUIRED_FIELDS.fetch(provider.to_s, [])
175
195
  required.select { |key| settings[key].to_s.strip.empty? }
176
196
  end
177
197
 
198
+ # The api provider needs a backend: its own endpoint, or the site's
199
+ # dynamic services (docs/dynamic-services.md).
200
+ def api_missing(settings)
201
+ services = site.config["dynamic_services"]
202
+ base = services.is_a?(Hash) ? services["base_url"].to_s.strip : ""
203
+ return [] unless settings["endpoint"].to_s.strip.empty? && base.empty?
204
+
205
+ ["endpoint (or dynamic_services.base_url)"]
206
+ end
207
+
178
208
  def logger
179
209
  Jekyll.logger
180
210
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "jekyll"
6
+ require "json"
7
+ require_relative "package"
8
+ require_relative "repository_checkout"
9
+
10
+ module Datalog
11
+ module Theme
12
+ # Stops a build that would serve a mix of theme versions. A site that uses
13
+ # the theme from a Git submodule or another checkout reads every theme file
14
+ # from it, but the script bundles are build output and have to be built
15
+ # again whenever the checkout moves. Any site can also hold copies of files
16
+ # the theme provides, left from copying the theme in by hand; a site's file
17
+ # takes the place of the theme's, and the copies fall behind on the next
18
+ # update.
19
+ module InstalledFiles
20
+ module_function
21
+
22
+ BUNDLE_SOURCES = "assets/js/dist/sources.json"
23
+ # Theme data a site has no reason to replace: the script manifest names
24
+ # the bundles this version of the theme ships, and the integrity hashes
25
+ # belong to the CDN files its layouts load. Translations are left out,
26
+ # since a site may change them.
27
+ DATA_FILES = %w[_data/js_manifest.json _data/cdn-integrity.yml].freeze
28
+
29
+ # Jekyll prints an error on one line, so the messages are written as
30
+ # sentences rather than laid out in lines.
31
+ def check_bundles(site)
32
+ return unless RepositoryCheckout.applies_to?(site)
33
+
34
+ root = site.theme.root
35
+ reasons = stale_bundle_reasons(root)
36
+ return if reasons.empty?
37
+
38
+ listed = reasons.first(5).join("; ")
39
+ listed += "; and #{reasons.size - 5} more" if reasons.size > 5
40
+ raise Jekyll::Errors::FatalException,
41
+ "The script bundles in #{File.join(root, 'assets', 'js', 'dist')} were not built from the theme " \
42
+ "files beside them (#{listed}). Pages would run scripts from another version of the theme. Build " \
43
+ "them again by running `npm ci && npm run build:js` in #{root}."
44
+ end
45
+
46
+ # scripts/build_js.mjs records a SHA-256 of each file it builds from.
47
+ def stale_bundle_reasons(root)
48
+ record = File.join(root, BUNDLE_SOURCES)
49
+ unless File.file?(record)
50
+ return ["#{BUNDLE_SOURCES} is missing: the bundles were never built, or built before it was recorded"]
51
+ end
52
+
53
+ JSON.parse(File.read(record)).fetch("sources").filter_map do |path, digest|
54
+ file = File.join(root, path)
55
+ if !File.file?(file)
56
+ "#{path} is gone"
57
+ elsif Digest::SHA256.file(file).hexdigest != digest
58
+ "#{path} has changed"
59
+ end
60
+ end
61
+ end
62
+
63
+ def check_site_copies(site)
64
+ theme = site.theme
65
+ return unless theme && theme.name == "datalog-theme"
66
+
67
+ copies = stale_asset_copies(site, theme.root) + stale_data_copies(site, theme.root)
68
+ return if copies.empty?
69
+
70
+ raise Jekyll::Errors::FatalException,
71
+ "Your site has its own copies of files datalog-theme provides, and they differ from the theme's: " \
72
+ "#{copies.join(', ')}. A site's file takes the place of the theme's, so pages would load scripts, " \
73
+ "or check their integrity, for another version of the theme. Delete these files: the theme " \
74
+ "provides them."
75
+ end
76
+
77
+ # The site's static files that stand in for theme files pages load. Jekyll
78
+ # leaves the theme's file out when the site has one at the same path.
79
+ def stale_asset_copies(site, root)
80
+ source = File.join(site.source, "")
81
+ site.static_files.filter_map do |file|
82
+ next unless file.path.start_with?(source)
83
+
84
+ relative = file.relative_path.delete_prefix("/")
85
+ next unless Package::SCRIPTS_PAGES_LOAD.any? { |entry| Package.inside?(relative, entry) }
86
+
87
+ theme_file = File.join(root, relative)
88
+ relative if File.file?(theme_file) && !FileUtils.identical?(file.path, theme_file)
89
+ end
90
+ end
91
+
92
+ def stale_data_copies(site, root)
93
+ reader = Jekyll::DataReader.new(site)
94
+ own = reader.read(site.config["data_dir"])
95
+ DATA_FILES.filter_map do |entry|
96
+ key = File.basename(entry, ".*")
97
+ theme_file = File.join(root, entry)
98
+ next unless own.key?(key) && File.file?(theme_file)
99
+ next if own[key] == reader.read_data_file(theme_file)
100
+
101
+ Dir.glob("#{key}.*", base: site.in_source_dir(site.config["data_dir"])).map do |name|
102
+ File.join(site.config["data_dir"], name)
103
+ end.first
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ Jekyll::Hooks.register :site, :post_read do |site|
111
+ Datalog::Theme::InstalledFiles.check_bundles(site)
112
+ Datalog::Theme::InstalledFiles.check_site_copies(site)
113
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ module Theme
5
+ # The repository is both the theme and the theme's demo site, and the demo
6
+ # keeps some of its files in directories a theme also uses. These lists say
7
+ # which of those files belong to the theme. The gemspec packages only those,
8
+ # and RepositoryCheckout applies the same split to sites that install the
9
+ # theme from a Git checkout or a local path, which see the whole repository.
10
+ module Package
11
+ # The _data files the layouts need in order to render. The rest of _data
12
+ # (navigation, social profiles, publications, the author profile) is the
13
+ # demo site's own; a site using the theme supplies its own versions.
14
+ DATA_FILES = %w[
15
+ _data/i18n
16
+ _data/js_manifest.json
17
+ _data/cdn-integrity.yml
18
+ ].freeze
19
+
20
+ # Downloads the demo site links to from assets/: the maintainer's CV
21
+ # templates and publication exports.
22
+ DEMO_ASSETS = %w[
23
+ assets/publications
24
+ assets/templates
25
+ ].freeze
26
+
27
+ # Pages load the bundles in assets/js/dist, which scripts/build_js.mjs
28
+ # builds from the sources beside them, and the loader that picks among
29
+ # the bundles. No page loads the sources themselves, and a site copies
30
+ # every theme asset into its output, so the gem leaves them out.
31
+ SCRIPTS_PAGES_LOAD = %w[
32
+ assets/js/dist
33
+ assets/js/loader.js
34
+ ].freeze
35
+
36
+ module_function
37
+
38
+ # Whether a repository-relative path is part of the theme rather than
39
+ # demo content that happens to live in a theme directory.
40
+ def theme_file?(path)
41
+ return DATA_FILES.any? { |entry| inside?(path, entry) } if inside?(path, "_data")
42
+ return SCRIPTS_PAGES_LOAD.any? { |entry| inside?(path, entry) } if inside?(path, "assets/js")
43
+
44
+ DEMO_ASSETS.none? { |entry| inside?(path, entry) }
45
+ end
46
+
47
+ # The site.data keys the theme's own data files are read into.
48
+ def data_keys
49
+ DATA_FILES.map { |entry| File.basename(entry, ".*") }
50
+ end
51
+
52
+ def inside?(path, entry)
53
+ path == entry || path.start_with?("#{entry}/")
54
+ end
55
+ end
56
+ end
57
+ end