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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +162 -0
- data/CITATION.cff +29 -0
- data/LICENSE +21 -0
- data/README.md +257 -0
- data/_data/academic.yml +217 -0
- data/_data/cdn-integrity.yml +51 -0
- data/_data/config/author.yml +121 -0
- data/_data/config/features.yml +262 -0
- data/_data/config/site.yml +42 -0
- data/_data/config/theme.yml +181 -0
- data/_data/datasets.yml +28 -0
- data/_data/i18n/en.yml +438 -0
- data/_data/i18n/es.yml +438 -0
- data/_data/i18n/pt.yml +438 -0
- data/_data/js_manifest.json +22 -0
- data/_data/js_meta.json +371 -0
- data/_data/navigation.yml +145 -0
- data/_data/projects.yml +41 -0
- data/_data/publications.yml +28 -0
- data/_data/social.yml +73 -0
- data/_data/visualizations.yml +51 -0
- data/_includes/analytics/dashboard.html +149 -0
- data/_includes/analytics.html +9 -0
- data/_includes/components/academic-dashboard.html +347 -0
- data/_includes/components/advanced-search.html +682 -0
- data/_includes/components/api-function.html +94 -0
- data/_includes/components/author-bio.html +166 -0
- data/_includes/components/bookmark-system.html +96 -0
- data/_includes/components/breadcrumbs.html +89 -0
- data/_includes/components/citation-tools.html +94 -0
- data/_includes/components/comments.html +244 -0
- data/_includes/components/content-provenance.html +43 -0
- data/_includes/components/content-recommendations.html +228 -0
- data/_includes/components/difficulty-badge.html +84 -0
- data/_includes/components/email-preferences.html +200 -0
- data/_includes/components/enhanced-code-block.html +212 -0
- data/_includes/components/enhanced-metadata.html +228 -0
- data/_includes/components/enhanced-toc.html +209 -0
- data/_includes/components/hero.html +15 -0
- data/_includes/components/language-switcher.html +396 -0
- data/_includes/components/math-fallback.html +19 -0
- data/_includes/components/navigation-enhancements.html +454 -0
- data/_includes/components/newsletter-signup.html +178 -0
- data/_includes/components/open-science-badges.html +16 -0
- data/_includes/components/package-install.html +194 -0
- data/_includes/components/performance-monitor.html +170 -0
- data/_includes/components/popular-posts.html +233 -0
- data/_includes/components/post-hero.html +62 -0
- data/_includes/components/reading-progress.html +133 -0
- data/_includes/components/reading-time.html +121 -0
- data/_includes/components/responsive-image.html +63 -0
- data/_includes/components/search-facets.html +307 -0
- data/_includes/components/series-navigation.html +124 -0
- data/_includes/components/social-proof.html +34 -0
- data/_includes/components/social-share.html +119 -0
- data/_includes/components/user-preferences.html +566 -0
- data/_includes/components/visualization-card.html +24 -0
- data/_includes/components/viz-table-fallback.html +19 -0
- data/_includes/critical-css/default.html +0 -0
- data/_includes/critical-css/home.html +0 -0
- data/_includes/critical-css/post.html +0 -0
- data/_includes/csp-meta.html +17 -0
- data/_includes/footer/nav-column.html +31 -0
- data/_includes/footer.html +76 -0
- data/_includes/head.html +337 -0
- data/_includes/header/navigation.html +48 -0
- data/_includes/header.html +70 -0
- data/_includes/helpers/array-to-sentence.html +21 -0
- data/_includes/helpers/date-format.html +16 -0
- data/_includes/helpers/link-with-icon.html +26 -0
- data/_includes/layouts/default/article.html +67 -0
- data/_includes/meta/language-attributes.html +25 -0
- data/_includes/meta/math-config.html +21 -0
- data/_includes/meta/schema.html +153 -0
- data/_includes/meta/scripts-loader.html +52 -0
- data/_includes/post/related-posts.html +63 -0
- data/_includes/scripts.html +72 -0
- data/_includes/skip-link.html +1 -0
- data/_includes/toc.html +2 -0
- data/_layouts/archive.html +282 -0
- data/_layouts/dataset.html +52 -0
- data/_layouts/default.html +33 -0
- data/_layouts/home.html +48 -0
- data/_layouts/notebook.html +173 -0
- data/_layouts/package.html +238 -0
- data/_layouts/page.html +16 -0
- data/_layouts/portfolio.html +41 -0
- data/_layouts/post-sidebar.html +183 -0
- data/_layouts/post.html +338 -0
- data/_layouts/project.html +252 -0
- data/_layouts/research.html +504 -0
- data/_plugins/analytics_dashboard.rb +315 -0
- data/_plugins/config_validator.rb +397 -0
- data/_plugins/csp_generator.rb +115 -0
- data/_plugins/datalog_bibliography.rb +19 -0
- data/_plugins/datalog_comments.rb +18 -0
- data/_plugins/datalog_slides.rb +20 -0
- data/_plugins/front_matter_compat.rb +109 -0
- data/_plugins/i18n.rb +160 -0
- data/_plugins/image_optimizer.rb +445 -0
- data/_plugins/math_preprocessor.rb +185 -0
- data/_plugins/notebook_converter.rb +1107 -0
- data/_plugins/plugin_loader.rb +185 -0
- data/_plugins/publications_generator.rb +234 -0
- data/_plugins/reading_time.rb +15 -0
- data/_plugins/search_normalizer.rb +74 -0
- data/_plugins/sri_filter.rb +18 -0
- data/_plugins/toc_filter.rb +68 -0
- data/_plugins/warning_filter.rb +3 -0
- data/_sass/_base.scss +142 -0
- data/_sass/_components.scss +2380 -0
- data/_sass/_header.scss +470 -0
- data/_sass/_interactive.scss +344 -0
- data/_sass/_layout.scss +1760 -0
- data/_sass/_mathematical.scss +368 -0
- data/_sass/_mixins.scss +99 -0
- data/_sass/_package-docs.scss +715 -0
- data/_sass/_phase1-enhancements.scss +721 -0
- data/_sass/_phase3-enhancements.scss +874 -0
- data/_sass/_phase4-enhancements.scss +1214 -0
- data/_sass/_phase5-enhancements.scss +414 -0
- data/_sass/_search.scss +654 -0
- data/_sass/_syntax-highlighting.scss +128 -0
- data/_sass/_theme.scss +79 -0
- data/_sass/_typography.scss +148 -0
- data/_sass/_utilities.scss +334 -0
- data/_sass/_variables.scss +153 -0
- data/_sass/_visualizations.scss +242 -0
- data/assets/css/main.scss +4 -0
- data/assets/img/20220607123041_detail.001.png +0 -0
- data/assets/img/favicons/android-chrome-192x192.png +0 -0
- data/assets/img/favicons/android-chrome-512x512.png +0 -0
- data/assets/img/favicons/apple-touch-icon.png +0 -0
- data/assets/img/favicons/favicon-16x16.png +0 -0
- data/assets/img/favicons/favicon-32x32.png +0 -0
- data/assets/img/favicons/favicon.ico +0 -0
- data/assets/img/favicons/site.webmanifest +21 -0
- data/assets/img/portfolio-placeholder.svg +20 -0
- data/assets/js/academic.js +262 -0
- data/assets/js/analytics-dashboard.js +382 -0
- data/assets/js/core/dark-mode.js +79 -0
- data/assets/js/core/github-cards.js +123 -0
- data/assets/js/core/language-filter.js +69 -0
- data/assets/js/core/navigation.js +184 -0
- data/assets/js/core/scroll-progress.js +45 -0
- data/assets/js/core/search-hotkeys.js +62 -0
- data/assets/js/core/skip-links.js +62 -0
- data/assets/js/loader.js +163 -0
- data/assets/js/main.js +23 -0
- data/assets/js/math.js +818 -0
- data/assets/js/notebook.js +158 -0
- data/assets/js/search/analytics.js +91 -0
- data/assets/js/search/app.js +271 -0
- data/assets/js/search/autocomplete.js +120 -0
- data/assets/js/search/engine.js +260 -0
- data/assets/js/search/filters.js +38 -0
- data/assets/js/search/render.js +217 -0
- data/assets/js/search/utils.js +99 -0
- data/assets/js/search.js +354 -0
- data/assets/js/visualizations.js +816 -0
- data/assets/publications/datalog-publications.bib +8 -0
- data/assets/publications/datalog-publications.ris +9 -0
- data/assets/publications/publications.bib +30 -0
- data/assets/templates/diogo-ribeiro-cv.md +31 -0
- data/assets/templates/diogo-ribeiro-cv.tex +32 -0
- data/bin/datalog +26 -0
- data/datalog-theme.gemspec +72 -0
- data/lib/datalog/cli.rb +563 -0
- data/lib/datalog/plugin_system/dependency_resolver.rb +223 -0
- data/lib/datalog/plugin_system.rb +237 -0
- data/lib/datalog/plugins/citations.rb +182 -0
- data/lib/datalog/plugins/comments.rb +183 -0
- data/lib/datalog/plugins/search.rb +45 -0
- data/lib/datalog/plugins/slides.rb +121 -0
- data/lib/datalog/theme/theme.rb +18 -0
- data/lib/datalog/theme/version.rb +7 -0
- data/lib/datalog/warning_filter.rb +28 -0
- data/lib/datalog-theme.rb +17 -0
- 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
|