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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +4 -2
  5. data/_data/i18n/en.yml +297 -0
  6. data/_data/i18n/es.yml +297 -0
  7. data/_data/i18n/pt.yml +297 -0
  8. data/_data/js_manifest.json +16 -0
  9. data/_includes/components/author-bio.html +21 -11
  10. data/_includes/components/author-list.html +32 -0
  11. data/_includes/components/citation-tools.html +33 -25
  12. data/_includes/components/comments-thread.html +90 -0
  13. data/_includes/components/contact-form.html +120 -0
  14. data/_includes/components/correction-report.html +82 -0
  15. data/_includes/components/enhanced-toc.html +6 -7
  16. data/_includes/components/license-link.html +13 -0
  17. data/_includes/components/license-notice.html +28 -0
  18. data/_includes/components/moderation-inbox.html +139 -0
  19. data/_includes/components/reactions.html +49 -0
  20. data/_includes/components/reading-list.html +31 -0
  21. data/_includes/components/reading-mode-toggle.html +65 -0
  22. data/_includes/components/reading-state-bookmark.html +27 -0
  23. data/_includes/components/reading-state-panel.html +71 -0
  24. data/_includes/components/reproducibility.html +61 -0
  25. data/_includes/components/responsive-image.html +3 -3
  26. data/_includes/components/revision-history.html +39 -0
  27. data/_includes/components/revision-notice.html +35 -0
  28. data/_includes/components/series-nav.html +64 -0
  29. data/_includes/components/subscribe-form.html +88 -0
  30. data/_includes/components/subscription-manage.html +68 -0
  31. data/_includes/components/webmentions.html +48 -0
  32. data/_includes/csp-meta.html +21 -1
  33. data/_includes/footer.html +3 -0
  34. data/_includes/head.html +51 -40
  35. data/_includes/layouts/default/article.html +4 -6
  36. data/_includes/meta/dynamic-services-config.html +14 -0
  37. data/_includes/meta/math-config.html +9 -5
  38. data/_includes/meta/person-json.html +27 -0
  39. data/_includes/meta/publisher.html +45 -0
  40. data/_includes/meta/schema.html +62 -28
  41. data/_includes/meta/scholarly.html +121 -0
  42. data/_includes/meta/scripts-loader.html +2 -0
  43. data/_includes/meta/webmention-discovery.html +14 -0
  44. data/_includes/scripts.html +57 -0
  45. data/_layouts/dataset.html +4 -3
  46. data/_layouts/default.html +1 -1
  47. data/_layouts/package.html +3 -2
  48. data/_layouts/page.html +15 -0
  49. data/_layouts/post.html +82 -18
  50. data/_layouts/project.html +2 -2
  51. data/_layouts/research.html +20 -8
  52. data/_plugins/authors.rb +133 -0
  53. data/_plugins/config_validator.rb +223 -20
  54. data/_plugins/critical_css_check.rb +42 -0
  55. data/_plugins/i18n.rb +6 -4
  56. data/_plugins/image_optimizer.rb +224 -172
  57. data/_plugins/licenses.rb +135 -0
  58. data/_plugins/math_preprocessor.rb +32 -10
  59. data/_plugins/references.rb +238 -0
  60. data/_plugins/reproducibility.rb +150 -0
  61. data/_plugins/revisions.rb +101 -0
  62. data/_plugins/scholarly.rb +50 -0
  63. data/_plugins/series.rb +104 -0
  64. data/_plugins/statements.rb +87 -0
  65. data/_sass/_base.scss +8 -1
  66. data/_sass/_comments-thread.scss +159 -0
  67. data/_sass/_layout.scss +384 -0
  68. data/_sass/_mathematical.scss +27 -0
  69. data/_sass/_moderation.scss +222 -0
  70. data/_sass/_post-components.scss +4 -2
  71. data/_sass/_print.scss +291 -0
  72. data/_sass/_reactions.scss +89 -0
  73. data/_sass/_reading-state.scss +290 -0
  74. data/_sass/_service-forms.scss +204 -0
  75. data/_sass/_subscriptions.scss +140 -0
  76. data/_sass/_syntax-highlighting.scss +2 -2
  77. data/_sass/_theme.scss +11 -0
  78. data/_sass/_typography.scss +116 -0
  79. data/_sass/_utilities.scss +5 -0
  80. data/_sass/_variables.scss +6 -0
  81. data/_sass/_webmentions.scss +125 -0
  82. data/assets/js/dist/academic.js +1 -1
  83. data/assets/js/dist/analytics-dashboard.js +1 -1
  84. data/assets/js/dist/chunks/chunk-2DYDWUFX.js +1 -0
  85. data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
  86. data/assets/js/dist/chunks/chunk-V7734B2G.js +1 -0
  87. data/assets/js/dist/comments.js +2 -0
  88. data/assets/js/dist/contact.js +1 -0
  89. data/assets/js/dist/core.js +1 -1
  90. data/assets/js/dist/corrections.js +1 -0
  91. data/assets/js/dist/loader.js +1 -1
  92. data/assets/js/dist/math.js +1 -1
  93. data/assets/js/dist/moderation.js +1 -0
  94. data/assets/js/dist/notebook.js +1 -1
  95. data/assets/js/dist/reactions.js +1 -0
  96. data/assets/js/dist/reading-state.js +1 -0
  97. data/assets/js/dist/search.js +1 -1
  98. data/assets/js/dist/sources.json +52 -0
  99. data/assets/js/dist/subscriptions.js +1 -0
  100. data/assets/js/dist/webmentions.js +1 -0
  101. data/assets/js/loader.js +34 -0
  102. data/datalog-theme.gemspec +1 -2
  103. data/lib/datalog/cli.rb +9 -1
  104. data/lib/datalog/critical_css.rb +168 -0
  105. data/lib/datalog/plugins/comments.rb +33 -3
  106. data/lib/datalog/theme/installed_files.rb +113 -0
  107. data/lib/datalog/theme/repository_checkout.rb +6 -2
  108. data/lib/datalog/theme/version.rb +5 -1
  109. data/lib/datalog-theme.rb +1 -0
  110. metadata +57 -23
  111. data/assets/js/dist/chunks/chunk-225H5YXE.js +0 -1
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ # One list of a page's authors, and one of its contributors, which the
5
+ # byline, the author cards, the JSON-LD, the citation meta tags and the
6
+ # citation exports all read. Each used to work the author out on its own: a
7
+ # post named one author, `author: john_doe` put "john_doe" in the byline while
8
+ # the card looked the key up in _data/authors.yml, the research layout gave
9
+ # every author the site author's ORCID and affiliation, and the JSON-LD listed
10
+ # the site author's profiles only when an ORCID was set.
11
+ #
12
+ # An entry is a name, a key of _data/authors.yml, or a map with `name` (or
13
+ # `id`, a key of _data/authors.yml). The data record and, for the site's own
14
+ # author, `author` in _config.yml fill in what the entry leaves out.
15
+ module Authors
16
+ module_function
17
+
18
+ PROFILE_URLS = {
19
+ "github" => "https://github.com/%s",
20
+ "twitter" => "https://twitter.com/%s",
21
+ "linkedin" => "https://linkedin.com/in/%s"
22
+ }.freeze
23
+ PROFILE_LINKS = %w[researchgate google_scholar].freeze
24
+ ORCID_ID = /\A\d{4}-\d{4}-\d{4}-\d{3}[\dX]\z/
25
+
26
+ # `authors:`, else `author:`, else the site's author.
27
+ def authors(page, site)
28
+ entries = entries(value(page, "authors"))
29
+ entries = entries(value(page, "author")) if entries.empty?
30
+ records = entries.filter_map { |entry| resolve(entry, site) }
31
+ records = [site_author(site)].compact if records.empty?
32
+ apply_page_affiliation(records, page)
33
+ end
34
+
35
+ def contributors(page, site)
36
+ entries(value(page, "contributors")).filter_map { |entry| resolve(entry, site) }
37
+ end
38
+
39
+ # A list, one entry, or names in a string separated by semicolons.
40
+ def entries(value)
41
+ list = if value.is_a?(String) then value.split(";")
42
+ elsif value.is_a?(Hash) then [value]
43
+ else Array(value)
44
+ end
45
+ list.reject { |entry| entry.nil? || (entry.is_a?(String) && entry.strip.empty?) }
46
+ end
47
+
48
+ # A plain entry is a key or a name; a map's own fields win over the record's.
49
+ def resolve(entry, site)
50
+ own = entry.is_a?(Hash) ? present(entry) : {}
51
+ key = own["id"] || own["name"] || entry.to_s.strip
52
+ record = known(key, site).merge(own)
53
+ record["name"] ||= key
54
+ return if record["name"].to_s.strip.empty?
55
+
56
+ finish(record, site)
57
+ end
58
+
59
+ # The _data/authors.yml record for a key, or the site author's profile
60
+ # when the key is the site author's name.
61
+ def known(key, site)
62
+ data = value(value(site, "data"), "authors")
63
+ record = value(data, key.to_s)
64
+ return present(record) if record.is_a?(Hash)
65
+
66
+ author = site_author_profile(site)
67
+ author && author["name"] == key ? author : {}
68
+ end
69
+
70
+ def site_author(site)
71
+ profile = site_author_profile(site)
72
+ profile && finish(profile, site)
73
+ end
74
+
75
+ def site_author_profile(site)
76
+ author = value(site, "author")
77
+ return { "name" => author.strip } if author.is_a?(String) && !author.strip.empty?
78
+
79
+ present(author) if author.is_a?(Hash) && author["name"]
80
+ end
81
+
82
+ def finish(record, site)
83
+ record["affiliation"] ||= record["institution"]
84
+ record["bio"] ||= record["biography"]
85
+ record["image"] ||= record["avatar"] || record["photo"]
86
+ record["orcid"] = "https://orcid.org/#{record['orcid']}" if record["orcid"].to_s.match?(ORCID_ID)
87
+ record["site_author"] = record["name"] == site_author_profile(site)&.fetch("name")
88
+ record["same_as"] = profiles(record)
89
+ record
90
+ end
91
+
92
+ def profiles(record)
93
+ urls = [record["orcid"]]
94
+ urls += PROFILE_URLS.map { |key, template| format(template, record[key]) if record[key] }
95
+ urls += PROFILE_LINKS.map { |key| record[key] }
96
+ urls.compact.map(&:to_s).reject(&:empty?).uniq
97
+ end
98
+
99
+ # `author_affiliation` in front matter names the affiliation of a page's
100
+ # only author; an empty one removes it.
101
+ def apply_page_affiliation(records, page)
102
+ affiliation = value(page, "author_affiliation")
103
+ return records if affiliation.nil? || records.size != 1
104
+
105
+ affiliation.to_s.strip.empty? ? records.first.delete("affiliation") : records.first["affiliation"] = affiliation
106
+ records
107
+ end
108
+
109
+ # String keys, without the blank values an entry leaves for its record to fill.
110
+ def present(hash)
111
+ hash.to_h.each_with_object({}) do |(key, entry), result|
112
+ result[key.to_s] = entry unless entry.nil? || (entry.is_a?(String) && entry.strip.empty?)
113
+ end
114
+ end
115
+
116
+ # Pages are hashes in Liquid, documents and the site are drops; both answer [].
117
+ def value(object, key)
118
+ object[key] if object.respond_to?(:[]) && !object.is_a?(String) && !object.is_a?(Array)
119
+ end
120
+ end
121
+
122
+ module AuthorFilters
123
+ def page_authors(page)
124
+ Authors.authors(page, @context["site"])
125
+ end
126
+
127
+ def page_contributors(page)
128
+ Authors.contributors(page, @context["site"])
129
+ end
130
+ end
131
+ end
132
+
133
+ Liquid::Template.register_filter(Datalog::AuthorFilters)
@@ -3,11 +3,66 @@
3
3
  require "uri"
