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,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll/utils"
4
+
5
+ require_relative "../lib/datalog/plugin_system"
6
+
7
+ Dir[File.expand_path("../lib/datalog/plugins/**/*.rb", __dir__)].each do |plugin_file|
8
+ require plugin_file
9
+ end
10
+
11
+ module Datalog
12
+ class PluginLoader < Jekyll::Generator
13
+ safe true
14
+ priority :highest
15
+
16
+ def generate(site)
17
+ plugin_classes = enabled_plugins(site).filter_map do |identifier|
18
+ resolve_plugin_class(identifier)
19
+ end
20
+
21
+ resolver = Datalog::PluginSystem::DependencyResolver.new(
22
+ plugin_classes,
23
+ available: Datalog::PluginSystem.available_plugins
24
+ )
25
+
26
+ ordered_classes = resolver.resolve
27
+ log_dependency_resolution(resolver, ordered_classes)
28
+
29
+ instances = ordered_classes.filter_map do |klass|
30
+ instantiate_plugin(site, klass)
31
+ end
32
+
33
+ Datalog::PluginSystem.activate!(site, instances)
34
+ register_liquid_tags(instances)
35
+
36
+ site.config["datalog_plugin_state"] = {
37
+ "active" => instances.map(&:id),
38
+ "available" => Datalog::PluginSystem.available_plugins.keys.sort
39
+ }
40
+ rescue Datalog::PluginSystem::PluginDependencyError => e
41
+ logger.error("datalog plugin", e.message)
42
+ raise e
43
+ end
44
+
45
+ private
46
+
47
+ def enabled_plugins(site)
48
+ config = site.config.fetch("datalog_plugins", {})
49
+
50
+ plugins =
51
+ case config
52
+ when Array
53
+ config
54
+ when Hash
55
+ Array(config["enabled"] || config["plugins"] || [])
56
+ else
57
+ []
58
+ end
59
+
60
+ plugins.each_with_object([]) do |identifier, acc|
61
+ next if identifier.nil?
62
+
63
+ value = identifier.to_s.strip
64
+ next if value.empty?
65
+
66
+ acc << value unless acc.include?(value)
67
+ end
68
+ end
69
+
70
+ def instantiate_plugin(site, klass)
71
+ identifier = klass.id
72
+ config = fetch_plugin_config(site, identifier)
73
+ klass.new(site, config)
74
+ rescue StandardError => e
75
+ logger.error("datalog plugin", "#{identifier} failed to initialize: #{e.message}")
76
+ nil
77
+ end
78
+
79
+ def resolve_plugin_class(identifier)
80
+ klass = Datalog::PluginSystem.available_plugins[identifier]
81
+ unless klass
82
+ logger.warn("datalog plugin", "#{identifier} is not registered; skipping")
83
+ return
84
+ end
85
+
86
+ klass
87
+ end
88
+
89
+ def fetch_plugin_config(site, identifier)
90
+ config = site.config.fetch("datalog_plugins", {})
91
+ return {} unless config.is_a?(Hash)
92
+
93
+ options = config.fetch("options", {})
94
+ return {} unless options.is_a?(Hash)
95
+
96
+ key = identifier
97
+ value = options[key] || options[key.to_s] || options[key.to_sym]
98
+ value.is_a?(Hash) ? value : {}
99
+ end
100
+
101
+ def register_liquid_tags(instances)
102
+ instances.each do |plugin|
103
+ next unless plugin.respond_to?(:custom_liquid_tags)
104
+
105
+ tags = plugin.custom_liquid_tags
106
+ unless tags.is_a?(Hash)
107
+ logger.warn("datalog plugin", "#{plugin.id} returned invalid Liquid tags payload; expected a hash")
108
+ next
109
+ end
110
+
111
+ tags.each do |tag_name, tag_class|
112
+ next if tag_name.to_s.empty? || tag_class.nil?
113
+
114
+ Liquid::Template.register_tag(tag_name, tag_class)
115
+ rescue StandardError => e
116
+ logger.warn("datalog plugin", "#{plugin.id} failed to register #{tag_name} tag: #{e.message}")
117
+ end
118
+ rescue StandardError => e
119
+ logger.warn("datalog plugin", "#{plugin.id} failed while registering Liquid tags: #{e.message}")
120
+ end
121
+ end
122
+
123
+ def logger
124
+ Jekyll.logger
125
+ end
126
+
127
+ def log_dependency_resolution(resolver, ordered_classes)
128
+ return unless debug_logging?
129
+
130
+ identifiers = ordered_classes.map(&:id)
131
+ logger.debug("datalog plugin", "resolved load order: #{identifiers.join(' -> ')}")
132
+
133
+ graph = resolver.dependency_graph
134
+ return if graph.empty?
135
+
136
+ graph_summary = graph.map do |plugin_id, dependencies|
137
+ deps = Array(dependencies).map(&:to_s).sort
138
+ deps.empty? ? "#{plugin_id}: []" : "#{plugin_id}: [#{deps.join(', ')}]"
139
+ end.join("; ")
140
+
141
+ logger.debug("datalog plugin", "dependency graph: #{graph_summary}")
142
+ end
143
+
144
+ def debug_logging?
145
+ logger.respond_to?(:debug?) && logger.debug?
146
+ end
147
+ end
148
+ end
149
+
150
+ module Datalog
151
+ module PluginLoaderHooks
152
+ HOOK_SCOPES = %i[pages documents posts].freeze
153
+
154
+ module_function
155
+
156
+ def register!
157
+ HOOK_SCOPES.each do |scope|
158
+ Jekyll::Hooks.register scope, :pre_render do |document, payload|
159
+ Datalog::PluginSystem.run_hook(:before_render, document, payload)
160
+ end
161
+
162
+ Jekyll::Hooks.register scope, :post_render do |document|
163
+ extensions = Datalog::PluginSystem.collect_hook(:search_indexing, document)
164
+ next if extensions.nil? || extensions.empty?
165
+
166
+ existing = document.data["datalog_search_extensions"] || {}
167
+ document.data["datalog_search_extensions"] = deep_merge_extensions(existing, extensions)
168
+ end
169
+
170
+ Jekyll::Hooks.register scope, :post_write do |document|
171
+ Datalog::PluginSystem.run_hook(:after_post_process, document)
172
+ end
173
+ end
174
+ end
175
+
176
+ def deep_merge_extensions(existing, extensions)
177
+ return extensions unless existing.is_a?(Hash)
178
+ return existing unless extensions.is_a?(Hash)
179
+
180
+ Jekyll::Utils.deep_merge_hashes(existing, extensions)
181
+ end
182
+ end
183
+ end
184
+
185
+ Datalog::PluginLoaderHooks.register!
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ class PublicationsGenerator < Generator
5
+ safe true
6
+ priority :low
7
+
8
+ def generate(site)
9
+ publications_data = site.data["publications"] ||= {}
10
+ settings = publications_data["settings"] || {}
11
+ config_source = site.config.dig("theme_options", "publications", "bibtex_source")
12
+ bibtex_source = settings["bibtex_source"] || config_source
13
+
14
+ imported_entries = []
15
+ if bibtex_source
16
+ bibtex_path = site.in_source_dir(bibtex_source)
17
+ if File.exist?(bibtex_path)
18
+ content = File.read(bibtex_path, encoding: "UTF-8")
19
+ imported_entries = parse_bibtex_entries(content)
20
+ else
21
+ Jekyll.logger.warn("PublicationsGenerator:", "BibTeX source not found at #{bibtex_path}")
22
+ end
23
+ end
24
+
25
+ manual_entries = publications_data["manual_entries"] || []
26
+ combined = (imported_entries + manual_entries).map { |entry| normalize_entry(entry) }
27
+
28
+ academic_citations = site.data.dig("academic", "citations", "per_publication") || {}
29
+ citations_map = {}
30
+ combined.each do |entry|
31
+ identifier = entry["id"] || parameterize(entry["title"])
32
+ citation_info = locate_citation_info(academic_citations, identifier)
33
+ if citation_info
34
+ entry["citations"] = citation_info["total"] || citation_info[:total]
35
+ entry["citations_last_updated"] = citation_info["last_updated"] || citation_info[:last_updated]
36
+ end
37
+ next unless identifier
38
+
39
+ citations_map[identifier] = {
40
+ "total" => entry["citations"]&.to_i,
41
+ "last_updated" => entry["citations_last_updated"]
42
+ }
43
+ end
44
+
45
+ sort_order = (settings["sort"] || settings["sort_order"] || "descending").to_s
46
+ combined.sort_by! { |entry| [entry["year"] ? entry["year"].to_i : 0, entry["title"].to_s.downcase] }
47
+ combined.reverse! if sort_order == "descending"
48
+
49
+ group_field = settings["group_by"] || site.config.dig("theme_options", "publications", "group_by")
50
+ grouped = group_entries(combined, group_field, sort_order)
51
+
52
+ include_bibliography = if settings.key?("include_bibliography")
53
+ settings["include_bibliography"]
54
+ else
55
+ site.config.dig(
56
+ "theme_options", "publications", "include_bibliography"
57
+ )
58
+ end
59
+ exports = settings["export_formats"] || site.config.dig("theme_options", "publications", "export_formats") || []
60
+
61
+ metrics_override = site.data.dig("academic", "citations", "metrics") || {}
62
+ metrics = compute_metrics(combined, citations_map, metrics_override)
63
+
64
+ publications_data["imported"] = imported_entries
65
+ publications_data["entries"] = combined
66
+ publications_data["grouped"] = grouped if grouped
67
+ publications_data["citation_totals"] = citations_map
68
+ publications_data["metrics"] = metrics
69
+ publications_data["yearly_totals"] = metrics["yearly_totals"]
70
+ return unless include_bibliography
71
+
72
+ publications_data["bibliography"] = build_bibliography(combined)
73
+ publications_data["export_formats"] = exports
74
+ end
75
+
76
+ private
77
+
78
+ ENTRY_REGEX = /@(?<type>\w+)\s*\{\s*(?<key>[^,]+),\s*(?<fields>.*?)\}\s*(?=@|\z)/m
79
+
80
+ def parse_bibtex_entries(content)
81
+ entries = []
82
+ content.to_s.scan(ENTRY_REGEX) do |type, key, raw_fields|
83
+ fields = {}
84
+ raw_fields.lines.each do |line|
85
+ cleaned = line.strip
86
+ next if cleaned.empty?
87
+
88
+ cleaned = cleaned.delete_suffix(",")
89
+ name, value = cleaned.split("=", 2)
90
+ next unless name && value
91
+
92
+ name = name.strip.downcase
93
+ value = value.strip
94
+ value = value.gsub(/\A["{]+/, "").gsub(/["}]+\z/, "")
95
+ fields[name] = value.strip
96
+ end
97
+ entries << {
98
+ "id" => key.strip,
99
+ "type" => type.strip.downcase,
100
+ "title" => fields["title"],
101
+ "authors" => parse_authors(fields["author"]),
102
+ "venue" => fields["journal"] || fields["booktitle"] || fields["institution"],
103
+ "year" => fields["year"],
104
+ "doi" => fields["doi"],
105
+ "url" => fields["url"],
106
+ "raw" => fields
107
+ }
108
+ end
109
+ entries
110
+ end
111
+
112
+ def parse_authors(raw)
113
+ return [] unless raw
114
+
115
+ raw.split(/\s+and\s+/i).map(&:strip)
116
+ end
117
+
118
+ def normalize_entry(entry)
119
+ normalized = entry.transform_keys(&:to_s)
120
+ normalized["authors"] = parse_authors(normalized["authors"]) if normalized["authors"].is_a?(String)
121
+ normalized["year"] = normalized["year"].to_s if normalized["year"]
122
+ normalized
123
+ end
124
+
125
+ def locate_citation_info(map, identifier)
126
+ return nil unless identifier
127
+
128
+ map[identifier] || map[identifier.to_s] || map[identifier.to_s.downcase]
129
+ end
130
+
131
+ def parameterize(value)
132
+ return nil unless value
133
+
134
+ value.to_s.downcase.strip.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
135
+ end
136
+
137
+ def group_entries(entries, field, sort_order)
138
+ return nil unless field && !field.to_s.empty?
139
+
140
+ grouped = entries.group_by { |entry| entry[field.to_s] || "Other" }
141
+ sorted = grouped.sort_by do |group_key, _|
142
+ group_value = group_key.to_s
143
+ if group_value.match?(/\A\d+\z/)
144
+ group_value.to_i
145
+ else
146
+ group_value.downcase
147
+ end
148
+ end
149
+ sorted.reverse! if sort_order == "descending"
150
+ sorted
151
+ end
152
+
153
+ def compute_metrics(entries, citations_map, override)
154
+ yearly_totals = Hash.new(0)
155
+ citation_counts = []
156
+ entries.each do |entry|
157
+ year = entry["year"]
158
+ citations = entry["citations"]
159
+ citation_value = citations.to_i if citations
160
+ if citation_value&.positive?
161
+ citation_counts << citation_value
162
+ yearly_totals[year.to_s] += citation_value if year
163
+ elsif year
164
+ yearly_totals[year.to_s] += 1
165
+ end
166
+ end
167
+
168
+ citation_counts.sort! { |a, b| b <=> a }
169
+ total = citation_counts.reduce(0, :+)
170
+ h_index = 0
171
+ citation_counts.each_with_index do |count, index|
172
+ h_index = index + 1 if count >= index + 1
173
+ end
174
+ i10_index = citation_counts.count { |count| count >= 10 }
175
+
176
+ metrics = {
177
+ "total" => total,
178
+ "h_index" => h_index,
179
+ "i10_index" => i10_index,
180
+ "per_publication" => citations_map,
181
+ "yearly_totals" => yearly_totals
182
+ }
183
+
184
+ override.each do |key, value|
185
+ metrics[key.to_s] = if value.is_a?(Hash)
186
+ merge_nested_hash(metrics[key.to_s], value)
187
+ else
188
+ value
189
+ end
190
+ end
191
+
192
+ metrics
193
+ end
194
+
195
+ def merge_nested_hash(base, override)
196
+ base = (base || {}).transform_keys(&:to_s)
197
+ override.each do |key, value|
198
+ key = key.to_s
199
+ base[key] = if value.is_a?(Hash)
200
+ merge_nested_hash(base[key], value)
201
+ else
202
+ value
203
+ end
204
+ end
205
+ base
206
+ end
207
+
208
+ def build_bibliography(entries)
209
+ entries.map { |entry| format_bibtex(entry) }.compact
210
+ end
211
+
212
+ def format_bibtex(entry)
213
+ type = entry["type"] || "article"
214
+ identifier = entry["id"] || parameterize(entry["title"]) || "reference"
215
+ authors = Array(entry["authors"]).map { |author| author.to_s.strip }.reject(&:empty?)
216
+ buffer = []
217
+ buffer << "@#{type}{ #{identifier},"
218
+ buffer << " title = { #{entry['title']} }," if entry["title"]
219
+ buffer << " author = { #{authors.join(' and ')} }," if authors.any?
220
+ buffer << " year = { #{entry['year']} }," if entry["year"]
221
+ buffer << " journal = { #{entry['venue']} }," if entry["venue"]
222
+ buffer << " doi = { #{entry['doi']} }," if entry["doi"]
223
+ buffer << " url = { #{entry['url']} }," if entry["url"]
224
+ raw_fields = entry["raw"] || {}
225
+ %w[volume number pages publisher].each do |field|
226
+ next unless raw_fields[field]
227
+
228
+ buffer << " #{field} = { #{raw_fields[field]} },"
229
+ end
230
+ buffer << "}"
231
+ buffer.join("\n")
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ReadingTimeFilter
5
+ WORDS_PER_MINUTE = 180.0
6
+
7
+ def reading_time(input)
8
+ words = input.to_s.split.size
9
+ minutes = (words / WORDS_PER_MINUTE).ceil
10
+ minutes.positive? ? minutes : 1
11
+ end
12
+ end
13
+ end
14
+
15
+ Liquid::Template.register_filter(Jekyll::ReadingTimeFilter)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "unicode_normalize"
5
+ UNICODE_NORMALIZE_SUPPORTED = true
6
+ rescue LoadError
7
+ UNICODE_NORMALIZE_SUPPORTED = false
8
+ warn "[search_normalizer] unicode_normalize gem not available; falling back to basic normalization"
9
+ end
10
+
11
+ module Datalog
12
+ module SearchFilters
13
+ module_function
14
+
15
+ def normalize_search(input)
16
+ value = input.to_s
17
+ return "" if value.empty?
18
+
19
+ normalized = if UNICODE_NORMALIZE_SUPPORTED && value.respond_to?(:unicode_normalize)
20
+ value.unicode_normalize(:nfkd).gsub(/\p{Mn}/, "")
21
+ else
22
+ transliterate(value)
23
+ end
24
+
25
+ normalized.downcase.strip
26
+ rescue StandardError
27
+ input.to_s.downcase
28
+ end
29
+
30
+ def transliterate(value)
31
+ value.encode("ASCII", fallback: lambda { |char|
32
+ approximate_character(char)
33
+ }, invalid: :replace, undef: :replace, replace: "")
34
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
35
+ value
36
+ end
37
+
38
+ def approximate_character(char)
39
+ @transliteration_map ||= build_transliteration_map
40
+ @transliteration_map.fetch(char, "")
41
+ end
42
+
43
+ def build_transliteration_map
44
+ basic_map = {}
45
+
46
+ accents = {
47
+ "ÀÁÂÃÄÅàáâãäå" => "a",
48
+ "ÈÉÊËèéêë" => "e",
49
+ "ÌÍÎÏìíîï" => "i",
50
+ "ÒÓÔÕÖØòóôõöø" => "o",
51
+ "ÙÚÛÜùúûü" => "u",
52
+ "Çç" => "c",
53
+ "Ññ" => "n",
54
+ "Ýýÿ" => "y",
55
+ "Ææ" => "ae",
56
+ "Œœ" => "oe"
57
+ }
58
+
59
+ accents.each do |chars, replacement|
60
+ chars.each_char { |char| basic_map[char] = replacement }
61
+ end
62
+
63
+ basic_map
64
+ end
65
+
66
+ def normalize_search_array(values)
67
+ Array(values)
68
+ .map { |value| normalize_search(value) }
69
+ .reject(&:empty?)
70
+ end
71
+ end
72
+ end
73
+
74
+ Liquid::Template.register_filter(Datalog::SearchFilters)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module SriFilter
5
+ def add_sri(url)
6
+ site = @context.registers[:site]
7
+ integrity_map = site.data["cdn-integrity"] || {}
8
+ entry = integrity_map[url]
9
+ return url unless entry
10
+
11
+ attributes = "#{url}\" integrity=\"#{entry['integrity']}"
12
+ attributes += "\" crossorigin=\"#{entry['crossorigin']}" if entry["crossorigin"]
13
+ attributes
14
+ end
15
+ end
16
+ end
17
+
18
+ Liquid::Template.register_filter(Jekyll::SriFilter)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ # `tocify` builds a nested list of links to the headings of a rendered HTML
6
+ # fragment. The table-of-contents includes (`components/enhanced-toc.html`,
7
+ # `toc.html`) call it as `content | tocify: h_min, h_max`.
8
+ #
9
+ # Headings keep the ids kramdown gives them; a heading without an id gets a
10
+ # slug of its text, which still needs an anchor in the page to be useful.
11
+ module Jekyll
12
+ module TocifyFilter
13
+ def tocify(html, h_min = 2, h_max = 4)
14
+ return "" if html.nil? || html.to_s.strip.empty?
15
+
16
+ lowest = h_min.to_i.clamp(1, 6)
17
+ highest = h_max.to_i.clamp(lowest, 6)
18
+ fragment = Nokogiri::HTML::DocumentFragment.parse(html.to_s)
19
+ selector = (lowest..highest).map { |level| "h#{level}" }.join(",")
20
+
21
+ headings = fragment.css(selector).filter_map do |node|
22
+ text = node.text.strip
23
+ next if text.empty?
24
+
25
+ id = node["id"].to_s.strip
26
+ id = Jekyll::Utils.slugify(text) if id.empty?
27
+ [node.name[1].to_i, id, text]
28
+ end
29
+ return "" if headings.empty?
30
+
31
+ tocify_render_list(headings, lowest)
32
+ end
33
+
34
+ private
35
+
36
+ def tocify_render_list(headings, top_level)
37
+ out = +""
38
+ open_levels = []
39
+ headings.each do |level, id, text|
40
+ level = [level, top_level].max
41
+ if open_levels.empty?
42
+ out << %(<ul class="toc-list">)
43
+ open_levels << level
44
+ elsif level > open_levels.last
45
+ out << "<ul>"
46
+ open_levels << level
47
+ else
48
+ out << "</li>"
49
+ while open_levels.size > 1 && level < open_levels.last
50
+ out << "</ul></li>"
51
+ open_levels.pop
52
+ end
53
+ end
54
+ out << %(<li class="toc-item toc-level-#{level}"><a href="##{id}">#{tocify_escape_text(text)}</a>)
55
+ end
56
+ out << "</li>"
57
+ out << "</ul></li>" * (open_levels.size - 1)
58
+ out << "</ul>"
59
+ out
60
+ end
61
+
62
+ def tocify_escape_text(text)
63
+ text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
64
+ end
65
+ end
66
+ end
67
+
68
+ Liquid::Template.register_filter(Jekyll::TocifyFilter)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/datalog/warning_filter"