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,1107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "cgi"
|
|
8
|
+
require "loofah"
|
|
9
|
+
require "base64"
|
|
10
|
+
|
|
11
|
+
module Datalog
|
|
12
|
+
module NotebookRenderer
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
NOTEBOOK_SANITIZE_CONFIG = {
|
|
16
|
+
elements: %w[
|
|
17
|
+
h1 h2 h3 h4 h5 h6 p br ul ol li
|
|
18
|
+
strong em code pre blockquote
|
|
19
|
+
a img table thead tbody tr td th
|
|
20
|
+
div span
|
|
21
|
+
],
|
|
22
|
+
attributes: {
|
|
23
|
+
"a" => %w[href title],
|
|
24
|
+
"img" => %w[src alt title width height],
|
|
25
|
+
"code" => ["class"],
|
|
26
|
+
"pre" => ["class"],
|
|
27
|
+
"div" => ["class"]
|
|
28
|
+
},
|
|
29
|
+
protocols: {
|
|
30
|
+
"a" => { "href" => %w[http https mailto] },
|
|
31
|
+
"img" => { "src" => %w[http https data] }
|
|
32
|
+
}
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
MAX_DATA_URI_BYTES = 256_000
|
|
36
|
+
SAFE_IFRAME_ATTRIBUTES = %w[src title width height loading sandbox allow allowfullscreen referrerpolicy].freeze
|
|
37
|
+
SAFE_DATA_ATTRIBUTE_PATTERN = /\Adata-[a-z0-9-]+\z/i
|
|
38
|
+
SAFE_CLASS_TOKEN_PATTERN = /\A[a-z0-9_-]+\z/i
|
|
39
|
+
MATH_DELIMITERS = ["(", ")", "[", "]", "$$"].freeze
|
|
40
|
+
VIZ_DATA_ATTRIBUTE_MAX_LENGTH = 4_096
|
|
41
|
+
|
|
42
|
+
def sanitization_metrics
|
|
43
|
+
@sanitization_metrics ||= Hash.new do |hash, key|
|
|
44
|
+
hash[key] = { fragments: 0, removed_nodes: 0, removed_attributes: 0 }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset_sanitization_metrics!
|
|
49
|
+
@sanitization_metrics = nil
|
|
50
|
+
sanitization_metrics
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sanitization_metrics_snapshot
|
|
54
|
+
sanitization_metrics.transform_values(&:dup)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render(notebook, metadata: {}, site: nil)
|
|
58
|
+
cells = Array(notebook["cells"])
|
|
59
|
+
return if cells.empty?
|
|
60
|
+
|
|
61
|
+
fragments = cells.each_with_index.filter_map do |cell, index|
|
|
62
|
+
source = Array(cell["source"]).join
|
|
63
|
+
next if source.strip.empty?
|
|
64
|
+
|
|
65
|
+
case cell["cell_type"]
|
|
66
|
+
when "markdown"
|
|
67
|
+
render_markdown(source, site, cell_index: index, metadata: metadata)
|
|
68
|
+
when "code"
|
|
69
|
+
render_code(cell, source, metadata, site, index)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return if fragments.empty?
|
|
74
|
+
|
|
75
|
+
fragments.join("\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_from_raw(raw, metadata: {}, site: nil)
|
|
79
|
+
notebook = JSON.parse(raw)
|
|
80
|
+
render(notebook, metadata: metadata, site: site)
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_markdown(source, site, cell_index:, metadata: {})
|
|
86
|
+
html = if (converter = markdown_converter(site))
|
|
87
|
+
converter.convert(source)
|
|
88
|
+
else
|
|
89
|
+
%(<p>#{CGI.escapeHTML(source)}</p>)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
meta = metadata.respond_to?(:merge) ? metadata.merge(kind: :markdown) : { kind: :markdown }
|
|
93
|
+
sanitized = sanitize_html(html, context: :markdown, site: site,
|
|
94
|
+
metadata: build_sanitization_metadata(meta, cell_index: cell_index))
|
|
95
|
+
return if sanitized.to_s.strip.empty?
|
|
96
|
+
|
|
97
|
+
%(<section class="notebook-cell notebook-cell--markdown">\n#{sanitized}\n</section>)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Class names mirror the theme stylesheet (`.notebook-cell--input`,
|
|
101
|
+
# `.notebook-cell__code` and `.notebook-cell__outputs` in _sass/_components.scss).
|
|
102
|
+
def render_code(cell, source, metadata, site, cell_index)
|
|
103
|
+
language = cell.dig("metadata", "language") || metadata[:language] || "text"
|
|
104
|
+
code_html = %(<pre class="notebook-cell__code"><code class="language-#{language}">#{CGI.escapeHTML(source)}</code></pre>)
|
|
105
|
+
base_metadata = metadata.respond_to?(:merge) ? metadata.merge(language: language) : { language: language }
|
|
106
|
+
outputs_html = render_outputs(Array(cell["outputs"]), site: site, cell_index: cell_index, metadata: base_metadata)
|
|
107
|
+
outputs_html = %(\n<div class="notebook-cell__outputs">\n#{outputs_html}\n</div>) unless outputs_html.empty?
|
|
108
|
+
%(<section class="notebook-cell notebook-cell--input">\n#{code_html}#{outputs_html}\n</section>)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def render_outputs(outputs, site:, cell_index:, metadata:)
|
|
112
|
+
return "" if outputs.empty?
|
|
113
|
+
|
|
114
|
+
base_metadata = metadata.respond_to?(:merge) ? metadata.merge(kind: :output) : { kind: :output }
|
|
115
|
+
rendered = outputs.each_with_index.filter_map do |output, output_index|
|
|
116
|
+
render_output(output, site: site, cell_index: cell_index, output_index: output_index, metadata: base_metadata)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
rendered.join("\n")
|
|
120
|
+
rescue StandardError
|
|
121
|
+
""
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_output(output, site:, cell_index:, output_index:, metadata:)
|
|
125
|
+
context = build_sanitization_metadata(metadata, cell_index: cell_index, output_index: output_index)
|
|
126
|
+
|
|
127
|
+
if (html = extract_html_output(output))
|
|
128
|
+
html_metadata = context.merge(mime: "text/html")
|
|
129
|
+
sanitized = sanitize_visualization_html(html, site: site, metadata: html_metadata)
|
|
130
|
+
return if sanitized.to_s.strip.empty?
|
|
131
|
+
|
|
132
|
+
return %(<div class="notebook-output notebook-output--html">#{sanitized}</div>)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if (image_data = image_output_html(output))
|
|
136
|
+
image_html, image_mime = image_data
|
|
137
|
+
image_metadata = context.merge(kind: :image, mime: image_mime)
|
|
138
|
+
sanitized = sanitize_html(image_html, context: :output, site: site, metadata: image_metadata)
|
|
139
|
+
return if sanitized.to_s.strip.empty?
|
|
140
|
+
|
|
141
|
+
return %(<div class="notebook-output notebook-output--image">#{sanitized}</div>)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
text = extract_output_text(output)
|
|
145
|
+
return if text.to_s.strip.empty?
|
|
146
|
+
|
|
147
|
+
%(<div class="notebook-output notebook-output--text"><pre>#{CGI.escapeHTML(text)}</pre></div>)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_html_output(output)
|
|
151
|
+
html = output.dig("data", "text/html")
|
|
152
|
+
return unless html
|
|
153
|
+
|
|
154
|
+
Array(html).join
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def image_output_html(output)
|
|
158
|
+
data = output["data"] || {}
|
|
159
|
+
|
|
160
|
+
if (png = data["image/png"])
|
|
161
|
+
html = %(<img src="data:image/png;base64,#{Array(png).join}" alt="Notebook output" />)
|
|
162
|
+
return [html, "image/png"]
|
|
163
|
+
elsif (jpeg = data["image/jpeg"])
|
|
164
|
+
html = %(<img src="data:image/jpeg;base64,#{Array(jpeg).join}" alt="Notebook output" />)
|
|
165
|
+
return [html, "image/jpeg"]
|
|
166
|
+
elsif (svg = data["image/svg+xml"])
|
|
167
|
+
encoded = Base64.strict_encode64(Array(svg).join)
|
|
168
|
+
html = %(<img src="data:image/svg+xml;base64,#{encoded}" alt="Notebook output" />)
|
|
169
|
+
return [html, "image/svg+xml"]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def extract_output_text(output)
|
|
176
|
+
if output["text"]
|
|
177
|
+
Array(output["text"]).join
|
|
178
|
+
elsif output.dig("data", "text/plain")
|
|
179
|
+
Array(output.dig("data", "text/plain")).join
|
|
180
|
+
else
|
|
181
|
+
""
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def sanitize_visualization_html(html, site:, metadata:)
|
|
186
|
+
sanitized = sanitize_html(html, context: :visualization, site: site, metadata: metadata)
|
|
187
|
+
enforce_visualization_rules(sanitized, metadata, site: site)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def sanitize_html(html, context:, site:, metadata: {})
|
|
191
|
+
fragment_source = html.to_s
|
|
192
|
+
protected_html, math_replacements = protect_mathjax_delimiters(fragment_source)
|
|
193
|
+
fragment = Loofah.fragment(protected_html)
|
|
194
|
+
|
|
195
|
+
removed_nodes = 0
|
|
196
|
+
removed_attributes = 0
|
|
197
|
+
|
|
198
|
+
fragment.traverse do |node|
|
|
199
|
+
next unless node.element?
|
|
200
|
+
|
|
201
|
+
unless allowed_element?(node)
|
|
202
|
+
node.remove
|
|
203
|
+
removed_nodes += 1
|
|
204
|
+
next
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
removed_attributes += sanitize_node_attributes(node)
|
|
208
|
+
|
|
209
|
+
next unless node.name == "iframe"
|
|
210
|
+
|
|
211
|
+
unless iframe_has_valid_src?(node)
|
|
212
|
+
node.remove
|
|
213
|
+
removed_nodes += 1
|
|
214
|
+
next
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
enforce_iframe_defaults(node)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
sanitized = fragment.to_html
|
|
221
|
+
sanitized = restore_mathjax_delimiters(sanitized, math_replacements)
|
|
222
|
+
|
|
223
|
+
record_sanitization(context, nodes_removed: removed_nodes, attrs_removed: removed_attributes)
|
|
224
|
+
log_sanitization(context, metadata, removed_nodes, removed_attributes, site)
|
|
225
|
+
|
|
226
|
+
sanitized
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
log_sanitization_error(context, metadata, e)
|
|
229
|
+
CGI.escapeHTML(html.to_s)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def enforce_visualization_rules(html, metadata, site: nil)
|
|
233
|
+
fragment = Loofah.fragment(html)
|
|
234
|
+
removed_attributes = 0
|
|
235
|
+
removed_nodes = 0
|
|
236
|
+
|
|
237
|
+
fragment.css("[class]").each do |node|
|
|
238
|
+
next unless viz_element?(node)
|
|
239
|
+
|
|
240
|
+
node.attribute_nodes.each do |attr|
|
|
241
|
+
next if attr.name == "class"
|
|
242
|
+
|
|
243
|
+
if attr.name.start_with?("data-")
|
|
244
|
+
next if safe_data_attribute?(attr.name, attr.value)
|
|
245
|
+
|
|
246
|
+
attr.remove
|
|
247
|
+
removed_attributes += 1
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
next if node.name == "iframe" && SAFE_IFRAME_ATTRIBUTES.include?(attr.name)
|
|
252
|
+
|
|
253
|
+
attr.remove
|
|
254
|
+
removed_attributes += 1
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
fragment.css("iframe").each do |iframe|
|
|
259
|
+
unless iframe_has_valid_src?(iframe)
|
|
260
|
+
iframe.remove
|
|
261
|
+
removed_nodes += 1
|
|
262
|
+
next
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
enforce_iframe_defaults(iframe)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
sanitized = fragment.to_html
|
|
269
|
+
|
|
270
|
+
if removed_attributes.positive? || removed_nodes.positive?
|
|
271
|
+
record_sanitization(:visualization, nodes_removed: removed_nodes, attrs_removed: removed_attributes,
|
|
272
|
+
increment_fragment: false)
|
|
273
|
+
log_sanitization(:visualization, metadata, removed_nodes, removed_attributes, site)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
sanitized
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def build_sanitization_metadata(metadata, cell_index:, output_index: nil)
|
|
280
|
+
context = { cell_index: cell_index }
|
|
281
|
+
context[:output_index] = output_index if output_index
|
|
282
|
+
|
|
283
|
+
if metadata.respond_to?(:[])
|
|
284
|
+
context[:notebook] = metadata[:slug] || metadata["slug"] if metadata[:slug] || metadata["slug"]
|
|
285
|
+
context[:language] = metadata[:language] || metadata["language"] if metadata[:language] || metadata["language"]
|
|
286
|
+
context[:kind] = metadata[:kind] || metadata["kind"] if metadata[:kind] || metadata["kind"]
|
|
287
|
+
context[:mime] = metadata[:mime] || metadata["mime"] if metadata[:mime] || metadata["mime"]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
context
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def allowed_element?(node)
|
|
294
|
+
NOTEBOOK_SANITIZE_CONFIG[:elements].include?(node.name) || node.name == "iframe"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def sanitize_node_attributes(node)
|
|
298
|
+
removed = 0
|
|
299
|
+
|
|
300
|
+
allowed_attrs = Array(NOTEBOOK_SANITIZE_CONFIG[:attributes][node.name]).dup
|
|
301
|
+
allowed_attrs << "class" if %w[div span code pre].include?(node.name)
|
|
302
|
+
|
|
303
|
+
if node.name == "iframe"
|
|
304
|
+
allowed_attrs = SAFE_IFRAME_ATTRIBUTES.dup
|
|
305
|
+
allowed_attrs << "class"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
node.attribute_nodes.each do |attr|
|
|
309
|
+
name = attr.name
|
|
310
|
+
value = attr.value.to_s
|
|
311
|
+
|
|
312
|
+
if name == "class"
|
|
313
|
+
removed += sanitize_class_attribute(attr)
|
|
314
|
+
next
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if node.name == "iframe"
|
|
318
|
+
unless SAFE_IFRAME_ATTRIBUTES.include?(name)
|
|
319
|
+
attr.remove
|
|
320
|
+
removed += 1
|
|
321
|
+
next
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
removed += sanitize_iframe_attribute(node, attr)
|
|
325
|
+
next
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
if allowed_attrs.include?(name)
|
|
329
|
+
if %w[width height].include?(name)
|
|
330
|
+
cleaned = sanitize_dimension_value(value)
|
|
331
|
+
if cleaned
|
|
332
|
+
attr.value = cleaned
|
|
333
|
+
else
|
|
334
|
+
attr.remove
|
|
335
|
+
removed += 1
|
|
336
|
+
end
|
|
337
|
+
next
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
if name == "src" && node.name == "img"
|
|
341
|
+
unless safe_image_src?(value)
|
|
342
|
+
attr.remove
|
|
343
|
+
removed += 1
|
|
344
|
+
end
|
|
345
|
+
next
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
if requires_protocol_validation?(node.name, name)
|
|
349
|
+
unless safe_url?(value, NOTEBOOK_SANITIZE_CONFIG[:protocols][node.name][name])
|
|
350
|
+
attr.remove
|
|
351
|
+
removed += 1
|
|
352
|
+
end
|
|
353
|
+
next
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
next
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
next if allow_data_attribute?(node, name, value)
|
|
360
|
+
|
|
361
|
+
attr.remove
|
|
362
|
+
removed += 1
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
removed
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def sanitize_iframe_attribute(_node, attr)
|
|
369
|
+
name = attr.name
|
|
370
|
+
value = attr.value.to_s
|
|
371
|
+
|
|
372
|
+
case name
|
|
373
|
+
when "src"
|
|
374
|
+
return 1 unless safe_url?(value, %w[http https])
|
|
375
|
+
when "sandbox"
|
|
376
|
+
attr.value = sanitize_sandbox_value(value)
|
|
377
|
+
when "allow"
|
|
378
|
+
attr.value = sanitize_allow_value(value)
|
|
379
|
+
when "allowfullscreen"
|
|
380
|
+
attr.value = "true" if value.to_s.strip.casecmp("true").zero?
|
|
381
|
+
when "loading"
|
|
382
|
+
attr.value = sanitize_loading_value(value)
|
|
383
|
+
when "referrerpolicy"
|
|
384
|
+
attr.value = sanitize_referrer_policy(value)
|
|
385
|
+
when "width", "height"
|
|
386
|
+
cleaned = sanitize_dimension_value(value)
|
|
387
|
+
if cleaned
|
|
388
|
+
attr.value = cleaned
|
|
389
|
+
else
|
|
390
|
+
attr.remove
|
|
391
|
+
return 1
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
0
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def sanitize_sandbox_value(value)
|
|
399
|
+
requested = value.to_s.split(/\s+/)
|
|
400
|
+
allowed = requested & %w[allow-same-origin allow-scripts allow-popups allow-forms]
|
|
401
|
+
allowed = %w[allow-scripts allow-same-origin] if allowed.empty?
|
|
402
|
+
allowed.join(" ")
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def sanitize_allow_value(value)
|
|
406
|
+
requested = value.to_s.split(/;\s*/)
|
|
407
|
+
safe = requested.grep(/\A[a-z0-9:-]+\z/i)
|
|
408
|
+
safe.join("; ")
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def sanitize_loading_value(value)
|
|
412
|
+
token = value.to_s.strip.downcase
|
|
413
|
+
return "lazy" if token.empty?
|
|
414
|
+
return token if %w[lazy eager auto].include?(token)
|
|
415
|
+
|
|
416
|
+
"lazy"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def sanitize_referrer_policy(value)
|
|
420
|
+
token = value.to_s.strip.downcase
|
|
421
|
+
return token if %w[no-referrer origin same-origin strict-origin strict-origin-when-cross-origin
|
|
422
|
+
origin-when-cross-origin unsafe-url].include?(token)
|
|
423
|
+
|
|
424
|
+
"no-referrer"
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def sanitize_class_attribute(attr)
|
|
428
|
+
tokens = attr.value.to_s.split(/\s+/).map(&:strip).reject(&:empty?)
|
|
429
|
+
safe = tokens.grep(SAFE_CLASS_TOKEN_PATTERN)
|
|
430
|
+
if safe.empty?
|
|
431
|
+
attr.remove
|
|
432
|
+
return 1
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
attr.value = safe.join(" ")
|
|
436
|
+
0
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def sanitize_dimension_value(value)
|
|
440
|
+
stripped = value.to_s.strip
|
|
441
|
+
return stripped if stripped.empty?
|
|
442
|
+
return stripped if stripped.match?(/\A\d{1,4}\z/)
|
|
443
|
+
return stripped if stripped.match?(/\A\d{1,3}%\z/)
|
|
444
|
+
|
|
445
|
+
nil
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def requires_protocol_validation?(tag, attribute)
|
|
449
|
+
NOTEBOOK_SANITIZE_CONFIG[:protocols].key?(tag) && NOTEBOOK_SANITIZE_CONFIG[:protocols][tag].key?(attribute)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def safe_image_src?(value)
|
|
453
|
+
return false if value.to_s.strip.empty?
|
|
454
|
+
return valid_data_uri?(value) if value.start_with?("data:")
|
|
455
|
+
return false if value =~ /\Ajavascript:/i
|
|
456
|
+
|
|
457
|
+
true
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def valid_data_uri?(uri)
|
|
461
|
+
match = uri.match(%r{\Adata:([a-z0-9\-.+/]+);base64,(.*)\z}i)
|
|
462
|
+
return false unless match
|
|
463
|
+
|
|
464
|
+
data = match[2]
|
|
465
|
+
decoded = Base64.strict_decode64(data)
|
|
466
|
+
decoded.bytesize <= MAX_DATA_URI_BYTES
|
|
467
|
+
rescue ArgumentError
|
|
468
|
+
false
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def safe_url?(value, allowed_protocols)
|
|
472
|
+
uri = value.to_s.strip
|
|
473
|
+
return true if uri.empty?
|
|
474
|
+
return false if uri =~ /\Ajavascript:/i
|
|
475
|
+
return true if uri.start_with?("#") || uri.start_with?("/")
|
|
476
|
+
|
|
477
|
+
scheme = uri[/\A([a-z0-9.+-]+):/i, 1]
|
|
478
|
+
|
|
479
|
+
if scheme.nil?
|
|
480
|
+
true
|
|
481
|
+
elsif scheme.casecmp("data").zero?
|
|
482
|
+
allowed_protocols&.include?("data") && valid_data_uri?(uri)
|
|
483
|
+
else
|
|
484
|
+
allowed_protocols&.include?(scheme.downcase)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def allow_data_attribute?(node, name, value)
|
|
489
|
+
return false unless name.start_with?("data-")
|
|
490
|
+
return false unless viz_element?(node)
|
|
491
|
+
return false unless SAFE_DATA_ATTRIBUTE_PATTERN.match?(name)
|
|
492
|
+
|
|
493
|
+
sanitized_value = value.to_s
|
|
494
|
+
return false if sanitized_value.length > VIZ_DATA_ATTRIBUTE_MAX_LENGTH
|
|
495
|
+
return false if sanitized_value =~ /javascript:/i
|
|
496
|
+
return false if sanitized_value.include?("<") || sanitized_value.include?(">")
|
|
497
|
+
|
|
498
|
+
true
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def safe_data_attribute?(name, value)
|
|
502
|
+
return false unless SAFE_DATA_ATTRIBUTE_PATTERN.match?(name)
|
|
503
|
+
|
|
504
|
+
sanitized_value = value.to_s
|
|
505
|
+
return false if sanitized_value.length > VIZ_DATA_ATTRIBUTE_MAX_LENGTH
|
|
506
|
+
return false if sanitized_value =~ /javascript:/i
|
|
507
|
+
return false if sanitized_value.include?("<") || sanitized_value.include?(">")
|
|
508
|
+
|
|
509
|
+
true
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def viz_element?(node)
|
|
513
|
+
classes = node["class"].to_s.split(/\s+/)
|
|
514
|
+
classes.any? { |token| token.start_with?("viz-") }
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def iframe_has_valid_src?(node)
|
|
518
|
+
return false unless node["src"]
|
|
519
|
+
|
|
520
|
+
safe_url?(node["src"], %w[http https])
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def enforce_iframe_defaults(node)
|
|
524
|
+
node["sandbox"] = sanitize_sandbox_value(node["sandbox"]) unless node["sandbox"]
|
|
525
|
+
node["loading"] = "lazy" unless node["loading"]
|
|
526
|
+
node["referrerpolicy"] = "no-referrer" unless node["referrerpolicy"]
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def record_sanitization(context, nodes_removed:, attrs_removed:, increment_fragment: true)
|
|
530
|
+
metrics = sanitization_metrics[context.to_sym]
|
|
531
|
+
metrics[:fragments] += 1 if increment_fragment
|
|
532
|
+
metrics[:removed_nodes] += nodes_removed
|
|
533
|
+
metrics[:removed_attributes] += attrs_removed
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def log_sanitization(context, metadata, nodes_removed, attrs_removed, site)
|
|
537
|
+
return if nodes_removed.zero? && attrs_removed.zero?
|
|
538
|
+
|
|
539
|
+
logger = sanitizer_logger
|
|
540
|
+
return unless logger
|
|
541
|
+
|
|
542
|
+
message = "sanitized #{context} fragment (removed #{nodes_removed} elements, #{attrs_removed} attributes)"
|
|
543
|
+
detail = format_sanitization_metadata(metadata)
|
|
544
|
+
message = "#{message} #{detail}" if detail
|
|
545
|
+
|
|
546
|
+
if current_env(site) == "development"
|
|
547
|
+
logger.info("notebook sanitizer", message)
|
|
548
|
+
elsif logger.respond_to?(:debug?)
|
|
549
|
+
logger.debug("notebook sanitizer", message)
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def log_sanitization_error(context, metadata, error)
|
|
554
|
+
logger = sanitizer_logger
|
|
555
|
+
return unless logger
|
|
556
|
+
|
|
557
|
+
detail = format_sanitization_metadata(metadata)
|
|
558
|
+
message = "failed to sanitize #{context} fragment"
|
|
559
|
+
message = "#{message} #{detail}" if detail
|
|
560
|
+
logger.warn("notebook sanitizer", "#{message}: #{error.message}")
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def sanitizer_logger
|
|
564
|
+
return unless defined?(Jekyll)
|
|
565
|
+
|
|
566
|
+
Jekyll.logger
|
|
567
|
+
rescue StandardError
|
|
568
|
+
nil
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def current_env(_site = nil)
|
|
572
|
+
if defined?(Jekyll) && Jekyll.respond_to?(:env)
|
|
573
|
+
Jekyll.env
|
|
574
|
+
else
|
|
575
|
+
ENV.fetch("JEKYLL_ENV", "development")
|
|
576
|
+
end
|
|
577
|
+
rescue StandardError
|
|
578
|
+
"development"
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def format_sanitization_metadata(metadata)
|
|
582
|
+
return unless metadata.respond_to?(:[])
|
|
583
|
+
|
|
584
|
+
details = []
|
|
585
|
+
details << "(cell #{metadata[:cell_index]})" if metadata[:cell_index]
|
|
586
|
+
details << "(output #{metadata[:output_index]})" if metadata[:output_index]
|
|
587
|
+
details << "[#{metadata[:notebook]}]" if metadata[:notebook]
|
|
588
|
+
details << "{#{metadata[:language]}}" if metadata[:language]
|
|
589
|
+
return if details.empty?
|
|
590
|
+
|
|
591
|
+
details.join(" ")
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def protect_mathjax_delimiters(html)
|
|
595
|
+
replacements = {}
|
|
596
|
+
index = 0
|
|
597
|
+
|
|
598
|
+
protected = html.to_s.gsub(/\\(|\\)|\\[|\\]|\$\$/) do |match|
|
|
599
|
+
key = "__MATH_DELIM_#{index}__"
|
|
600
|
+
replacements[key] = match
|
|
601
|
+
index += 1
|
|
602
|
+
key
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
[protected, replacements]
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def restore_mathjax_delimiters(html, replacements)
|
|
609
|
+
restored = html.to_s
|
|
610
|
+
replacements.each do |token, original|
|
|
611
|
+
restored = restored.gsub(token, original)
|
|
612
|
+
end
|
|
613
|
+
restored
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def summary_html(summary)
|
|
617
|
+
text = summary.to_s.strip
|
|
618
|
+
return if text.empty?
|
|
619
|
+
|
|
620
|
+
%(<section class="notebook-cell notebook-cell--summary"><p>#{CGI.escapeHTML(text)}</p></section>)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def markdown_converter(site)
|
|
624
|
+
return unless site
|
|
625
|
+
|
|
626
|
+
@markdown_converters ||= {}
|
|
627
|
+
@markdown_converters[site.object_id] ||= site.find_converter_instance(Jekyll::Converters::Markdown)
|
|
628
|
+
rescue StandardError
|
|
629
|
+
nil
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def patch_jupyter_converter!
|
|
633
|
+
return unless defined?(JekyllJupyterNotebook::Converter)
|
|
634
|
+
return if @converter_fallback_applied
|
|
635
|
+
|
|
636
|
+
fallback = Module.new do
|
|
637
|
+
def convert(content)
|
|
638
|
+
super
|
|
639
|
+
rescue Errno::ENOENT, StandardError => e
|
|
640
|
+
Jekyll.logger.warn("notebook converter", "primary conversion failed: #{e.message}; using fallback renderer")
|
|
641
|
+
Datalog::NotebookRenderer.render_from_raw(content) || ""
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
JekyllJupyterNotebook::Converter.prepend(fallback)
|
|
646
|
+
@converter_fallback_applied = true
|
|
647
|
+
rescue StandardError => e
|
|
648
|
+
Jekyll.logger.warn("notebook converter", "failed to apply converter fallback: #{e.message}")
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
Jekyll::Hooks.register :site, :after_init do |_site|
|
|
654
|
+
Datalog::NotebookRenderer.patch_jupyter_converter!
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
module Jekyll
|
|
658
|
+
# Converts Jupyter notebooks into HTML pages and downloadable assets.
|
|
659
|
+
class NotebookConverter < Generator
|
|
660
|
+
safe true
|
|
661
|
+
priority :low
|
|
662
|
+
|
|
663
|
+
def generate(site)
|
|
664
|
+
@site = site
|
|
665
|
+
@config = build_config(site)
|
|
666
|
+
|
|
667
|
+
Datalog::NotebookRenderer.reset_sanitization_metrics!
|
|
668
|
+
|
|
669
|
+
unless config["enabled"]
|
|
670
|
+
logger.debug("notebook converter", "disabled via configuration")
|
|
671
|
+
return
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
ensure_dependency
|
|
675
|
+
|
|
676
|
+
files = notebook_files
|
|
677
|
+
logger.debug("notebook converter", "located #{files.size} notebooks")
|
|
678
|
+
return if files.empty?
|
|
679
|
+
|
|
680
|
+
index_entries = []
|
|
681
|
+
|
|
682
|
+
files.each do |path|
|
|
683
|
+
process_notebook(path, index_entries)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
site.data["notebook_sanitization"] = Datalog::NotebookRenderer.sanitization_metrics_snapshot
|
|
687
|
+
site.data["datalog_notebooks"] = index_entries if index_entries.any?
|
|
688
|
+
rescue StandardError => e
|
|
689
|
+
logger.error("notebook converter", "unexpected error: #{e.message}")
|
|
690
|
+
debug_backtrace(e)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
private
|
|
694
|
+
|
|
695
|
+
attr_reader :site, :config
|
|
696
|
+
|
|
697
|
+
def build_config(site)
|
|
698
|
+
defaults = {
|
|
699
|
+
"enabled" => true,
|
|
700
|
+
"source" => "_notebooks",
|
|
701
|
+
"output" => "notebooks",
|
|
702
|
+
"download_dir" => "notebooks",
|
|
703
|
+
"default_layout" => "notebook",
|
|
704
|
+
"collection" => "notebooks",
|
|
705
|
+
"permalink_base" => "/notebooks"
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
merged = defaults.merge(site.config.fetch("notebooks", {}))
|
|
709
|
+
merged["source"] = merged["source"].to_s
|
|
710
|
+
merged["output"] = merged["output"].to_s
|
|
711
|
+
merged["download_dir"] = merged["download_dir"].to_s
|
|
712
|
+
merged["permalink_base"] = normalized_base_path(merged["permalink_base"])
|
|
713
|
+
merged["collection"] = merged["collection"].to_s
|
|
714
|
+
merged["default_layout"] = merged["default_layout"].to_s
|
|
715
|
+
merged
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def normalized_base_path(base)
|
|
719
|
+
value = base.to_s.strip
|
|
720
|
+
value = "/notebooks" if value.empty?
|
|
721
|
+
value = "/#{value}" unless value.start_with?("/")
|
|
722
|
+
value.sub(%r{/+$}, "")
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Notebook pages do not need the gem (see #convert_notebook). When it is
|
|
726
|
+
# present, its converter is given the same renderer as a fallback so the
|
|
727
|
+
# gem's own `.ipynb` handling and `{% jupyter_notebook %}` tag keep working
|
|
728
|
+
# on machines without a `jupyter` executable.
|
|
729
|
+
def ensure_dependency
|
|
730
|
+
return true if defined?(JekyllJupyterNotebook::Converter)
|
|
731
|
+
|
|
732
|
+
require "jekyll-jupyter-notebook"
|
|
733
|
+
Datalog::NotebookRenderer.patch_jupyter_converter!
|
|
734
|
+
true
|
|
735
|
+
rescue LoadError => e
|
|
736
|
+
logger.debug("notebook converter", "jekyll-jupyter-notebook not loaded: #{e.message}")
|
|
737
|
+
false
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def notebook_files
|
|
741
|
+
glob = File.join(site.source, config["source"], "**", "*.ipynb")
|
|
742
|
+
Dir.glob(glob)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def process_notebook(path, index_entries)
|
|
746
|
+
relative_path = Pathname.new(path).relative_path_from(Pathname.new(site.source)).to_s
|
|
747
|
+
raw = File.binread(path)
|
|
748
|
+
notebook = JSON.parse(raw)
|
|
749
|
+
|
|
750
|
+
metadata = extract_metadata(notebook, path, relative_path)
|
|
751
|
+
html = convert_notebook(notebook, metadata, relative_path)
|
|
752
|
+
html ||= Datalog::NotebookRenderer.summary_html(metadata[:summary])
|
|
753
|
+
return unless html
|
|
754
|
+
|
|
755
|
+
page = build_page(metadata, html)
|
|
756
|
+
site.pages << page
|
|
757
|
+
attach_to_collection(page)
|
|
758
|
+
register_download(path, metadata)
|
|
759
|
+
|
|
760
|
+
search_entry = build_search_entry(metadata)
|
|
761
|
+
index_entries << search_entry if search_entry
|
|
762
|
+
page.data["datalog_search_extensions"] = { "notebook" => search_entry }.compact
|
|
763
|
+
rescue JSON::ParserError => e
|
|
764
|
+
logger.error("notebook converter", "failed to parse #{relative_path}: #{e.message}")
|
|
765
|
+
debug_backtrace(e)
|
|
766
|
+
rescue StandardError => e
|
|
767
|
+
logger.error("notebook converter", "failed to process #{relative_path}: #{e.message}")
|
|
768
|
+
debug_backtrace(e)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Pages are rendered by Datalog::NotebookRenderer rather than by
|
|
772
|
+
# `jupyter nbconvert`. Its output is sanitized (tests/test_notebook_sanitization.rb),
|
|
773
|
+
# carries no inline scripts or styles for the CSP to block, uses the
|
|
774
|
+
# `.notebook-cell` markup the stylesheet styles, and does not change shape
|
|
775
|
+
# between nbconvert releases.
|
|
776
|
+
def convert_notebook(notebook, metadata, relative_path)
|
|
777
|
+
Datalog::NotebookRenderer.render(notebook, metadata: metadata, site: site)
|
|
778
|
+
rescue StandardError => e
|
|
779
|
+
logger.error("notebook converter", "conversion failed for #{relative_path}: #{e.message}")
|
|
780
|
+
debug_backtrace(e)
|
|
781
|
+
nil
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def extract_metadata(notebook, absolute_path, relative_path)
|
|
785
|
+
meta = notebook.fetch("metadata", {})
|
|
786
|
+
cells = Array(notebook["cells"])
|
|
787
|
+
|
|
788
|
+
title = meta["title"] || meta.dig("datalog", "title") || first_heading_from(cells)
|
|
789
|
+
# An explicit slug wins so a notebook's URL can stay stable when its title
|
|
790
|
+
# changes, and so it can follow the file name the collection permalink
|
|
791
|
+
# (/notebooks/:name/) advertises.
|
|
792
|
+
explicit_slug = meta["slug"] || meta.dig("datalog", "slug")
|
|
793
|
+
slug_source = explicit_slug || title || File.basename(absolute_path, ".ipynb")
|
|
794
|
+
slug = Jekyll::Utils.slugify(slug_source)
|
|
795
|
+
title ||= slug_source.split(/[-_]/).map(&:capitalize).join(" ")
|
|
796
|
+
|
|
797
|
+
summary = summary_from(cells)
|
|
798
|
+
tags = Array(meta["tags"] || meta["keywords"]).map(&:to_s).reject(&:empty?).uniq
|
|
799
|
+
authors = extract_authors(meta)
|
|
800
|
+
kernelspec = (meta["kernelspec"] || {}).dup
|
|
801
|
+
language_info = (meta["language_info"] || {}).dup
|
|
802
|
+
language = kernelspec["language"] || language_info["name"]
|
|
803
|
+
|
|
804
|
+
execution_meta = meta["execution_info"] || meta["execution"] || meta.dig("datalog", "execution") || {}
|
|
805
|
+
executed_at = parse_time(execution_meta["finished_at"] || execution_meta["timestamp"] || meta["modified"])
|
|
806
|
+
executed_at ||= File.mtime(absolute_path)
|
|
807
|
+
duration = format_duration(execution_meta["duration"] || meta.dig("datalog", "duration"))
|
|
808
|
+
|
|
809
|
+
counts = count_cells(cells)
|
|
810
|
+
git = git_info(absolute_path)
|
|
811
|
+
integrations = build_integrations(relative_path)
|
|
812
|
+
|
|
813
|
+
download_dir = config["download_dir"].sub(%r{^/}, "")
|
|
814
|
+
download_url = File.join("/", download_dir, "#{slug}.ipynb")
|
|
815
|
+
permalink = File.join(config["permalink_base"], "#{slug}/")
|
|
816
|
+
|
|
817
|
+
{
|
|
818
|
+
title: title,
|
|
819
|
+
slug: slug,
|
|
820
|
+
summary: summary,
|
|
821
|
+
tags: tags,
|
|
822
|
+
authors: authors,
|
|
823
|
+
kernelspec: kernelspec,
|
|
824
|
+
language_info: language_info,
|
|
825
|
+
language: language,
|
|
826
|
+
counts: counts,
|
|
827
|
+
executed_at: executed_at,
|
|
828
|
+
duration: duration,
|
|
829
|
+
git: git,
|
|
830
|
+
integrations: integrations,
|
|
831
|
+
download_url: download_url,
|
|
832
|
+
permalink: permalink,
|
|
833
|
+
relative_source: relative_path,
|
|
834
|
+
absolute_path: absolute_path,
|
|
835
|
+
date: executed_at,
|
|
836
|
+
last_modified_at: git[:last_modified_at] || executed_at
|
|
837
|
+
}
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def extract_authors(meta)
|
|
841
|
+
Array(meta["authors"]).map do |entry|
|
|
842
|
+
case entry
|
|
843
|
+
when String
|
|
844
|
+
entry.strip
|
|
845
|
+
when Hash
|
|
846
|
+
entry["name"] || entry["full_name"] || entry["email"]
|
|
847
|
+
end
|
|
848
|
+
end.compact.reject(&:empty?)
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def count_cells(cells)
|
|
852
|
+
code = 0
|
|
853
|
+
markdown = 0
|
|
854
|
+
errors = 0
|
|
855
|
+
|
|
856
|
+
cells.each do |cell|
|
|
857
|
+
case cell["cell_type"]
|
|
858
|
+
when "code"
|
|
859
|
+
code += 1
|
|
860
|
+
outputs = Array(cell["outputs"])
|
|
861
|
+
errors += 1 if outputs.any? { |output| output["output_type"] == "error" }
|
|
862
|
+
when "markdown"
|
|
863
|
+
markdown += 1
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
{ code: code, markdown: markdown, errors: errors }
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def summary_from(cells)
|
|
871
|
+
first_markdown = cells.find { |cell| cell["cell_type"] == "markdown" }
|
|
872
|
+
return unless first_markdown
|
|
873
|
+
|
|
874
|
+
text = Array(first_markdown["source"]).join
|
|
875
|
+
text = text.gsub(/^#.+$/, "").strip
|
|
876
|
+
text.split(/\n\n+/).first&.strip
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def first_heading_from(cells)
|
|
880
|
+
cells.each do |cell|
|
|
881
|
+
next unless cell["cell_type"] == "markdown"
|
|
882
|
+
|
|
883
|
+
Array(cell["source"]).each do |line|
|
|
884
|
+
next unless line.is_a?(String)
|
|
885
|
+
|
|
886
|
+
stripped = line.strip
|
|
887
|
+
next unless stripped.start_with?("#")
|
|
888
|
+
|
|
889
|
+
heading = stripped.gsub(/^#+\s*/, "")
|
|
890
|
+
return heading unless heading.empty?
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
nil
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
def parse_time(value)
|
|
897
|
+
return if value.nil?
|
|
898
|
+
|
|
899
|
+
case value
|
|
900
|
+
when Time
|
|
901
|
+
value
|
|
902
|
+
when Numeric
|
|
903
|
+
Time.at(value)
|
|
904
|
+
when String
|
|
905
|
+
Time.parse(value)
|
|
906
|
+
end
|
|
907
|
+
rescue ArgumentError
|
|
908
|
+
nil
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def format_duration(value)
|
|
912
|
+
numeric =
|
|
913
|
+
case value
|
|
914
|
+
when Numeric
|
|
915
|
+
value.to_f
|
|
916
|
+
when String
|
|
917
|
+
stripped = value.strip
|
|
918
|
+
if stripped =~ /\A\d+(?:\.\d+)?\z/
|
|
919
|
+
stripped.to_f
|
|
920
|
+
elsif stripped =~ /\A\d{2}:\d{2}:\d{2}\z/
|
|
921
|
+
h, m, s = stripped.split(":").map(&:to_i)
|
|
922
|
+
(h * 3600) + (m * 60) + s
|
|
923
|
+
else
|
|
924
|
+
return stripped
|
|
925
|
+
end
|
|
926
|
+
else
|
|
927
|
+
return nil
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
hours = (numeric / 3600).floor
|
|
931
|
+
minutes = ((numeric % 3600) / 60).floor
|
|
932
|
+
seconds = (numeric % 60).round
|
|
933
|
+
|
|
934
|
+
parts = []
|
|
935
|
+
parts << "#{hours}h" if hours.positive?
|
|
936
|
+
parts << "#{minutes}m" if minutes.positive?
|
|
937
|
+
parts << "#{seconds}s" if seconds.positive? || parts.empty?
|
|
938
|
+
parts.join(" ")
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
def git_info(path)
|
|
942
|
+
return { last_commit: nil, last_modified_at: nil } unless File.exist?(path)
|
|
943
|
+
|
|
944
|
+
command = ["git", "log", "-1", "--format=%H|%ad", "--date=iso-strict", path]
|
|
945
|
+
output = IO.popen(command, &:read).to_s.strip
|
|
946
|
+
return { last_commit: nil, last_modified_at: nil } if output.empty?
|
|
947
|
+
|
|
948
|
+
commit, timestamp = output.split("|", 2)
|
|
949
|
+
{ last_commit: commit, last_modified_at: parse_time(timestamp) }
|
|
950
|
+
rescue StandardError
|
|
951
|
+
{ last_commit: nil, last_modified_at: nil }
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def build_integrations(relative_source)
|
|
955
|
+
repo_url = config["repository"] || site.config["repository_url"] || site.config.dig("github", "repository_url") ||
|
|
956
|
+
site.config.dig("github", "url") || site.config.dig("social", "github_url")
|
|
957
|
+
return {} unless repo_url
|
|
958
|
+
|
|
959
|
+
clean_repo = repo_url.gsub(/\.git$/, "")
|
|
960
|
+
repo_slug = clean_repo.split("github.com/").last
|
|
961
|
+
return {} unless repo_slug
|
|
962
|
+
|
|
963
|
+
branch = config.fetch("branch", "main")
|
|
964
|
+
binder_template = config.dig("binder", "base_url") || "https://mybinder.org/v2/gh/%{slug}/%{branch}?labpath=%{path}"
|
|
965
|
+
colab_template = config.dig("colab", "base_url") || "https://colab.research.google.com/github/%{slug}/blob/%{branch}/%{path}"
|
|
966
|
+
|
|
967
|
+
{
|
|
968
|
+
"binder" => format(binder_template, slug: repo_slug, branch: branch, path: relative_source),
|
|
969
|
+
"colab" => format(colab_template, slug: repo_slug, branch: branch, path: relative_source),
|
|
970
|
+
"repository" => clean_repo,
|
|
971
|
+
"branch" => branch,
|
|
972
|
+
"source" => File.join(clean_repo, "blob", branch, relative_source)
|
|
973
|
+
}
|
|
974
|
+
rescue KeyError, StandardError
|
|
975
|
+
{}
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def build_page(metadata, html)
|
|
979
|
+
dir = File.join(config["output"], metadata[:slug])
|
|
980
|
+
page = NotebookPage.new(site, site.source, dir, "index.html")
|
|
981
|
+
page.content = html
|
|
982
|
+
page.data["layout"] = config["default_layout"].empty? ? "notebook" : config["default_layout"]
|
|
983
|
+
page.data["title"] = metadata[:title]
|
|
984
|
+
page.data["description"] = metadata[:summary]
|
|
985
|
+
page.data["summary"] = metadata[:summary]
|
|
986
|
+
page.data["excerpt"] = metadata[:summary]
|
|
987
|
+
page.data["tags"] = metadata[:tags]
|
|
988
|
+
page.data["categories"] = Array(page.data["categories"]) | ["notebook"]
|
|
989
|
+
page.data["language"] = metadata[:language]
|
|
990
|
+
page.data["permalink"] = metadata[:permalink]
|
|
991
|
+
page.data["date"] = metadata[:date]
|
|
992
|
+
page.data["last_modified_at"] = metadata[:last_modified_at]
|
|
993
|
+
page.data["collection"] = config["collection"] unless config["collection"].empty?
|
|
994
|
+
page.data["author"] ||= metadata[:authors].first || site.config.dig("author", "name")
|
|
995
|
+
page.data["notebook_post"] = true
|
|
996
|
+
|
|
997
|
+
page.data["notebook"] = {
|
|
998
|
+
"authors" => metadata[:authors],
|
|
999
|
+
"kernelspec" => metadata[:kernelspec],
|
|
1000
|
+
"language_info" => metadata[:language_info],
|
|
1001
|
+
"language" => metadata[:language],
|
|
1002
|
+
"code_cells" => metadata.dig(:counts, :code),
|
|
1003
|
+
"markdown_cells" => metadata.dig(:counts, :markdown),
|
|
1004
|
+
"error_cells" => metadata.dig(:counts, :errors),
|
|
1005
|
+
"executed_at" => metadata[:executed_at],
|
|
1006
|
+
"duration" => metadata[:duration],
|
|
1007
|
+
"download" => metadata[:download_url],
|
|
1008
|
+
"source" => metadata[:relative_source],
|
|
1009
|
+
"git" => metadata[:git],
|
|
1010
|
+
"integrations" => metadata[:integrations]
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
page
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def attach_to_collection(page)
|
|
1017
|
+
collection = config["collection"].empty? ? nil : site.collections[config["collection"]]
|
|
1018
|
+
return unless collection
|
|
1019
|
+
# Only attach to collection if collection output is enabled to avoid conflicts
|
|
1020
|
+
return unless collection.metadata["output"]
|
|
1021
|
+
|
|
1022
|
+
return if collection.docs.include?(page)
|
|
1023
|
+
|
|
1024
|
+
collection.docs << page
|
|
1025
|
+
collection.docs.sort_by! do |doc|
|
|
1026
|
+
(doc.respond_to?(:date) ? doc.date : doc.data["date"]) || Time.at(0)
|
|
1027
|
+
end
|
|
1028
|
+
collection.docs.reverse!
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
def register_download(source_path, metadata)
|
|
1032
|
+
dir = config["download_dir"].sub(%r{^/}, "")
|
|
1033
|
+
download = NotebookDownload.new(site, source_path, dir, metadata[:slug])
|
|
1034
|
+
|
|
1035
|
+
unless site.static_files.any? do |static|
|
|
1036
|
+
static.is_a?(NotebookDownload) && static.name == download.name && static.dir == download.dir
|
|
1037
|
+
end
|
|
1038
|
+
site.static_files << download
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
def build_search_entry(metadata)
|
|
1043
|
+
{
|
|
1044
|
+
"title" => metadata[:title],
|
|
1045
|
+
"summary" => metadata[:summary],
|
|
1046
|
+
"url" => metadata[:permalink],
|
|
1047
|
+
"tags" => metadata[:tags],
|
|
1048
|
+
"authors" => metadata[:authors],
|
|
1049
|
+
"download" => metadata[:download_url],
|
|
1050
|
+
"kernelspec" => metadata[:kernelspec],
|
|
1051
|
+
"language" => metadata[:language],
|
|
1052
|
+
"code_cells" => metadata.dig(:counts, :code),
|
|
1053
|
+
"markdown_cells" => metadata.dig(:counts, :markdown),
|
|
1054
|
+
"error_cells" => metadata.dig(:counts, :errors),
|
|
1055
|
+
"executed_at" => metadata[:executed_at]&.iso8601,
|
|
1056
|
+
"duration" => metadata[:duration],
|
|
1057
|
+
"git" => metadata[:git].merge("last_modified_at" => metadata[:git][:last_modified_at]&.iso8601),
|
|
1058
|
+
"integrations" => metadata[:integrations]
|
|
1059
|
+
}
|
|
1060
|
+
rescue StandardError => e
|
|
1061
|
+
logger.warn("notebook converter", "unable to build search metadata for #{metadata[:slug]}: #{e.message}")
|
|
1062
|
+
debug_backtrace(e)
|
|
1063
|
+
nil
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def logger
|
|
1067
|
+
Jekyll.logger
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
def debug_backtrace(error)
|
|
1071
|
+
return unless logger.respond_to?(:debug?) && logger.debug?
|
|
1072
|
+
|
|
1073
|
+
Array(error.backtrace).each do |line|
|
|
1074
|
+
logger.debug("notebook converter", line)
|
|
1075
|
+
end
|
|
1076
|
+
end
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
class NotebookPage < PageWithoutAFile
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
class NotebookDownload < StaticFile
|
|
1083
|
+
def initialize(site, source_path, download_dir, slug)
|
|
1084
|
+
@site = site
|
|
1085
|
+
@base = site.source
|
|
1086
|
+
@source_path = source_path
|
|
1087
|
+
relative_dir = File.dirname(source_path.sub(%r{^#{Regexp.escape(site.source)}/}, ""))
|
|
1088
|
+
relative_name = File.basename(source_path)
|
|
1089
|
+
super(site, site.source, relative_dir, relative_name)
|
|
1090
|
+
@dir = download_dir
|
|
1091
|
+
@name = "#{slug}.ipynb"
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
def destination(dest)
|
|
1095
|
+
File.join(dest, @dir, @name)
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
def write(dest)
|
|
1099
|
+
FileUtils.mkdir_p(File.dirname(destination(dest)))
|
|
1100
|
+
FileUtils.cp(@source_path, destination(dest))
|
|
1101
|
+
true
|
|
1102
|
+
rescue StandardError => e
|
|
1103
|
+
Jekyll.logger.warn("notebook converter", "failed to copy #{@source_path} to #{destination(dest)}: #{e.message}")
|
|
1104
|
+
false
|
|
1105
|
+
end
|
|
1106
|
+
end
|
|
1107
|
+
end
|