datalog-theme 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +188 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +25 -17
  5. data/_data/cdn-integrity.yml +0 -30
  6. data/_data/i18n/en.yml +297 -0
  7. data/_data/i18n/es.yml +297 -0
  8. data/_data/i18n/pt.yml +297 -0
  9. data/_data/js_manifest.json +16 -0
  10. data/_includes/analytics/dashboard.html +3 -1
  11. data/_includes/components/api-function.html +20 -1
  12. data/_includes/components/author-bio.html +21 -11
  13. data/_includes/components/author-list.html +32 -0
  14. data/_includes/components/citation-tools.html +33 -25
  15. data/_includes/components/comments-thread.html +90 -0
  16. data/_includes/components/contact-form.html +120 -0
  17. data/_includes/components/correction-report.html +82 -0
  18. data/_includes/components/enhanced-code-block.html +1 -1
  19. data/_includes/components/enhanced-toc.html +6 -7
  20. data/_includes/components/license-link.html +13 -0
  21. data/_includes/components/license-notice.html +28 -0
  22. data/_includes/components/moderation-inbox.html +139 -0
  23. data/_includes/components/reactions.html +49 -0
  24. data/_includes/components/reading-list.html +31 -0
  25. data/_includes/components/reading-mode-toggle.html +65 -0
  26. data/_includes/components/reading-state-bookmark.html +27 -0
  27. data/_includes/components/reading-state-panel.html +71 -0
  28. data/_includes/components/reproducibility.html +61 -0
  29. data/_includes/components/responsive-image.html +3 -3
  30. data/_includes/components/revision-history.html +39 -0
  31. data/_includes/components/revision-notice.html +35 -0
  32. data/_includes/components/series-nav.html +64 -0
  33. data/_includes/components/subscribe-form.html +88 -0
  34. data/_includes/components/subscription-manage.html +68 -0
  35. data/_includes/components/webmentions.html +48 -0
  36. data/_includes/csp-meta.html +135 -11
  37. data/_includes/footer.html +22 -20
  38. data/_includes/head.html +111 -63
  39. data/_includes/header/navigation.html +12 -15
  40. data/_includes/header.html +28 -19
  41. data/_includes/layouts/default/article.html +9 -7
  42. data/_includes/meta/dynamic-services-config.html +14 -0
  43. data/_includes/meta/math-config.html +18 -10
  44. data/_includes/meta/person-json.html +27 -0
  45. data/_includes/meta/publisher.html +45 -0
  46. data/_includes/meta/schema.html +67 -30
  47. data/_includes/meta/scholarly.html +121 -0
  48. data/_includes/meta/scripts-loader.html +18 -32
  49. data/_includes/meta/webmention-discovery.html +14 -0
  50. data/_includes/post/related-posts.html +4 -7
  51. data/_includes/scripts.html +57 -0
  52. data/_includes/search/index-data.json +9 -34
  53. data/_layouts/dataset.html +5 -3
  54. data/_layouts/default.html +16 -8
  55. data/_layouts/notebook.html +1 -0
  56. data/_layouts/package.html +4 -2
  57. data/_layouts/page.html +15 -0
  58. data/_layouts/portfolio.html +1 -0
  59. data/_layouts/post.html +102 -23
  60. data/_layouts/project.html +4 -3
  61. data/_layouts/research.html +20 -8
  62. data/_plugins/analytics_dashboard.rb +9 -3
  63. data/_plugins/authors.rb +133 -0
  64. data/_plugins/config_validator.rb +231 -23
  65. data/_plugins/critical_css_check.rb +42 -0
  66. data/_plugins/csp_generator.rb +18 -28
  67. data/_plugins/datalog_bibliography.rb +9 -7
  68. data/_plugins/datalog_comments.rb +8 -5
  69. data/_plugins/datalog_slides.rb +9 -8
  70. data/_plugins/i18n.rb +13 -12
  71. data/_plugins/image_optimizer.rb +241 -178
  72. data/_plugins/licenses.rb +135 -0
  73. data/_plugins/math_preprocessor.rb +59 -7
  74. data/_plugins/notebook_converter.rb +23 -4
  75. data/_plugins/plugin_loader.rb +3 -1
  76. data/_plugins/publications_generator.rb +8 -2
  77. data/_plugins/references.rb +238 -0
  78. data/_plugins/reproducibility.rb +150 -0
  79. data/_plugins/revisions.rb +101 -0
  80. data/_plugins/rouge_highlight_filter.rb +42 -0
  81. data/_plugins/scholarly.rb +50 -0
  82. data/_plugins/search_code_blocks.rb +30 -0
  83. data/_plugins/search_normalizer.rb +15 -53
  84. data/_plugins/search_pages.rb +3 -4
  85. data/_plugins/series.rb +104 -0
  86. data/_plugins/statements.rb +87 -0
  87. data/_sass/_academic-dashboard.scss +262 -0
  88. data/_sass/_base.scss +20 -1
  89. data/_sass/_comments-thread.scss +159 -0
  90. data/_sass/_components.scss +64 -1153
  91. data/_sass/_features.scss +17 -0
  92. data/_sass/_layout.scss +385 -1
  93. data/_sass/_mathematical.scss +27 -0
  94. data/_sass/_moderation.scss +222 -0
  95. data/_sass/_notebooks.scss +322 -0
  96. data/_sass/_open-science-badges.scss +56 -0
  97. data/_sass/{_phase1-enhancements.scss → _post-components.scss} +5 -3
  98. data/_sass/_print.scss +291 -0
  99. data/_sass/_reactions.scss +89 -0
  100. data/_sass/_reading-state.scss +290 -0
  101. data/_sass/_search-page.scss +530 -0
  102. data/_sass/_search.scss +46 -0
  103. data/_sass/_service-forms.scss +204 -0
  104. data/_sass/_subscriptions.scss +140 -0
  105. data/_sass/_syntax-highlighting.scss +212 -97
  106. data/_sass/_theme.scss +40 -19
  107. data/_sass/_typography.scss +130 -0
  108. data/_sass/_utilities.scss +5 -0
  109. data/_sass/_variables.scss +6 -0
  110. data/_sass/_webmentions.scss +125 -0
  111. data/assets/css/main.scss +14 -0
  112. data/assets/js/dist/academic.js +1 -1
  113. data/assets/js/dist/analytics-dashboard.js +1 -1
  114. data/assets/js/dist/chunks/chunk-2DYDWUFX.js +1 -0
  115. data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
  116. data/assets/js/dist/chunks/chunk-V7734B2G.js +1 -0
  117. data/assets/js/dist/comments.js +2 -0
  118. data/assets/js/dist/contact.js +1 -0
  119. data/assets/js/dist/core.js +1 -1
  120. data/assets/js/dist/corrections.js +1 -0
  121. data/assets/js/dist/loader.js +1 -1
  122. data/assets/js/dist/math.js +1 -1
  123. data/assets/js/dist/moderation.js +1 -0
  124. data/assets/js/dist/notebook.js +1 -1
  125. data/assets/js/dist/reactions.js +1 -0
  126. data/assets/js/dist/reading-state.js +1 -0
  127. data/assets/js/dist/search.js +1 -1
  128. data/assets/js/dist/sources.json +52 -0
  129. data/assets/js/dist/subscriptions.js +1 -0
  130. data/assets/js/dist/visualizations.js +11 -2
  131. data/assets/js/dist/webmentions.js +1 -0
  132. data/assets/js/loader.js +37 -1
  133. data/datalog-theme.gemspec +35 -23
  134. data/lib/datalog/cli.rb +43 -15
  135. data/lib/datalog/critical_css.rb +168 -0
  136. data/lib/datalog/plugin_system/dependency_resolver.rb +0 -2
  137. data/lib/datalog/plugins/comments.rb +33 -3
  138. data/lib/datalog/theme/installed_files.rb +113 -0
  139. data/lib/datalog/theme/package.rb +57 -0
  140. data/lib/datalog/theme/repository_checkout.rb +94 -0
  141. data/lib/datalog/theme/version.rb +5 -1
  142. data/lib/datalog/warning_filter.rb +5 -11
  143. data/lib/datalog-theme.rb +6 -0
  144. metadata +96 -147
  145. data/_data/academic.yml +0 -217
  146. data/_data/config/author.yml +0 -121
  147. data/_data/datasets.yml +0 -28
  148. data/_data/js_meta.json +0 -371
  149. data/_data/navigation.yml +0 -145
  150. data/_data/projects.yml +0 -41
  151. data/_data/publications.yml +0 -28
  152. data/_data/social.yml +0 -73
  153. data/_data/visualizations.yml +0 -51
  154. data/_includes/components/advanced-search.html +0 -682
  155. data/_includes/components/bookmark-system.html +0 -96
  156. data/_includes/components/comments.html +0 -244
  157. data/_includes/components/content-recommendations.html +0 -228
  158. data/_includes/components/email-preferences.html +0 -200
  159. data/_includes/components/enhanced-metadata.html +0 -228
  160. data/_includes/components/language-switcher.html +0 -396
  161. data/_includes/components/navigation-enhancements.html +0 -454
  162. data/_includes/components/newsletter-signup.html +0 -178
  163. data/_includes/components/popular-posts.html +0 -233
  164. data/_includes/components/reading-progress.html +0 -133
  165. data/_includes/components/reading-time.html +0 -121
  166. data/_includes/components/series-navigation.html +0 -124
  167. data/_includes/components/social-proof.html +0 -34
  168. data/_includes/components/user-preferences.html +0 -566
  169. data/_includes/meta/syntax-config.html +0 -19
  170. data/_layouts/archive.html +0 -282
  171. data/_layouts/post-sidebar.html +0 -183
  172. data/_sass/_phase3-enhancements.scss +0 -874
  173. data/_sass/_phase4-enhancements.scss +0 -1214
  174. data/_sass/_phase5-enhancements.scss +0 -414
  175. data/assets/js/academic.js +0 -262
  176. data/assets/js/analytics-dashboard.js +0 -382
  177. data/assets/js/core/dark-mode.js +0 -79
  178. data/assets/js/core/github-cards.js +0 -123
  179. data/assets/js/core/language-filter.js +0 -69
  180. data/assets/js/core/navigation.js +0 -184
  181. data/assets/js/core/scroll-progress.js +0 -45
  182. data/assets/js/core/search-hotkeys.js +0 -62
  183. data/assets/js/core/skip-links.js +0 -62
  184. data/assets/js/dist/manifest.json +0 -22
  185. data/assets/js/dist/meta.json +0 -371
  186. data/assets/js/main.js +0 -23
  187. data/assets/js/math.js +0 -818
  188. data/assets/js/notebook.js +0 -158
  189. data/assets/js/search/analytics.js +0 -91
  190. data/assets/js/search/app.js +0 -271
  191. data/assets/js/search/autocomplete.js +0 -120
  192. data/assets/js/search/engine.js +0 -260
  193. data/assets/js/search/filters.js +0 -45
  194. data/assets/js/search/render.js +0 -217
  195. data/assets/js/search/utils.js +0 -99
  196. data/assets/js/search.js +0 -354
  197. data/assets/js/visualizations.js +0 -816
  198. data/assets/publications/datalog-publications.bib +0 -8
  199. data/assets/publications/datalog-publications.ris +0 -9
  200. data/assets/publications/publications.bib +0 -30
  201. data/assets/templates/diogo-ribeiro-cv.md +0 -31
  202. data/assets/templates/diogo-ribeiro-cv.tex +0 -32
  203. data/lib/datalog/theme/theme.rb +0 -18
