datalog-theme 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +4 -2
  5. data/_data/i18n/en.yml +55 -3
  6. data/_data/i18n/es.yml +55 -3
  7. data/_data/i18n/pt.yml +55 -3
  8. data/_includes/analytics/dashboard.html +24 -9
  9. data/_includes/components/academic-dashboard.html +168 -133
  10. data/_includes/components/author-bio.html +7 -5
  11. data/_includes/components/author-list.html +6 -5
  12. data/_includes/components/citation-tools.html +2 -1
  13. data/_includes/components/content-provenance.html +19 -1
  14. data/_includes/components/correction-report.html +15 -8
  15. data/_includes/components/hero.html +22 -15
  16. data/_includes/components/package-index.html +121 -0
  17. data/_includes/components/package-install.html +27 -50
  18. data/_includes/components/post-list.html +14 -0
  19. data/_includes/components/responsive-image.html +15 -21
  20. data/_includes/footer.html +1 -1
  21. data/_includes/head.html +70 -11
  22. data/_includes/layouts/default/article.html +76 -46
  23. data/_includes/meta/dataset-json.html +171 -0
  24. data/_includes/meta/math-config.html +15 -1
  25. data/_includes/meta/package-json.html +69 -0
  26. data/_includes/meta/person-json.html +36 -13
  27. data/_includes/meta/publisher.html +15 -0
  28. data/_includes/meta/schema.html +108 -23
  29. data/_includes/meta/scholarly.html +10 -5
  30. data/_includes/post/related-posts.html +17 -43
  31. data/_includes/scripts.html +2 -2
  32. data/_includes/search/index-data.json +10 -1
  33. data/_includes/search/page.html +11 -6
  34. data/_layouts/archive.html +67 -0
  35. data/_layouts/dataset.html +2 -1
  36. data/_layouts/default.html +13 -11
  37. data/_layouts/docs.html +53 -0
  38. data/_layouts/home.html +30 -1
  39. data/_layouts/notebook.html +5 -1
  40. data/_layouts/package.html +38 -21
  41. data/_layouts/portfolio.html +1 -0
  42. data/_layouts/post.html +21 -62
  43. data/_layouts/project.html +2 -1
  44. data/_layouts/research.html +14 -2
  45. data/_plugins/analytics_dashboard.rb +4 -1
  46. data/_plugins/archive.rb +114 -0
  47. data/_plugins/authors.rb +50 -1
  48. data/_plugins/citation_exports.rb +12 -1
  49. data/_plugins/config_validator.rb +77 -2
  50. data/_plugins/correction_fallback.rb +110 -0
  51. data/_plugins/image_optimizer.rb +129 -22
  52. data/_plugins/math_preprocessor.rb +7 -48
  53. data/_plugins/notebook_converter.rb +44 -5
  54. data/_plugins/packages.rb +84 -0
  55. data/_plugins/page_dates.rb +37 -0
  56. data/_plugins/references.rb +16 -2
  57. data/_plugins/related_posts.rb +149 -0
  58. data/_plugins/search_sections.rb +110 -0
  59. data/_plugins/site_identity.rb +42 -0
  60. data/_plugins/social_cards.rb +17 -0
  61. data/_sass/_academic-dashboard.scss +18 -30
  62. data/_sass/_base.scss +2 -0
  63. data/_sass/_components.scss +21 -23
  64. data/_sass/_docs.scss +143 -0
  65. data/_sass/_features.scss +6 -0
  66. data/_sass/_layout.scss +65 -6
  67. data/_sass/_mathematical.scss +61 -19
  68. data/_sass/_notebooks.scss +1 -32
  69. data/_sass/_package-docs.scss +6 -9
  70. data/_sass/_post-components.scss +23 -23
  71. data/_sass/_print.scss +1 -1
  72. data/_sass/_search-page.scss +75 -0
  73. data/_sass/_search.scss +17 -26
  74. data/_sass/_syntax-highlighting.scss +23 -1
  75. data/_sass/_theme.scss +1 -0
  76. data/_sass/_utilities.scss +87 -40
  77. data/_sass/_variables.scss +31 -29
  78. data/assets/css/main.scss +2 -1
  79. data/assets/js/dist/academic.js +1 -1
  80. data/assets/js/dist/analytics-dashboard.js +1 -1
  81. data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
  82. data/assets/js/dist/comments.js +1 -1
  83. data/assets/js/dist/contact.js +1 -1
  84. data/assets/js/dist/core.js +1 -1
  85. data/assets/js/dist/corrections.js +5 -1
  86. data/assets/js/dist/loader.js +1 -1
  87. data/assets/js/dist/math.js +2 -1
  88. data/assets/js/dist/moderation.js +1 -1
  89. data/assets/js/dist/reactions.js +1 -1
  90. data/assets/js/dist/search.js +1 -1
  91. data/assets/js/dist/sources.json +20 -16
  92. data/assets/js/dist/subscriptions.js +1 -1
  93. data/assets/js/loader.js +6 -2
  94. data/lib/datalog/audit/checks.rb +269 -0
  95. data/lib/datalog/audit/known_keys.rb +61 -0
  96. data/lib/datalog/audit/site_reader.rb +118 -0
  97. data/lib/datalog/audit/source_file.rb +85 -0
  98. data/lib/datalog/audit.rb +145 -0
  99. data/lib/datalog/citations/bibtex.rb +200 -0
  100. data/lib/datalog/citations/entry.rb +333 -0
  101. data/lib/datalog/citations/markup.rb +79 -0
  102. data/lib/datalog/cli.rb +164 -89
  103. data/lib/datalog/critical_css.rb +126 -21
  104. data/lib/datalog/latex_speech/words.json +88 -0
  105. data/lib/datalog/latex_speech.rb +355 -0
  106. data/lib/datalog/packages/command.rb +51 -0
  107. data/lib/datalog/packages/refresh.rb +187 -0
  108. data/lib/datalog/packages.rb +177 -0
  109. data/lib/datalog/plugin_system.rb +5 -0
  110. data/lib/datalog/plugins/citations.rb +325 -109
  111. data/lib/datalog/site_config.rb +26 -0
  112. data/lib/datalog/social_cards/font.rb +286 -0
  113. data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
  114. data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
  115. data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
  116. data/lib/datalog/social_cards/geometry.rb +51 -0
  117. data/lib/datalog/social_cards/outline.rb +54 -0
  118. data/lib/datalog/social_cards/plain_text.rb +258 -0
  119. data/lib/datalog/social_cards/template.rb +322 -0
  120. data/lib/datalog/social_cards/template.svg +17 -0
  121. data/lib/datalog/social_cards/typesetter.rb +178 -0
  122. data/lib/datalog/social_cards.rb +369 -0
  123. data/lib/datalog/theme/updater.rb +307 -0
  124. data/lib/datalog/theme/version.rb +1 -1
  125. metadata +44 -7
  126. data/_includes/components/enhanced-code-block.html +0 -212
  127. data/_includes/components/performance-monitor.html +0 -170
  128. data/_includes/components/viz-table-fallback.html +0 -19
  129. data/_plugins/datalog_bibliography.rb +0 -21
  130. data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +0 -1
@@ -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
- Runs Bundler to update the datalog-theme dependency to the latest compatible
113
- release and refreshes npm packages if a package.json file is present.
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
- root = site_root
117
- gemfile = gemfile_path(root)
118
-
119
- if File.exist?(gemfile)
120
- run_or_exit(:bundle, "bundle update datalog-theme", root, "Bundler could not update datalog-theme.",
121
- { "BUNDLE_GEMFILE" => gemfile })
122
- else
123
- say_status :skip, "No Gemfile detected—skipping Bundler update", :yellow
124
- end
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@8)"
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
- YAML.safe_load_file(path, permitted_classes: [Date, Time]) || {}
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
@@ -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@8"
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 = YAML.safe_load_file(File.join(root, "_config.yml"), permitted_classes: [Date, Time]) || {}
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@8`.
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
- # 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)
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) ? [local] : ["npx", "--yes", PACKAGE]
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
- 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']}"] }
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 penthouse_arguments
152
- options = settings["penthouse_options"]
153
- return [] unless options.is_a?(Hash)
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
- options.flat_map { |key, value| ["--penthouse-#{key}", value.to_s] }
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)