datalog-theme 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +127 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -2
- data/_data/i18n/en.yml +55 -3
- data/_data/i18n/es.yml +55 -3
- data/_data/i18n/pt.yml +55 -3
- data/_includes/analytics/dashboard.html +24 -9
- data/_includes/components/academic-dashboard.html +168 -133
- data/_includes/components/author-bio.html +7 -5
- data/_includes/components/author-list.html +6 -5
- data/_includes/components/citation-tools.html +2 -1
- data/_includes/components/content-provenance.html +19 -1
- data/_includes/components/correction-report.html +15 -8
- data/_includes/components/hero.html +22 -15
- data/_includes/components/package-index.html +121 -0
- data/_includes/components/package-install.html +27 -50
- data/_includes/components/post-list.html +14 -0
- data/_includes/components/responsive-image.html +15 -21
- data/_includes/footer.html +1 -1
- data/_includes/head.html +70 -11
- data/_includes/layouts/default/article.html +76 -46
- data/_includes/meta/dataset-json.html +171 -0
- data/_includes/meta/math-config.html +15 -1
- data/_includes/meta/package-json.html +69 -0
- data/_includes/meta/person-json.html +36 -13
- data/_includes/meta/publisher.html +15 -0
- data/_includes/meta/schema.html +108 -23
- data/_includes/meta/scholarly.html +10 -5
- data/_includes/post/related-posts.html +17 -43
- data/_includes/scripts.html +2 -2
- data/_includes/search/index-data.json +10 -1
- data/_includes/search/page.html +11 -6
- data/_layouts/archive.html +67 -0
- data/_layouts/dataset.html +2 -1
- data/_layouts/default.html +13 -11
- data/_layouts/docs.html +53 -0
- data/_layouts/home.html +30 -1
- data/_layouts/notebook.html +5 -1
- data/_layouts/package.html +38 -21
- data/_layouts/portfolio.html +1 -0
- data/_layouts/post.html +21 -62
- data/_layouts/project.html +2 -1
- data/_layouts/research.html +14 -2
- data/_plugins/analytics_dashboard.rb +4 -1
- data/_plugins/archive.rb +114 -0
- data/_plugins/authors.rb +50 -1
- data/_plugins/citation_exports.rb +12 -1
- data/_plugins/config_validator.rb +77 -2
- data/_plugins/correction_fallback.rb +110 -0
- data/_plugins/image_optimizer.rb +129 -22
- data/_plugins/math_preprocessor.rb +7 -48
- data/_plugins/notebook_converter.rb +44 -5
- data/_plugins/packages.rb +84 -0
- data/_plugins/page_dates.rb +37 -0
- data/_plugins/references.rb +16 -2
- data/_plugins/related_posts.rb +149 -0
- data/_plugins/search_sections.rb +110 -0
- data/_plugins/site_identity.rb +42 -0
- data/_plugins/social_cards.rb +17 -0
- data/_sass/_academic-dashboard.scss +18 -30
- data/_sass/_base.scss +2 -0
- data/_sass/_components.scss +21 -23
- data/_sass/_docs.scss +143 -0
- data/_sass/_features.scss +6 -0
- data/_sass/_layout.scss +65 -6
- data/_sass/_mathematical.scss +61 -19
- data/_sass/_notebooks.scss +1 -32
- data/_sass/_package-docs.scss +6 -9
- data/_sass/_post-components.scss +23 -23
- data/_sass/_print.scss +1 -1
- data/_sass/_search-page.scss +75 -0
- data/_sass/_search.scss +17 -26
- data/_sass/_syntax-highlighting.scss +23 -1
- data/_sass/_theme.scss +1 -0
- data/_sass/_utilities.scss +87 -40
- data/_sass/_variables.scss +31 -29
- data/assets/css/main.scss +2 -1
- data/assets/js/dist/academic.js +1 -1
- data/assets/js/dist/analytics-dashboard.js +1 -1
- data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
- data/assets/js/dist/comments.js +1 -1
- data/assets/js/dist/contact.js +1 -1
- data/assets/js/dist/core.js +1 -1
- data/assets/js/dist/corrections.js +5 -1
- data/assets/js/dist/loader.js +1 -1
- data/assets/js/dist/math.js +2 -1
- data/assets/js/dist/moderation.js +1 -1
- data/assets/js/dist/reactions.js +1 -1
- data/assets/js/dist/search.js +1 -1
- data/assets/js/dist/sources.json +20 -16
- data/assets/js/dist/subscriptions.js +1 -1
- data/assets/js/loader.js +6 -2
- data/lib/datalog/audit/checks.rb +269 -0
- data/lib/datalog/audit/known_keys.rb +61 -0
- data/lib/datalog/audit/site_reader.rb +118 -0
- data/lib/datalog/audit/source_file.rb +85 -0
- data/lib/datalog/audit.rb +145 -0
- data/lib/datalog/citations/bibtex.rb +200 -0
- data/lib/datalog/citations/entry.rb +333 -0
- data/lib/datalog/citations/markup.rb +79 -0
- data/lib/datalog/cli.rb +164 -89
- data/lib/datalog/critical_css.rb +126 -21
- data/lib/datalog/latex_speech/words.json +88 -0
- data/lib/datalog/latex_speech.rb +355 -0
- data/lib/datalog/packages/command.rb +51 -0
- data/lib/datalog/packages/refresh.rb +187 -0
- data/lib/datalog/packages.rb +177 -0
- data/lib/datalog/plugin_system.rb +5 -0
- data/lib/datalog/plugins/citations.rb +325 -109
- data/lib/datalog/site_config.rb +26 -0
- data/lib/datalog/social_cards/font.rb +286 -0
- data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
- data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
- data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
- data/lib/datalog/social_cards/geometry.rb +51 -0
- data/lib/datalog/social_cards/outline.rb +54 -0
- data/lib/datalog/social_cards/plain_text.rb +258 -0
- data/lib/datalog/social_cards/template.rb +322 -0
- data/lib/datalog/social_cards/template.svg +17 -0
- data/lib/datalog/social_cards/typesetter.rb +178 -0
- data/lib/datalog/social_cards.rb +369 -0
- data/lib/datalog/theme/updater.rb +307 -0
- data/lib/datalog/theme/version.rb +1 -1
- metadata +44 -7
- data/_includes/components/enhanced-code-block.html +0 -212
- data/_includes/components/performance-monitor.html +0 -170
- data/_includes/components/viz-table-fallback.html +0 -19
- data/_plugins/datalog_bibliography.rb +0 -21
- data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +0 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Datalog
|
|
6
|
+
module Citations
|
|
7
|
+
# The HTML a page's citations become: the label of each cited work, the
|
|
8
|
+
# marker in the text, and the reference list with its links back.
|
|
9
|
+
module Markup
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# key => "1" (numeric) or "Smith 2020a" (author-year).
|
|
13
|
+
def labels(entries, style, words)
|
|
14
|
+
return entries.each_with_index.to_h { |entry, index| [entry.key, (index + 1).to_s] } if style == "numeric"
|
|
15
|
+
|
|
16
|
+
base = entries.to_h { |entry| [entry.key, [entry.names_in_text(words), entry.year_or(words[:no_date])]] }
|
|
17
|
+
suffixes = {}
|
|
18
|
+
base.group_by { |_, label| label }.each_value do |group|
|
|
19
|
+
next if group.size < 2
|
|
20
|
+
|
|
21
|
+
group.each_with_index { |(key, _), index| suffixes[key] = ("a".ord + index).chr }
|
|
22
|
+
end
|
|
23
|
+
base.to_h { |key, (names, year)| [key, "#{names} #{year}#{suffixes[key]}"] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# An id for each key, safe in a URL fragment and unique on the page.
|
|
27
|
+
def anchors(entries)
|
|
28
|
+
seen = Hash.new(0)
|
|
29
|
+
entries.to_h do |entry|
|
|
30
|
+
anchor = entry.key.gsub(/[^A-Za-z0-9_.:-]/, "-")
|
|
31
|
+
seen[anchor] += 1
|
|
32
|
+
[entry.key, seen[anchor] > 1 ? "#{anchor}-#{seen[anchor]}" : anchor]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def marker(links, locator, style)
|
|
37
|
+
tail = locator ? ", #{CGI.escapeHTML(locator)}" : ""
|
|
38
|
+
if style == "numeric"
|
|
39
|
+
%(<span class="datalog-cite">[#{links.join(', ')}#{tail}]</span>)
|
|
40
|
+
else
|
|
41
|
+
%(<span class="datalog-cite">(#{links.join('; ')}#{tail})</span>)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def locator_text(value, words)
|
|
46
|
+
return nil if value.to_s.empty?
|
|
47
|
+
|
|
48
|
+
kind, text = CGI.unescapeHTML(value).split("|", 2)
|
|
49
|
+
kind == "loc" ? text : "#{words[:locators][kind]} #{text}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def list_html(entries, labels, anchors, backlinks, style, words)
|
|
53
|
+
return "" if entries.empty?
|
|
54
|
+
|
|
55
|
+
tag = style == "numeric" ? "ol" : "ul"
|
|
56
|
+
items = entries.map do |entry|
|
|
57
|
+
suffix = style == "author-year" ? labels[entry.key][/\d([a-z])\z/, 1].to_s : ""
|
|
58
|
+
back = backlinks_html(backlinks[entry.key], words)
|
|
59
|
+
%(<li id="cite-#{anchors[entry.key]}">#{entry.reference_html(words, suffix: suffix)}#{back}</li>)
|
|
60
|
+
end
|
|
61
|
+
%(<#{tag} class="datalog-bibliography datalog-bibliography--#{style}">#{items.join}</#{tag}>)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A return arrow for one citation; the arrow and a b c for several, each
|
|
65
|
+
# letter a link back to its place in the text.
|
|
66
|
+
def backlinks_html(refs, words)
|
|
67
|
+
return "" if refs.empty?
|
|
68
|
+
|
|
69
|
+
links = refs.each_with_index.map do |ref, index|
|
|
70
|
+
label = CGI.escapeHTML(words[:back].gsub("{{n}}", (index + 1).to_s))
|
|
71
|
+
text = refs.size == 1 ? "↩" : ("a".ord + index).chr
|
|
72
|
+
%(<a href="##{ref}" aria-label="#{label}">#{text}</a>)
|
|
73
|
+
end
|
|
74
|
+
prefix = refs.size == 1 ? "" : "↩ "
|
|
75
|
+
%( <span class="datalog-bibliography__back">#{prefix}#{links.join(' ')}</span>)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/datalog/cli.rb
CHANGED
|
@@ -9,13 +9,117 @@ require "tmpdir"
|
|
|
9
9
|
require "yaml"
|
|
10
10
|
require "thor"
|
|
11
11
|
|
|
12
|
+
require_relative "site_config"
|
|
12
13
|
require_relative "slug"
|
|
13
14
|
require_relative "theme/version"
|
|
15
|
+
require_relative "packages/command"
|
|
14
16
|
|
|
15
17
|
module Datalog
|
|
18
|
+
module ArchiveScaffold
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def scaffold_archive
|
|
22
|
+
type = options[:type].to_s.downcase
|
|
23
|
+
unless %w[year tag category topic].include?(type)
|
|
24
|
+
say_status :error, "Archive type must be year, tag, category, or topic", :red
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
path = options[:path] || { "year" => "/archives/years/", "tag" => "/tags/",
|
|
29
|
+
"category" => "/categories/", "topic" => "/archives/topic/" }.fetch(type)
|
|
30
|
+
unless path.match?(%r{\A/[a-zA-Z0-9/_-]+/\z}) && !path.include?("//")
|
|
31
|
+
say_status :error, "Archive path must be a site URL such as /tags/", :red
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
title = options[:title] || { "year" => "Years", "tag" => "Tags", "category" => "Categories",
|
|
36
|
+
"topic" => "Topic" }.fetch(type)
|
|
37
|
+
file = File.join(ensure_directory!("_pages"), "archive-#{type}.md")
|
|
38
|
+
if File.exist?(file)
|
|
39
|
+
say_status :error, "Archive already exists: #{file}", :red
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
topic = type == "topic" ? "topic:\n tags: [example-topic]\n categories: []\n featured: []\n" : ""
|
|
44
|
+
front = "---\nlayout: archive\ntitle: #{title.to_json}\narchive: #{type}\n"
|
|
45
|
+
create_file(file, "#{front}permalink: #{path.to_json}\n#{topic}---\n\n")
|
|
46
|
+
say_status :create, relative_to_root(file), :green
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Publishing to a branch through a temporary git worktree: `datalog publish`.
|
|
51
|
+
module PublishWorktree
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def remote_branch?(root, branch)
|
|
55
|
+
system("git", "ls-remote", "--exit-code", "origin", "refs/heads/#{branch}", out: File::NULL, err: File::NULL,
|
|
56
|
+
chdir: root)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def prepare_worktree(root, branch, worktree_path)
|
|
60
|
+
unless system("git", "show-ref", "--verify", "--quiet", "refs/heads/#{branch}", chdir: root)
|
|
61
|
+
say_status :info, "Creating #{branch} branch", :blue
|
|
62
|
+
if remote_branch?(root, branch)
|
|
63
|
+
system("git", "branch", branch, "origin/#{branch}", chdir: root)
|
|
64
|
+
else
|
|
65
|
+
system("git", "branch", branch, chdir: root)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
say_status :git, "git worktree add --force #{worktree_path} #{branch}", :blue
|
|
70
|
+
return if system("git", "worktree", "add", "--force", worktree_path, branch, chdir: root)
|
|
71
|
+
|
|
72
|
+
say_error "Unable to create git worktree for #{branch}."
|
|
73
|
+
exit 1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def copy_site_output(root, worktree_path)
|
|
77
|
+
say_status :sync, "Copying _site to #{worktree_path}", :blue
|
|
78
|
+
FileUtils.rm_rf(Dir.glob(File.join(worktree_path, "*"),
|
|
79
|
+
File::FNM_DOTMATCH) - [File.join(worktree_path, "."), File.join(worktree_path, ".."),
|
|
80
|
+
File.join(worktree_path, ".git")])
|
|
81
|
+
Dir.glob(File.join(root, "_site", "*"), File::FNM_DOTMATCH).each do |entry|
|
|
82
|
+
next if [".", ".."].include?(File.basename(entry))
|
|
83
|
+
|
|
84
|
+
FileUtils.cp_r(entry, worktree_path, preserve: true)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns whether the branch now holds the site. The exit status of commit
|
|
89
|
+
# and push used to be ignored, so a rejected push still ended as a publish.
|
|
90
|
+
def commit_and_push(worktree_path, branch, message)
|
|
91
|
+
Dir.chdir(worktree_path) do
|
|
92
|
+
system("git", "add", "--all")
|
|
93
|
+
if system("git", "diff", "--cached", "--quiet")
|
|
94
|
+
say_status :skip, "No changes to publish", :yellow
|
|
95
|
+
return true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
unless system("git", "commit", "-m", message)
|
|
99
|
+
say_error "git commit failed, so nothing was published."
|
|
100
|
+
return false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
say_status :git, "git push origin #{branch}", :blue
|
|
104
|
+
return true if system("git", "push", "origin", branch)
|
|
105
|
+
|
|
106
|
+
say_error "git push to #{branch} failed, so the site was not published."
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cleanup_worktree(root, worktree_path)
|
|
112
|
+
say_status :git, "git worktree remove --force #{worktree_path}", :blue
|
|
113
|
+
system("git", "worktree", "remove", "--force", worktree_path, chdir: root)
|
|
114
|
+
ensure
|
|
115
|
+
FileUtils.rm_rf(File.dirname(worktree_path)) if worktree_path && File.directory?(File.dirname(worktree_path))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
16
119
|
# Command line interface for automating common theme workflows.
|
|
17
120
|
class CLI < Thor
|
|
18
121
|
include Thor::Actions
|
|
122
|
+
include Datalog::PublishWorktree
|
|
19
123
|
|
|
20
124
|
class_option :root,
|
|
21
125
|
type: :string,
|
|
@@ -62,6 +166,36 @@ module Datalog
|
|
|
62
166
|
exit 1 unless critical_failures.empty?
|
|
63
167
|
end
|
|
64
168
|
|
|
169
|
+
desc "audit", "Report pages that would gain from newer authoring features, and content problems"
|
|
170
|
+
method_option :strict, type: :boolean, default: false, desc: "Exit 1 when the audit finds a problem"
|
|
171
|
+
method_option :format, type: :string, default: "text", enum: %w[text json markdown], desc: "Output format"
|
|
172
|
+
method_option :only, type: :string, desc: "Comma-separated checks to run, such as statements,series"
|
|
173
|
+
method_option :path, type: :string, desc: "Audit only the files under this path, such as _posts/statistics"
|
|
174
|
+
long_desc <<~DESC
|
|
175
|
+
Reads the site's pages and posts, without building or writing anything, and
|
|
176
|
+
lists two kinds of finding with the file and line of each. Opportunities are
|
|
177
|
+
content the theme's newer features would number, link or describe (statements
|
|
178
|
+
and figure numbers typed by hand, parts of a series, links to code with no
|
|
179
|
+
reproducibility block, edits with no revision entry, hand-written references).
|
|
180
|
+
Problems are front matter keys nothing reads, images without alt text, links
|
|
181
|
+
to pages the site does not build, and math switched on or off against the
|
|
182
|
+
page's content. Exit codes: 0, or 1 with --strict when there is a problem;
|
|
183
|
+
2 when the audit cannot run.
|
|
184
|
+
DESC
|
|
185
|
+
def audit
|
|
186
|
+
require_relative "audit"
|
|
187
|
+
report = Audit.new(root: site_root, only: options[:only], path: options[:path]).run
|
|
188
|
+
case options[:format]
|
|
189
|
+
when "json" then say report.to_json
|
|
190
|
+
when "markdown" then say report.to_markdown
|
|
191
|
+
else say report.to_text
|
|
192
|
+
end
|
|
193
|
+
exit 1 if options[:strict] && report.problems.any?
|
|
194
|
+
rescue Audit::Error => e
|
|
195
|
+
say_error e.message
|
|
196
|
+
exit 2
|
|
197
|
+
end
|
|
198
|
+
|
|
65
199
|
desc "publish", "Build the site and deploy the _site artifacts to GitHub Pages"
|
|
66
200
|
method_option :branch,
|
|
67
201
|
type: :string,
|
|
@@ -108,35 +242,30 @@ module Datalog
|
|
|
108
242
|
end
|
|
109
243
|
|
|
110
244
|
desc "update", "Update the DataLog theme and related assets to the latest version"
|
|
245
|
+
method_option :to, type: :string, default: "latest", desc: "Release tag to check out (default: latest stable)"
|
|
246
|
+
method_option :dry_run, type: :boolean, default: false, desc: "Show the update without changing files"
|
|
247
|
+
method_option :build, type: :boolean, default: false, desc: "Build the site in a temporary directory after updating"
|
|
111
248
|
long_desc <<~DESC
|
|
112
|
-
|
|
113
|
-
|
|
249
|
+
For a path-installed Git checkout, checks out a release, builds its script
|
|
250
|
+
bundles, installs site gems, checks for stale files and stages the submodule.
|
|
251
|
+
For a published gem, updates Bundler and the site's npm packages.
|
|
252
|
+
Exit codes: 0 already current, 2 updated, 1 failed.
|
|
114
253
|
DESC
|
|
115
254
|
def update
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
package_json = File.join(root, "package.json")
|
|
127
|
-
if File.exist?(package_json) && command_available?("npm")
|
|
128
|
-
# npm's exit status used to be dropped, so a failed install still ended
|
|
129
|
-
# with "Theme dependencies are up to date!".
|
|
130
|
-
run_or_exit(:npm, "npm install", root, "npm could not install the site's packages.")
|
|
131
|
-
elsif File.exist?(package_json)
|
|
132
|
-
say_status :warn, "Node.js tooling not available—skipping npm install", :yellow
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
say "Theme dependencies are up to date!"
|
|
255
|
+
require_relative "theme/updater"
|
|
256
|
+
status = Theme::Updater.new(root: site_root, options: options,
|
|
257
|
+
report: ->(message) { say(message) },
|
|
258
|
+
execute: ->(env, command, dir) { system(env, command, chdir: dir) },
|
|
259
|
+
available: ->(command) { command_available?(command) }).run
|
|
260
|
+
exit status if status == 2
|
|
261
|
+
rescue Theme::Updater::Error => e
|
|
262
|
+
say_error e.message
|
|
263
|
+
exit 1
|
|
136
264
|
end
|
|
137
265
|
|
|
138
266
|
class New < Thor
|
|
139
267
|
include Thor::Actions
|
|
268
|
+
include Datalog::ArchiveScaffold
|
|
140
269
|
|
|
141
270
|
class_option :root,
|
|
142
271
|
type: :string,
|
|
@@ -239,6 +368,14 @@ module Datalog
|
|
|
239
368
|
say_status :create, relative_to_root(filename), :green
|
|
240
369
|
end
|
|
241
370
|
|
|
371
|
+
desc "archive", "Scaffold a year, tag, category, or topic archive page"
|
|
372
|
+
method_option :type, type: :string, default: "year", desc: "Archive type: year, tag, category, or topic"
|
|
373
|
+
method_option :title, type: :string, desc: "Page title"
|
|
374
|
+
method_option :path, type: :string, desc: "URL path, such as /archives/years/"
|
|
375
|
+
def archive
|
|
376
|
+
scaffold_archive
|
|
377
|
+
end
|
|
378
|
+
|
|
242
379
|
desc "notebook", "Generate a notebook landing page and starter .ipynb file"
|
|
243
380
|
method_option :title, type: :string, aliases: "-t", desc: "Title for the notebook"
|
|
244
381
|
method_option :language, type: :string, aliases: "-l", default: "python", desc: "Primary notebook language"
|
|
@@ -423,13 +560,15 @@ module Datalog
|
|
|
423
560
|
|
|
424
561
|
desc "critical-css", "Write the critical CSS a production build inlines, into _includes/critical-css"
|
|
425
562
|
method_option :critical, type: :array,
|
|
426
|
-
desc: "Command that runs critical (default: node_modules/.bin/critical or npx critical@
|
|
563
|
+
desc: "Command that runs critical (default: node_modules/.bin/critical or npx critical@9)"
|
|
427
564
|
def critical_css
|
|
428
565
|
require_relative "critical_css"
|
|
429
566
|
CriticalCss.command(site_root, options[:critical], ->(*line) { say_status(*line) })
|
|
430
567
|
end
|
|
431
568
|
|
|
432
|
-
register(New, "new", "new COMMAND", "Scaffold posts, notebooks, and portfolio projects")
|
|
569
|
+
register(New, "new", "new COMMAND", "Scaffold posts, archives, notebooks, and portfolio projects")
|
|
570
|
+
register(PackagesCommand, "packages", "packages COMMAND",
|
|
571
|
+
"Read package releases from their registries into _data/package_releases.yml")
|
|
433
572
|
|
|
434
573
|
private
|
|
435
574
|
|
|
@@ -522,7 +661,7 @@ module Datalog
|
|
|
522
661
|
end
|
|
523
662
|
|
|
524
663
|
def load_config(path)
|
|
525
|
-
|
|
664
|
+
SiteConfig.load(path)
|
|
526
665
|
end
|
|
527
666
|
|
|
528
667
|
def summarize_checks(critical, warnings)
|
|
@@ -547,70 +686,6 @@ module Datalog
|
|
|
547
686
|
end
|
|
548
687
|
end
|
|
549
688
|
|
|
550
|
-
def remote_branch?(root, branch)
|
|
551
|
-
system("git", "ls-remote", "--exit-code", "origin", "refs/heads/#{branch}", out: File::NULL, err: File::NULL,
|
|
552
|
-
chdir: root)
|
|
553
|
-
end
|
|
554
|
-
|
|
555
|
-
def prepare_worktree(root, branch, worktree_path)
|
|
556
|
-
unless system("git", "show-ref", "--verify", "--quiet", "refs/heads/#{branch}", chdir: root)
|
|
557
|
-
say_status :info, "Creating #{branch} branch", :blue
|
|
558
|
-
if remote_branch?(root, branch)
|
|
559
|
-
system("git", "branch", branch, "origin/#{branch}", chdir: root)
|
|
560
|
-
else
|
|
561
|
-
system("git", "branch", branch, chdir: root)
|
|
562
|
-
end
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
say_status :git, "git worktree add --force #{worktree_path} #{branch}", :blue
|
|
566
|
-
return if system("git", "worktree", "add", "--force", worktree_path, branch, chdir: root)
|
|
567
|
-
|
|
568
|
-
say_error "Unable to create git worktree for #{branch}."
|
|
569
|
-
exit 1
|
|
570
|
-
end
|
|
571
|
-
|
|
572
|
-
def copy_site_output(root, worktree_path)
|
|
573
|
-
say_status :sync, "Copying _site to #{worktree_path}", :blue
|
|
574
|
-
FileUtils.rm_rf(Dir.glob(File.join(worktree_path, "*"),
|
|
575
|
-
File::FNM_DOTMATCH) - [File.join(worktree_path, "."), File.join(worktree_path, ".."),
|
|
576
|
-
File.join(worktree_path, ".git")])
|
|
577
|
-
Dir.glob(File.join(root, "_site", "*"), File::FNM_DOTMATCH).each do |entry|
|
|
578
|
-
next if [".", ".."].include?(File.basename(entry))
|
|
579
|
-
|
|
580
|
-
FileUtils.cp_r(entry, worktree_path, preserve: true)
|
|
581
|
-
end
|
|
582
|
-
end
|
|
583
|
-
|
|
584
|
-
# Returns whether the branch now holds the site. The exit status of commit
|
|
585
|
-
# and push used to be ignored, so a rejected push still ended as a publish.
|
|
586
|
-
def commit_and_push(worktree_path, branch, message)
|
|
587
|
-
Dir.chdir(worktree_path) do
|
|
588
|
-
system("git", "add", "--all")
|
|
589
|
-
if system("git", "diff", "--cached", "--quiet")
|
|
590
|
-
say_status :skip, "No changes to publish", :yellow
|
|
591
|
-
return true
|
|
592
|
-
end
|
|
593
|
-
|
|
594
|
-
unless system("git", "commit", "-m", message)
|
|
595
|
-
say_error "git commit failed, so nothing was published."
|
|
596
|
-
return false
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
say_status :git, "git push origin #{branch}", :blue
|
|
600
|
-
return true if system("git", "push", "origin", branch)
|
|
601
|
-
|
|
602
|
-
say_error "git push to #{branch} failed, so the site was not published."
|
|
603
|
-
false
|
|
604
|
-
end
|
|
605
|
-
end
|
|
606
|
-
|
|
607
|
-
def cleanup_worktree(root, worktree_path)
|
|
608
|
-
say_status :git, "git worktree remove --force #{worktree_path}", :blue
|
|
609
|
-
system("git", "worktree", "remove", "--force", worktree_path, chdir: root)
|
|
610
|
-
ensure
|
|
611
|
-
FileUtils.rm_rf(File.dirname(worktree_path)) if worktree_path && File.directory?(File.dirname(worktree_path))
|
|
612
|
-
end
|
|
613
|
-
|
|
614
689
|
def say_error(message)
|
|
615
690
|
say message, :red
|
|
616
691
|
end
|
data/lib/datalog/critical_css.rb
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
require "date"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "jekyll"
|
|
6
|
+
require "json"
|
|
7
|
+
require "nokogiri"
|
|
6
8
|
require "open3"
|
|
7
9
|
require "tmpdir"
|
|
8
10
|
require "yaml"
|
|
11
|
+
require_relative "site_config"
|
|
9
12
|
|
|
10
13
|
module Datalog
|
|
11
14
|
# Writes the critical CSS a production build inlines. head.html inlines
|
|
@@ -14,10 +17,24 @@ module Datalog
|
|
|
14
17
|
# rendering. Critical CSS depends on a site's own pages and styles, so the
|
|
15
18
|
# theme ships those files empty and each site writes its own with
|
|
16
19
|
# `datalog critical-css`.
|
|
20
|
+
#
|
|
21
|
+
# It runs critical 9, which has two engines. `render`, the default, opens
|
|
22
|
+
# the page in Playwright's Chromium at each viewport in `dimensions` and
|
|
23
|
+
# keeps the rules for what is painted there: the first screen, as critical 8
|
|
24
|
+
# measured it. `static` needs no browser: it keeps every rule the page's
|
|
25
|
+
# markup uses, which is more CSS but takes a second rather than a browser.
|
|
17
26
|
class CriticalCss
|
|
18
27
|
TARGETS = %w[home post default].freeze
|
|
28
|
+
ENGINES = %w[render static].freeze
|
|
19
29
|
DEFAULT_DIMENSIONS = [{ "width" => 1920, "height" => 1080 }, { "width" => 375, "height" => 667 }].freeze
|
|
20
|
-
PACKAGE = "critical@
|
|
30
|
+
PACKAGE = "critical@9"
|
|
31
|
+
# The render engine's browser. critical 9 leaves Playwright to the site, as
|
|
32
|
+
# an optional peer, so npx is asked for both.
|
|
33
|
+
PLAYWRIGHT = "playwright@1"
|
|
34
|
+
MAJOR = 9
|
|
35
|
+
# critical 9 needs it, as critical 8 did.
|
|
36
|
+
NODE = Gem::Version.new("22.13.0")
|
|
37
|
+
STYLESHEET = "assets/css/main.css"
|
|
21
38
|
|
|
22
39
|
class Error < StandardError; end
|
|
23
40
|
|
|
@@ -26,7 +43,7 @@ module Datalog
|
|
|
26
43
|
# `datalog critical-css`. status reports a line as the command's shell
|
|
27
44
|
# does; an error ends the command with exit status 1.
|
|
28
45
|
def self.command(root, critical, status)
|
|
29
|
-
config =
|
|
46
|
+
config = SiteConfig.load(File.join(root, "_config.yml"))
|
|
30
47
|
log = ->(message) { status.call(:critical, message, :blue) }
|
|
31
48
|
written = new(root, config["critical_css"], critical: critical, log: log).run
|
|
32
49
|
written.each_value { |path| status.call(:write, path, :green) }
|
|
@@ -36,7 +53,8 @@ module Datalog
|
|
|
36
53
|
end
|
|
37
54
|
|
|
38
55
|
# 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@
|
|
56
|
+
# the site's own node_modules/.bin/critical, or `npx --yes critical@9`
|
|
57
|
+
# (with Playwright for the render engine).
|
|
40
58
|
def initialize(root, settings, critical: nil, log: ->(_message) {})
|
|
41
59
|
@root = File.expand_path(root)
|
|
42
60
|
@settings = settings || {}
|
|
@@ -50,6 +68,15 @@ module Datalog
|
|
|
50
68
|
unless settings["enabled"] == true
|
|
51
69
|
raise Error, "critical_css.enabled is not true in _config.yml, so no page would inline critical CSS"
|
|
52
70
|
end
|
|
71
|
+
unless ENGINES.include?(engine)
|
|
72
|
+
raise Error, "critical_css.engine is #{engine.inspect}; it is render (the default) or static"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if settings["penthouse_options"]
|
|
76
|
+
@log.call("critical 9 has no penthouse options, so critical_css.penthouse_options is ignored")
|
|
77
|
+
end
|
|
78
|
+
command = critical_command
|
|
79
|
+
install_browser if command == npx_command && engine == "render"
|
|
53
80
|
|
|
54
81
|
Dir.mktmpdir("datalog-critical") do |site_dir|
|
|
55
82
|
@log.call("Building the site for production into a temporary directory")
|
|
@@ -60,8 +87,8 @@ module Datalog
|
|
|
60
87
|
next
|
|
61
88
|
end
|
|
62
89
|
|
|
63
|
-
@log.call("Extracting critical CSS for #{target} from #{page}")
|
|
64
|
-
written[target] = write(target, extract(site_dir, page))
|
|
90
|
+
@log.call("Extracting critical CSS for #{target} from #{page} (#{engine} engine)")
|
|
91
|
+
written[target] = write(target, extract(site_dir, page, command))
|
|
65
92
|
end
|
|
66
93
|
end
|
|
67
94
|
end
|
|
@@ -110,13 +137,12 @@ module Datalog
|
|
|
110
137
|
"default" unless html.match?(/<meta name="robots" content="noindex/)
|
|
111
138
|
end
|
|
112
139
|
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
def extract(site_dir, page)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
output, errors, status = Open3.capture3(*critical_command, *arguments, chdir: site_dir)
|
|
140
|
+
# The page goes to critical on its standard input, from the built site's
|
|
141
|
+
# root, so the stylesheet resolves there whatever the page's folder or
|
|
142
|
+
# the site's baseurl.
|
|
143
|
+
def extract(site_dir, page, command = critical_command)
|
|
144
|
+
html = page_for_critical(File.read(File.join(site_dir, page), encoding: "utf-8"))
|
|
145
|
+
output, errors, status = Open3.capture3(*command, *arguments, stdin_data: html, chdir: site_dir)
|
|
120
146
|
unless status.success?
|
|
121
147
|
raise Error, "critical could not extract CSS from #{page}: #{errors.strip.lines.last(5).join.strip}"
|
|
122
148
|
end
|
|
@@ -135,24 +161,103 @@ module Datalog
|
|
|
135
161
|
path
|
|
136
162
|
end
|
|
137
163
|
|
|
164
|
+
# The page as critical should read it: linking the site's stylesheet and
|
|
165
|
+
# nothing else, as critical 8's --css did. The Google Fonts stylesheet
|
|
166
|
+
# would bring @font-face rules for the font files a headless browser is
|
|
167
|
+
# served, and fetching it made each page take over a minute, so its
|
|
168
|
+
# preload goes with it, as do the hints that open connections to it. The
|
|
169
|
+
# Content Security Policy goes too: it would refuse the stylesheet the
|
|
170
|
+
# render engine adds to the page to measure it.
|
|
171
|
+
UNREAD = 'link[rel~="stylesheet"], link[rel~="preload"][as="style"], link[rel~="preload"][as="font"], ' \
|
|
172
|
+
'link[rel~="preconnect"], link[rel~="dns-prefetch"], style, noscript'
|
|
173
|
+
|
|
174
|
+
def page_for_critical(html)
|
|
175
|
+
document = Nokogiri::HTML5(html)
|
|
176
|
+
document.css(UNREAD).each(&:remove)
|
|
177
|
+
document.css("meta[http-equiv]").each do |meta|
|
|
178
|
+
meta.remove if meta["http-equiv"].casecmp?("content-security-policy")
|
|
179
|
+
end
|
|
180
|
+
head = document.at_css("head") || document.root.prepend_child(document.create_element("head"))
|
|
181
|
+
head.add_child(document.create_element("link", rel: "stylesheet", href: STYLESHEET))
|
|
182
|
+
document.to_html
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The arguments critical 9's command line takes: the engine, and for the
|
|
186
|
+
# render engine the viewports, as one list ("1920x1080,375x667").
|
|
187
|
+
def arguments
|
|
188
|
+
return ["--engine", "static"] if engine == "static"
|
|
189
|
+
|
|
190
|
+
dimensions = Array(settings["dimensions"]).grep(Hash)
|
|
191
|
+
dimensions = DEFAULT_DIMENSIONS if dimensions.empty?
|
|
192
|
+
viewports = dimensions.map { |entry| "#{entry['width']}x#{entry['height']}" }
|
|
193
|
+
["--engine", "render", "--dimensions", viewports.join(",")]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def engine
|
|
197
|
+
settings.fetch("engine", "render").to_s
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The command that runs critical: the one given, else the site's own,
|
|
201
|
+
# else npx's. The site's own must be critical 9: critical 8 takes other
|
|
202
|
+
# arguments, and every option this passes would be refused.
|
|
138
203
|
def critical_command
|
|
139
204
|
return @critical if @critical
|
|
140
205
|
|
|
206
|
+
check_node
|
|
141
207
|
local = File.join(root, "node_modules", ".bin", Gem.win_platform? ? "critical.cmd" : "critical")
|
|
142
|
-
File.file?(local)
|
|
208
|
+
return npx_command unless File.file?(local)
|
|
209
|
+
|
|
210
|
+
version = installed_version
|
|
211
|
+
if version && version.segments.first < MAJOR
|
|
212
|
+
raise Error, "This site's node_modules has critical #{version}, and `datalog critical-css` runs critical " \
|
|
213
|
+
"#{MAJOR}, whose command line differs. Update it: npm install --save-dev critical@#{MAJOR}" \
|
|
214
|
+
"#{' playwright' if engine == 'render'}"
|
|
215
|
+
end
|
|
216
|
+
[local]
|
|
143
217
|
end
|
|
144
218
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
219
|
+
# npx brings Playwright but not the browser it drives, which critical 8's
|
|
220
|
+
# Puppeteer downloaded as it installed. Playwright fetches it once; a later
|
|
221
|
+
# run finds it in place.
|
|
222
|
+
def install_browser
|
|
223
|
+
@log.call("Making sure Playwright's Chromium is installed for the render engine")
|
|
224
|
+
output, status = Open3.capture2e("npx", "--yes", PLAYWRIGHT, "install", "chromium")
|
|
225
|
+
return if status.success?
|
|
226
|
+
|
|
227
|
+
raise Error, "Playwright could not install Chromium: #{output.strip.lines.last(5).join.strip}"
|
|
149
228
|
end
|
|
150
229
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
230
|
+
def npx_command
|
|
231
|
+
return ["npx", "--yes", PACKAGE] if engine == "static"
|
|
232
|
+
|
|
233
|
+
["npx", "--yes", "--package", PACKAGE, "--package", PLAYWRIGHT, "critical"]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def installed_version
|
|
237
|
+
manifest = File.join(root, "node_modules", "critical", "package.json")
|
|
238
|
+
return unless File.file?(manifest)
|
|
239
|
+
|
|
240
|
+
Gem::Version.new(JSON.parse(File.read(manifest))["version"].to_s)
|
|
241
|
+
rescue JSON::ParserError, ArgumentError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# critical 9 needs Node.js 22.13; an older one fails on an import, with a
|
|
246
|
+
# message that does not say so.
|
|
247
|
+
def check_node
|
|
248
|
+
version = node_version
|
|
249
|
+
raise Error, "Node.js was not found; critical needs Node.js #{NODE} or later" unless version
|
|
250
|
+
return if Gem::Version.new(version) >= NODE
|
|
251
|
+
|
|
252
|
+
raise Error, "critical needs Node.js #{NODE} or later, and this is Node.js #{version}"
|
|
253
|
+
end
|
|
154
254
|
|
|
155
|
-
|
|
255
|
+
# "22.13.0", or nil when there is no node to ask.
|
|
256
|
+
def node_version
|
|
257
|
+
output, status = Open3.capture2e("node", "--version")
|
|
258
|
+
status.success? ? output[/\d+\.\d+\.\d+/] : nil
|
|
259
|
+
rescue SystemCallError
|
|
260
|
+
nil
|
|
156
261
|
end
|
|
157
262
|
|
|
158
263
|
def page_file(site_dir, url)
|