4
4
 
5
5
  module Datalog
6
+ # The dynamic-services settings are inlined into every page for the
7
+ # browser (docs/dynamic-services.md), so a key that names a secret is a
8
+ # leak whatever its value; the validator reports each one.
9
+ module PublicConfiguration
10
+ module_function
11
+
12
+ # `credentials` is fetch's cookie mode, not a secret, so "credential" is not in the list.
13
+ SECRET_KEY = /secret|token|password|passwd|api_key|apikey|private_key/i
14
+
15
+ # The blocks of _config.yml that reach the browser as they are. `moderation`
16
+ # is where an admin password is most tempting and least use: a static page
17
+ # cannot keep one (docs/moderation.md).
18
+ PUBLIC_BLOCKS = %w[dynamic_services moderation].freeze
19
+
20
+ def errors(config)
21
+ PUBLIC_BLOCKS.flat_map do |block|
22
+ settings = config[block]
23
+ next [] unless settings.is_a?(Hash)
24
+
25
+ secret_keys(settings, [block]).map { |path| secret_error(block, path) }
26
+ end
27
+ end
28
+
29
+ def secret_error(block, path)
30
+ {
31
+ headline: "Secret in public configuration '#{path.join('.')}'",
32
+ path: path,
33
+ expected: "no credential: #{block} is sent to every reader's browser, so a key, token or " \
34
+ "password belongs on the server, never in _config.yml",
35
+ actual: "a key named '#{path.last}'"
36
+ }
37
+ end
38
+
39
+ def secret_keys(hash, path)
40
+ hash.flat_map do |key, value|
41
+ here = path + [key.to_s]
42
+ found = key.to_s.match?(SECRET_KEY) ? [here] : []
43
+ value.is_a?(Hash) ? found + secret_keys(value, here) : found
44
+ end
45
+ end
46
+ end
47
+
6
48
  class ConfigValidator < Jekyll::Generator
