datalog-theme 0.8.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 +63 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -2
- 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/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-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 +21 -1
- data/_includes/footer.html +3 -0
- data/_includes/head.html +51 -40
- data/_includes/layouts/default/article.html +4 -6
- data/_includes/meta/dynamic-services-config.html +14 -0
- data/_includes/meta/math-config.html +9 -5
- data/_includes/meta/person-json.html +27 -0
- data/_includes/meta/publisher.html +45 -0
- data/_includes/meta/schema.html +62 -28
- data/_includes/meta/scholarly.html +121 -0
- data/_includes/meta/scripts-loader.html +2 -0
- data/_includes/meta/webmention-discovery.html +14 -0
- data/_includes/scripts.html +57 -0
- data/_layouts/dataset.html +4 -3
- data/_layouts/default.html +1 -1
- data/_layouts/package.html +3 -2
- data/_layouts/page.html +15 -0
- data/_layouts/post.html +82 -18
- data/_layouts/project.html +2 -2
- data/_layouts/research.html +20 -8
- data/_plugins/authors.rb +133 -0
- data/_plugins/config_validator.rb +223 -20
- data/_plugins/critical_css_check.rb +42 -0
- data/_plugins/i18n.rb +6 -4
- data/_plugins/image_optimizer.rb +224 -172
- data/_plugins/licenses.rb +135 -0
- data/_plugins/math_preprocessor.rb +32 -10
- data/_plugins/references.rb +238 -0
- data/_plugins/reproducibility.rb +150 -0
- data/_plugins/revisions.rb +101 -0
- data/_plugins/scholarly.rb +50 -0
- data/_plugins/series.rb +104 -0
- data/_plugins/statements.rb +87 -0
- data/_sass/_base.scss +8 -1
- data/_sass/_comments-thread.scss +159 -0
- data/_sass/_layout.scss +384 -0
- data/_sass/_mathematical.scss +27 -0
- data/_sass/_moderation.scss +222 -0
- data/_sass/_post-components.scss +4 -2
- data/_sass/_print.scss +291 -0
- data/_sass/_reactions.scss +89 -0
- data/_sass/_reading-state.scss +290 -0
- data/_sass/_service-forms.scss +204 -0
- data/_sass/_subscriptions.scss +140 -0
- data/_sass/_syntax-highlighting.scss +2 -2
- data/_sass/_theme.scss +11 -0
- data/_sass/_typography.scss +116 -0
- data/_sass/_utilities.scss +5 -0
- data/_sass/_variables.scss +6 -0
- data/_sass/_webmentions.scss +125 -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/webmentions.js +1 -0
- data/assets/js/loader.js +34 -0
- data/datalog-theme.gemspec +1 -2
- data/lib/datalog/cli.rb +9 -1
- data/lib/datalog/critical_css.rb +168 -0
- data/lib/datalog/plugins/comments.rb +33 -3
- data/lib/datalog/theme/installed_files.rb +113 -0
- data/lib/datalog/theme/repository_checkout.rb +6 -2
- data/lib/datalog/theme/version.rb +5 -1
- data/lib/datalog-theme.rb +1 -0
- metadata +57 -23
- data/assets/js/dist/chunks/chunk-225H5YXE.js +0 -1
data/assets/js/loader.js
CHANGED
|
@@ -80,6 +80,40 @@ const FEATURE_CONFIG = [
|
|
|
80
80
|
{
|
|
81
81
|
name: "notebook",
|
|
82
82
|
test: () => document.body?.dataset.featureNotebook === "true" || document.querySelector(".notebook-output")
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "reading-state",
|
|
86
|
+
// A post with the reading-state panel, or a page listing the saved articles.
|
|
87
|
+
test: () => document.querySelector("[data-reading-state], [data-reading-list]")
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "corrections",
|
|
91
|
+
test: () => document.querySelector("[data-correction-report]")
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "contact",
|
|
95
|
+
test: () => document.querySelector("[data-contact-form]")
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "comments",
|
|
99
|
+
test: () => document.querySelector("[data-comments-thread]")
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "reactions",
|
|
103
|
+
test: () => document.querySelector("[data-reactions]")
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "webmentions",
|
|
107
|
+
test: () => document.querySelector("[data-webmentions]")
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "subscriptions",
|
|
111
|
+
// A subscribe form, or the page the newsletter's emails link to.
|
|
112
|
+
test: () => document.querySelector("[data-subscribe], [data-subscription-manage]")
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "moderation",
|
|
116
|
+
test: () => document.querySelector("[data-moderation-inbox]")
|
|
83
117
|
}
|
|
84
118
|
];
|
|
85
119
|
|
data/datalog-theme.gemspec
CHANGED
|
@@ -26,7 +26,7 @@ Gem::Specification.new do |spec|
|
|
|
26
26
|
|
|
27
27
|
# The sass-embedded and nokogiri releases the theme is tested with need Ruby
|
|
28
28
|
# 3.2; the gemspec said 3.0.
|
|
29
|
-
spec.required_ruby_version =
|
|
29
|
+
spec.required_ruby_version = Datalog::Theme::RUBY_REQUIREMENT
|
|
30
30
|
spec.require_paths = ["lib"]
|
|
31
31
|
spec.bindir = "bin"
|
|
32
32
|
spec.executables = ["datalog"]
|
|
@@ -79,7 +79,6 @@ Gem::Specification.new do |spec|
|
|
|
79
79
|
spec.add_runtime_dependency "fastimage", "~> 2.2"
|
|
80
80
|
spec.add_runtime_dependency "loofah", "~> 2.19"
|
|
81
81
|
spec.add_runtime_dependency "nokogiri", "~> 1.15"
|
|
82
|
-
spec.add_runtime_dependency "mini_magick", ">= 4.12", "< 6"
|
|
83
82
|
spec.add_runtime_dependency "logger", "~> 1.6"
|
|
84
83
|
spec.add_runtime_dependency "thor", "~> 1.3"
|
|
85
84
|
# googleauth, with the Google Cloud gems it brings, is needed only by the
|
data/lib/datalog/cli.rb
CHANGED
|
@@ -422,6 +422,14 @@ module Datalog
|
|
|
422
422
|
end
|
|
423
423
|
end
|
|
424
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
|
+
|
|
425
433
|
register(New, "new", "new COMMAND", "Scaffold posts, notebooks, and portfolio projects")
|
|
426
434
|
|
|
427
435
|
private
|
|
@@ -439,7 +447,7 @@ module Datalog
|
|
|
439
447
|
end
|
|
440
448
|
|
|
441
449
|
def ruby_supported?
|
|
442
|
-
Gem::
|
|
450
|
+
Gem::Requirement.new(Datalog::Theme::RUBY_REQUIREMENT).satisfied_by?(Gem::Version.new(RUBY_VERSION))
|
|
443
451
|
end
|
|
444
452
|
|
|
445
453
|
def gemfile_path(root)
|
|
@@ -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
|
|
@@ -54,9 +54,13 @@ module Datalog
|
|
|
54
54
|
remove_demo_data(site)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# A checkout also holds the script sources the bundles are built from,
|
|
58
|
+
# which no page loads. The site gets the theme assets the gem would give it.
|
|
57
59
|
def remove_demo_assets(site)
|
|
58
|
-
|
|
59
|
-
site.static_files.reject!
|
|
60
|
+
root = File.join(site.theme.root, "")
|
|
61
|
+
site.static_files.reject! do |file|
|
|
62
|
+
file.path.start_with?(root) && !Package.theme_file?(file.path.delete_prefix(root))
|
|
63
|
+
end
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
# site.data holds the theme's data with the site's merged over it. Each key
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Datalog
|
|
4
4
|
module Theme
|
|
5
|
-
VERSION = "0.
|
|
5
|
+
VERSION = "0.9.0" unless const_defined?(:VERSION)
|
|
6
|
+
# The Ruby the gem installs on, for the gemspec and `datalog check`. The
|
|
7
|
+
# release workflow rewrites every quoted version assigned to a name ending
|
|
8
|
+
# in VERSION in this file, so this name must not end that way.
|
|
9
|
+
RUBY_REQUIREMENT = ">= 3.2" unless const_defined?(:RUBY_REQUIREMENT)
|
|
6
10
|
end
|
|
7
11
|
end
|
data/lib/datalog-theme.rb
CHANGED