datalog-theme 0.7.0 → 0.9.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +188 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +25 -17
  5. data/_data/cdn-integrity.yml +0 -30
  6. data/_data/i18n/en.yml +297 -0
  7. data/_data/i18n/es.yml +297 -0
  8. data/_data/i18n/pt.yml +297 -0
  9. data/_data/js_manifest.json +16 -0
  10. data/_includes/analytics/dashboard.html +3 -1
  11. data/_includes/components/api-function.html +20 -1
  12. data/_includes/components/author-bio.html +21 -11
  13. data/_includes/components/author-list.html +32 -0
  14. data/_includes/components/citation-tools.html +33 -25
  15. data/_includes/components/comments-thread.html +90 -0
  16. data/_includes/components/contact-form.html +120 -0
  17. data/_includes/components/correction-report.html +82 -0
  18. data/_includes/components/enhanced-code-block.html +1 -1
  19. data/_includes/components/enhanced-toc.html +6 -7
  20. data/_includes/components/license-link.html +13 -0
  21. data/_includes/components/license-notice.html +28 -0
  22. data/_includes/components/moderation-inbox.html +139 -0
  23. data/_includes/components/reactions.html +49 -0
  24. data/_includes/components/reading-list.html +31 -0
  25. data/_includes/components/reading-mode-toggle.html +65 -0
  26. data/_includes/components/reading-state-bookmark.html +27 -0
  27. data/_includes/components/reading-state-panel.html +71 -0
  28. data/_includes/components/reproducibility.html +61 -0
  29. data/_includes/components/responsive-image.html +3 -3
  30. data/_includes/components/revision-history.html +39 -0
  31. data/_includes/components/revision-notice.html +35 -0
  32. data/_includes/components/series-nav.html +64 -0
  33. data/_includes/components/subscribe-form.html +88 -0
  34. data/_includes/components/subscription-manage.html +68 -0
  35. data/_includes/components/webmentions.html +48 -0
  36. data/_includes/csp-meta.html +135 -11
  37. data/_includes/footer.html +22 -20
  38. data/_includes/head.html +111 -63
  39. data/_includes/header/navigation.html +12 -15
  40. data/_includes/header.html +28 -19
  41. data/_includes/layouts/default/article.html +9 -7
  42. data/_includes/meta/dynamic-services-config.html +14 -0
  43. data/_includes/meta/math-config.html +18 -10
  44. data/_includes/meta/person-json.html +27 -0
  45. data/_includes/meta/publisher.html +45 -0
  46. data/_includes/meta/schema.html +67 -30
  47. data/_includes/meta/scholarly.html +121 -0
  48. data/_includes/meta/scripts-loader.html +18 -32
  49. data/_includes/meta/webmention-discovery.html +14 -0
  50. data/_includes/post/related-posts.html +4 -7
  51. data/_includes/scripts.html +57 -0
  52. data/_includes/search/index-data.json +9 -34
  53. data/_layouts/dataset.html +5 -3
  54. data/_layouts/default.html +16 -8
  55. data/_layouts/notebook.html +1 -0
  56. data/_layouts/package.html +4 -2
  57. data/_layouts/page.html +15 -0
  58. data/_layouts/portfolio.html +1 -0
  59. data/_layouts/post.html +102 -23
  60. data/_layouts/project.html +4 -3
  61. data/_layouts/research.html +20 -8
  62. data/_plugins/analytics_dashboard.rb +9 -3
  63. data/_plugins/authors.rb +133 -0
  64. data/_plugins/config_validator.rb +231 -23
  65. data/_plugins/critical_css_check.rb +42 -0
  66. data/_plugins/csp_generator.rb +18 -28
  67. data/_plugins/datalog_bibliography.rb +9 -7
  68. data/_plugins/datalog_comments.rb +8 -5
  69. data/_plugins/datalog_slides.rb +9 -8
  70. data/_plugins/i18n.rb +13 -12
  71. data/_plugins/image_optimizer.rb +241 -178
  72. data/_plugins/licenses.rb +135 -0
  73. data/_plugins/math_preprocessor.rb +59 -7
  74. data/_plugins/notebook_converter.rb +23 -4
  75. data/_plugins/plugin_loader.rb +3 -1
  76. data/_plugins/publications_generator.rb +8 -2
  77. data/_plugins/references.rb +238 -0
  78. data/_plugins/reproducibility.rb +150 -0
  79. data/_plugins/revisions.rb +101 -0
  80. data/_plugins/rouge_highlight_filter.rb +42 -0
  81. data/_plugins/scholarly.rb +50 -0
  82. data/_plugins/search_code_blocks.rb +30 -0
  83. data/_plugins/search_normalizer.rb +15 -53
  84. data/_plugins/search_pages.rb +3 -4
  85. data/_plugins/series.rb +104 -0
  86. data/_plugins/statements.rb +87 -0
  87. data/_sass/_academic-dashboard.scss +262 -0
  88. data/_sass/_base.scss +20 -1
  89. data/_sass/_comments-thread.scss +159 -0
  90. data/_sass/_components.scss +64 -1153
  91. data/_sass/_features.scss +17 -0
  92. data/_sass/_layout.scss +385 -1
  93. data/_sass/_mathematical.scss +27 -0
  94. data/_sass/_moderation.scss +222 -0
  95. data/_sass/_notebooks.scss +322 -0
  96. data/_sass/_open-science-badges.scss +56 -0
  97. data/_sass/{_phase1-enhancements.scss → _post-components.scss} +5 -3
  98. data/_sass/_print.scss +291 -0
  99. data/_sass/_reactions.scss +89 -0
  100. data/_sass/_reading-state.scss +290 -0
  101. data/_sass/_search-page.scss +530 -0
  102. data/_sass/_search.scss +46 -0
  103. data/_sass/_service-forms.scss +204 -0
  104. data/_sass/_subscriptions.scss +140 -0
  105. data/_sass/_syntax-highlighting.scss +212 -97
  106. data/_sass/_theme.scss +40 -19
  107. data/_sass/_typography.scss +130 -0
  108. data/_sass/_utilities.scss +5 -0
  109. data/_sass/_variables.scss +6 -0
  110. data/_sass/_webmentions.scss +125 -0
  111. data/assets/css/main.scss +14 -0
  112. data/assets/js/dist/academic.js +1 -1
  113. data/assets/js/dist/analytics-dashboard.js +1 -1
  114. data/assets/js/dist/chunks/chunk-2DYDWUFX.js +1 -0
  115. data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
  116. data/assets/js/dist/chunks/chunk-V7734B2G.js +1 -0
  117. data/assets/js/dist/comments.js +2 -0
  118. data/assets/js/dist/contact.js +1 -0
  119. data/assets/js/dist/core.js +1 -1
  120. data/assets/js/dist/corrections.js +1 -0
  121. data/assets/js/dist/loader.js +1 -1
  122. data/assets/js/dist/math.js +1 -1
  123. data/assets/js/dist/moderation.js +1 -0
  124. data/assets/js/dist/notebook.js +1 -1
  125. data/assets/js/dist/reactions.js +1 -0
  126. data/assets/js/dist/reading-state.js +1 -0
  127. data/assets/js/dist/search.js +1 -1
  128. data/assets/js/dist/sources.json +52 -0
  129. data/assets/js/dist/subscriptions.js +1 -0
  130. data/assets/js/dist/visualizations.js +11 -2
  131. data/assets/js/dist/webmentions.js +1 -0
  132. data/assets/js/loader.js +37 -1
  133. data/datalog-theme.gemspec +35 -23
  134. data/lib/datalog/cli.rb +43 -15
  135. data/lib/datalog/critical_css.rb +168 -0
  136. data/lib/datalog/plugin_system/dependency_resolver.rb +0 -2
  137. data/lib/datalog/plugins/comments.rb +33 -3
  138. data/lib/datalog/theme/installed_files.rb +113 -0
  139. data/lib/datalog/theme/package.rb +57 -0
  140. data/lib/datalog/theme/repository_checkout.rb +94 -0
  141. data/lib/datalog/theme/version.rb +5 -1
  142. data/lib/datalog/warning_filter.rb +5 -11
  143. data/lib/datalog-theme.rb +6 -0
  144. metadata +96 -147
  145. data/_data/academic.yml +0 -217
  146. data/_data/config/author.yml +0 -121
  147. data/_data/datasets.yml +0 -28
  148. data/_data/js_meta.json +0 -371
  149. data/_data/navigation.yml +0 -145
  150. data/_data/projects.yml +0 -41
  151. data/_data/publications.yml +0 -28
  152. data/_data/social.yml +0 -73
  153. data/_data/visualizations.yml +0 -51
  154. data/_includes/components/advanced-search.html +0 -682
  155. data/_includes/components/bookmark-system.html +0 -96
  156. data/_includes/components/comments.html +0 -244
  157. data/_includes/components/content-recommendations.html +0 -228
  158. data/_includes/components/email-preferences.html +0 -200
  159. data/_includes/components/enhanced-metadata.html +0 -228
  160. data/_includes/components/language-switcher.html +0 -396
  161. data/_includes/components/navigation-enhancements.html +0 -454
  162. data/_includes/components/newsletter-signup.html +0 -178
  163. data/_includes/components/popular-posts.html +0 -233
  164. data/_includes/components/reading-progress.html +0 -133
  165. data/_includes/components/reading-time.html +0 -121
  166. data/_includes/components/series-navigation.html +0 -124
  167. data/_includes/components/social-proof.html +0 -34
  168. data/_includes/components/user-preferences.html +0 -566
  169. data/_includes/meta/syntax-config.html +0 -19
  170. data/_layouts/archive.html +0 -282
  171. data/_layouts/post-sidebar.html +0 -183
  172. data/_sass/_phase3-enhancements.scss +0 -874
  173. data/_sass/_phase4-enhancements.scss +0 -1214
  174. data/_sass/_phase5-enhancements.scss +0 -414
  175. data/assets/js/academic.js +0 -262
  176. data/assets/js/analytics-dashboard.js +0 -382
  177. data/assets/js/core/dark-mode.js +0 -79
  178. data/assets/js/core/github-cards.js +0 -123
  179. data/assets/js/core/language-filter.js +0 -69
  180. data/assets/js/core/navigation.js +0 -184
  181. data/assets/js/core/scroll-progress.js +0 -45
  182. data/assets/js/core/search-hotkeys.js +0 -62
  183. data/assets/js/core/skip-links.js +0 -62
  184. data/assets/js/dist/manifest.json +0 -22
  185. data/assets/js/dist/meta.json +0 -371
  186. data/assets/js/main.js +0 -23
  187. data/assets/js/math.js +0 -818
  188. data/assets/js/notebook.js +0 -158
  189. data/assets/js/search/analytics.js +0 -91
  190. data/assets/js/search/app.js +0 -271
  191. data/assets/js/search/autocomplete.js +0 -120
  192. data/assets/js/search/engine.js +0 -260
  193. data/assets/js/search/filters.js +0 -45
  194. data/assets/js/search/render.js +0 -217
  195. data/assets/js/search/utils.js +0 -99
  196. data/assets/js/search.js +0 -354
  197. data/assets/js/visualizations.js +0 -816
  198. data/assets/publications/datalog-publications.bib +0 -8
  199. data/assets/publications/datalog-publications.ris +0 -9
  200. data/assets/publications/publications.bib +0 -30
  201. data/assets/templates/diogo-ribeiro-cv.md +0 -31
  202. data/assets/templates/diogo-ribeiro-cv.tex +0 -32
  203. data/lib/datalog/theme/theme.rb +0 -18
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Datalog
6
+ # The computational artifacts behind an article, for the "Reproduce this
7
+ # analysis" panel (_includes/components/reproducibility.html):
8
+ #
9
+ # reproducibility:
10
+ # code:
11
+ # url: https://github.com/example/project
12
+ # ref: 4f2c1ab # the commit, tag or branch the article used
13
+ # data:
14
+ # doi: 10.5281/zenodo.1234567 # or url:
15
+ # version: v2
16
+ # environment:
17
+ # file: requirements.txt # in the code repository at the ref, a site path or a URL
18
+ # container: ghcr.io/example/project:1.4.0
19
+ # archive: https://doi.org/10.5281/zenodo.7654321
20
+ # notebook:
21
+ # url: /notebooks/example/
22
+ # results:
23
+ # url: https://github.com/example/project/releases/tag/results-v1
24
+ # version: results-v1
25
+ #
26
+ # Every artifact is optional, and each may be a bare URL. The panel shows
27
+ # what is given and claims nothing more: a link is a link, a ref is a ref.
28
+ # A URL that is not http(s), a site path or a DOI stops the build.
29
+ module Reproducibility
30
+ module_function
31
+
32
+ KINDS = %w[code data notebook environment results].freeze
33
+ # Where a ref and a file in the repository can be linked.
34
+ HOSTS = {
35
+ "github.com" => { tree: "/tree/%s", blob: "/blob/%s/%s" },
36
+ "gitlab.com" => { tree: "/-/tree/%s", blob: "/-/blob/%s/%s" }
37
+ }.freeze
38
+ URL = %r{\Ahttps?://[^\s"'<>]+\z}i
39
+ PATH = %r{\A/[^\s"'<>]*\z}
40
+
41
+ # The artifacts, in KINDS order, or nil when the page gives none.
42
+ def resolve(page, site)
43
+ value = Authors.value(page, "reproducibility")
44
+ return unless value.is_a?(Hash)
45
+
46
+ given = value.transform_keys(&:to_s)
47
+ baseurl = Authors.value(site, "baseurl").to_s
48
+ artifacts = KINDS.filter_map { |kind| artifact(kind, given[kind], given, page, baseurl) }
49
+ { "artifacts" => artifacts } unless artifacts.empty?
50
+ end
51
+
52
+ def artifact(kind, value, all, page, baseurl)
53
+ return if value.nil? || value == false || (value.is_a?(String) && value.strip.empty?)
54
+
55
+ given = value.is_a?(Hash) ? Authors.present(value) : { "url" => value.to_s }
56
+ url = link(given["url"] || doi_url(given["doi"]), page, "#{kind}.url", baseurl)
57
+ artifact = { "kind" => kind, "url" => url, "external" => external?(url), "version" => given["version"]&.to_s,
58
+ "label" => given["label"] || display(url), "doi" => bare_doi(given["doi"]) }
59
+ artifact.merge!(code(given, url)) if kind == "code"
60
+ artifact.merge!(environment(given, all, page, baseurl)) if kind == "environment"
61
+ artifact = artifact.compact
62
+ artifact if artifact.values_at("url", "file", "container", "archive").any?
63
+ end
64
+
65
+ def code(given, url)
66
+ ref = given["ref"].to_s.strip
67
+ return {} if ref.empty?
68
+
69
+ { "ref" => ref, "ref_url" => host_url(url, :tree, ref) }
70
+ end
71
+
72
+ # The environment file lives in the code repository at the article's ref
73
+ # unless it is a site path or a URL of its own.
74
+ def environment(given, all, page, baseurl)
75
+ file = given["file"].to_s.strip
76
+ archive = link(given["archive"], page, "environment.archive", baseurl)
77
+ {
78
+ "file" => (file unless file.empty?),
79
+ "file_url" => (file_url(file, all["code"], page, baseurl) unless file.empty?),
80
+ "container" => given["container"]&.to_s,
81
+ "archive" => archive, "archive_label" => display(archive)
82
+ }
83
+ end
84
+
85
+ def file_url(file, code, page, baseurl)
86
+ own = file.match?(URL) || file.match?(PATH) || file.match?(/\Adoi:/i)
87
+ return link(file, page, "environment.file", baseurl) if own
88
+
89
+ code = code.is_a?(Hash) ? Authors.present(code) : { "url" => code.to_s }
90
+ code_url = link(code["url"], page, "code.url", baseurl)
91
+ ref = code["ref"].to_s.strip
92
+ host_url(code_url, :blob, ref.empty? ? "HEAD" : ref, file)
93
+ end
94
+
95
+ # A page of a known host under the repository URL, such as a tree or a blob.
96
+ def host_url(url, kind, *parts)
97
+ return unless url
98
+
99
+ host = URI.parse(url).host.to_s.sub(/\Awww\./, "")
100
+ pattern = HOSTS.dig(host, kind)
101
+ "#{url.chomp('/')}#{format(pattern, *parts)}" if pattern
102
+ rescue URI::InvalidURIError
103
+ nil
104
+ end
105
+
106
+ # An http(s) URL as given, a site path with the baseurl, or a DOI as its
107
+ # URL; anything else, such as a javascript: URL or an address without its
108
+ # scheme, stops the build.
109
+ def link(value, page, field, baseurl)
110
+ text = value.to_s.strip
111
+ return if text.empty?
112
+ return doi_url(text) if text.match?(/\Adoi:/i)
113
+ return text if text.match?(URL)
114
+ return "#{baseurl}#{text}" if text.match?(PATH)
115
+
116
+ raise Jekyll::Errors::FatalException,
117
+ "#{Authors.value(page, 'path')} reproducibility.#{field} is #{text.inspect}, which is not an http(s) " \
118
+ "URL, a site path starting with / or a doi:; give the whole address, such as https://github.com/example/project"
119
+ end
120
+
121
+ def bare_doi(doi)
122
+ text = doi.to_s.strip.sub(%r{\Ahttps?://(dx\.)?doi\.org/}i, "").sub(/\Adoi:\s*/i, "")
123
+ text unless text.empty?
124
+ end
125
+
126
+ def doi_url(doi)
127
+ bare = bare_doi(doi)
128
+ "https://doi.org/#{bare}" if bare
129
+ end
130
+
131
+ # What a link reads: the address without its scheme, or the site path.
132
+ def display(url)
133
+ return url unless external?(url)
134
+
135
+ url.sub(%r{\Ahttps?://(www\.)?}i, "").chomp("/")
136
+ end
137
+
138
+ def external?(url)
139
+ url.to_s.match?(URL)
140
+ end
141
+ end
142
+
143
+ module ReproducibilityFilters
144
+ def page_reproducibility(page)
145
+ Reproducibility.resolve(page, @context["site"])
146
+ end
147
+ end
148
+ end
149
+
150
+ Liquid::Template.register_filter(Datalog::ReproducibilityFilters)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Datalog
7
+ # The revision history of an article, for a post that is corrected or
8
+ # rewritten while keeping its URL:
9
+ #
10
+ # revisions:
11
+ # - date: 2026-09-16
12
+ # type: correction
13
+ # summary: Replaced the sample-size rules with model diagnostics.
14
+ # details_url: https://github.com/example/repo/pull/425
15
+ # - date: 2024-03-12
16
+ # type: update
17
+ # summary: Updated the code examples for the current SciPy.
18
+ #
19
+ # Before the site renders, each page's list is checked, its dates are
20
+ # parsed and its entries sorted newest first, so the includes read one
21
+ # shape. A correction or an update is substantive: the newest one becomes
22
+ # `revision_notice`, which the post layout announces under the metadata.
23
+ # A review or an editorial change appears in the history only.
24
+ #
25
+ # Every revision but a review changed the article, so the newest one sets
26
+ # `last_modified_at` when it is later than the date the page gives, and
27
+ # the JSON-LD, the microdata, the feed and the sitemap all carry it.
28
+ module Revisions
29
+ module_function
30
+
31
+ TYPES = %w[correction update review editorial].freeze
32
+ SUBSTANTIVE = %w[correction update].freeze
33
+ DEFAULT_TYPE = "update"
34
+
35
+ def normalize!(document)
36
+ data = document.data
37
+ return unless data.key?("revisions")
38
+
39
+ revisions = entries(data["revisions"], document)
40
+ data["revisions"] = revisions
41
+ notice = revisions.find { |revision| revision["substantive"] }
42
+ data["revision_notice"] = notice unless data["revision_notice"] == false
43
+
44
+ changed = revisions.find { |revision| revision["type"] != "review" }
45
+ modified = to_time(data["last_modified_at"] || data["updated"])
46
+ data["last_modified_at"] = changed["date"] if changed && (modified.nil? || changed["date"] > modified)
47
+ end
48
+
49
+ # Newest first; revisions on one day keep their order.
50
+ def entries(list, document)
51
+ unless list.is_a?(Array)
52
+ stop(document, "has revisions that is not a list; each revision is a map with a date, a summary and, " \
53
+ "if wanted, a type and a details_url")
54
+ end
55
+
56
+ revisions = list.each_with_index.map { |entry, index| revision(entry, index + 1, document) }
57
+ revisions.each_with_index.sort_by { |revision, index| [-revision["date"].to_i, index] }.map(&:first)
58
+ end
59
+
60
+ def revision(entry, number, document)
61
+ stop(document, "revision #{number} is not a map with a date and a summary") unless entry.is_a?(Hash)
62
+
63
+ entry = entry.transform_keys(&:to_s)
64
+ type = (entry["type"] || DEFAULT_TYPE).to_s.strip.downcase
65
+ unless TYPES.include?(type)
66
+ stop(document, "revision #{number} has the type #{entry['type'].inspect}; it takes #{TYPES.join(', ')}")
67
+ end
68
+ summary = entry["summary"].to_s.strip
69
+ stop(document, "revision #{number} needs a summary saying what changed") if summary.empty?
70
+ date = to_time(entry["date"])
71
+ unless date
72
+ stop(document, "revision #{number} has the date #{entry['date'].inspect}, which is not a date such as " \
73
+ "2026-09-16")
74
+ end
75
+
76
+ details = entry["details_url"].to_s.strip
77
+ { "date" => date, "type" => type, "summary" => summary, "details_url" => (details unless details.empty?),
78
+ "substantive" => SUBSTANTIVE.include?(type) }.compact
79
+ end
80
+
81
+ def stop(document, problem)
82
+ raise Jekyll::Errors::FatalException, "#{document.relative_path} #{problem}"
83
+ end
84
+
85
+ # A Date, a Time or a string naming a day, such as "2026-09-16".
86
+ def to_time(value)
87
+ case value
88
+ when Time then value
89
+ when Date then value.to_time
90
+ when String
91
+ parts = Date._parse(value)
92
+ Time.parse(value) if parts[:year] && parts[:mon] && parts[:mday]
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ Jekyll::Hooks.register :site, :post_read do |site|
99
+ site.documents.each { |document| Datalog::Revisions.normalize!(document) }
100
+ site.pages.each { |page| Datalog::Revisions.normalize!(page) }
101
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ # `rouge_highlight` highlights code with Rouge as the site builds, the way
6
+ # kramdown highlights fenced code blocks. Includes that print code passed to
7
+ # them, such as `components/api-function.html`, call it as
8
+ # `code | rouge_highlight: language` inside `<pre class="highlight"><code>`;
9
+ # the notebook converter calls `RougeHighlightFilter.highlight` for code cells.
10
+ # The result is escaped; a language Rouge does not know comes back as plain
11
+ # text.
12
+ module Jekyll
13
+ module RougeHighlightFilter
14
+ # kramdown's opening tag for a block Rouge highlighted.
15
+ KRAMDOWN_CODE_BLOCK = '<pre class="highlight">'
16
+
17
+ def self.highlight(code, language = nil)
18
+ return "" if code.nil?
19
+
20
+ source = code.to_s
21
+ lexer = Rouge::Lexer.find_fancy(language.to_s.strip.downcase, source) || Rouge::Lexers::PlainText
22
+ Rouge::Formatters::HTML.new.format(lexer.lex(source))
23
+ end
24
+
25
+ def rouge_highlight(code, language = nil)
26
+ RougeHighlightFilter.highlight(code, language)
27
+ end
28
+ end
29
+ end
30
+
31
+ Liquid::Template.register_filter(Jekyll::RougeHighlightFilter)
32
+
33
+ # A code block wider than the page scrolls, and a keyboard user can only scroll
34
+ # it once it takes focus. Prism made every block focusable in the browser; the
35
+ # blocks kramdown highlights get the attribute here, and the includes and the
36
+ # notebook converter write it themselves.
37
+ Jekyll::Hooks.register %i[pages documents], :post_convert do |document|
38
+ block = Jekyll::RougeHighlightFilter::KRAMDOWN_CODE_BLOCK
39
+ next unless document.content&.include?(block)
40
+
41
+ document.content = document.content.gsub(block, '<pre class="highlight" tabindex="0">')
42
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ # Which pages get scholarly discovery metadata (_includes/meta/scholarly.html):
5
+ # the Highwire meta tags Google Scholar and reference managers read, and
6
+ # their Dublin Core equivalents. Not every post is a paper, so the tags are
7
+ # opt-in:
8
+ #
9
+ # scholarly: true # front matter: this page is a research article
10
+ # scholarly: false # front matter: this one is not, whatever the site says
11
+ #
12
+ # scholarly: true # _config.yml: every post and research article
13
+ # scholarly: [notebooks] # _config.yml: these collections or layouts as well
14
+ #
15
+ # A page with the research layout, or in a research collection, is
16
+ # scholarly unless it says otherwise.
17
+ module Scholarly
18
+ module_function
19
+
20
+ ALWAYS = %w[research].freeze
21
+ POSTS = %w[posts post].freeze
22
+
23
+ def scholarly?(page, site)
24
+ own = Authors.value(page, "scholarly")
25
+ return own == true unless own.nil?
26
+
27
+ kinds = kinds(Authors.value(site, "scholarly"))
28
+ [Authors.value(page, "layout"), Authors.value(page, "collection")].any? { |kind| kinds.include?(kind.to_s) }
29
+ end
30
+
31
+ # The layouts and collections the site's setting covers, besides research.
32
+ def kinds(setting)
33
+ case setting
34
+ when true then ALWAYS + POSTS
35
+ when Array then ALWAYS + setting.map(&:to_s)
36
+ when String then ALWAYS + setting.split(/[\s,]+/)
37
+ else ALWAYS
38
+ end
39
+ end
40
+ end
41
+
42
+ module ScholarlyFilters
43
+ # A Liquid filter's name cannot end with "?".
44
+ def scholarly(page) # rubocop:disable Naming/PredicateMethod
45
+ Scholarly.scholarly?(page, @context["site"])
46
+ end
47
+ end
48
+ end
49
+
50
+ Liquid::Template.register_filter(Datalog::ScholarlyFilters)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ # Collects the fenced code blocks of every page and document for the search
5
+ # index. The index template used to split `doc.content` on backticks, but
6
+ # Jekyll renders documents before pages, so by the time search.json rendered
7
+ # that content was HTML and every document's code list came out empty.
8
+ module SearchCodeBlocks
9
+ # An opening fence of three or more backticks or tildes with an optional
10
+ # language, the code, and a closing fence of the same characters.
11
+ FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`~{]*)[^\n]*\n(.*?)^ {0,3}\1[ \t]*$/m
12
+
13
+ module_function
14
+
15
+ def extract(source)
16
+ text = source.to_s.gsub("\r\n", "\n")
17
+ text.scan(FENCE).map do |_fence, language, code|
18
+ { "language" => language.empty? ? "text" : language.downcase, "code" => code.chomp }
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ # Content is still the author's source before rendering starts. Collection
25
+ # docs only: site.documents also lists a collection's static files.
26
+ Jekyll::Hooks.register :site, :pre_render do |site|
27
+ (site.pages + site.collections.values.flat_map(&:docs)).each do |item|
28
+ item.data["search_code"] = Datalog::SearchCodeBlocks.extract(item.content)
29
+ end
30
+ end
@@ -1,66 +1,28 @@
1
1
  # frozen_string_literal: true
2
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
3
  module Datalog
12
4
  module SearchFilters
13
5
  module_function
14
6
 
7
+ # Lower-cases text and strips combining marks, so "Café" and "cafe" match
8
+ # while letters outside ASCII ("ß", "ł", Cyrillic, CJK) are kept. The
9
+ # browser normalizes queries the same way (assets/js/search/utils.js), so
10
+ # the index and the query agree. String#unicode_normalize is part of Ruby:
11
+ # this file used to require it as if it were a gem, fail, and fall back to
12
+ # dropping every character outside ASCII.
15
13
  def normalize_search(input)
16
- value = input.to_s
14
+ # Invalid byte sequences are dropped first: unicode_normalize raises on them.
15
+ value = input.to_s.scrub("")
17
16
  return "" if value.empty?
18
17
 
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 }
18
+ stripped = begin
19
+ value.unicode_normalize(:nfkd).gsub(/\p{Mn}/, "")
20
+ rescue Encoding::CompatibilityError
21
+ # Text in an encoding other than Unicode cannot be normalized, so it is
22
+ # indexed as it is.
23
+ value
61
24
  end
62
-
63
- basic_map
25
+ stripped.downcase.strip
64
26
  end
65
27
 
66
28
  def normalize_search_array(values)
@@ -61,10 +61,9 @@ module Datalog
61
61
  "title" => search_config["title"] || "Search",
62
62
  "permalink" => PAGE_URL,
63
63
  "page_classes" => "search-page",
64
- # Results can contain LaTeX and code, so both engines are wanted here
65
- # even when the rest of the site loads them only where they appear.
66
- "math" => true,
67
- "syntax_highlighting" => true
64
+ # Results can contain LaTeX, so the math engine is wanted here even
65
+ # when the rest of the site loads it only where math appears.
66
+ "math" => true
68
67
  )
69
68
  page.data["subtitle"] = search_config["subtitle"] if search_config["subtitle"]
70
69
  site.pages << page
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datalog
4
+ # Article series: multi-part writing a reader follows in order, whatever
5
+ # was published in between.
6
+ #
7
+ # series: # front matter, as a map
8
+ # id: missing-data
9
+ # title: Missing Data and Statistical Inference
10
+ # order: 3
11
+ #
12
+ # series: missing-data # or flat
13
+ # series_title: Missing Data and Statistical Inference
14
+ # series_order: 3
15
+ #
16
+ # missing-data: # _data/series.yml, so the title lives once
17
+ # title: Missing Data and Statistical Inference
18
+ # description: Four parts, from the missing-data mechanisms to sensitivity analysis.
19
+ #
20
+ # Before the site renders, every part is checked (an order that is missing,
21
+ # not a whole number or taken by another part stops the build), the parts
22
+ # are put in order, and each page's `series` becomes one shape: id, title,
23
+ # description, order, position, count, parts (title, url, order, position,
24
+ # current), previous and next. _includes/components/series-nav.html reads it.
25
+ module Series
26
+ module_function
27
+
28
+ def normalize!(site)
29
+ documents = site.documents + site.pages
30
+ registry = site.data["series"].is_a?(Hash) ? site.data["series"] : {}
31
+ documents.filter_map { |document| part(document) }.group_by { |part| part[:id] }.each do |id, parts|
32
+ check_orders!(id, parts)
33
+ meta = registry[id].is_a?(Hash) ? registry[id] : {}
34
+ resolve!(id, parts.sort_by { |part| part[:order] }, meta)
35
+ end
36
+ end
37
+
38
+ # The document's part of a series, as {doc, id, order, title}, or nil.
39
+ def part(document)
40
+ data = document.data
41
+ value = data["series"]
42
+ return if value.nil? || value == false || (value.is_a?(String) && value.strip.empty?)
43
+
44
+ given = case value
45
+ when Hash then value.transform_keys(&:to_s)
46
+ when String, Symbol then { "id" => value.to_s }
47
+ else stop(document, "has a series that is neither a name nor a map with an id and an order")
48
+ end
49
+ id = given["id"].to_s.strip
50
+ stop(document, "has a series without an id, such as series: missing-data") if id.empty?
51
+
52
+ order = given["order"] || data["series_order"]
53
+ unless whole_number?(order)
54
+ stop(document, "is part of the series \"#{id}\" without an order; give the part a whole number from 1, " \
55
+ "such as order: 2")
56
+ end
57
+
58
+ { doc: document, id: id, order: order.to_i, title: given["title"] || data["series_title"] }
59
+ end
60
+
61
+ def whole_number?(value)
62
+ (value.is_a?(Integer) && value >= 1) || (value.is_a?(String) && value.match?(/\A[1-9]\d*\z/))
63
+ end
64
+
65
+ def check_orders!(id, parts)
66
+ parts.group_by { |part| part[:order] }.each_value do |same|
67
+ next if same.size == 1
68
+
69
+ paths = same.map { |part| part[:doc].relative_path }.sort.join(" and ")
70
+ raise Jekyll::Errors::FatalException,
71
+ "#{paths} are both part #{same.first[:order]} of the series \"#{id}\"; each part needs its own order"
72
+ end
73
+ end
74
+
75
+ # Writes each part's `series` from the ordered parts and the data file's entry.
76
+ def resolve!(id, ordered, meta)
77
+ title = meta["title"] || ordered.filter_map { |part| part[:title] }.first || titleize(id)
78
+ summaries = ordered.each_with_index.map do |part, index|
79
+ { "title" => part[:doc].data["title"], "url" => part[:doc].url, "order" => part[:order],
80
+ "position" => index + 1 }
81
+ end
82
+ ordered.each_with_index do |part, index|
83
+ part[:doc].data["series"] = {
84
+ "id" => id, "title" => title, "description" => meta["description"],
85
+ "order" => part[:order], "position" => index + 1, "count" => ordered.size,
86
+ "parts" => summaries.map { |summary| summary.merge("current" => summary["url"] == part[:doc].url) },
87
+ "previous" => (summaries[index - 1] if index.positive?), "next" => summaries[index + 1]
88
+ }.compact
89
+ end
90
+ end
91
+
92
+ def titleize(id)
93
+ id.tr("-_", " ").split.map(&:capitalize).join(" ")
94
+ end
95
+
96
+ def stop(document, problem)
97
+ raise Jekyll::Errors::FatalException, "#{document.relative_path} #{problem}"
98
+ end
99
+ end
100
+ end
101
+
102
+ Jekyll::Hooks.register :site, :post_read do |site|
103
+ Datalog::Series.normalize!(site)
104
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Datalog
6
+ # Theorems, lemmas and the other statements of mathematical writing, and
7
+ # proofs. Articles marked them with a blockquote and a bold "Theorem 1.",
8
+ # typed by hand:
9
+ #
10
+ # {% theorem id="thm-consistency" title="Consistency" %}
11
+ # Let $\hat\theta_n$ be ...
12
+ # {% endtheorem %}
13
+ #
14
+ # {% proof for="thm-consistency" %}
15
+ # ...
16
+ # {% endproof %}
17
+ #
18
+ # Statements are numbered with figures and tables (_plugins/references.rb):
19
+ # each kind counts on its own, {% ref thm-consistency %} reads "Theorem 1",
20
+ # and a duplicate id stops the build. `label="A"` names a statement
21
+ # "Theorem A" instead of numbering it. A proof is not numbered; with `for`
22
+ # its heading reads "Proof of Theorem 1".
23
+ class StatementTag < Liquid::Block
24
+ def initialize(tag_name, markup, options)
25
+ super
26
+ @kind = tag_name
27
+ @markup = markup
28
+ end
29
+
30
+ def render(context)
31
+ attributes = References.attributes(@markup, context)
32
+ id = References.validate_id(attributes["id"], @kind)
33
+ custom = attributes["label"]
34
+ References.validate_label(custom, @kind)
35
+ body = References.markdown(context, super)
36
+
37
+ number = custom ? %( data-ref-number="#{CGI.escapeHTML(custom)}") : ""
38
+ title = attributes["title"].to_s.strip
39
+ title = title.empty? ? "" : %( <span class="datalog-statement__title">(#{CGI.escapeHTML(title)})</span>)
40
+ %(<div class="datalog-statement datalog-statement--#{@kind}" role="group" aria-labelledby="#{id}-heading" ) +
41
+ %(id="#{id}" data-ref-target="#{id}" data-ref-kind="#{@kind}"#{number}>) +
42
+ %(<p class="datalog-statement__heading" id="#{id}-heading">) +
43
+ %(<span class="datalog-ref-label" data-ref-for="#{id}" data-ref-end=""></span>#{title}.</p>) +
44
+ %(<div class="datalog-statement__body">#{body.strip}</div></div>\n)
45
+ end
46
+ end
47
+
48
+ class ProofTag < Liquid::Block
49
+ def initialize(tag_name, markup, options)
50
+ super
51
+ @markup = markup
52
+ end
53
+
54
+ def render(context)
55
+ attributes = References.attributes(@markup, context)
56
+ target = attributes["for"]
57
+ body = References.markdown(context, super)
58
+ heading_id = "proof-#{target ? References.validate_id(target, 'proof') : Statements.next_proof(context)}"
59
+ heading = if target
60
+ link = %(<a class="datalog-ref" href="##{target}" data-ref="#{target}">#{target}</a>)
61
+ I18n.translate(context, "references.proof_of", "target" => link)
62
+ else
63
+ CGI.escapeHTML(I18n.translate(context, "references.proof"))
64
+ end
65
+ end_mark = %(<p class="datalog-proof__end"><span aria-hidden="true">∎</span></p>)
66
+ end_mark = "" if attributes["qed"] == "false"
67
+
68
+ %(<div class="datalog-proof" role="group" aria-labelledby="#{heading_id}">) +
69
+ %(<p class="datalog-proof__heading" id="#{heading_id}">#{heading}.</p>) +
70
+ %(<div class="datalog-proof__body">#{body.strip}</div>#{end_mark}</div>\n)
71
+ end
72
+ end
73
+
74
+ module Statements
75
+ module_function
76
+
77
+ KINDS = %w[theorem lemma proposition corollary definition assumption example remark].freeze
78
+
79
+ # Proofs without a statement are numbered in the page for their heading ids only.
80
+ def next_proof(context)
81
+ context.registers[:datalog_proofs] = context.registers[:datalog_proofs].to_i + 1
82
+ end
83
+ end
84
+ end
85
+
86
+ Datalog::Statements::KINDS.each { |kind| Liquid::Template.register_tag(kind, Datalog::StatementTag) }
87
+ Liquid::Template.register_tag("proof", Datalog::ProofTag)