7
49
  safe true
8
50
  priority :highest
9
51
 
10
- DOCUMENTATION_BASE_URL = "https://datalog-theme.github.io/docs/configuration-reference"
52
+ # docs/ is not part of the built site, so errors link to the file on GitHub.
53
+ DOCUMENTATION_URL = "https://github.com/DiogoRibeiro7/analytics-blog-jekyll/blob/main/docs/configuration-reference.md"
54
+
55
+ # A licence: an identifier such as CC-BY-4.0 or MIT, or a map naming one.
56
+ LICENSE = {
57
+ type: %i[string hash],
58
+ schema: {
59
+ id: { type: :string },
60
+ name: { type: :string },
61
+ url: { type: :string },
62
+ holder: { type: %i[string array] },
63
+ year: { type: %i[integer string] }
64
+ }
65
+ }.freeze
11
66
 
12
67
  SCHEMA = {
13
68
  title: { type: :string, required: true },
@@ -21,6 +76,68 @@ module Datalog
21
76
  email: { type: :string, format: :email }
22
77
  }
23
78
  },
79
+ # Who publishes the site in structured data and citations. Without it, the author does.
80
+ publisher: {
81
+ type: :hash,
82
+ schema: {
83
+ type: { type: :string, enum: %w[Person Organization] },
84
+ name: { type: :string },
85
+ url: { type: :string },
86
+ logo: { type: :string }
87
+ }
88
+ },
89
+ # The licence of the site's articles (text and figures) and of their code samples.
90
+ content_license: LICENSE,
91
+ code_license: LICENSE,
92
+ # Which pages get scholarly discovery metadata: true for every post, or a list of collections and layouts.
93
+ scholarly: { type: %i[boolean array string] },
94
+ dynamic_services: {
95
+ type: :hash,
96
+ schema: {
97
+ base_url: { type: :string, format: :url }, api_version: { type: %i[string integer] },
98
+ timeout_ms: { type: :integer }, credentials: { type: :string, enum: %w[omit same-origin include] },
99
+ features: { type: :hash }, paths: { type: :hash }, csrf_header: { type: :string }, csrf_cookie: { type: :string }
100
+ }
101
+ },
102
+ # The correction-report form on posts (components/correction-report.html).
103
+ corrections: {
104
+ type: :hash,
105
+ schema: { enabled: { type: :boolean }, categories: { type: :array } }
106
+ },
107
+ # The contact and collaboration form (components/contact-form.html).
108
+ contact: {
109
+ type: :hash,
110
+ schema: {
111
+ enabled: { type: :boolean }, categories: { type: :array }, prompts: { type: :hash },
112
+ privacy_notice: { type: :string }, retention: { type: :string }
113
+ }
114
+ },
115
+ # "Was this useful?" after a post (components/reactions.html).
116
+ reactions: {
117
+ type: :hash,
118
+ schema: { enabled: { type: :boolean }, counts: { type: :boolean }, types: { type: :array } }
119
+ },
120
+ # Webmentions: the receiver advertised in the head, the mentions shown under a post.
121
+ webmentions: {
122
+ type: :hash,
123
+ schema: { enabled: { type: :boolean }, endpoint: { type: :string }, types: { type: :array } }
124
+ },
125
+ # The moderation inbox (docs/moderation.md): public settings only, never a credential.
126
+ moderation: {
127
+ type: :hash,
128
+ schema: {
129
+ enabled: { type: :boolean }, endpoint: { type: :string }, sign_in_url: { type: :string },
130
+ credentials: { type: :string, enum: %w[omit same-origin include] }
131
+ }
132
+ },
133
+ # The newsletter form and the page its emails link to (components/subscribe-form.html).
134
+ subscriptions: {
135
+ type: :hash,
136
+ schema: {
137
+ enabled: { type: :boolean }, double_opt_in: { type: :boolean }, placement: { type: :array },
138
+ topics: { type: :array }, privacy_url: { type: :string }
139
+ }
140
+ },
24
141
  markdown: { type: :string, enum: %w[kramdown commonmark] },