@@ -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,23 +3,141 @@
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 },
14
69
  url: { type: :string, required: true, format: :url },
70
+ # `author: Jane Doe` is a common Jekyll setting; the hash adds an email and profile links.
15
71
  author: {
16
- type: :hash,
72
+ type: %i[string hash],
17
73
  required: true,
18
74
  schema: {
19
75
  name: { type: :string, required: true },
20
76
  email: { type: :string, format: :email }
21
77
  }
22
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
+ },
23
141
  markdown: { type: :string, enum: %w[kramdown commonmark] },
24
142
  highlighter: { type: :string, enum: %w[rouge pygments] },
25
143
  permalink: { type: :string },
@@ -69,10 +187,23 @@ module Datalog
69
187
  enabled: { type: :boolean }
70
188
  }
71
189
  },
72
- syntax_highlighting: {
190
+ # The "Reading mode" control on posts, and whether a reader's choice is kept.
191
+ reading_mode: {
73
192
  type: :hash,
74
193
  schema: {
75
- cdn: { type: :string }
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 }
76
207
  }
77
208
  }
78
209
  }
@@ -84,6 +215,15 @@ module Datalog
84
215
  replacement: "theme_options.math.engine",
85
216
  message: "'math_engine' has moved under theme_options.math.engine.",
