datalog-theme 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +127 -0
- data/CITATION.cff +2 -2
- data/README.md +4 -2
- data/_data/i18n/en.yml +55 -3
- data/_data/i18n/es.yml +55 -3
- data/_data/i18n/pt.yml +55 -3
- data/_includes/analytics/dashboard.html +24 -9
- data/_includes/components/academic-dashboard.html +168 -133
- data/_includes/components/author-bio.html +7 -5
- data/_includes/components/author-list.html +6 -5
- data/_includes/components/citation-tools.html +2 -1
- data/_includes/components/content-provenance.html +19 -1
- data/_includes/components/correction-report.html +15 -8
- data/_includes/components/hero.html +22 -15
- data/_includes/components/package-index.html +121 -0
- data/_includes/components/package-install.html +27 -50
- data/_includes/components/post-list.html +14 -0
- data/_includes/components/responsive-image.html +15 -21
- data/_includes/footer.html +1 -1
- data/_includes/head.html +70 -11
- data/_includes/layouts/default/article.html +76 -46
- data/_includes/meta/dataset-json.html +171 -0
- data/_includes/meta/math-config.html +15 -1
- data/_includes/meta/package-json.html +69 -0
- data/_includes/meta/person-json.html +36 -13
- data/_includes/meta/publisher.html +15 -0
- data/_includes/meta/schema.html +108 -23
- data/_includes/meta/scholarly.html +10 -5
- data/_includes/post/related-posts.html +17 -43
- data/_includes/scripts.html +2 -2
- data/_includes/search/index-data.json +10 -1
- data/_includes/search/page.html +11 -6
- data/_layouts/archive.html +67 -0
- data/_layouts/dataset.html +2 -1
- data/_layouts/default.html +13 -11
- data/_layouts/docs.html +53 -0
- data/_layouts/home.html +30 -1
- data/_layouts/notebook.html +5 -1
- data/_layouts/package.html +38 -21
- data/_layouts/portfolio.html +1 -0
- data/_layouts/post.html +21 -62
- data/_layouts/project.html +2 -1
- data/_layouts/research.html +14 -2
- data/_plugins/analytics_dashboard.rb +4 -1
- data/_plugins/archive.rb +114 -0
- data/_plugins/authors.rb +50 -1
- data/_plugins/citation_exports.rb +12 -1
- data/_plugins/config_validator.rb +77 -2
- data/_plugins/correction_fallback.rb +110 -0
- data/_plugins/image_optimizer.rb +129 -22
- data/_plugins/math_preprocessor.rb +7 -48
- data/_plugins/notebook_converter.rb +44 -5
- data/_plugins/packages.rb +84 -0
- data/_plugins/page_dates.rb +37 -0
- data/_plugins/references.rb +16 -2
- data/_plugins/related_posts.rb +149 -0
- data/_plugins/search_sections.rb +110 -0
- data/_plugins/site_identity.rb +42 -0
- data/_plugins/social_cards.rb +17 -0
- data/_sass/_academic-dashboard.scss +18 -30
- data/_sass/_base.scss +2 -0
- data/_sass/_components.scss +21 -23
- data/_sass/_docs.scss +143 -0
- data/_sass/_features.scss +6 -0
- data/_sass/_layout.scss +65 -6
- data/_sass/_mathematical.scss +61 -19
- data/_sass/_notebooks.scss +1 -32
- data/_sass/_package-docs.scss +6 -9
- data/_sass/_post-components.scss +23 -23
- data/_sass/_print.scss +1 -1
- data/_sass/_search-page.scss +75 -0
- data/_sass/_search.scss +17 -26
- data/_sass/_syntax-highlighting.scss +23 -1
- data/_sass/_theme.scss +1 -0
- data/_sass/_utilities.scss +87 -40
- data/_sass/_variables.scss +31 -29
- data/assets/css/main.scss +2 -1
- data/assets/js/dist/academic.js +1 -1
- data/assets/js/dist/analytics-dashboard.js +1 -1
- data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
- data/assets/js/dist/comments.js +1 -1
- data/assets/js/dist/contact.js +1 -1
- data/assets/js/dist/core.js +1 -1
- data/assets/js/dist/corrections.js +5 -1
- data/assets/js/dist/loader.js +1 -1
- data/assets/js/dist/math.js +2 -1
- data/assets/js/dist/moderation.js +1 -1
- data/assets/js/dist/reactions.js +1 -1
- data/assets/js/dist/search.js +1 -1
- data/assets/js/dist/sources.json +20 -16
- data/assets/js/dist/subscriptions.js +1 -1
- data/assets/js/loader.js +6 -2
- data/lib/datalog/audit/checks.rb +269 -0
- data/lib/datalog/audit/known_keys.rb +61 -0
- data/lib/datalog/audit/site_reader.rb +118 -0
- data/lib/datalog/audit/source_file.rb +85 -0
- data/lib/datalog/audit.rb +145 -0
- data/lib/datalog/citations/bibtex.rb +200 -0
- data/lib/datalog/citations/entry.rb +333 -0
- data/lib/datalog/citations/markup.rb +79 -0
- data/lib/datalog/cli.rb +164 -89
- data/lib/datalog/critical_css.rb +126 -21
- data/lib/datalog/latex_speech/words.json +88 -0
- data/lib/datalog/latex_speech.rb +355 -0
- data/lib/datalog/packages/command.rb +51 -0
- data/lib/datalog/packages/refresh.rb +187 -0
- data/lib/datalog/packages.rb +177 -0
- data/lib/datalog/plugin_system.rb +5 -0
- data/lib/datalog/plugins/citations.rb +325 -109
- data/lib/datalog/site_config.rb +26 -0
- data/lib/datalog/social_cards/font.rb +286 -0
- data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
- data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
- data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
- data/lib/datalog/social_cards/geometry.rb +51 -0
- data/lib/datalog/social_cards/outline.rb +54 -0
- data/lib/datalog/social_cards/plain_text.rb +258 -0
- data/lib/datalog/social_cards/template.rb +322 -0
- data/lib/datalog/social_cards/template.svg +17 -0
- data/lib/datalog/social_cards/typesetter.rb +178 -0
- data/lib/datalog/social_cards.rb +369 -0
- data/lib/datalog/theme/updater.rb +307 -0
- data/lib/datalog/theme/version.rb +1 -1
- metadata +44 -7
- data/_includes/components/enhanced-code-block.html +0 -212
- data/_includes/components/performance-monitor.html +0 -170
- data/_includes/components/viz-table-fallback.html +0 -19
- data/_plugins/datalog_bibliography.rb +0 -21
- data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +0 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "font"
|
|
4
|
+
|
|
5
|
+
module Datalog
|
|
6
|
+
module SocialCards
|
|
7
|
+
# Sets a card's text in the fonts the theme ships: wraps it to a width,
|
|
8
|
+
# shrinks it until it fits its lines and height, and draws each line as a
|
|
9
|
+
# path. A character neither font has is decomposed (ℝ reads R), spelled
|
|
10
|
+
# out (∈ reads "in"), or left out; never drawn as a box.
|
|
11
|
+
class Typesetter
|
|
12
|
+
FONT_DIR = File.expand_path("fonts", __dir__)
|
|
13
|
+
# The serif is the theme's heading face; the sans its text face.
|
|
14
|
+
FONTS = { "serif" => "IBMPlexSerif-SemiBold.ttf", "sans" => "IBMPlexSans-Regular.ttf" }.freeze
|
|
15
|
+
# Each in characters both fonts have, so spelling one out never comes
|
|
16
|
+
# back here for the same character.
|
|
17
|
+
SPELLED = {
|
|
18
|
+
"∈" => " in ", "∉" => " not in ", "∀" => "for all ", "∃" => "there is ", "∇" => "grad ",
|
|
19
|
+
"∝" => " ~ ", "⋯" => "…", "‖" => "||", "∪" => " union ", "∩" => " intersect ", "⊂" => " subset of ",
|
|
20
|
+
"⊆" => " subset of ", "∅" => "{}", "⟨" => "<", "⟩" => ">", "∘" => "o", "⇒" => "=>", "⇔" => "<=>",
|
|
21
|
+
"∓" => "-+", "↦" => "->"
|
|
22
|
+
}.freeze
|
|
23
|
+
ELLIPSIS = "…"
|
|
24
|
+
|
|
25
|
+
Line = Struct.new(:text, :width)
|
|
26
|
+
# A text slot's room: its width and, when it has one, its height; the
|
|
27
|
+
# size to start from and the smallest to go to; its lines; the line
|
|
28
|
+
# height, as a share of the size.
|
|
29
|
+
Box = Struct.new(:width, :height, :font_size, :min_font_size, :max_lines, :line_height, keyword_init: true) do
|
|
30
|
+
def initialize(min_font_size: nil, max_lines: 1, line_height: 1.2, **rest)
|
|
31
|
+
super
|
|
32
|
+
self.min_font_size ||= font_size
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.fonts
|
|
37
|
+
@fonts ||= FONTS.transform_values { |file| Font.new(File.join(FONT_DIR, file)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :font
|
|
41
|
+
|
|
42
|
+
def initialize(name)
|
|
43
|
+
fonts = self.class.fonts
|
|
44
|
+
@font = fonts.fetch(name) { fonts.fetch("sans") }
|
|
45
|
+
@fallback = fonts.values.find { |other| !other.equal?(@font) }
|
|
46
|
+
@resolved = {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Each character the text keeps, with the font that draws it.
|
|
50
|
+
def runs(text)
|
|
51
|
+
text.each_char.flat_map { |char| resolve(char) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def width(text, size)
|
|
55
|
+
runs(text).sum { |char, font| font.advance(font.glyph_id(char)) * size / font.units_per_em.to_f }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The size it fits at and its lines, from `size` down to `min_size` in
|
|
59
|
+
# steps of two pixels. Past the smallest size the last line ends in an
|
|
60
|
+
# ellipsis.
|
|
61
|
+
def fit(text, box)
|
|
62
|
+
text = text.to_s.gsub(/\s+/, " ").strip
|
|
63
|
+
return [box.font_size, []] if text.empty?
|
|
64
|
+
|
|
65
|
+
sizes(box.font_size, box.min_font_size).each do |candidate|
|
|
66
|
+
lines = wrap(text, box.width, candidate)
|
|
67
|
+
return [candidate, lines] if fits?(lines.size, candidate, box)
|
|
68
|
+
end
|
|
69
|
+
smallest = box.min_font_size
|
|
70
|
+
[smallest, truncate(wrap(text, box.width, smallest), lines_at(smallest, box), box.width, smallest)]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Greedy, at spaces; a word wider than the line is cut where it must be.
|
|
74
|
+
def wrap(text, max_width, size)
|
|
75
|
+
lines = []
|
|
76
|
+
current = nil
|
|
77
|
+
text.split.each do |word|
|
|
78
|
+
candidate = current ? "#{current} #{word}" : word
|
|
79
|
+
if width(candidate, size) <= max_width
|
|
80
|
+
current = candidate
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
lines << current if current
|
|
84
|
+
current = word
|
|
85
|
+
while width(current, size) > max_width && current.length > 1
|
|
86
|
+
cut = cut_point(current, max_width, size)
|
|
87
|
+
lines << current[0...cut]
|
|
88
|
+
current = current[cut..]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
lines << current if current
|
|
92
|
+
lines.map { |line| Line.new(line, width(line, size)) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One path for a line, its baseline at `baseline`.
|
|
96
|
+
def path(text, left, baseline, size)
|
|
97
|
+
runs(text).map do |char, font|
|
|
98
|
+
drawn = font.path(char, left, baseline, size)
|
|
99
|
+
left += font.advance(font.glyph_id(char)) * size / font.units_per_em.to_f
|
|
100
|
+
drawn
|
|
101
|
+
end.reject(&:empty?).join(" ")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The height of a capital, as a share of the size: where the first
|
|
105
|
+
# baseline goes below the top of a text box.
|
|
106
|
+
def cap_height
|
|
107
|
+
@cap_height ||= begin
|
|
108
|
+
tops = @font.outline(@font.glyph_id("H")).flatten(1).map { |point| point[1] }
|
|
109
|
+
(tops.max || (@font.ascender * 0.7)) / @font.units_per_em.to_f
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def resolve(char)
|
|
116
|
+
@resolved[char] ||= if @font.glyph?(char) then [[char, @font]]
|
|
117
|
+
elsif @fallback&.glyph?(char) then [[char, @fallback]]
|
|
118
|
+
elsif SPELLED.key?(char) then SPELLED[char].each_char.flat_map do |spelled|
|
|
119
|
+
resolve(spelled)
|
|
120
|
+
end
|
|
121
|
+
else decomposed(char)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def decomposed(char)
|
|
126
|
+
parts = char.unicode_normalize(:nfkd)
|
|
127
|
+
return [] if parts == char
|
|
128
|
+
|
|
129
|
+
parts.each_char.flat_map do |part|
|
|
130
|
+
if @font.glyph?(part) then [[part, @font]]
|
|
131
|
+
elsif @fallback&.glyph?(part) then [[part, @fallback]]
|
|
132
|
+
else []
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def sizes(size, min_size)
|
|
138
|
+
found = []
|
|
139
|
+
while size > min_size
|
|
140
|
+
found << size
|
|
141
|
+
size -= 2
|
|
142
|
+
end
|
|
143
|
+
found << min_size
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def fits?(count, size, box)
|
|
147
|
+
count <= lines_at(size, box)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The lines the box holds at a size: its line count, and no more than
|
|
151
|
+
# its height has room for.
|
|
152
|
+
def lines_at(size, box)
|
|
153
|
+
return box.max_lines unless box.height
|
|
154
|
+
|
|
155
|
+
[box.max_lines, ((box.height + 0.01) / (size * box.line_height)).floor].min.clamp(1, nil)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def cut_point(word, max_width, size)
|
|
159
|
+
cut = word.length - 1
|
|
160
|
+
cut -= 1 while cut > 1 && width(word[0...cut], size) > max_width
|
|
161
|
+
cut
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def truncate(lines, allowed, max_width, size)
|
|
165
|
+
return lines if lines.size <= allowed
|
|
166
|
+
|
|
167
|
+
kept = lines.first(allowed)
|
|
168
|
+
last = kept.last.text
|
|
169
|
+
until last.length <= 1 || width("#{last}#{ELLIPSIS}", size) <= max_width
|
|
170
|
+
shorter = last.sub(/\s*\S+\z/, "")
|
|
171
|
+
last = shorter.empty? ? last[0...-1] : shorter
|
|
172
|
+
end
|
|
173
|
+
kept[-1] = Line.new("#{last}#{ELLIPSIS}", width("#{last}#{ELLIPSIS}", size))
|
|
174
|
+
kept
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "etc"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require_relative "social_cards/plain_text"
|
|
9
|
+
require_relative "social_cards/template"
|
|
10
|
+
|
|
11
|
+
module Datalog
|
|
12
|
+
# A share card for each page that names no image of its own: its title,
|
|
13
|
+
# series or subtitle, author and date, and the site's name and logo, drawn
|
|
14
|
+
# on the theme's colours as a 1200×630 PNG (#297). Without one, every post
|
|
15
|
+
# was shared with `social.default_image`, and a link to an article looked
|
|
16
|
+
# like a link to the about page.
|
|
17
|
+
#
|
|
18
|
+
# The template is filled in Ruby, its text set in the IBM Plex fonts the
|
|
19
|
+
# gem ships, and drawn by ImageMagick, which the image optimizer already
|
|
20
|
+
# finds. Each card is kept in .jekyll-cache, keyed by everything drawn on
|
|
21
|
+
# it, so a build renders only the cards whose pages changed.
|
|
22
|
+
#
|
|
23
|
+
# theme_options:
|
|
24
|
+
# social_cards:
|
|
25
|
+
# enabled: true # off by default: it needs ImageMagick
|
|
26
|
+
# scheme: dark # or light
|
|
27
|
+
# background: "#0b1d3d" # over the scheme's background
|
|
28
|
+
# logo: /assets/img/logo.svg # an SVG or an image; false for none
|
|
29
|
+
# template: _social/card.svg # a site's own SVG template
|
|
30
|
+
# collections: [posts] # and "pages" for the site's pages
|
|
31
|
+
#
|
|
32
|
+
# Front matter: `og_image` or `image` wins over the card, and
|
|
33
|
+
# `social_card: false` asks for none.
|
|
34
|
+
module SocialCards
|
|
35
|
+
# The scheme's colours, all at least 4.5:1 against its background.
|
|
36
|
+
SCHEMES = {
|
|
37
|
+
"dark" => { "background" => "#111b31", "ink" => "#e7edf9", "muted" => "#cbd5e1", "accent" => "#67e8f9" },
|
|
38
|
+
"light" => { "background" => "#f8fafc", "ink" => "#172033", "muted" => "#334155", "accent" => "#3730a3" }
|
|
39
|
+
}.freeze
|
|
40
|
+
TEXT_COLORS = %w[ink muted accent].freeze
|
|
41
|
+
# The theme's mark, as the header draws it; currentColor is the accent.
|
|
42
|
+
MARK = <<~SVG
|
|
43
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
44
|
+
<path d="M8 48c12-24 24-32 32-32 8 0 12 4 16 8" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
|
45
|
+
<path d="M44 16c0 4-4 8-8 8s-8-4-8-8 4-8 8-8 8 4 8 8z" fill="currentColor" opacity="0.25"/>
|
|
46
|
+
</svg>
|
|
47
|
+
SVG
|
|
48
|
+
WIDTH = Template::WIDTH
|
|
49
|
+
HEIGHT = Template::HEIGHT
|
|
50
|
+
CACHE_DIR = "datalog-social-cards"
|
|
51
|
+
OUTPUT_DIR = "assets/social"
|
|
52
|
+
# Bumped when the drawing changes for the same inputs, so cached cards
|
|
53
|
+
# drawn the old way are drawn again.
|
|
54
|
+
REVISION = 1
|
|
55
|
+
MAX_AUTHORS = 3
|
|
56
|
+
|
|
57
|
+
Job = Struct.new(:page, :fields, :mvg, :key, :cached)
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# Draws the cards the site's pages need and tells each page where its
|
|
62
|
+
# card is. Returns { rendered:, reused: } or nil when the cards are off.
|
|
63
|
+
def generate(site, tools: nil)
|
|
64
|
+
config = settings(site)
|
|
65
|
+
return unless config
|
|
66
|
+
|
|
67
|
+
pages = pages_for(site, config)
|
|
68
|
+
return { rendered: 0, reused: 0 } if pages.empty?
|
|
69
|
+
|
|
70
|
+
tools ||= Jekyll::ImageOptimizer.tools
|
|
71
|
+
unless renderable?(tools)
|
|
72
|
+
warn_once("ImageMagick, which draws them, was not found (magick, or convert outside Windows), " \
|
|
73
|
+
"so pages are shared with social.default_image")
|
|
74
|
+
return { rendered: 0, reused: 0 }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
template = Template.new(config[:template], colors: config[:colors], resolve: resolver(site))
|
|
78
|
+
jobs = pages.map { |page| job(site, page, template, config) }
|
|
79
|
+
warn_once("the card template's #{template.ignored.uniq.join(', ')} are not drawn") unless template.ignored.empty?
|
|
80
|
+
|
|
81
|
+
pending = jobs.reject { |item| File.exist?(item.cached) }
|
|
82
|
+
failed = render_all(pending, tools)
|
|
83
|
+
jobs.each { |item| publish(site, item) if File.exist?(item.cached) }
|
|
84
|
+
report(failed, pending.size - failed.size, jobs.size - pending.size)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def settings(site)
|
|
88
|
+
options = site.config.dig("theme_options", "social_cards")
|
|
89
|
+
return unless options.is_a?(Hash) && options["enabled"] == true
|
|
90
|
+
|
|
91
|
+
scheme = SCHEMES.key?(options["scheme"].to_s) ? options["scheme"].to_s : "dark"
|
|
92
|
+
colors = SCHEMES[scheme].dup
|
|
93
|
+
colors["background"] = options["background"].to_s.strip if options["background"].is_a?(String)
|
|
94
|
+
check_contrast(colors)
|
|
95
|
+
{ colors: colors, template: template(site, options), logo: logo(site, options),
|
|
96
|
+
collections: Array(options.fetch("collections", ["posts"])).map(&:to_s) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The pages that get a card: those in the configured collections (and
|
|
100
|
+
# the site's own pages, for "pages") that are written, have a title, and
|
|
101
|
+
# name no image of their own.
|
|
102
|
+
def pages_for(site, config)
|
|
103
|
+
candidates = config[:collections].flat_map do |label|
|
|
104
|
+
next site.pages.select { |page| page.output_ext == ".html" } if label == "pages"
|
|
105
|
+
|
|
106
|
+
site.collections[label]&.docs || []
|
|
107
|
+
end
|
|
108
|
+
candidates.select { |page| wants_card?(page) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def wants_card?(page)
|
|
112
|
+
data = page.data
|
|
113
|
+
return false if data["social_card"] == false || data["title"].to_s.strip.empty?
|
|
114
|
+
return false if %w[og_image image].any? { |key| data[key].is_a?(String) && !data[key].strip.empty? }
|
|
115
|
+
|
|
116
|
+
!page.respond_to?(:write?) || page.write?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# What the card says about a page.
|
|
120
|
+
def fields(site, page)
|
|
121
|
+
data = page.data
|
|
122
|
+
locale = I18n.locale_code(site, data["lang"])
|
|
123
|
+
{
|
|
124
|
+
"site" => site_name(site),
|
|
125
|
+
"kicker" => kicker(site, locale, data),
|
|
126
|
+
"title" => PlainText.from_tex(data["title"]),
|
|
127
|
+
"byline" => [authors(site, page), date(site, page, locale)].compact.join(" · "),
|
|
128
|
+
"detail" => detail(data)
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The name beside the logo, as the header shows it: the name
|
|
133
|
+
# `site_identity` gives, else `name`, else `title`.
|
|
134
|
+
def site_name(site)
|
|
135
|
+
identity = site.config["site_identity"]
|
|
136
|
+
named = identity["name"].to_s.strip if identity.is_a?(Hash)
|
|
137
|
+
[named, site.config["name"], site.config["title"]].map(&:to_s).map(&:strip).find { |name| !name.empty? }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# "Part 3 of 4 · Missing data" for a part of a series, else the subtitle.
|
|
141
|
+
def kicker(site, locale, data)
|
|
142
|
+
series = data["series"]
|
|
143
|
+
if series.is_a?(Hash) && series["count"]
|
|
144
|
+
part = I18n.interpolate(I18n.lookup(site, locale, "series.part_of") || "Part {{part}} of {{total}}",
|
|
145
|
+
"part" => series["position"], "total" => series["count"])
|
|
146
|
+
return [part, PlainText.from_tex(series["title"])].reject(&:empty?).join(" · ")
|
|
147
|
+
end
|
|
148
|
+
PlainText.from_tex(data["subtitle"]) unless data["subtitle"].to_s.strip.empty?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def authors(site, page)
|
|
152
|
+
page_hash = page.data.merge("path" => page.relative_path)
|
|
153
|
+
names = Authors.authors(page_hash, site.config.merge("data" => site.data)).filter_map { |author| author["name"] }
|
|
154
|
+
return if names.empty?
|
|
155
|
+
|
|
156
|
+
names.size > MAX_AUTHORS ? "#{names.first} et al." : names.join(", ")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A post's date. Any other document has one too, the time of the build
|
|
160
|
+
# when it names none, so only one given in front matter counts.
|
|
161
|
+
def date(site, page, locale)
|
|
162
|
+
given = page.data["date"]
|
|
163
|
+
post = page.respond_to?(:collection) && page.collection&.label == "posts"
|
|
164
|
+
return unless given && (post || given != site.time)
|
|
165
|
+
|
|
166
|
+
context = Liquid::Context.new([{ "page" => { "lang" => locale } }], {}, { site: site })
|
|
167
|
+
I18n.localized_format(context, given, "long")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A research article's venue and DOI.
|
|
171
|
+
def detail(data)
|
|
172
|
+
venue = data["publication"] || data["journal"] || data["conference"]
|
|
173
|
+
venue = nil unless venue.is_a?(String)
|
|
174
|
+
doi = data["doi"].to_s.strip
|
|
175
|
+
[venue, (doi.empty? ? nil : "DOI #{doi}")].compact.join(" · ")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def job(site, page, template, config)
|
|
179
|
+
fields = fields(site, page)
|
|
180
|
+
mvg = template.to_mvg(fields, logo: config[:logo])
|
|
181
|
+
key = Digest::SHA256.hexdigest([REVISION, mvg, *image_digests(mvg)].join("\0"))
|
|
182
|
+
Job.new(page, fields, mvg, key, File.join(cache_root(site), "#{key}.png"))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The drawing names an image by its path; its content goes into the key
|
|
186
|
+
# too, so a logo or a template's image replaced under the same name draws
|
|
187
|
+
# the card again.
|
|
188
|
+
def image_digests(mvg)
|
|
189
|
+
mvg.scan(/^image over \S+ \S+ '([^']+)'$/).flatten.uniq.map do |file|
|
|
190
|
+
File.file?(file) ? Digest::SHA256.file(file).hexdigest : file
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Draws each card into the cache, a few at a time. Returns the failures.
|
|
195
|
+
def render_all(jobs, tools)
|
|
196
|
+
queue = Queue.new
|
|
197
|
+
jobs.each { |item| queue << item }
|
|
198
|
+
queue.close
|
|
199
|
+
failed = Queue.new
|
|
200
|
+
workers = [Etc.nprocessors, 4, jobs.size].min
|
|
201
|
+
Array.new(workers) do
|
|
202
|
+
Thread.new do
|
|
203
|
+
while (item = queue.pop)
|
|
204
|
+
begin
|
|
205
|
+
render(item, tools)
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
failed << [item, e.message]
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end.each(&:join)
|
|
212
|
+
Array.new(failed.size) { failed.pop }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Writes beside the cache entry and renames it into place, so an
|
|
216
|
+
# interrupted render never leaves a card a later build takes for done.
|
|
217
|
+
def render(item, tools)
|
|
218
|
+
FileUtils.mkdir_p(File.dirname(item.cached))
|
|
219
|
+
drawing = "#{item.cached}.mvg"
|
|
220
|
+
partial = item.cached.sub(/\.png\z/, ".partial.png")
|
|
221
|
+
File.write(drawing, item.mvg)
|
|
222
|
+
command = [*tools["imagemagick"], "mvg:#{drawing}", "-depth", "8", "-strip",
|
|
223
|
+
"-define", "png:exclude-chunks=date,time", "png:#{partial}"]
|
|
224
|
+
output, status = Open3.capture2e(*command)
|
|
225
|
+
unless status.success?
|
|
226
|
+
tail = output.strip.lines.last(3).join.strip
|
|
227
|
+
raise "#{File.basename(command.first)} exited with #{status.exitstatus}: #{tail}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
File.rename(partial, item.cached)
|
|
231
|
+
ensure
|
|
232
|
+
FileUtils.rm_f([drawing, partial].compact)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def publish(site, item)
|
|
236
|
+
name = "#{slug(item.page.url)}-#{item.key[0, 12]}.png"
|
|
237
|
+
file = CardFile.new(site, OUTPUT_DIR, name, item.cached)
|
|
238
|
+
site.static_files << file
|
|
239
|
+
item.page.data["datalog_social_card"] = {
|
|
240
|
+
"url" => file.url, "width" => WIDTH, "height" => HEIGHT, "alt" => item.fields["title"]
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def slug(url)
|
|
245
|
+
name = url.to_s.delete_prefix("/").delete_suffix("/").sub(/\.html\z/, "").tr("/", "-")
|
|
246
|
+
name = name.gsub(/[^\w.-]+/, "-")
|
|
247
|
+
name.empty? ? "index" : name
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def report(failed, rendered, reused)
|
|
251
|
+
failed.first(5).each do |item, message|
|
|
252
|
+
Jekyll.logger.warn "Social cards:", "could not draw the card for #{item.page.relative_path}: #{message}"
|
|
253
|
+
end
|
|
254
|
+
Jekyll.logger.warn "Social cards:", "#{failed.size - 5} more could not be drawn" if failed.size > 5
|
|
255
|
+
if (rendered + reused).positive?
|
|
256
|
+
Jekyll.logger.info "Social cards:",
|
|
257
|
+
"#{rendered} drawn, #{reused} reused from the cache"
|
|
258
|
+
end
|
|
259
|
+
{ rendered: rendered, reused: reused, failed: failed.size }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def renderable?(tools)
|
|
263
|
+
tools && tools["imagemagick"] && %w[MVG PNG].all? { |format| Array(tools["writable"]).include?(format) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def template(site, options)
|
|
267
|
+
return File.read(Template::DEFAULT) unless options["template"]
|
|
268
|
+
|
|
269
|
+
path = site_file(site, options["template"], "theme_options.social_cards.template")
|
|
270
|
+
File.read(path)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The theme's mark unless `logo` names a file (or `true`, the mark too);
|
|
274
|
+
# an SVG's text, or an image's path; nil for `false`.
|
|
275
|
+
def logo(site, options)
|
|
276
|
+
value = options.fetch("logo", true)
|
|
277
|
+
return MARK if value == true
|
|
278
|
+
return if value == false || value.to_s.strip.empty?
|
|
279
|
+
|
|
280
|
+
path = site_file(site, value, "theme_options.social_cards.logo")
|
|
281
|
+
File.extname(path).casecmp?(".svg") ? File.read(path) : path
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def site_file(site, value, setting)
|
|
285
|
+
path = inside_site(site, value)
|
|
286
|
+
return path if path
|
|
287
|
+
|
|
288
|
+
raise Jekyll::Errors::FatalException, "#{setting} names #{value}, which is not a file in the site"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# An image a template names, from the site's source.
|
|
292
|
+
def resolver(site)
|
|
293
|
+
lambda do |href|
|
|
294
|
+
inside_site(site, href) unless href.to_s.strip.empty? || href.to_s.match?(/\A[a-z]+:/i)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The file a site path names, when it is one and inside the site: a
|
|
299
|
+
# sibling directory whose name starts with the site's does not count,
|
|
300
|
+
# nor does a link inside the site to a file outside it.
|
|
301
|
+
def inside_site(site, value)
|
|
302
|
+
root = File.realpath(site.source)
|
|
303
|
+
path = File.expand_path(value.to_s.delete_prefix("/"), root)
|
|
304
|
+
return unless File.file?(path)
|
|
305
|
+
|
|
306
|
+
real = File.realpath(path)
|
|
307
|
+
real if real.start_with?("#{root}/")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def cache_root(site)
|
|
311
|
+
return site.in_cache_dir(CACHE_DIR) unless site.config["disable_disk_cache"]
|
|
312
|
+
|
|
313
|
+
@cache_root ||= Dir.mktmpdir(CACHE_DIR).tap { |dir| at_exit { FileUtils.rm_rf(dir) } }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# WCAG's contrast ratio between two #rgb or #rrggbb colours, or nil.
|
|
317
|
+
def contrast(first, second)
|
|
318
|
+
lighter, darker = [luminance(first), luminance(second)].then do |values|
|
|
319
|
+
return nil if values.any?(&:nil?)
|
|
320
|
+
|
|
321
|
+
values.sort.reverse
|
|
322
|
+
end
|
|
323
|
+
(lighter + 0.05) / (darker + 0.05)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def luminance(color)
|
|
327
|
+
hex = color.to_s.delete_prefix("#")
|
|
328
|
+
hex = hex.chars.map { |char| char * 2 }.join if hex.length == 3
|
|
329
|
+
return unless hex.match?(/\A\h{6}\z/)
|
|
330
|
+
|
|
331
|
+
channels = hex.scan(/../).map do |pair|
|
|
332
|
+
value = pair.to_i(16) / 255.0
|
|
333
|
+
value <= 0.039_28 ? value / 12.92 : ((value + 0.055) / 1.055)**2.4
|
|
334
|
+
end
|
|
335
|
+
(0.2126 * channels[0]) + (0.7152 * channels[1]) + (0.0722 * channels[2])
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def check_contrast(colors)
|
|
339
|
+
TEXT_COLORS.each do |name|
|
|
340
|
+
ratio = contrast(colors[name], colors["background"])
|
|
341
|
+
next if ratio.nil? || ratio >= 4.5
|
|
342
|
+
|
|
343
|
+
warn_once(format("the %<name>s colour %<color>s on the background %<background>s is %<ratio>.2f:1, " \
|
|
344
|
+
"below the 4.5:1 WCAG AA asks for text", name: name, color: colors[name],
|
|
345
|
+
background: colors["background"], ratio: ratio))
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def warn_once(message)
|
|
350
|
+
@warned ||= {}
|
|
351
|
+
return if @warned[message]
|
|
352
|
+
|
|
353
|
+
@warned[message] = true
|
|
354
|
+
Jekyll.logger.warn "Social cards:", message
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# A card kept in the cache, which Jekyll copies into the site from there.
|
|
358
|
+
class CardFile < Jekyll::StaticFile
|
|
359
|
+
def initialize(site, dir, name, cached)
|
|
360
|
+
@cached = cached
|
|
361
|
+
super(site, site.source, "/#{dir}", name)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def path
|
|
365
|
+
@cached
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|