25
142
  highlighter: { type: :string, enum: %w[rouge pygments] },
26
143
  permalink: { type: :string },
@@ -69,6 +186,25 @@ module Datalog
69
186
  engine: { type: :string, enum: %w[mathjax katex] },
70
187
  enabled: { type: :boolean }
71
188
  }
189
+ },
190
+ # The "Reading mode" control on posts, and whether a reader's choice is kept.
191
+ reading_mode: {
192
+ type: :hash,
193
+ schema: {
194
+ enabled: { type: :boolean },
195
+ remember: { type: :boolean }
196
+ }
197
+ },
198
+ # Bookmarks, reading progress and private highlights, kept in the reader's browser.
199
+ reading_state: {
200
+ type: :hash,
201
+ schema: {
202
+ enabled: { type: :boolean },
203
+ bookmarks: { type: :boolean },
204
+ progress: { type: :boolean },
205
+ highlights: { type: :boolean },
206
+ list_url: { type: :string }
207
+ }
72
208
  }
73
209
  }
74
210
  }
@@ -83,6 +219,11 @@ module Datalog
83
219
  "theme_options.syntax_highlighting" => {
84
220
  message: "Code is highlighted by Rouge when the site builds and the theme no longer loads Prism, " \
85
221
  "so these settings have no effect. Remove them."
222
+ },
223
+ # The user guide's troubleshooting table sent readers here.
224
+ "theme_options.math.enabled" => {
225
+ message: "Nothing reads it: theme_options.math.render_on_load decides which pages load the math engine, " \
226
+ "and a page's `math` front matter overrides that. Remove it."
86
227
  }