86
217
  auto_migrate: true
218
+ },
219
+ "theme_options.syntax_highlighting" => {
220
+ message: "Code is highlighted by Rouge when the site builds and the theme no longer loads Prism, " \
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."
87
227
  }
88
228
  }.freeze
89
229
 
@@ -94,6 +234,32 @@ module Datalog
94
234
  }
95
235
  }.freeze
96
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
+
97
263
  class Validator
98
264
  attr_reader :errors, :warnings
99
265
 
@@ -109,6 +275,7 @@ module Datalog
109
275
 
110
276
  apply_deprecated_migrations
111
277
  validate_schema(@config, SCHEMA)
278
+ check_math_defaults
112
279
 
113
280
  self
114
281
  end
@@ -144,6 +311,36 @@ module Datalog
144
311
  end
145
312
  end
146
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
+
147
344
  def validate_schema(data, schema, path = [])
148
345
  schema.each do |key, rules|
149
346
  key_path = path + [key.to_s]
@@ -168,6 +365,10 @@ module Datalog
168
365
  end
169
366
 
170
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
+
171
372
  expected_type = rules[:type]
172
373
  if expected_type && !type_valid?(value, expected_type)
173
374
  errors << build_type_error(path, expected_type, value)
@@ -204,6 +405,8 @@ module Datalog
204
405
  end
