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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +188 -0
- data/CITATION.cff +2 -2
- data/README.md +25 -17
- data/_data/cdn-integrity.yml +0 -30
- data/_data/i18n/en.yml +297 -0
- data/_data/i18n/es.yml +297 -0
- data/_data/i18n/pt.yml +297 -0
- data/_data/js_manifest.json +16 -0
- data/_includes/analytics/dashboard.html +3 -1
- data/_includes/components/api-function.html +20 -1
- data/_includes/components/author-bio.html +21 -11
- data/_includes/components/author-list.html +32 -0
- data/_includes/components/citation-tools.html +33 -25
- data/_includes/components/comments-thread.html +90 -0
- data/_includes/components/contact-form.html +120 -0
- data/_includes/components/correction-report.html +82 -0
- data/_includes/components/enhanced-code-block.html +1 -1
- data/_includes/components/enhanced-toc.html +6 -7
- data/_includes/components/license-link.html +13 -0
- data/_includes/components/license-notice.html +28 -0
- data/_includes/components/moderation-inbox.html +139 -0
- data/_includes/components/reactions.html +49 -0
- data/_includes/components/reading-list.html +31 -0
- data/_includes/components/reading-mode-toggle.html +65 -0
- data/_includes/components/reading-state-bookmark.html +27 -0
- data/_includes/components/reading-state-panel.html +71 -0
- data/_includes/components/reproducibility.html +61 -0
- data/_includes/components/responsive-image.html +3 -3
- data/_includes/components/revision-history.html +39 -0
- data/_includes/components/revision-notice.html +35 -0
- data/_includes/components/series-nav.html +64 -0
- data/_includes/components/subscribe-form.html +88 -0
- data/_includes/components/subscription-manage.html +68 -0
- data/_includes/components/webmentions.html +48 -0
- data/_includes/csp-meta.html +135 -11
- data/_includes/footer.html +22 -20
- data/_includes/head.html +111 -63
- data/_includes/header/navigation.html +12 -15
- data/_includes/header.html +28 -19
- data/_includes/layouts/default/article.html +9 -7
- data/_includes/meta/dynamic-services-config.html +14 -0
- data/_includes/meta/math-config.html +18 -10
- data/_includes/meta/person-json.html +27 -0
- data/_includes/meta/publisher.html +45 -0
- data/_includes/meta/schema.html +67 -30
- data/_includes/meta/scholarly.html +121 -0
- data/_includes/meta/scripts-loader.html +18 -32
- data/_includes/meta/webmention-discovery.html +14 -0
- data/_includes/post/related-posts.html +4 -7
- data/_includes/scripts.html +57 -0
- data/_includes/search/index-data.json +9 -34
- data/_layouts/dataset.html +5 -3
- data/_layouts/default.html +16 -8
- data/_layouts/notebook.html +1 -0
- data/_layouts/package.html +4 -2
- data/_layouts/page.html +15 -0
- data/_layouts/portfolio.html +1 -0
- data/_layouts/post.html +102 -23
- data/_layouts/project.html +4 -3
- data/_layouts/research.html +20 -8
- data/_plugins/analytics_dashboard.rb +9 -3
- data/_plugins/authors.rb +133 -0
- data/_plugins/config_validator.rb +231 -23
- data/_plugins/critical_css_check.rb +42 -0
- data/_plugins/csp_generator.rb +18 -28
- data/_plugins/datalog_bibliography.rb +9 -7
- data/_plugins/datalog_comments.rb +8 -5
- data/_plugins/datalog_slides.rb +9 -8
- data/_plugins/i18n.rb +13 -12
- data/_plugins/image_optimizer.rb +241 -178
- data/_plugins/licenses.rb +135 -0
- data/_plugins/math_preprocessor.rb +59 -7
- data/_plugins/notebook_converter.rb +23 -4
- data/_plugins/plugin_loader.rb +3 -1
- data/_plugins/publications_generator.rb +8 -2
- data/_plugins/references.rb +238 -0
- data/_plugins/reproducibility.rb +150 -0
- data/_plugins/revisions.rb +101 -0
- data/_plugins/rouge_highlight_filter.rb +42 -0
- data/_plugins/scholarly.rb +50 -0
- data/_plugins/search_code_blocks.rb +30 -0
- data/_plugins/search_normalizer.rb +15 -53
- data/_plugins/search_pages.rb +3 -4
- data/_plugins/series.rb +104 -0
- data/_plugins/statements.rb +87 -0
- data/_sass/_academic-dashboard.scss +262 -0
- data/_sass/_base.scss +20 -1
- data/_sass/_comments-thread.scss +159 -0
- data/_sass/_components.scss +64 -1153
- data/_sass/_features.scss +17 -0
- data/_sass/_layout.scss +385 -1
- data/_sass/_mathematical.scss +27 -0
- data/_sass/_moderation.scss +222 -0
- data/_sass/_notebooks.scss +322 -0
- data/_sass/_open-science-badges.scss +56 -0
- data/_sass/{_phase1-enhancements.scss → _post-components.scss} +5 -3
- data/_sass/_print.scss +291 -0
- data/_sass/_reactions.scss +89 -0
- data/_sass/_reading-state.scss +290 -0
- data/_sass/_search-page.scss +530 -0
- data/_sass/_search.scss +46 -0
- data/_sass/_service-forms.scss +204 -0
- data/_sass/_subscriptions.scss +140 -0
- data/_sass/_syntax-highlighting.scss +212 -97
- data/_sass/_theme.scss +40 -19
- data/_sass/_typography.scss +130 -0
- data/_sass/_utilities.scss +5 -0
- data/_sass/_variables.scss +6 -0
- data/_sass/_webmentions.scss +125 -0
- data/assets/css/main.scss +14 -0
- data/assets/js/dist/academic.js +1 -1
- data/assets/js/dist/analytics-dashboard.js +1 -1
- data/assets/js/dist/chunks/chunk-2DYDWUFX.js +1 -0
- data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
- data/assets/js/dist/chunks/chunk-V7734B2G.js +1 -0
- data/assets/js/dist/comments.js +2 -0
- data/assets/js/dist/contact.js +1 -0
- data/assets/js/dist/core.js +1 -1
- data/assets/js/dist/corrections.js +1 -0
- data/assets/js/dist/loader.js +1 -1
- data/assets/js/dist/math.js +1 -1
- data/assets/js/dist/moderation.js +1 -0
- data/assets/js/dist/notebook.js +1 -1
- data/assets/js/dist/reactions.js +1 -0
- data/assets/js/dist/reading-state.js +1 -0
- data/assets/js/dist/search.js +1 -1
- data/assets/js/dist/sources.json +52 -0
- data/assets/js/dist/subscriptions.js +1 -0
- data/assets/js/dist/visualizations.js +11 -2
- data/assets/js/dist/webmentions.js +1 -0
- data/assets/js/loader.js +37 -1
- data/datalog-theme.gemspec +35 -23
- data/lib/datalog/cli.rb +43 -15
- data/lib/datalog/critical_css.rb +168 -0
- data/lib/datalog/plugin_system/dependency_resolver.rb +0 -2
- data/lib/datalog/plugins/comments.rb +33 -3
- data/lib/datalog/theme/installed_files.rb +113 -0
- data/lib/datalog/theme/package.rb +57 -0
- data/lib/datalog/theme/repository_checkout.rb +94 -0
- data/lib/datalog/theme/version.rb +5 -1
- data/lib/datalog/warning_filter.rb +5 -11
- data/lib/datalog-theme.rb +6 -0
- metadata +96 -147
- data/_data/academic.yml +0 -217
- data/_data/config/author.yml +0 -121
- data/_data/datasets.yml +0 -28
- data/_data/js_meta.json +0 -371
- data/_data/navigation.yml +0 -145
- data/_data/projects.yml +0 -41
- data/_data/publications.yml +0 -28
- data/_data/social.yml +0 -73
- data/_data/visualizations.yml +0 -51
- data/_includes/components/advanced-search.html +0 -682
- data/_includes/components/bookmark-system.html +0 -96
- data/_includes/components/comments.html +0 -244
- data/_includes/components/content-recommendations.html +0 -228
- data/_includes/components/email-preferences.html +0 -200
- data/_includes/components/enhanced-metadata.html +0 -228
- data/_includes/components/language-switcher.html +0 -396
- data/_includes/components/navigation-enhancements.html +0 -454
- data/_includes/components/newsletter-signup.html +0 -178
- data/_includes/components/popular-posts.html +0 -233
- data/_includes/components/reading-progress.html +0 -133
- data/_includes/components/reading-time.html +0 -121
- data/_includes/components/series-navigation.html +0 -124
- data/_includes/components/social-proof.html +0 -34
- data/_includes/components/user-preferences.html +0 -566
- data/_includes/meta/syntax-config.html +0 -19
- data/_layouts/archive.html +0 -282
- data/_layouts/post-sidebar.html +0 -183
- data/_sass/_phase3-enhancements.scss +0 -874
- data/_sass/_phase4-enhancements.scss +0 -1214
- data/_sass/_phase5-enhancements.scss +0 -414
- data/assets/js/academic.js +0 -262
- data/assets/js/analytics-dashboard.js +0 -382
- data/assets/js/core/dark-mode.js +0 -79
- data/assets/js/core/github-cards.js +0 -123
- data/assets/js/core/language-filter.js +0 -69
- data/assets/js/core/navigation.js +0 -184
- data/assets/js/core/scroll-progress.js +0 -45
- data/assets/js/core/search-hotkeys.js +0 -62
- data/assets/js/core/skip-links.js +0 -62
- data/assets/js/dist/manifest.json +0 -22
- data/assets/js/dist/meta.json +0 -371
- data/assets/js/main.js +0 -23
- data/assets/js/math.js +0 -818
- data/assets/js/notebook.js +0 -158
- data/assets/js/search/analytics.js +0 -91
- data/assets/js/search/app.js +0 -271
- data/assets/js/search/autocomplete.js +0 -120
- data/assets/js/search/engine.js +0 -260
- data/assets/js/search/filters.js +0 -45
- data/assets/js/search/render.js +0 -217
- data/assets/js/search/utils.js +0 -99
- data/assets/js/search.js +0 -354
- data/assets/js/visualizations.js +0 -816
- data/assets/publications/datalog-publications.bib +0 -8
- data/assets/publications/datalog-publications.ris +0 -9
- data/assets/publications/publications.bib +0 -30
- data/assets/templates/diogo-ribeiro-cv.md +0 -31
- data/assets/templates/diogo-ribeiro-cv.tex +0 -32
- data/lib/datalog/theme/theme.rb +0 -18
data/datalog-theme.gemspec
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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", "
|
|
59
|
-
spec.add_runtime_dependency "jekyll-seo-tag", "
|
|
60
|
-
spec.add_runtime_dependency "jekyll-sitemap", "
|
|
61
|
-
spec.add_runtime_dependency "sass-embedded", "
|
|
62
|
-
spec.add_runtime_dependency "jekyll-paginate", "
|
|
63
|
-
spec.add_runtime_dependency "jekyll-include-cache", "
|
|
64
|
-
spec.add_runtime_dependency "jekyll-
|
|
65
|
-
spec.add_runtime_dependency "
|
|
66
|
-
spec.add_runtime_dependency "
|
|
67
|
-
spec.add_runtime_dependency "
|
|
68
|
-
spec.add_runtime_dependency "
|
|
69
|
-
spec.add_runtime_dependency "
|
|
70
|
-
spec.add_runtime_dependency "
|
|
71
|
-
spec.add_runtime_dependency "
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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:
|
|
194
|
-
description:
|
|
195
|
-
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:
|
|
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::
|
|
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
|
|
@@ -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
|
-
#{
|
|
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
|