87
228
  }.freeze
88
229
 
@@ -93,6 +234,32 @@ module Datalog
93
234
  }
94
235
  }.freeze
95
236
 
237
+ # Pointing layouts_dir and the rest into a copy of the theme is how a site
238
+ # used a Git submodule before the theme loaded its own plugins. Jekyll
239
+ # cannot read the theme's assets or _data that way, so those were copied
240
+ # into the site, and the copies fall behind the theme on every update.
241
+ module ThemeDirectories
242
+ module_function
243
+
244
+ URL = "https://github.com/DiogoRibeiro7/analytics-blog-jekyll/blob/main/docs/install.md#keep-the-theme-in-a-git-submodule"
245
+ KEYS = [%w[layouts_dir], %w[includes_dir], %w[plugins_dir], %w[sass sass_dir]].freeze
246
+
247
+ def warnings(config)
248
+ source = File.expand_path(config["source"] || Dir.pwd)
249
+ keys = KEYS.select { |path| Array(config.dig(*path)).any? { |dir| theme_checkout?(dir, source) } }
250
+ return [] if keys.empty?
251
+
252
+ ["#{keys.map { |path| path.join('.') }.join(', ')} point into a copy of datalog-theme. Jekyll cannot " \
253
+ "read the theme's assets and _data that way, so a site copies them in, and the copies fall behind " \
254
+ "the theme. Use the copy as the theme instead: #{URL}"]
255
+ end
256
+
257
+ def theme_checkout?(dir, source)
258
+ checkout = File.dirname(File.expand_path(dir.to_s, source))
259
+ checkout != source && File.file?(File.join(checkout, "datalog-theme.gemspec"))
260
+ end
261
+ end
262
+
96
263
  class Validator
97
264
  attr_reader :errors, :warnings
98
265
 
@@ -108,6 +275,7 @@ module Datalog
108
275
 
109
276
  apply_deprecated_migrations
110
277
  validate_schema(@config, SCHEMA)
278
+ check_math_defaults
111
279
 
112
280
  self
113
281
  end
@@ -143,6 +311,36 @@ module Datalog
143
311
  end
144
312
  end
145
313
 