205
406
 
206
407
  def type_valid?(value, expected_type)
408
+ return expected_type.any? { |type| type_valid?(value, type) } if expected_type.is_a?(Array)
409
+
207
410
  case expected_type
208
411
  when :string
209
412
  value.is_a?(String)
@@ -222,6 +425,8 @@ module Datalog
222
425
 
223
426
  def url_valid?(value)
224
427
  return false unless value.is_a?(String)
428
+ # `url: ""` is what `jekyll new` writes, and what a site built locally keeps.
429
+ return true if value.strip.empty?
225
430
 
226
431
  uri = URI.parse(value)
227
432
  uri.is_a?(URI::HTTP) && !uri.host.nil?
@@ -241,8 +446,7 @@ module Datalog
241
446
  headline: "Missing required configuration '#{identifier}'",
242
447
  path: path,
243
448
  expected: "Value required",
244
- actual: "nil",
245
- doc_url: documentation_url(path)
449
+ actual: "nil"
246
450
  }
247
451
  end
248
452
 
@@ -252,8 +456,7 @@ module Datalog
252
456
  headline: "Invalid type for '#{identifier}'",
253
457
  path: path,
254
458
  expected: human_type(expected_type),
255
- actual: value.inspect,
256
- doc_url: documentation_url(path)
459
+ actual: value.inspect
257
460
  }
