datalog-theme 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -2
- data/_data/i18n/en.yml +321 -0
- data/_data/i18n/es.yml +321 -0
- data/_data/i18n/pt.yml +321 -0
- data/_data/js_manifest.json +16 -0
- data/_includes/components/academic-dashboard.html +4 -4
- data/_includes/components/author-bio.html +21 -11
- data/_includes/components/author-list.html +32 -0
- data/_includes/components/breadcrumbs.html +6 -4
- data/_includes/components/citation-tools.html +18 -58
- data/_includes/components/comments-thread.html +90 -0
- data/_includes/components/contact-form.html +120 -0
- data/_includes/components/correction-report.html +82 -0
- data/_includes/components/enhanced-toc.html +6 -7
- data/_includes/components/hero.html +1 -1
- data/_includes/components/license-link.html +13 -0
- data/_includes/components/license-notice.html +28 -0
- data/_includes/components/moderation-inbox.html +139 -0
- data/_includes/components/post-hero.html +2 -2
- data/_includes/components/reactions.html +49 -0
- data/_includes/components/reading-list.html +34 -0
- data/_includes/components/reading-mode-toggle.html +67 -0
- data/_includes/components/reading-state-bookmark.html +27 -0
- data/_includes/components/reading-state-panel.html +62 -0
- data/_includes/components/reading-state-resume.html +17 -0
- data/_includes/components/reproducibility.html +61 -0
- data/_includes/components/responsive-image.html +3 -3
- data/_includes/components/revision-history.html +39 -0
- data/_includes/components/revision-notice.html +35 -0
- data/_includes/components/series-nav.html +64 -0
- data/_includes/components/subscribe-form.html +88 -0
- data/_includes/components/subscription-manage.html +69 -0
- data/_includes/components/visualization-card.html +1 -1
- data/_includes/components/webmentions.html +48 -0
- data/_includes/csp-meta.html +21 -1
- data/_includes/footer/nav-column.html +1 -1
- data/_includes/footer.html +5 -2
- data/_includes/head.html +64 -52
- data/_includes/header/navigation.html +2 -2
- data/_includes/header.html +1 -1
- data/_includes/layouts/default/article.html +6 -8
- data/_includes/meta/dynamic-services-config.html +14 -0
- data/_includes/meta/math-config.html +12 -5
- data/_includes/meta/person-json.html +27 -0
- data/_includes/meta/publisher.html +45 -0
- data/_includes/meta/schema.html +84 -48
- data/_includes/meta/scholarly.html +122 -0
- data/_includes/meta/scripts-loader.html +2 -0
- data/_includes/meta/webmention-discovery.html +14 -0
- data/_includes/post/related-posts.html +1 -1
- data/_includes/scripts.html +57 -0
- data/_layouts/dataset.html +5 -4
- data/_layouts/default.html +1 -1
- data/_layouts/home.html +2 -2
- data/_layouts/notebook.html +1 -1
- data/_layouts/package.html +8 -7
- data/_layouts/page.html +15 -0
- data/_layouts/portfolio.html +1 -1
- data/_layouts/post.html +94 -25
- data/_layouts/project.html +5 -5
- data/_layouts/research.html +27 -75
- data/_plugins/authors.rb +151 -0
- data/_plugins/citation_exports.rb +93 -0
- data/_plugins/config_validator.rb +223 -20
- data/_plugins/critical_css_check.rb +42 -0
- data/_plugins/i18n.rb +6 -4
- data/_plugins/image_optimizer.rb +240 -186
- data/_plugins/licenses.rb +147 -0
- data/_plugins/math_preprocessor.rb +55 -11
- data/_plugins/references.rb +260 -0
- data/_plugins/reproducibility.rb +167 -0
- data/_plugins/responsive_content.rb +40 -0
- data/_plugins/revisions.rb +102 -0
- data/_plugins/scholarly.rb +55 -0
- data/_plugins/series.rb +104 -0
- data/_plugins/statements.rb +87 -0
- data/_sass/_base.scss +13 -1
- data/_sass/_comments-thread.scss +159 -0
- data/_sass/_layout.scss +402 -1
- data/_sass/_mathematical.scss +38 -1
- data/_sass/_moderation.scss +222 -0
- data/_sass/_post-components.scss +4 -2
- data/_sass/_print.scss +299 -0
- data/_sass/_reactions.scss +89 -0
- data/_sass/_reading-state.scss +290 -0
- data/_sass/_service-forms.scss +204 -0
- data/_sass/_subscriptions.scss +140 -0
- data/_sass/_syntax-highlighting.scss +3 -3
- data/_sass/_theme.scss +11 -0
- data/_sass/_typography.scss +116 -0
- data/_sass/_utilities.scss +5 -0
- data/_sass/_variables.scss +6 -0
- data/_sass/_webmentions.scss +125 -0
- data/assets/js/dist/academic.js +1 -1
- data/assets/js/dist/analytics-dashboard.js +1 -1
- data/assets/js/dist/chunks/chunk-PATLC23F.js +1 -0
- data/assets/js/dist/chunks/chunk-VZ5WKQVA.js +1 -0
- data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +1 -0
- data/assets/js/dist/comments.js +2 -0
- data/assets/js/dist/contact.js +1 -0
- data/assets/js/dist/core.js +1 -1
- data/assets/js/dist/corrections.js +1 -0
- data/assets/js/dist/loader.js +1 -1
- data/assets/js/dist/math.js +1 -1
- data/assets/js/dist/moderation.js +1 -0
- data/assets/js/dist/notebook.js +1 -1
- data/assets/js/dist/reactions.js +1 -0
- data/assets/js/dist/reading-state.js +1 -0
- data/assets/js/dist/search.js +1 -1
- data/assets/js/dist/sources.json +52 -0
- data/assets/js/dist/subscriptions.js +1 -0
- data/assets/js/dist/visualizations.js +2 -2
- data/assets/js/dist/webmentions.js +1 -0
- data/assets/js/loader.js +34 -0
- data/datalog-theme.gemspec +1 -2
- data/lib/datalog/cli.rb +9 -1
- data/lib/datalog/critical_css.rb +168 -0
- data/lib/datalog/plugins/comments.rb +33 -3
- data/lib/datalog/theme/installed_files.rb +113 -0
- data/lib/datalog/theme/repository_checkout.rb +6 -2
- data/lib/datalog/theme/version.rb +5 -1
- data/lib/datalog-theme.rb +1 -0
- metadata +60 -23
- data/assets/js/dist/chunks/chunk-225H5YXE.js +0 -1
data/_plugins/authors.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Datalog
|
|
4
|
+
# One list of a page's authors, and one of its contributors, which the
|
|
5
|
+
# byline, the author cards, the JSON-LD, the citation meta tags and the
|
|
6
|
+
# citation exports all read. Each used to work the author out on its own: a
|
|
7
|
+
# post named one author, `author: john_doe` put "john_doe" in the byline while
|
|
8
|
+
# the card looked the key up in _data/authors.yml, the research layout gave
|
|
9
|
+
# every author the site author's ORCID and affiliation, and the JSON-LD listed
|
|
10
|
+
# the site author's profiles only when an ORCID was set.
|
|
11
|
+
#
|
|
12
|
+
# An entry is a name, a key of _data/authors.yml, or a map with `name` (or
|
|
13
|
+
# `id`, a key of _data/authors.yml). The data record and, for the site's own
|
|
14
|
+
# author, `author` in _config.yml fill in what the entry leaves out.
|
|
15
|
+
module Authors
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
PROFILE_URLS = {
|
|
19
|
+
"github" => "https://github.com/%s",
|
|
20
|
+
"twitter" => "https://twitter.com/%s",
|
|
21
|
+
"linkedin" => "https://linkedin.com/in/%s"
|
|
22
|
+
}.freeze
|
|
23
|
+
PROFILE_LINKS = %w[researchgate google_scholar].freeze
|
|
24
|
+
ORCID_ID = /\A\d{4}-\d{4}-\d{4}-\d{3}[\dX]\z/
|
|
25
|
+
|
|
26
|
+
# `authors:`, else `author:`, else the site's author.
|
|
27
|
+
def authors(page, site)
|
|
28
|
+
entries = entries(value(page, "authors"))
|
|
29
|
+
entries = entries(value(page, "author")) if entries.empty?
|
|
30
|
+
records = entries.filter_map { |entry| resolve(entry, site, page) }
|
|
31
|
+
records = [site_author(site)].compact if records.empty?
|
|
32
|
+
apply_page_affiliation(records, page)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def contributors(page, site)
|
|
36
|
+
entries(value(page, "contributors")).filter_map { |entry| resolve(entry, site, page) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A list, one entry, or names in a string separated by semicolons.
|
|
40
|
+
def entries(value)
|
|
41
|
+
list = if value.is_a?(String) then value.split(";")
|
|
42
|
+
elsif value.is_a?(Hash) then [value]
|
|
43
|
+
else Array(value)
|
|
44
|
+
end
|
|
45
|
+
list.reject { |entry| entry.nil? || (entry.is_a?(String) && entry.strip.empty?) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A plain entry is a key or a name; a map's own fields win over the record's.
|
|
49
|
+
def resolve(entry, site, page = {})
|
|
50
|
+
own = entry.is_a?(Hash) ? present(entry) : {}
|
|
51
|
+
key = own["id"] || own["name"] || (entry if entry.is_a?(String) || entry.is_a?(Symbol))
|
|
52
|
+
unless (key.is_a?(String) || key.is_a?(Symbol)) && !key.to_s.strip.empty?
|
|
53
|
+
path = value(page, "path")
|
|
54
|
+
raise Jekyll::Errors::FatalException,
|
|
55
|
+
"#{path} author entry #{entry.inspect} needs a name, an author id, or a map with name or id"
|
|
56
|
+
end
|
|
57
|
+
key = key.to_s.strip
|
|
58
|
+
record = known(key, site).merge(own)
|
|
59
|
+
record["name"] ||= key
|
|
60
|
+
return if record["name"].to_s.strip.empty?
|
|
61
|
+
|
|
62
|
+
finish(record, site)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The _data/authors.yml record for a key, or the site author's profile
|
|
66
|
+
# when the key is the site author's name.
|
|
67
|
+
def known(key, site)
|
|
68
|
+
data = value(value(site, "data"), "authors")
|
|
69
|
+
record = value(data, key.to_s)
|
|
70
|
+
return present(record) if record.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
if data.is_a?(Hash)
|
|
73
|
+
record = data.values.find { |candidate| candidate.is_a?(Hash) && candidate["name"] == key }
|
|
74
|
+
return present(record) if record
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
author = site_author_profile(site)
|
|
78
|
+
author && author["name"] == key ? author : {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def site_author(site)
|
|
82
|
+
profile = site_author_profile(site)
|
|
83
|
+
profile && finish(profile, site)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def site_author_profile(site)
|
|
87
|
+
author = value(site, "author")
|
|
88
|
+
return { "name" => author.strip } if author.is_a?(String) && !author.strip.empty?
|
|
89
|
+
|
|
90
|
+
present(author) if author.is_a?(Hash) && author["name"]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def finish(record, site)
|
|
94
|
+
record["affiliation"] ||= record["institution"]
|
|
95
|
+
record["bio"] ||= record["biography"]
|
|
96
|
+
record["image"] ||= record["avatar"] || record["photo"]
|
|
97
|
+
record["orcid"] = "https://orcid.org/#{record['orcid']}" if record["orcid"].to_s.match?(ORCID_ID)
|
|
98
|
+
record["site_author"] = record["name"] == site_author_profile(site)&.fetch("name")
|
|
99
|
+
PROFILE_URLS.each do |key, template|
|
|
100
|
+
next unless record[key]
|
|
101
|
+
|
|
102
|
+
prefix = template.delete_suffix("%s").delete_prefix("https://")
|
|
103
|
+
record[key] = record[key].to_s.strip.sub(%r{\Ahttps?://(?:www\.)?#{Regexp.escape(prefix)}}i, "")
|
|
104
|
+
.sub(/\A@/, "").chomp("/")
|
|
105
|
+
end
|
|
106
|
+
record["same_as"] = profiles(record)
|
|
107
|
+
record
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def profiles(record)
|
|
111
|
+
urls = [record["orcid"]]
|
|
112
|
+
urls += PROFILE_URLS.map { |key, template| format(template, record[key]) if record[key] }
|
|
113
|
+
urls += PROFILE_LINKS.map { |key| record[key] }
|
|
114
|
+
urls.compact.map(&:to_s).reject(&:empty?).uniq
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# `author_affiliation` in front matter names the affiliation of a page's
|
|
118
|
+
# only author; an empty one removes it.
|
|
119
|
+
def apply_page_affiliation(records, page)
|
|
120
|
+
affiliation = value(page, "author_affiliation")
|
|
121
|
+
return records if affiliation.nil? || records.size != 1
|
|
122
|
+
|
|
123
|
+
affiliation.to_s.strip.empty? ? records.first.delete("affiliation") : records.first["affiliation"] = affiliation
|
|
124
|
+
records
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# String keys, without the blank values an entry leaves for its record to fill.
|
|
128
|
+
def present(hash)
|
|
129
|
+
hash.to_h.each_with_object({}) do |(key, entry), result|
|
|
130
|
+
result[key.to_s] = entry unless entry.nil? || (entry.is_a?(String) && entry.strip.empty?)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Pages are hashes in Liquid, documents and the site are drops; both answer [].
|
|
135
|
+
def value(object, key)
|
|
136
|
+
object[key] if object.respond_to?(:[]) && !object.is_a?(String) && !object.is_a?(Array)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
module AuthorFilters
|
|
141
|
+
def page_authors(page)
|
|
142
|
+
Authors.authors(page, @context["site"])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def page_contributors(page)
|
|
146
|
+
Authors.contributors(page, @context["site"])
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Liquid::Template.register_filter(Datalog::AuthorFilters)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Datalog
|
|
4
|
+
module CitationExports
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
TYPES = {
|
|
8
|
+
"article" => ["article", "JOUR", "Journal Article"],
|
|
9
|
+
"journal" => ["article", "JOUR", "Journal Article"],
|
|
10
|
+
"blog" => %w[misc BLOG Blog],
|
|
11
|
+
"dataset" => %w[misc DATA Dataset],
|
|
12
|
+
"software" => ["misc", "COMP", "Computer Program"],
|
|
13
|
+
"book" => %w[book BOOK Book],
|
|
14
|
+
"inproceedings" => ["inproceedings", "CPAPER", "Conference Paper"],
|
|
15
|
+
"misc" => %w[misc GEN Generic]
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def text(value)
|
|
19
|
+
value.to_s.gsub(/\s+/, " ").strip
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def bibtex(value)
|
|
23
|
+
text(value).gsub(/[\\&%$#_{}~^]/) do |character|
|
|
24
|
+
{ "\\" => '\\textbackslash{}', "~" => '\\textasciitilde{}', "^" => '\\textasciicircum{}' }
|
|
25
|
+
.fetch(character) { "\\#{character}" }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render(record, authors, kind, key)
|
|
30
|
+
bib_type, ris_type, endnote_type = TYPES.fetch(kind.to_s.downcase, TYPES["misc"])
|
|
31
|
+
names = authors.map { |author| text(Authors.value(author, "name") || author) }
|
|
32
|
+
bib_names = authors.each_with_index.map do |author, index|
|
|
33
|
+
name = bibtex(names[index])
|
|
34
|
+
Authors.value(author, "type") == "Organization" ? "{#{name}}" : name
|
|
35
|
+
end
|
|
36
|
+
fields = { "title" => bibtex(record["title"]), "author" => bib_names.join(" and ") }
|
|
37
|
+
record.each { |field, value| fields[field] = bibtex(value) unless field == "title" || value.to_s.empty? }
|
|
38
|
+
body = fields.map { |field, value| " #{field} = { #{value} }" }.join(",\n")
|
|
39
|
+
bib = "@#{bib_type}{ #{key},\n#{body}\n}"
|
|
40
|
+
|
|
41
|
+
{ "bibtex" => bib, "ris" => ris(record, names, ris_type), "endnote" => endnote(record, names, endnote_type) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ris(record, names, type)
|
|
45
|
+
lines = ["TY - #{type}", "TI - #{text(record['title'])}"]
|
|
46
|
+
lines.concat(names.map { |name| "AU - #{name}" })
|
|
47
|
+
{ "year" => "PY", "publisher" => "PB", "journal" => "JO", "doi" => "DO", "url" => "UR",
|
|
48
|
+
"volume" => "VL", "number" => "IS" }.each do |field, tag|
|
|
49
|
+
lines << "#{tag} - #{text(record[field])}" unless record[field].to_s.empty?
|
|
50
|
+
end
|
|
51
|
+
unless record["pages"].to_s.empty?
|
|
52
|
+
first, last = text(record["pages"]).split(/[-–—]+/, 2)
|
|
53
|
+
lines << "SP - #{first}"
|
|
54
|
+
lines << "EP - #{last}" if last
|
|
55
|
+
end
|
|
56
|
+
(lines << "ER -").join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def endnote(record, names, type)
|
|
60
|
+
lines = ["%0 #{type}", "%T #{text(record['title'])}"]
|
|
61
|
+
lines.concat(names.map { |name| "%A #{name}" })
|
|
62
|
+
{ "year" => "D", "publisher" => "I", "journal" => "J", "doi" => "R", "url" => "U",
|
|
63
|
+
"volume" => "V", "number" => "N", "pages" => "P" }.each do |field, tag|
|
|
64
|
+
lines << "%#{tag} #{text(record[field])}" unless record[field].to_s.empty?
|
|
65
|
+
end
|
|
66
|
+
lines.join("\n")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module CitationExportFilters
|
|
71
|
+
def citation_exports(page, options = {})
|
|
72
|
+
site = @context["site"]
|
|
73
|
+
get = ->(key) { Authors.value(options, key) || Authors.value(page, key) }
|
|
74
|
+
title = get.call("title") || Authors.value(site, "title")
|
|
75
|
+
authors = Authors.value(options, "authors") || Authors.authors(page, site)
|
|
76
|
+
authors = Authors.entries(authors)
|
|
77
|
+
publisher = get.call("publisher") || Authors.value(Authors.value(site, "publisher"), "name") ||
|
|
78
|
+
Authors.value(site, "title")
|
|
79
|
+
key = slugify(get.call("citation_key") || Authors.value(page, "slug") || title)
|
|
80
|
+
kind = get.call("citation_type") || Authors.value(options, "type") || "article"
|
|
81
|
+
record = {
|
|
82
|
+
"title" => title, "year" => (date(get.call("date"), "%Y") if get.call("date")),
|
|
83
|
+
"publisher" => publisher, "journal" => get.call("journal") || get.call("publication"),
|
|
84
|
+
"doi" => get.call("doi"), "url" => absolute_url(get.call("url") || "/"),
|
|
85
|
+
"volume" => get.call("volume"), "number" => get.call("issue") || get.call("number"),
|
|
86
|
+
"pages" => get.call("pages")
|
|
87
|
+
}
|
|
88
|
+
CitationExports.render(record, authors, kind, key)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Liquid::Template.register_filter(Datalog::CitationExportFilters)
|
|
@@ -3,11 +3,66 @@
|
|
|
3
3
|
require "uri"
|
|
4
4
|
|
|
5
5
|
module Datalog
|
|
6
|
+
# The dynamic-services settings are inlined into every page for the
|
|
7
|
+
# browser (docs/dynamic-services.md), so a key that names a secret is a
|
|
8
|
+
# leak whatever its value; the validator reports each one.
|
|
9
|
+
module PublicConfiguration
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# `credentials` is fetch's cookie mode, not a secret, so "credential" is not in the list.
|
|
13
|
+
SECRET_KEY = /secret|token|password|passwd|api_key|apikey|private_key/i
|
|
14
|
+
|
|
15
|
+
# The blocks of _config.yml that reach the browser as they are. `moderation`
|
|
16
|
+
# is where an admin password is most tempting and least use: a static page
|
|
17
|
+
# cannot keep one (docs/moderation.md).
|
|
18
|
+
PUBLIC_BLOCKS = %w[dynamic_services moderation].freeze
|
|
19
|
+
|
|
20
|
+
def errors(config)
|
|
21
|
+
PUBLIC_BLOCKS.flat_map do |block|
|
|
22
|
+
settings = config[block]
|
|
23
|
+
next [] unless settings.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
secret_keys(settings, [block]).map { |path| secret_error(block, path) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def secret_error(block, path)
|
|
30
|
+
{
|
|
31
|
+
headline: "Secret in public configuration '#{path.join('.')}'",
|
|
32
|
+
path: path,
|
|
33
|
+
expected: "no credential: #{block} is sent to every reader's browser, so a key, token or " \
|
|
34
|
+
"password belongs on the server, never in _config.yml",
|
|
35
|
+
actual: "a key named '#{path.last}'"
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def secret_keys(hash, path)
|
|
40
|
+
hash.flat_map do |key, value|
|
|
41
|
+
here = path + [key.to_s]
|
|
42
|
+
found = key.to_s.match?(SECRET_KEY) ? [here] : []
|
|
43
|
+
value.is_a?(Hash) ? found + secret_keys(value, here) : found
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
6
48
|
class ConfigValidator < Jekyll::Generator
|
|
7
49
|
safe true
|
|
8
50
|
priority :highest
|
|
9
51
|
|
|
10
|
-
|
|
52
|
+
# docs/ is not part of the built site, so errors link to the file on GitHub.
|
|
53
|
+
DOCUMENTATION_URL = "https://github.com/DiogoRibeiro7/analytics-blog-jekyll/blob/main/docs/configuration-reference.md"
|
|
54
|
+
|
|
55
|
+
# A licence: an identifier such as CC-BY-4.0 or MIT, or a map naming one.
|
|
56
|
+
LICENSE = {
|
|
57
|
+
type: %i[string hash],
|
|
58
|
+
schema: {
|
|
59
|
+
id: { type: :string },
|
|
60
|
+
name: { type: :string },
|
|
61
|
+
url: { type: :string },
|
|
62
|
+
holder: { type: %i[string array] },
|
|
63
|
+
year: { type: %i[integer string] }
|
|
64
|
+
}
|
|
65
|
+
}.freeze
|
|
11
66
|
|
|
12
67
|
SCHEMA = {
|
|
13
68
|
title: { type: :string, required: true },
|
|
@@ -21,6 +76,68 @@ module Datalog
|
|
|
21
76
|
email: { type: :string, format: :email }
|
|
22
77
|
}
|
|
23
78
|
},
|
|
79
|
+
# Who publishes the site in structured data and citations. Without it, the author does.
|
|
80
|
+
publisher: {
|
|
81
|
+
type: :hash,
|
|
82
|
+
schema: {
|
|
83
|
+
type: { type: :string, enum: %w[Person Organization] },
|
|
84
|
+
name: { type: :string },
|
|
85
|
+
url: { type: :string },
|
|
86
|
+
logo: { type: :string }
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
# The licence of the site's articles (text and figures) and of their code samples.
|
|
90
|
+
content_license: LICENSE,
|
|
91
|
+
code_license: LICENSE,
|
|
92
|
+
# Which pages get scholarly discovery metadata: true for every post, or a list of collections and layouts.
|
|
93
|
+
scholarly: { type: %i[boolean array string] },
|
|
94
|
+
dynamic_services: {
|
|
95
|
+
type: :hash,
|
|
96
|
+
schema: {
|
|
97
|
+
base_url: { type: :string, format: :url }, api_version: { type: %i[string integer] },
|
|
98
|
+
timeout_ms: { type: :integer }, credentials: { type: :string, enum: %w[omit same-origin include] },
|
|
99
|
+
features: { type: :hash }, paths: { type: :hash }, csrf_header: { type: :string }, csrf_cookie: { type: :string }
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
# The correction-report form on posts (components/correction-report.html).
|
|
103
|
+
corrections: {
|
|
104
|
+
type: :hash,
|
|
105
|
+
schema: { enabled: { type: :boolean }, categories: { type: :array } }
|
|
106
|
+
},
|
|
107
|
+
# The contact and collaboration form (components/contact-form.html).
|
|
108
|
+
contact: {
|
|
109
|
+
type: :hash,
|
|
110
|
+
schema: {
|
|
111
|
+
enabled: { type: :boolean }, categories: { type: :array }, prompts: { type: :hash },
|
|
112
|
+
privacy_notice: { type: :string }, retention: { type: :string }
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
# "Was this useful?" after a post (components/reactions.html).
|
|
116
|
+
reactions: {
|
|
117
|
+
type: :hash,
|
|
118
|
+
schema: { enabled: { type: :boolean }, counts: { type: :boolean }, types: { type: :array } }
|
|
119
|
+
},
|
|
120
|
+
# Webmentions: the receiver advertised in the head, the mentions shown under a post.
|
|
121
|
+
webmentions: {
|
|
122
|
+
type: :hash,
|
|
123
|
+
schema: { enabled: { type: :boolean }, endpoint: { type: :string }, types: { type: :array } }
|
|
124
|
+
},
|
|
125
|
+
# The moderation inbox (docs/moderation.md): public settings only, never a credential.
|
|
126
|
+
moderation: {
|
|
127
|
+
type: :hash,
|
|
128
|
+
schema: {
|
|
129
|
+
enabled: { type: :boolean }, endpoint: { type: :string }, sign_in_url: { type: :string },
|
|
130
|
+
credentials: { type: :string, enum: %w[omit same-origin include] }
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
# The newsletter form and the page its emails link to (components/subscribe-form.html).
|
|
134
|
+
subscriptions: {
|
|
135
|
+
type: :hash,
|
|
136
|
+
schema: {
|
|
137
|
+
enabled: { type: :boolean }, double_opt_in: { type: :boolean }, placement: { type: :array },
|
|
138
|
+
topics: { type: :array }, privacy_url: { type: :string }
|
|
139
|
+
}
|
|
140
|
+
},
|
|
24
141
|
markdown: { type: :string, enum: %w[kramdown commonmark] },
|
|
25
142
|
highlighter: { type: :string, enum: %w[rouge pygments] },
|
|
26
143
|
permalink: { type: :string },
|
|
@@ -69,6 +186,25 @@ module Datalog
|
|
|
69
186
|
engine: { type: :string, enum: %w[mathjax katex] },
|
|
70
187
|
enabled: { type: :boolean }
|
|
71
188
|
}
|
|
189
|
+
},
|
|
190
|
+
# The "Reading mode" control on posts, and whether a reader's choice is kept.
|
|
191
|
+
reading_mode: {
|
|
192
|
+
type: :hash,
|
|
193
|
+
schema: {
|
|
194
|
+
enabled: { type: :boolean },
|
|
195
|
+
remember: { type: :boolean }
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
# Bookmarks, reading progress and private highlights, kept in the reader's browser.
|
|
199
|
+
reading_state: {
|
|
200
|
+
type: :hash,
|
|
201
|
+
schema: {
|
|
202
|
+
enabled: { type: :boolean },
|
|
203
|
+
bookmarks: { type: :boolean },
|
|
204
|
+
progress: { type: :boolean },
|
|
205
|
+
highlights: { type: :boolean },
|
|
206
|
+
list_url: { type: :string }
|
|
207
|
+
}
|
|
72
208
|
}
|
|
73
209
|
}
|
|
74
210
|
}
|
|
@@ -83,6 +219,11 @@ module Datalog
|
|
|
83
219
|
"theme_options.syntax_highlighting" => {
|
|
84
220
|
message: "Code is highlighted by Rouge when the site builds and the theme no longer loads Prism, " \
|
|
85
221
|
"so these settings have no effect. Remove them."
|
|
222
|
+
},
|
|
223
|
+
# The user guide's troubleshooting table sent readers here.
|
|
224
|
+
"theme_options.math.enabled" => {
|
|
225
|
+
message: "Nothing reads it: theme_options.math.render_on_load decides which pages load the math engine, " \
|
|
226
|
+
"and a page's `math` front matter overrides that. Remove it."
|
|
86
227
|
}
|
|
87
228
|
}.freeze
|
|
88
229
|
|
|
@@ -93,6 +234,32 @@ module Datalog
|
|
|
93
234
|
}
|
|
94
235
|
}.freeze
|
|
95
236
|
|
|
237
|
+
# Pointing layouts_dir and the rest into a copy of the theme is how a site
|
|
238
|
+
# used a Git submodule before the theme loaded its own plugins. Jekyll
|
|
239
|
+
# cannot read the theme's assets or _data that way, so those were copied
|
|
240
|
+
# into the site, and the copies fall behind the theme on every update.
|
|
241
|
+
module ThemeDirectories
|
|
242
|
+
module_function
|
|
243
|
+
|
|
244
|
+
URL = "https://github.com/DiogoRibeiro7/analytics-blog-jekyll/blob/main/docs/install.md#keep-the-theme-in-a-git-submodule"
|
|
245
|
+
KEYS = [%w[layouts_dir], %w[includes_dir], %w[plugins_dir], %w[sass sass_dir]].freeze
|
|
246
|
+
|
|
247
|
+
def warnings(config)
|
|
248
|
+
source = File.expand_path(config["source"] || Dir.pwd)
|
|
249
|
+
keys = KEYS.select { |path| Array(config.dig(*path)).any? { |dir| theme_checkout?(dir, source) } }
|
|
250
|
+
return [] if keys.empty?
|
|
251
|
+
|
|
252
|
+
["#{keys.map { |path| path.join('.') }.join(', ')} point into a copy of datalog-theme. Jekyll cannot " \
|
|
253
|
+
"read the theme's assets and _data that way, so a site copies them in, and the copies fall behind " \
|
|
254
|
+
"the theme. Use the copy as the theme instead: #{URL}"]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def theme_checkout?(dir, source)
|
|
258
|
+
checkout = File.dirname(File.expand_path(dir.to_s, source))
|
|
259
|
+
checkout != source && File.file?(File.join(checkout, "datalog-theme.gemspec"))
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
96
263
|
class Validator
|
|
97
264
|
attr_reader :errors, :warnings
|
|
98
265
|
|
|
@@ -108,6 +275,7 @@ module Datalog
|
|
|
108
275
|
|
|
109
276
|
apply_deprecated_migrations
|
|
110
277
|
validate_schema(@config, SCHEMA)
|
|
278
|
+
check_math_defaults
|
|
111
279
|
|
|
112
280
|
self
|
|
113
281
|
end
|
|
@@ -143,6 +311,36 @@ module Datalog
|
|
|
143
311
|
end
|
|
144
312
|
end
|
|
145
313
|
|
|
314
|
+
# With render_on_load: auto, only the pages with math load the math
|
|
315
|
+
# engine. `math: true` or `mathjax: true` in front matter defaults loads it
|
|
316
|
+
# on every page in their scope instead, and nothing in the build said so.
|
|
317
|
+
def check_math_defaults
|
|
318
|
+
return unless dig_value(@config, %w[theme_options math render_on_load]) == "auto"
|
|
319
|
+
|
|
320
|
+
Array(@config["defaults"]).each do |entry|
|
|
321
|
+
values = entry["values"] if entry.is_a?(Hash)
|
|
322
|
+
next unless values.is_a?(Hash)
|
|
323
|
+
|
|
324
|
+
%w[math mathjax].each do |key|
|
|
325
|
+
next unless values[key] == true
|
|
326
|
+
|
|
327
|
+
@warnings << "Front matter defaults set '#{key}: true' for #{describe_scope(entry['scope'])}, so they all " \
|
|
328
|
+
"load the math engine, whatever theme_options.math.render_on_load: auto finds. " \
|
|
329
|
+
"Remove it, and set 'math: true' on the pages that need the engine."
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def describe_scope(scope)
|
|
335
|
+
scope = {} unless scope.is_a?(Hash)
|
|
336
|
+
type = scope["type"].to_s
|
|
337
|
+
path = scope["path"].to_s
|
|
338
|
+
return "every page" if type.empty? && path.empty?
|
|
339
|
+
|
|
340
|
+
pages = type.empty? ? "the pages" : "the pages of type '#{type}'"
|
|
341
|
+
path.empty? ? pages : "#{pages} under '#{path}'"
|
|
342
|
+
end
|
|
343
|
+
|
|
146
344
|
def validate_schema(data, schema, path = [])
|
|
147
345
|
schema.each do |key, rules|
|
|
148
346
|
key_path = path + [key.to_s]
|
|
@@ -167,6 +365,10 @@ module Datalog
|
|
|
167
365
|
end
|
|
168
366
|
|
|
169
367
|
def validate_type(path, value, rules)
|
|
368
|
+
# YAML never yields a Symbol, but converters rewrite the site's own
|
|
369
|
+
# configuration: jekyll-sass-converter turns sass.style into one.
|
|
370
|
+
value = value.to_s if value.is_a?(Symbol)
|
|
371
|
+
|
|
170
372
|
expected_type = rules[:type]
|
|
171
373
|
if expected_type && !type_valid?(value, expected_type)
|
|
172
374
|
errors << build_type_error(path, expected_type, value)
|
|
@@ -244,8 +446,7 @@ module Datalog
|
|
|
244
446
|
headline: "Missing required configuration '#{identifier}'",
|
|
245
447
|
path: path,
|
|
246
448
|
expected: "Value required",
|
|
247
|
-
actual: "nil"
|
|
248
|
-
doc_url: documentation_url(path)
|
|
449
|
+
actual: "nil"
|
|
249
450
|
}
|
|
250
451
|
end
|
|
251
452
|
|
|
@@ -255,8 +456,7 @@ module Datalog
|
|
|
255
456
|
headline: "Invalid type for '#{identifier}'",
|
|
256
457
|
path: path,
|
|
257
458
|
expected: human_type(expected_type),
|
|
258
|
-
actual: value.inspect
|
|
259
|
-
doc_url: documentation_url(path)
|
|
459
|
+
actual: value.inspect
|
|
260
460
|
}
|
|
261
461
|
end
|
|
262
462
|
|
|
@@ -268,8 +468,7 @@ module Datalog
|
|
|
268
468
|
path: path,
|
|
269
469
|
expected: enum_values.map { |v| "\"#{v}\"" }.join(" or "),
|
|
270
470
|
actual: value.inspect,
|
|
271
|
-
suggestion: suggestion
|
|
272
|
-
doc_url: documentation_url(path)
|
|
471
|
+
suggestion: suggestion
|
|
273
472
|
}
|
|
274
473
|
end
|
|
275
474
|
|
|
@@ -279,8 +478,7 @@ module Datalog
|
|
|
279
478
|
headline: "Invalid value for '#{identifier}'",
|
|
280
479
|
path: path,
|
|
281
480
|
expected: expectation,
|
|
282
|
-
actual: value.inspect
|
|
283
|
-
doc_url: documentation_url(path)
|
|
481
|
+
actual: value.inspect
|
|
284
482
|
}
|
|
285
483
|
end
|
|
286
484
|
|
|
@@ -303,16 +501,11 @@ module Datalog
|
|
|
303
501
|
lines << " Received: #{error[:actual]}" if error[:actual]
|
|
304
502
|
lines << ""
|
|
305
503
|
lines << " #{error[:suggestion]}" if error[:suggestion]
|
|
306
|
-
lines << " Documentation: #{
|
|
504
|
+
lines << " Documentation: #{DOCUMENTATION_URL}"
|
|
307
505
|
lines.compact!
|
|
308
506
|
lines.join("\n")
|
|
309
507
|
end
|
|
310
508
|
|
|
311
|
-
def documentation_url(path)
|
|
312
|
-
anchor = path.map { |segment| segment.gsub(/[^a-z0-9]+/i, "-") }.join("-").downcase
|
|
313
|
-
"#{DOCUMENTATION_BASE_URL}##{anchor}"
|
|
314
|
-
end
|
|
315
|
-
|
|
316
509
|
def human_type(type)
|
|
317
510
|
return type.map { |entry| human_type(entry) }.join(" or ") if type.is_a?(Array)
|
|
318
511
|
|
|
@@ -344,7 +537,8 @@ module Datalog
|
|
|
344
537
|
head, *tail = path
|
|
345
538
|
return nil if head.nil?
|
|
346
539
|
|
|
347
|
-
|
|
540
|
+
# `data[head] || data[head.to_sym]` turned a `false` into nil.
|
|
541
|
+
value = data.key?(head) ? data[head] : data[head.to_sym]
|
|
348
542
|
return value if tail.empty?
|
|
349
543
|
|
|
350
544
|
dig_value(value, tail)
|
|
@@ -380,17 +574,26 @@ module Datalog
|
|
|
380
574
|
end
|
|
381
575
|
end
|
|
382
576
|
|
|
577
|
+
# `jekyll serve` processes the same site again on every change, without
|
|
578
|
+
# reading _config.yml again, and by then converters have rewritten parts of
|
|
579
|
+
# the configuration: jekyll-sass-converter turns sass.style into a Symbol.
|
|
580
|
+
# Checking on every build stopped each rebuild, so a site is checked on its
|
|
581
|
+
# first build. A `:site, :after_init` hook would also run once, but Jekyll's
|
|
582
|
+
# command line reports an error raised there as a backtrace, not a message.
|
|
383
583
|
def generate(site)
|
|
584
|
+
return if @validated
|
|
585
|
+
|
|
384
586
|
validator = Validator.new(site.config)
|
|
385
587
|
validator.run
|
|
588
|
+
# The Validator class is at its length limit; the public-configuration check lives beside it.
|
|
589
|
+
validator.errors.concat(PublicConfiguration.errors(site.config))
|
|
386
590
|
|
|
387
|
-
validator.warnings.each do |warning|
|
|
591
|
+
(validator.warnings + ThemeDirectories.warnings(site.config)).each do |warning|
|
|
388
592
|
logger.warn("config", warning)
|
|
389
593
|
end
|
|
390
594
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
raise Jekyll::Errors::FatalException, validator.formatted_errors
|
|
595
|
+
@validated = validator.errors.empty?
|
|
596
|
+
raise Jekyll::Errors::FatalException, validator.formatted_errors unless @validated
|
|
394
597
|
end
|
|
395
598
|
|
|
396
599
|
private
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Datalog
|
|
4
|
+
# The theme ships _includes/critical-css/*.html empty, since critical CSS
|
|
5
|
+
# depends on a site's own pages. A production build with critical_css.enabled
|
|
6
|
+
# and no critical CSS written inlined nothing and kept main.css blocking
|
|
7
|
+
# rendering, and nothing said so.
|
|
8
|
+
module CriticalCssCheck
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
TARGETS = %w[home post default].freeze
|
|
12
|
+
|
|
13
|
+
def warning(site)
|
|
14
|
+
return unless Jekyll.env == "production"
|
|
15
|
+
|
|
16
|
+
settings = site.config["critical_css"]
|
|
17
|
+
return unless settings.is_a?(Hash) && settings["enabled"] == true
|
|
18
|
+
|
|
19
|
+
empty = TARGETS.select { |target| fragment(site, target).strip.empty? }
|
|
20
|
+
return if empty.empty?
|
|
21
|
+
|
|
22
|
+
files = empty.map { |target| "_includes/critical-css/#{target}.html" }.join(", ")
|
|
23
|
+
"critical_css.enabled is true, but #{files} #{empty.size == 1 ? 'is' : 'are'} empty, so pages with " \
|
|
24
|
+
"#{empty.size == 1 ? 'that layout' : 'those layouts'} inline no critical CSS and main.css blocks rendering. " \
|
|
25
|
+
"Run `bundle exec datalog critical-css` before building."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The site's file, or the theme's when the site has none.
|
|
29
|
+
def fragment(site, target)
|
|
30
|
+
name = File.join("critical-css", "#{target}.html")
|
|
31
|
+
directories = [site.in_source_dir(site.config["includes_dir"].to_s)]
|
|
32
|
+
directories << site.theme.includes_path if site.theme&.includes_path
|
|
33
|
+
path = directories.map { |directory| File.join(directory, name) }.find { |candidate| File.file?(candidate) }
|
|
34
|
+
path ? File.read(path) : ""
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Jekyll::Hooks.register :site, :post_read do |site|
|
|
40
|
+
warning = Datalog::CriticalCssCheck.warning(site)
|
|
41
|
+
Jekyll.logger.warn("Critical CSS:", warning) if warning
|
|
42
|
+
end
|
data/_plugins/i18n.rb
CHANGED
|
@@ -5,11 +5,13 @@ module Datalog
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def locale(context)
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
locale_code(context.registers[:site], context["page"] && context["page"]["lang"])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# A page's language, or the site's, as a code such as "pt".
|
|
12
|
+
def locale_code(site, page_lang)
|
|
10
13
|
site_locale = site.config.dig("theme_options", "localization", "default_locale") || site.config["locale"] || "en"
|
|
11
|
-
|
|
12
|
-
lang.to_s.split(/[-_]/).first
|
|
14
|
+
(page_lang || site_locale).to_s.split(/[-_]/).first
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def data_for(site)
|