314
+ # With render_on_load: auto, only the pages with math load the math
315
+ # engine. `math: true` or `mathjax: true` in front matter defaults loads it
316
+ # on every page in their scope instead, and nothing in the build said so.
317
+ def check_math_defaults
318
+ return unless dig_value(@config, %w[theme_options math render_on_load]) == "auto"
319
+
320
+ Array(@config["defaults"]).each do |entry|
321
+ values = entry["values"] if entry.is_a?(Hash)
322
+ next unless values.is_a?(Hash)
323
+
324
+ %w[math mathjax].each do |key|
325
+ next unless values[key] == true
326
+
327
+ @warnings << "Front matter defaults set '#{key}: true' for #{describe_scope(entry['scope'])}, so they all " \
328
+ "load the math engine, whatever theme_options.math.render_on_load: auto finds. " \
329
+ "Remove it, and set 'math: true' on the pages that need the engine."
330
+ end
331
+ end
332
+ end
333
+
334
+ def describe_scope(scope)
335
+ scope = {} unless scope.is_a?(Hash)
336
+ type = scope["type"].to_s
337
+ path = scope["path"].to_s
338
+ return "every page" if type.empty? && path.empty?
339
+
340
+ pages = type.empty? ? "the pages" : "the pages of type '#{type}'"
341
+ path.empty? ? pages : "#{pages} under '#{path}'"
342
+ end
343
+
146
344
  def validate_schema(data, schema, path = [])
147
345
  schema.each do |key, rules|
148
346
  key_path = path + [key.to_s]
@@ -167,6 +365,10 @@ module Datalog
167
365
  end
168
366
 
169
367
  def validate_type(path, value, rules)
368
+ # YAML never yields a Symbol, but converters rewrite the site's own
369
+ # configuration: jekyll-sass-converter turns sass.style into one.
370
+ value = value.to_s if value.is_a?(Symbol)
371
+
170
372
  expected_type = rules[:type]
171
373
  if expected_type && !type_valid?(value, expected_type)
172
374
  errors << build_type_error(path, expected_type, value)
@@ -244,8 +446,7 @@ module Datalog
244
446
  headline: "Missing required configuration '#{identifier}'",
245
447
  path: path,
246
448
  expected: "Value required",
247
- actual: "nil",
248
- doc_url: documentation_url(path)
449
+ actual: "nil"
249
450
  }
250
451
  end
251
452
 
@@ -255,8 +456,7 @@ module Datalog
255
456
  headline: "Invalid type for '#{identifier}'",
256
457
  path: path,
257
458
  expected: human_type(expected_type),
258
- actual: value.inspect,
259
- doc_url: documentation_url(path)
459
+ actual: value.inspect
260
460
  }
261
461
  end
262
462
 
@@ -268,8 +468,7 @@ module Datalog
268
468
  path: path,
269
469
  expected: enum_values.map { |v| "\"#{v}\"" }.join(" or "),
270
470
  actual: value.inspect,
271
- suggestion: suggestion,
272
- doc_url: documentation_url(path)
471
+ suggestion: suggestion
273
472
  }
274
473
  end
275
474
 
@@ -279,8 +478,7 @@ module Datalog
279
478
  headline: "Invalid value for '#{identifier}'",
280
479
  path: path,
281
480
  expected: expectation,
282
- actual: value.inspect,
283
- doc_url: documentation_url(path)
481
+ actual: value.inspect
284
482
  }
285
483
  end
286
484
 
@@ -303,16 +501,11 @@ module Datalog
303
501
  lines << " Received: #{error[:actual]}" if error[:actual]
304
502
  lines << ""
305
503
  lines << " #{error[:suggestion]}" if error[:suggestion]
306
- lines << " Documentation: #{error[:doc_url]}"
504
+ lines << " Documentation: #{DOCUMENTATION_URL}"
307
505
  lines.compact!
308
506
  lines.join("\n")
309
507
  end
310
508
 
311
- def documentation_url(path)
312
- anchor = path.map { |segment| segment.gsub(/[^a-z0-9]+/i, "-") }.join("-").downcase
313
- "#{DOCUMENTATION_BASE_URL}##{anchor}"
314
- end
315
-
316
509
  def human_type(type)
317
510
  return type.map { |entry| human_type(entry) }.join(" or ") if type.is_a?(Array)
318
511
 
@@ -344,7 +537,8 @@ module Datalog
344
537
  head, *tail = path
345
538
  return nil if head.nil?
346
539
 
