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,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