258
461
  end
259
462
 
@@ -265,8 +468,7 @@ module Datalog
265
468
  path: path,
266
469
  expected: enum_values.map { |v| "\"#{v}\"" }.join(" or "),
267
470
  actual: value.inspect,
268
- suggestion: suggestion,
269
- doc_url: documentation_url(path)
471
+ suggestion: suggestion
270
472
  }
271
473
  end
272
474
 
@@ -276,8 +478,7 @@ module Datalog
276
478
  headline: "Invalid value for '#{identifier}'",
277
479
  path: path,
278
480
  expected: expectation,
279
- actual: value.inspect,
280
- doc_url: documentation_url(path)
481
+ actual: value.inspect
281
482
  }
282
483
  end
283
484
 
@@ -300,17 +501,14 @@ module Datalog
300
501
  lines << " Received: #{error[:actual]}" if error[:actual]
301
502
  lines << ""
302
503
  lines << " #{error[:suggestion]}" if error[:suggestion]
303
- lines << " Documentation: #{error[:doc_url]}"
504
+ lines << " Documentation: #{DOCUMENTATION_URL}"
304
505
  lines.compact!
305
506
  lines.join("\n")
306
507
  end
307
508
 
308
- def documentation_url(path)
309
- anchor = path.map { |segment| segment.gsub(/[^a-z0-9]+/i, "-") }.join("-").downcase
310
- "#{DOCUMENTATION_BASE_URL}##{anchor}"
311
- end
312
-
313
509
  def human_type(type)
510
+ return type.map { |entry| human_type(entry) }.join(" or ") if type.is_a?(Array)
511
+
314
512
  case type
315
513
  when :string then "a String"
316
514
  when :integer then "an Integer"
@@ -339,7 +537,8 @@ module Datalog
339
537
  head, *tail = path
340
538
  return nil if head.nil?
341
539
 
342
- 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]
343
542
  return value if tail.empty?
344
543
 
345
544
  dig_value(value, tail)
@@ -375,17 +574,26 @@ module Datalog
375
574
  end
376
575
  end
377
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.
378
583
  def generate(site)
584
+ return if @validated
585
+
379
586
  validator = Validator.new(site.config)
380
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))
381
590
 
382
- validator.warnings.each do |warning|
591
+ (validator.warnings + ThemeDirectories.warnings(site.config)).each do |warning|
383
592
  logger.warn("config", warning)
384
593
  end
385
594
 
386
- return if validator.errors.empty?
387
-
388
- raise Jekyll::Errors::FatalException, validator.formatted_errors
595
+ @validated = validator.errors.empty?
596
+ raise Jekyll::Errors::FatalException, validator.formatted_errors unless @validated
389
597
  end
390
598
 
391
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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require "digest"
5
4
 
6
5
  module Datalog
7
6
  module Security
@@ -18,7 +17,6 @@ module Datalog
18
17
  def generate(site)
19
18
  site.data["csp"] ||= {}
20
19
  site.data["csp"]["nonces"] ||= {}
21
- site.data["csp"]["hashes"] ||= {}
22
20
 
23
21
  assign_nonces(site, site.pages)
24
22
  site.collections.each_value do |collection|
@@ -40,7 +38,6 @@ module Datalog
40
38
 
41
39
  nonce = SecureRandom.base64(nonce_bytes)
42
40
  document.data["csp_nonce"] = nonce
43
- document.data["csp_hashes"] ||= []
44
41
 
45
42
  registry_site = site || (document.respond_to?(:site) ? document.site : nil)
46
43
  if registry_site.respond_to?(:data)
@@ -61,7 +58,12 @@ module Datalog
61
58
  end
62
59
  end
63
60
 