347
- value = data[head] || data[head.to_sym]
540
+ # `data[head] || data[head.to_sym]` turned a `false` into nil.
541
+ value = data.key?(head) ? data[head] : data[head.to_sym]
348
542
  return value if tail.empty?
349
543
 
350
544
  dig_value(value, tail)
@@ -380,17 +574,26 @@ module Datalog
380
574
  end
381
575
  end
382
576
 
577
+ # `jekyll serve` processes the same site again on every change, without
578
+ # reading _config.yml again, and by then converters have rewritten parts of
579
+ # the configuration: jekyll-sass-converter turns sass.style into a Symbol.
580
+ # Checking on every build stopped each rebuild, so a site is checked on its
581
+ # first build. A `:site, :after_init` hook would also run once, but Jekyll's
582
+ # command line reports an error raised there as a backtrace, not a message.
383
583
  def generate(site)
584
+ return if @validated
585
+
384
586
  validator = Validator.new(site.config)
385
587
  validator.run
588
+ # The Validator class is at its length limit; the public-configuration check lives beside it.
589
+ validator.errors.concat(PublicConfiguration.errors(site.config))
386
590
 
387
- validator.warnings.each do |warning|
591
+ (validator.warnings + ThemeDirectories.warnings(site.config)).each do |warning|
388
592
  logger.warn("config", warning)
389
593
  end
390
594
 
391
- return if validator.errors.empty?
392
-
393
- raise Jekyll::Errors::FatalException, validator.formatted_errors
595
+ @validated = validator.errors.empty?
596
+ raise Jekyll::Errors::FatalException, validator.formatted_errors unless @validated
394
597
  end
395
598
 
396
599
  private
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ # The theme ships _includes/critical-css/*.html empty, since critical CSS
5
+ # depends on a site's own pages. A production build with critical_css.enabled
6
+ # and no critical CSS written inlined nothing and kept main.css blocking
7
+ # rendering, and nothing said so.
8
+ module CriticalCssCheck
9
+ module_function
10
+
11
+ TARGETS = %w[home post default].freeze
12
+
13
+ def warning(site)
14
+ return unless Jekyll.env == "production"
15
+
16
+ settings = site.config["critical_css"]
17
+ return unless settings.is_a?(Hash) && settings["enabled"] == true
18
+
19
+ empty = TARGETS.select { |target| fragment(site, target).strip.empty? }
20
+ return if empty.empty?
21
+
22
+ files = empty.map { |target| "_includes/critical-css/#{target}.html" }.join(", ")
23
+ "critical_css.enabled is true, but #{files} #{empty.size == 1 ? 'is' : 'are'} empty, so pages with " \
24
+ "#{empty.size == 1 ? 'that layout' : 'those layouts'} inline no critical CSS and main.css blocks rendering. " \
25
+ "Run `bundle exec datalog critical-css` before building."
26
+ end
27
+
28
+ # The site's file, or the theme's when the site has none.
29
+ def fragment(site, target)
30
+ name = File.join("critical-css", "#{target}.html")
31
+ directories = [site.in_source_dir(site.config["includes_dir"].to_s)]
32
+ directories << site.theme.includes_path if site.theme&.includes_path
33
+ path = directories.map { |directory| File.join(directory, name) }.find { |candidate| File.file?(candidate) }
34
+ path ? File.read(path) : ""
35
+ end
36
+ end
37
+ end
38
+
39
+ Jekyll::Hooks.register :site, :post_read do |site|
40
+ warning = Datalog::CriticalCssCheck.warning(site)
41
+ Jekyll.logger.warn("Critical CSS:", warning) if warning
42
+ end
data/_plugins/i18n.rb CHANGED
@@ -5,11 +5,13 @@ module Datalog
5
5
  module_function
6
6
 
7
7
  def locale(context)
8
- page_lang = context["page"] && context["page"]["lang"]
9
- site = context.registers[:site]
8
+ locale_code(context.registers[:site], context["page"] && context["page"]["lang"])
9
+ end
10
+
11
+ # A page's language, or the site's, as a code such as "pt".
12
+ def locale_code(site, page_lang)
10
13
  site_locale = site.config.dig("theme_options", "localization", "default_locale") || site.config["locale"] || "en"
11
- lang = page_lang || site_locale
12
- lang.to_s.split(/[-_]/).first
14
+ (page_lang || site_locale).to_s.split(/[-_]/).first
13
15
  end
14
16
 
15
17
  def data_for(site)