datalog-theme 0.6.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 (180) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +162 -0
  3. data/CITATION.cff +29 -0
  4. data/LICENSE +21 -0
  5. data/README.md +257 -0
  6. data/_data/academic.yml +217 -0
  7. data/_data/cdn-integrity.yml +51 -0
  8. data/_data/config/author.yml +121 -0
  9. data/_data/config/features.yml +262 -0
  10. data/_data/config/site.yml +42 -0
  11. data/_data/config/theme.yml +181 -0
  12. data/_data/datasets.yml +28 -0
  13. data/_data/i18n/en.yml +438 -0
  14. data/_data/i18n/es.yml +438 -0
  15. data/_data/i18n/pt.yml +438 -0
  16. data/_data/js_manifest.json +22 -0
  17. data/_data/js_meta.json +371 -0
  18. data/_data/navigation.yml +145 -0
  19. data/_data/projects.yml +41 -0
  20. data/_data/publications.yml +28 -0
  21. data/_data/social.yml +73 -0
  22. data/_data/visualizations.yml +51 -0
  23. data/_includes/analytics/dashboard.html +149 -0
  24. data/_includes/analytics.html +9 -0
  25. data/_includes/components/academic-dashboard.html +347 -0
  26. data/_includes/components/advanced-search.html +682 -0
  27. data/_includes/components/api-function.html +94 -0
  28. data/_includes/components/author-bio.html +166 -0
  29. data/_includes/components/bookmark-system.html +96 -0
  30. data/_includes/components/breadcrumbs.html +89 -0
  31. data/_includes/components/citation-tools.html +94 -0
  32. data/_includes/components/comments.html +244 -0
  33. data/_includes/components/content-provenance.html +43 -0
  34. data/_includes/components/content-recommendations.html +228 -0
  35. data/_includes/components/difficulty-badge.html +84 -0
  36. data/_includes/components/email-preferences.html +200 -0
  37. data/_includes/components/enhanced-code-block.html +212 -0
  38. data/_includes/components/enhanced-metadata.html +228 -0
  39. data/_includes/components/enhanced-toc.html +209 -0
  40. data/_includes/components/hero.html +15 -0
  41. data/_includes/components/language-switcher.html +396 -0
  42. data/_includes/components/math-fallback.html +19 -0
  43. data/_includes/components/navigation-enhancements.html +454 -0
  44. data/_includes/components/newsletter-signup.html +178 -0
  45. data/_includes/components/open-science-badges.html +16 -0
  46. data/_includes/components/package-install.html +194 -0
  47. data/_includes/components/performance-monitor.html +170 -0
  48. data/_includes/components/popular-posts.html +233 -0
  49. data/_includes/components/post-hero.html +62 -0
  50. data/_includes/components/reading-progress.html +133 -0
  51. data/_includes/components/reading-time.html +121 -0
  52. data/_includes/components/responsive-image.html +63 -0
  53. data/_includes/components/search-facets.html +307 -0
  54. data/_includes/components/series-navigation.html +124 -0
  55. data/_includes/components/social-proof.html +34 -0
  56. data/_includes/components/social-share.html +119 -0
  57. data/_includes/components/user-preferences.html +566 -0
  58. data/_includes/components/visualization-card.html +24 -0
  59. data/_includes/components/viz-table-fallback.html +19 -0
  60. data/_includes/critical-css/default.html +0 -0
  61. data/_includes/critical-css/home.html +0 -0
  62. data/_includes/critical-css/post.html +0 -0
  63. data/_includes/csp-meta.html +17 -0
  64. data/_includes/footer/nav-column.html +31 -0
  65. data/_includes/footer.html +76 -0
  66. data/_includes/head.html +337 -0
  67. data/_includes/header/navigation.html +48 -0
  68. data/_includes/header.html +70 -0
  69. data/_includes/helpers/array-to-sentence.html +21 -0
  70. data/_includes/helpers/date-format.html +16 -0
  71. data/_includes/helpers/link-with-icon.html +26 -0
  72. data/_includes/layouts/default/article.html +67 -0
  73. data/_includes/meta/language-attributes.html +25 -0
  74. data/_includes/meta/math-config.html +21 -0
  75. data/_includes/meta/schema.html +153 -0
  76. data/_includes/meta/scripts-loader.html +52 -0
  77. data/_includes/post/related-posts.html +63 -0
  78. data/_includes/scripts.html +72 -0
  79. data/_includes/skip-link.html +1 -0
  80. data/_includes/toc.html +2 -0
  81. data/_layouts/archive.html +282 -0
  82. data/_layouts/dataset.html +52 -0
  83. data/_layouts/default.html +33 -0
  84. data/_layouts/home.html +48 -0
  85. data/_layouts/notebook.html +173 -0
  86. data/_layouts/package.html +238 -0
  87. data/_layouts/page.html +16 -0
  88. data/_layouts/portfolio.html +41 -0
  89. data/_layouts/post-sidebar.html +183 -0
  90. data/_layouts/post.html +338 -0
  91. data/_layouts/project.html +252 -0
  92. data/_layouts/research.html +504 -0
  93. data/_plugins/analytics_dashboard.rb +315 -0
  94. data/_plugins/config_validator.rb +397 -0
  95. data/_plugins/csp_generator.rb +115 -0
  96. data/_plugins/datalog_bibliography.rb +19 -0
  97. data/_plugins/datalog_comments.rb +18 -0
  98. data/_plugins/datalog_slides.rb +20 -0
  99. data/_plugins/front_matter_compat.rb +109 -0
  100. data/_plugins/i18n.rb +160 -0
  101. data/_plugins/image_optimizer.rb +445 -0
  102. data/_plugins/math_preprocessor.rb +185 -0
  103. data/_plugins/notebook_converter.rb +1107 -0
  104. data/_plugins/plugin_loader.rb +185 -0
  105. data/_plugins/publications_generator.rb +234 -0
  106. data/_plugins/reading_time.rb +15 -0
  107. data/_plugins/search_normalizer.rb +74 -0
  108. data/_plugins/sri_filter.rb +18 -0
  109. data/_plugins/toc_filter.rb +68 -0
  110. data/_plugins/warning_filter.rb +3 -0
  111. data/_sass/_base.scss +142 -0
  112. data/_sass/_components.scss +2380 -0
  113. data/_sass/_header.scss +470 -0
  114. data/_sass/_interactive.scss +344 -0
  115. data/_sass/_layout.scss +1760 -0
  116. data/_sass/_mathematical.scss +368 -0
  117. data/_sass/_mixins.scss +99 -0
  118. data/_sass/_package-docs.scss +715 -0
  119. data/_sass/_phase1-enhancements.scss +721 -0
  120. data/_sass/_phase3-enhancements.scss +874 -0
  121. data/_sass/_phase4-enhancements.scss +1214 -0
  122. data/_sass/_phase5-enhancements.scss +414 -0
  123. data/_sass/_search.scss +654 -0
  124. data/_sass/_syntax-highlighting.scss +128 -0
  125. data/_sass/_theme.scss +79 -0
  126. data/_sass/_typography.scss +148 -0
  127. data/_sass/_utilities.scss +334 -0
  128. data/_sass/_variables.scss +153 -0
  129. data/_sass/_visualizations.scss +242 -0
  130. data/assets/css/main.scss +4 -0
  131. data/assets/img/20220607123041_detail.001.png +0 -0
  132. data/assets/img/favicons/android-chrome-192x192.png +0 -0
  133. data/assets/img/favicons/android-chrome-512x512.png +0 -0
  134. data/assets/img/favicons/apple-touch-icon.png +0 -0
  135. data/assets/img/favicons/favicon-16x16.png +0 -0
  136. data/assets/img/favicons/favicon-32x32.png +0 -0
  137. data/assets/img/favicons/favicon.ico +0 -0
  138. data/assets/img/favicons/site.webmanifest +21 -0
  139. data/assets/img/portfolio-placeholder.svg +20 -0
  140. data/assets/js/academic.js +262 -0
  141. data/assets/js/analytics-dashboard.js +382 -0
  142. data/assets/js/core/dark-mode.js +79 -0
  143. data/assets/js/core/github-cards.js +123 -0
  144. data/assets/js/core/language-filter.js +69 -0
  145. data/assets/js/core/navigation.js +184 -0
  146. data/assets/js/core/scroll-progress.js +45 -0
  147. data/assets/js/core/search-hotkeys.js +62 -0
  148. data/assets/js/core/skip-links.js +62 -0
  149. data/assets/js/loader.js +163 -0
  150. data/assets/js/main.js +23 -0
  151. data/assets/js/math.js +818 -0
  152. data/assets/js/notebook.js +158 -0
  153. data/assets/js/search/analytics.js +91 -0
  154. data/assets/js/search/app.js +271 -0
  155. data/assets/js/search/autocomplete.js +120 -0
  156. data/assets/js/search/engine.js +260 -0
  157. data/assets/js/search/filters.js +38 -0
  158. data/assets/js/search/render.js +217 -0
  159. data/assets/js/search/utils.js +99 -0
  160. data/assets/js/search.js +354 -0
  161. data/assets/js/visualizations.js +816 -0
  162. data/assets/publications/datalog-publications.bib +8 -0
  163. data/assets/publications/datalog-publications.ris +9 -0
  164. data/assets/publications/publications.bib +30 -0
  165. data/assets/templates/diogo-ribeiro-cv.md +31 -0
  166. data/assets/templates/diogo-ribeiro-cv.tex +32 -0
  167. data/bin/datalog +26 -0
  168. data/datalog-theme.gemspec +72 -0
  169. data/lib/datalog/cli.rb +563 -0
  170. data/lib/datalog/plugin_system/dependency_resolver.rb +223 -0
  171. data/lib/datalog/plugin_system.rb +237 -0
  172. data/lib/datalog/plugins/citations.rb +182 -0
  173. data/lib/datalog/plugins/comments.rb +183 -0
  174. data/lib/datalog/plugins/search.rb +45 -0
  175. data/lib/datalog/plugins/slides.rb +121 -0
  176. data/lib/datalog/theme/theme.rb +18 -0
  177. data/lib/datalog/theme/version.rb +7 -0
  178. data/lib/datalog/warning_filter.rb +28 -0
  179. data/lib/datalog-theme.rb +17 -0
  180. metadata +555 -0
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "time"
6
+ require "stringio"
7
+ require "fileutils"
8
+
9
+ module Datalog
10
+ module Analytics
11
+ GA_SCOPE = "https://www.googleapis.com/auth/analytics.readonly"
12
+ CACHE_RELATIVE_PATH = ".jekyll-cache/datalog-analytics.json"
13
+ CACHE_TTL = 24 * 60 * 60
14
+ HTTP_TIMEOUTS = { open: 5, read: 15, write: 15 }.freeze
15
+ HTTP_MAX_RETRIES = 2
16
+ RETRYABLE_ERRORS = [
17
+ Timeout::Error,
18
+ Errno::ECONNRESET,
19
+ Errno::ETIMEDOUT,
20
+ SocketError,
21
+ Net::OpenTimeout,
22
+ Net::ReadTimeout
23
+ ].freeze
24
+
25
+ module_function
26
+
27
+ def fetch(site)
28
+ cache_path = cache_path_for(site)
29
+ cached = read_cache(cache_path)
30
+ return cached if fresh?(cached)
31
+
32
+ data = query_analytics(site)
33
+ data["fetched_at"] = Time.now.utc.iso8601
34
+ write_cache(cache_path, data)
35
+ data
36
+ rescue StandardError => e
37
+ Jekyll.logger.warn("Analytics", "Falling back to cached analytics data: #{e.message}")
38
+ cached || fallback_payload("error", e.message)
39
+ end
40
+
41
+ def cache_path_for(site)
42
+ File.join(site.source, CACHE_RELATIVE_PATH)
43
+ end
44
+
45
+ def fresh?(payload)
46
+ return false unless payload.is_a?(Hash)
47
+
48
+ fetched_at = payload["fetched_at"]
49
+ return false if fetched_at.to_s.empty?
50
+
51
+ Time.parse(fetched_at) > Time.now - CACHE_TTL
52
+ rescue ArgumentError
53
+ false
54
+ end
55
+
56
+ def read_cache(path)
57
+ return unless File.exist?(path)
58
+
59
+ JSON.parse(File.read(path))
60
+ rescue JSON::ParserError
61
+ nil
62
+ end
63
+
64
+ def write_cache(path, payload)
65
+ FileUtils.mkdir_p(File.dirname(path))
66
+ File.write(path, JSON.pretty_generate(payload))
67
+ rescue StandardError => e
68
+ Jekyll.logger.warn("Analytics", "Unable to persist analytics cache: #{e.message}")
69
+ end
70
+
71
+ def query_analytics(site)
72
+ config = site.config.fetch("analytics", {})
73
+ property_id = ENV["GA4_PROPERTY_ID"] || config["ga4_property_id"]
74
+ credentials_json = credentials_payload(config)
75
+
76
+ unless property_id && !property_id.empty?
77
+ return fallback_payload("missing_configuration", "Set GA4_PROPERTY_ID to enable analytics dashboards.")
78
+ end
79
+
80
+ unless credentials_json
81
+ return fallback_payload("missing_credentials",
82
+ "Provide GA4 service account credentials to query the Analytics API.")
83
+ end
84
+
85
+ token = fetch_access_token(credentials_json)
86
+
87
+ requests = build_requests
88
+ results = {}
89
+ requests.each do |key, body|
90
+ results[key] = run_report(property_id, token, body)
91
+ end
92
+
93
+ enrich_with_monthly_reports!(results)
94
+ inject_scholar_metrics!(results, site)
95
+ results["status"] = "ok"
96
+ results
97
+ rescue LoadError => e
98
+ fallback_payload("missing_dependency", "Install googleauth to enable GA4 integration: #{e.message}")
99
+ rescue StandardError => e
100
+ fallback_payload("error", e.message)
101
+ end
102
+
103
+ def credentials_payload(config)
104
+ json = ENV.fetch("GA4_CREDENTIALS_JSON", nil)
105
+ return json unless json.to_s.strip.empty?
106
+
107
+ path = ENV["GA4_CREDENTIALS_PATH"] || config["ga4_credentials_path"]
108
+ return File.read(path) if path && File.exist?(path)
109
+
110
+ config_json = config["ga4_credentials_json"]
111
+ return config_json unless config_json.to_s.strip.empty?
112
+
113
+ nil
114
+ end
115
+
116
+ def fetch_access_token(credentials_json)
117
+ require "googleauth"
118
+
119
+ credentials = Google::Auth::ServiceAccountCredentials.make_creds(
120
+ scope: GA_SCOPE,
121
+ json_key_io: StringIO.new(credentials_json)
122
+ )
123
+ credentials.fetch_access_token!
124
+ credentials.access_token
125
+ end
126
+
127
+ def build_requests
128
+ {
129
+ "top_posts" => {
130
+ "dimensions" => [{ "name" => "pagePath" }],
131
+ "metrics" => [
132
+ { "name" => "screenPageViews" },
133
+ { "name" => "userEngagementDuration" }
134
+ ],
135
+ "dateRanges" => [{ "startDate" => "28daysAgo", "endDate" => "today" }],
136
+ "limit" => 10,
137
+ "orderBys" => [{ "metric" => { "metricName" => "screenPageViews" }, "desc" => true }]
138
+ },
139
+ "search_terms" => {
140
+ "dimensions" => [{ "name" => "searchTerm" }],
141
+ "metrics" => [{ "name" => "sessions" }],
142
+ "dateRanges" => [{ "startDate" => "28daysAgo", "endDate" => "today" }],
143
+ "limit" => 10,
144
+ "orderBys" => [{ "metric" => { "metricName" => "sessions" }, "desc" => true }]
145
+ },
146
+ "visitor_trends" => {
147
+ "dimensions" => [{ "name" => "date" }],
148
+ "metrics" => [
149
+ { "name" => "totalUsers" },
150
+ { "name" => "newUsers" },
151
+ { "name" => "sessions" },
152
+ { "name" => "screenPageViews" }
153
+ ],
154
+ "dateRanges" => [{ "startDate" => "90daysAgo", "endDate" => "today" }],
155
+ "orderBys" => [{ "dimension" => { "dimensionName" => "date" } }]
156
+ },
157
+ "engagement_by_page" => {
158
+ "dimensions" => [{ "name" => "pagePath" }],
159
+ "metrics" => [
160
+ { "name" => "screenPageViews" },
161
+ { "name" => "averageSessionDuration" },
162
+ { "name" => "engagedSessions" }
163
+ ],
164
+ "dateRanges" => [{ "startDate" => "28daysAgo", "endDate" => "today" }],
165
+ "limit" => 20,
166
+ "orderBys" => [{ "metric" => { "metricName" => "engagedSessions" }, "desc" => true }]
167
+ },
168
+ "key_events" => {
169
+ "dimensions" => [{ "name" => "eventName" }],
170
+ "metrics" => [{ "name" => "eventCount" }],
171
+ "dimensionFilter" => {
172
+ "filter" => {
173
+ "inListFilter" => {
174
+ "dimensionName" => "eventName",
175
+ "values" => %w[notebook_download demo_launch]
176
+ }
177
+ }
178
+ },
179
+ "dateRanges" => [{ "startDate" => "28daysAgo", "endDate" => "today" }],
180
+ "limit" => 10,
181
+ "orderBys" => [{ "metric" => { "metricName" => "eventCount" }, "desc" => true }]
182
+ }
183
+ }
184
+ end
185
+
186
+ def run_report(property_id, token, body)
187
+ uri = URI("https://analyticsdata.googleapis.com/v1beta/properties/#{property_id}:runReport")
188
+ request = Net::HTTP::Post.new(uri)
189
+ request["Authorization"] = "Bearer #{token}"
190
+ request["Content-Type"] = "application/json"
191
+ request.body = JSON.dump(body)
192
+
193
+ response = perform_http_request(uri) { |http| http.request(request) }
194
+
195
+ raise "GA4 API error: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
196
+
197
+ JSON.parse(response.body)
198
+ end
199
+
200
+ def enrich_with_monthly_reports!(results)
201
+ trend_rows = extract_rows(results.fetch("visitor_trends", {}))
202
+ monthly = Hash.new do |hash, key|
203
+ hash[key] = {
204
+ "total_users" => 0,
205
+ "new_users" => 0,
206
+ "sessions" => 0,
207
+ "page_views" => 0
208
+ }
209
+ end
210
+
211
+ trend_rows.each do |row|
212
+ month = row.dig("dimensionValues", 0, "value")&.to_s&.slice(0, 6)
213
+ next unless month
214
+
215
+ data = monthly[month]
216
+ metrics = row["metricValues"] || []
217
+ data["total_users"] += metrics.fetch(0, {})["value"].to_f
218
+ data["new_users"] += metrics.fetch(1, {})["value"].to_f
219
+ data["sessions"] += metrics.fetch(2, {})["value"].to_f
220
+ data["page_views"] += metrics.fetch(3, {})["value"].to_f
221
+ end
222
+
223
+ reports = monthly.keys.sort.reverse.map do |month|
224
+ {
225
+ "month" => format_month(month),
226
+ "total_users" => monthly[month]["total_users"].round,
227
+ "new_users" => monthly[month]["new_users"].round,
228
+ "sessions" => monthly[month]["sessions"].round,
229
+ "page_views" => monthly[month]["page_views"].round
230
+ }
231
+ end
232
+
233
+ results["monthly_reports"] = reports
234
+ end
235
+
236
+ def format_month(key)
237
+ return key unless key && key.length == 6
238
+
239
+ year = key[0, 4]
240
+ month = key[4, 2]
241
+ "#{year}-#{month}"
242
+ end
243
+
244
+ def inject_scholar_metrics!(results, site)
245
+ academic = site.data["academic"] || {}
246
+ citations = academic.fetch("citations", {})
247
+ metrics = citations.fetch("metrics", {})
248
+ results["scholar"] = {
249
+ "total" => metrics["total"],
250
+ "h_index" => metrics["h_index"],
251
+ "i10_index" => metrics["i10_index"],
252
+ "since_2019" => metrics.fetch("since_2019", {}),
253
+ "yearly_totals" => citations.fetch("yearly_totals", {})
254
+ }
255
+ results
256
+ end
257
+
258
+ def extract_rows(response)
259
+ return [] unless response.is_a?(Hash)
260
+
261
+ response.fetch("rows", [])
262
+ end
263
+
264
+ def fallback_payload(status, message)
265
+ {
266
+ "status" => status,
267
+ "message" => message,
268
+ "top_posts" => { "rows" => [] },
269
+ "search_terms" => { "rows" => [] },
270
+ "visitor_trends" => { "rows" => [] },
271
+ "engagement_by_page" => { "rows" => [] },
272
+ "key_events" => { "rows" => [] },
273
+ "monthly_reports" => [],
274
+ "scholar" => {}
275
+ }
276
+ end
277
+
278
+ def perform_http_request(uri)
279
+ attempts = 0
280
+ begin
281
+ attempts += 1
282
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
283
+ apply_timeouts(http)
284
+ yield(http)
285
+ end
286
+ rescue *RETRYABLE_ERRORS => e
287
+ raise if attempts > HTTP_MAX_RETRIES
288
+
289
+ Jekyll.logger.warn(
290
+ "Analytics",
291
+ "Retrying GA4 request (attempt #{attempts}): #{e.class}: #{e.message}"
292
+ )
293
+ sleep(0.25 * attempts)
294
+ retry
295
+ end
296
+ end
297
+
298
+ def apply_timeouts(http)
299
+ http.open_timeout = HTTP_TIMEOUTS[:open]
300
+ http.read_timeout = HTTP_TIMEOUTS[:read]
301
+ return unless http.respond_to?(:write_timeout=) && HTTP_TIMEOUTS[:write]
302
+
303
+ http.write_timeout = HTTP_TIMEOUTS[:write]
304
+ end
305
+ end
306
+
307
+ class AnalyticsDashboardGenerator < Jekyll::Generator
308
+ safe true
309
+ priority :low
310
+
311
+ def generate(site)
312
+ site.data["analytics_dashboard"] = Analytics.fetch(site)
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Datalog
6
+ class ConfigValidator < Jekyll::Generator
7
+ safe true
8
+ priority :highest
9
+
10
+ DOCUMENTATION_BASE_URL = "https://datalog-theme.github.io/docs/configuration-reference"
11
+
12
+ SCHEMA = {
13
+ title: { type: :string, required: true },
14
+ url: { type: :string, required: true, format: :url },
15
+ author: {
16
+ type: :hash,
17
+ required: true,
18
+ schema: {
19
+ name: { type: :string, required: true },
20
+ email: { type: :string, format: :email }
21
+ }
22
+ },
23
+ markdown: { type: :string, enum: %w[kramdown commonmark] },
24
+ highlighter: { type: :string, enum: %w[rouge pygments] },
25
+ permalink: { type: :string },
26
+ paginate: { type: :integer },
27
+ timezone: { type: :string },
28
+ collections: { type: :hash },
29
+ plugins: { type: :array },
30
+ features: {
31
+ type: :hash,
32
+ schema: {
33
+ mathjax: { type: :boolean },
34
+ search: { type: :boolean },
35
+ dark_mode_toggle: { type: :boolean },
36
+ notebook_support: { type: :boolean },
37
+ portfolio: { type: :boolean },
38
+ datasets: { type: :boolean }
39
+ }
40
+ },
41
+ notebooks: {
42
+ type: :hash,
43
+ schema: {
44
+ enabled: { type: :boolean },
45
+ source: { type: :string },
46
+ output_dir: { type: :string }
47
+ }
48
+ },
49
+ seo: {
50
+ type: :hash,
51
+ schema: {
52
+ type: { type: :string },
53
+ name: { type: :string }
54
+ }
55
+ },
56
+ sass: {
57
+ type: :hash,
58
+ schema: {
59
+ style: { type: :string, enum: %w[compressed expanded] }
60
+ }
61
+ },
62
+ theme_options: {
63
+ type: :hash,
64
+ schema: {
65
+ math: {
66
+ type: :hash,
67
+ schema: {
68
+ engine: { type: :string, enum: %w[mathjax katex] },
69
+ enabled: { type: :boolean }
70
+ }
71
+ },
72
+ syntax_highlighting: {
73
+ type: :hash,
74
+ schema: {
75
+ cdn: { type: :string }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }.freeze
81
+
82
+ DEPRECATED_KEYS = {
83
+ "math_engine" => {
84
+ replacement: "theme_options.math.engine",
85
+ message: "'math_engine' has moved under theme_options.math.engine.",
86
+ auto_migrate: true
87
+ }
88
+ }.freeze
89
+
90
+ COMMON_SUGGESTIONS = {
91
+ %w[theme_options math engine] => {
92
+ "latex" => "mathjax",
93
+ "mathjax3" => "mathjax"
94
+ }
95
+ }.freeze
96
+
97
+ class Validator
98
+ attr_reader :errors, :warnings
99
+
100
+ def initialize(config)
101
+ @config = config
102
+ @errors = []
103
+ @warnings = []
104
+ end
105
+
106
+ def run
107
+ @errors.clear
108
+ @warnings.clear
109
+
110
+ apply_deprecated_migrations
111
+ validate_schema(@config, SCHEMA)
112
+
113
+ self
114
+ end
115
+
116
+ def formatted_errors
117
+ return "" if errors.empty?
118
+
119
+ errors.map { |error| format_error(error) }.join("\n\n") +
120
+ "\n\nBuild failed. Fix the above error in _config.yml and try again."
121
+ end
122
+
123
+ private
124
+
125
+ def apply_deprecated_migrations
126
+ DEPRECATED_KEYS.each do |path, metadata|
127
+ key_path = normalize_path(path)
128
+ value = dig_value(@config, key_path)
129
+ next if value.nil?
130
+
131
+ if metadata[:auto_migrate] && metadata[:replacement]
132
+ target_path = normalize_path(metadata[:replacement])
133
+ existing = dig_value(@config, target_path)
134
+ if existing.nil?
135
+ assign_value(@config, target_path, value)
136
+ @warnings << "Deprecated config key '#{path}' detected. Automatically migrated to '#{metadata[:replacement]}'."
137
+ else
138
+ @warnings << "Deprecated config key '#{path}' detected. Value already set at '#{metadata[:replacement]}', skipping migration."
139
+ end
140
+ remove_value(@config, key_path)
141
+ else
142
+ @warnings << "Deprecated config key '#{path}' detected. #{metadata[:message]}"
143
+ end
144
+ end
145
+ end
146
+
147
+ def validate_schema(data, schema, path = [])
148
+ schema.each do |key, rules|
149
+ key_path = path + [key.to_s]
150
+ present, value = fetch_value(data, key)
151
+
152
+ unless present
153
+ errors << build_missing_error(key_path) if rules[:required]
154
+ next
155
+ end
156
+
157
+ if value.nil?
158
+ errors << build_missing_error(key_path) if rules[:required]
159
+ next
160
+ end
161
+
162
+ validate_type(key_path, value, rules)
163
+
164
+ next unless rules[:schema] && value.is_a?(Hash)
165
+
166
+ validate_schema(value, rules[:schema], key_path)
167
+ end
168
+ end
169
+
170
+ def validate_type(path, value, rules)
171
+ expected_type = rules[:type]
172
+ if expected_type && !type_valid?(value, expected_type)
173
+ errors << build_type_error(path, expected_type, value)
174
+ return
175
+ end
176
+
177
+ if rules[:enum]
178
+ enum_values = Array(rules[:enum])
179
+ unless enum_values.include?(value)
180
+ errors << build_enum_error(path, enum_values, value)
181
+ return
182
+ end
183
+ end
184
+
185
+ case rules[:format]
186
+ when :url
187
+ errors << build_format_error(path, "a valid URL", value) unless url_valid?(value)
188
+ when :email
189
+ errors << build_format_error(path, "a valid email address", value) unless email_valid?(value)
190
+ end
191
+ end
192
+
193
+ def fetch_value(data, key)
194
+ return [false, nil] unless data.is_a?(Hash)
195
+
196
+ key_str = key.to_s
197
+ if data.key?(key_str)
198
+ [true, data[key_str]]
199
+ elsif data.key?(key.to_sym)
200
+ [true, data[key.to_sym]]
201
+ else
202
+ [false, nil]
203
+ end
204
+ end
205
+
206
+ def type_valid?(value, expected_type)
207
+ case expected_type
208
+ when :string
209
+ value.is_a?(String)
210
+ when :integer
211
+ value.is_a?(Integer)
212
+ when :boolean
213
+ [true, false].include?(value)
214
+ when :array
215
+ value.is_a?(Array)
216
+ when :hash
217
+ value.is_a?(Hash)
218
+ else
219
+ true
220
+ end
221
+ end
222
+
223
+ def url_valid?(value)
224
+ return false unless value.is_a?(String)
225
+
226
+ uri = URI.parse(value)
227
+ uri.is_a?(URI::HTTP) && !uri.host.nil?
228
+ rescue URI::InvalidURIError
229
+ false
230
+ end
231
+
232
+ def email_valid?(value)
233
+ return false unless value.is_a?(String)
234
+
235
+ /\A[^\s@]+@[^\s@]+\.[^\s@]+\z/.match?(value)
236
+ end
237
+
238
+ def build_missing_error(path)
239
+ identifier = path.join(".")
240
+ {
241
+ headline: "Missing required configuration '#{identifier}'",
242
+ path: path,
243
+ expected: "Value required",
244
+ actual: "nil",
245
+ doc_url: documentation_url(path)
246
+ }
247
+ end
248
+
249
+ def build_type_error(path, expected_type, value)
250
+ identifier = path.join(".")
251
+ {
252
+ headline: "Invalid type for '#{identifier}'",
253
+ path: path,
254
+ expected: human_type(expected_type),
255
+ actual: value.inspect,
256
+ doc_url: documentation_url(path)
257
+ }
258
+ end
259
+
260
+ def build_enum_error(path, enum_values, value)
261
+ identifier = path.join(".")
262
+ suggestion = suggestion_for(path, value)
263
+ {
264
+ headline: "Invalid value for '#{identifier}'",
265
+ path: path,
266
+ expected: enum_values.map { |v| "\"#{v}\"" }.join(" or "),
267
+ actual: value.inspect,
268
+ suggestion: suggestion,
269
+ doc_url: documentation_url(path)
270
+ }
271
+ end
272
+
273
+ def build_format_error(path, expectation, value)
274
+ identifier = path.join(".")
275
+ {
276
+ headline: "Invalid value for '#{identifier}'",
277
+ path: path,
278
+ expected: expectation,
279
+ actual: value.inspect,
280
+ doc_url: documentation_url(path)
281
+ }
282
+ end
283
+
284
+ def suggestion_for(path, value)
285
+ map = COMMON_SUGGESTIONS[path]
286
+ return unless map
287
+
288
+ suggestion = map[value.to_s.downcase]
289
+ return unless suggestion
290
+
291
+ "Did you mean \"#{suggestion}\"?"
292
+ end
293
+
294
+ def format_error(error)
295
+ lines = []
296
+ lines << "Configuration Error: #{error[:headline]}"
297
+ lines << ""
298
+ lines << " Path: #{error[:path].join(' → ')}"
299
+ lines << " Expected: #{error[:expected]}" if error[:expected]
300
+ lines << " Received: #{error[:actual]}" if error[:actual]
301
+ lines << ""
302
+ lines << " #{error[:suggestion]}" if error[:suggestion]
303
+ lines << " Documentation: #{error[:doc_url]}"
304
+ lines.compact!
305
+ lines.join("\n")
306
+ end
307
+
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
+ def human_type(type)
314
+ case type
315
+ when :string then "a String"
316
+ when :integer then "an Integer"
317
+ when :boolean then "a Boolean"
318
+ when :array then "an Array"
319
+ when :hash then "a Hash"
320
+ else
321
+ type.to_s
322
+ end
323
+ end
324
+
325
+ def normalize_path(path)
326
+ case path
327
+ when String
328
+ path.split(".")
329
+ when Array
330
+ path.map(&:to_s)
331
+ else
332
+ [path.to_s]
333
+ end
334
+ end
335
+
336
+ def dig_value(data, path)
337
+ return nil unless data.is_a?(Hash)
338
+
339
+ head, *tail = path
340
+ return nil if head.nil?
341
+
342
+ value = data[head] || data[head.to_sym]
343
+ return value if tail.empty?
344
+
345
+ dig_value(value, tail)
346
+ end
347
+
348
+ def assign_value(data, path, value)
349
+ head, *tail = path
350
+ key_str = head.to_s
351
+ if tail.empty?
352
+ data[key_str] = value
353
+ return
354
+ end
355
+
356
+ data[key_str] = {} unless data[key_str].is_a?(Hash)
357
+ assign_value(data[key_str], tail, value)
358
+ end
359
+
360
+ def remove_value(data, path)
361
+ return unless data.is_a?(Hash)
362
+
363
+ head, *tail = path
364
+ key_str = head.to_s
365
+ if tail.empty?
366
+ data.delete(key_str)
367
+ data.delete(head.to_sym)
368
+ return
369
+ end
370
+
371
+ child = data[key_str] || data[head.to_sym]
372
+ return unless child.is_a?(Hash)
373
+
374
+ remove_value(child, tail)
375
+ end
376
+ end
377
+
378
+ def generate(site)
379
+ validator = Validator.new(site.config)
380
+ validator.run
381
+
382
+ validator.warnings.each do |warning|
383
+ logger.warn("config", warning)
384
+ end
385
+
386
+ return if validator.errors.empty?
387
+
388
+ raise Jekyll::Errors::FatalException, validator.formatted_errors
389
+ end
390
+
391
+ private
392
+
393
+ def logger
394
+ Jekyll.logger
395
+ end
396
+ end
397
+ end