64
- def self.compute_hashes(document)
61
+ # Gives every inline script in the rendered page the page's nonce. The
62
+ # generator also took a SHA-256 of each inline script, but the policy is
63
+ # written into the head while the page renders, so the hashes of the
64
+ # finished page never reached it; with every script nonced they are not
65
+ # needed.
66
+ def self.add_nonces(document)
65
67
  return unless document.respond_to?(:output)
66
68
 
67
69
  output = document.output
@@ -77,39 +79,27 @@ module Datalog
77
79
  attributes = Regexp.last_match(1)
78
80
  "<script nonce=\"#{nonce}\"#{attributes}>"
79
81
  end
80
-
81
- document.output = output
82
-
83
- hashes = []
84
- output.scan(%r{<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>}mi) do |match|
85
- content = match.first
86
- next if content.nil? || content.empty?
87
-
88
- hashes << Digest::SHA256.base64digest(content)
82
+ # A template that printed page.csp_nonce before the page had one left
83
+ # nonce="", which authorises nothing.
84
+ document.output = output.gsub(/(<(?:script|style)\b[^>]*\bnonce=)""/i) do
85
+ "#{Regexp.last_match(1)}\"#{nonce}\""
89
86
  end
90
-
91
- hashes.uniq!
92
- document.data["csp_hashes"] = hashes
93
-
94
- return unless site.respond_to?(:data)
95
-
96
- site.data["csp"] ||= {}
97
- site.data["csp"]["hashes"] ||= {}
98
-
99
- key = document_key(document)
100
-
101
- site.data["csp"]["hashes"][key] = hashes if key
102
87
  end
103
88
  end
104
89
  end
105
90
  end
106
91
 
107
92
  %i[pages documents].each do |target|
108
- Jekyll::Hooks.register target, :pre_render do |document|
109
- Datalog::Security::CspGenerator.assign_nonce(document.respond_to?(:site) ? document.site : nil, document)
93
+ Jekyll::Hooks.register target, :pre_render do |document, payload|
94
+ nonce = Datalog::Security::CspGenerator.assign_nonce(document.respond_to?(:site) ? document.site : nil, document)
95
+ # A page another generator creates after this one runs, such as a notebook
96
+ # page, only gets its nonce here. Jekyll has already copied a page's data
97
+ # into the hash its templates read, so page.csp_nonce rendered empty there.
98
+ page_data = payload && payload["page"]
99
+ page_data["csp_nonce"] ||= nonce if nonce && page_data.is_a?(Hash)
110
100
  end
111
101
 
112
102
  Jekyll::Hooks.register target, :post_render do |document|
113
- Datalog::Security::CspGenerator.compute_hashes(document)
103
+ Datalog::Security::CspGenerator.add_nonces(document)
114
104
  end
115
105
  end
@@ -1,17 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jekyll
4
+ # Stands in for the `datalog_bibliography` tag when the datalog-citations
5
+ # plugin is not enabled, so a layout that uses the tag still builds. The plugin
6
+ # registers the real tag and sets `datalog_bibliography` on the pages it
7
+ # handles; a page that sets the key by hand without the plugin gets nothing and
8
+ # a build warning, where it used to get a "coming soon" notice.
4
9
  class DatalogBibliographyTag < Liquid::Tag
5
10
  def render(context)
6
11
  page = context.registers[:page]
7
- bib_data = page["datalog_bibliography"]
8
- return "" unless bib_data
12
+ return "" unless page["datalog_bibliography"]
9
13
 
10
- <<~HTML
11
- <div class="datalog-bibliography-placeholder">
12
- <p>Bibliography feature coming soon.</p>
13
- </div>
14
- HTML
14
+ Jekyll.logger.warn("datalog-citations", "#{page['path']} sets datalog_bibliography, " \
15
+ "but datalog_plugins.enabled does not list datalog-citations")
16
+ ""
15
17
  end
16
18
  end
17
19
  end