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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -0
  3. data/CITATION.cff +2 -2
  4. data/README.md +4 -2
  5. data/_data/i18n/en.yml +55 -3
  6. data/_data/i18n/es.yml +55 -3
  7. data/_data/i18n/pt.yml +55 -3
  8. data/_includes/analytics/dashboard.html +24 -9
  9. data/_includes/components/academic-dashboard.html +168 -133
  10. data/_includes/components/author-bio.html +7 -5
  11. data/_includes/components/author-list.html +6 -5
  12. data/_includes/components/citation-tools.html +2 -1
  13. data/_includes/components/content-provenance.html +19 -1
  14. data/_includes/components/correction-report.html +15 -8
  15. data/_includes/components/hero.html +22 -15
  16. data/_includes/components/package-index.html +121 -0
  17. data/_includes/components/package-install.html +27 -50
  18. data/_includes/components/post-list.html +14 -0
  19. data/_includes/components/responsive-image.html +15 -21
  20. data/_includes/footer.html +1 -1
  21. data/_includes/head.html +70 -11
  22. data/_includes/layouts/default/article.html +76 -46
  23. data/_includes/meta/dataset-json.html +171 -0
  24. data/_includes/meta/math-config.html +15 -1
  25. data/_includes/meta/package-json.html +69 -0
  26. data/_includes/meta/person-json.html +36 -13
  27. data/_includes/meta/publisher.html +15 -0
  28. data/_includes/meta/schema.html +108 -23
  29. data/_includes/meta/scholarly.html +10 -5
  30. data/_includes/post/related-posts.html +17 -43
  31. data/_includes/scripts.html +2 -2
  32. data/_includes/search/index-data.json +10 -1
  33. data/_includes/search/page.html +11 -6
  34. data/_layouts/archive.html +67 -0
  35. data/_layouts/dataset.html +2 -1
  36. data/_layouts/default.html +13 -11
  37. data/_layouts/docs.html +53 -0
  38. data/_layouts/home.html +30 -1
  39. data/_layouts/notebook.html +5 -1
  40. data/_layouts/package.html +38 -21
  41. data/_layouts/portfolio.html +1 -0
  42. data/_layouts/post.html +21 -62
  43. data/_layouts/project.html +2 -1
  44. data/_layouts/research.html +14 -2
  45. data/_plugins/analytics_dashboard.rb +4 -1
  46. data/_plugins/archive.rb +114 -0
  47. data/_plugins/authors.rb +50 -1
  48. data/_plugins/citation_exports.rb +12 -1
  49. data/_plugins/config_validator.rb +77 -2
  50. data/_plugins/correction_fallback.rb +110 -0
  51. data/_plugins/image_optimizer.rb +129 -22
  52. data/_plugins/math_preprocessor.rb +7 -48
  53. data/_plugins/notebook_converter.rb +44 -5
  54. data/_plugins/packages.rb +84 -0
  55. data/_plugins/page_dates.rb +37 -0
  56. data/_plugins/references.rb +16 -2
  57. data/_plugins/related_posts.rb +149 -0
  58. data/_plugins/search_sections.rb +110 -0
  59. data/_plugins/site_identity.rb +42 -0
  60. data/_plugins/social_cards.rb +17 -0
  61. data/_sass/_academic-dashboard.scss +18 -30
  62. data/_sass/_base.scss +2 -0
  63. data/_sass/_components.scss +21 -23
  64. data/_sass/_docs.scss +143 -0
  65. data/_sass/_features.scss +6 -0
  66. data/_sass/_layout.scss +65 -6
  67. data/_sass/_mathematical.scss +61 -19
  68. data/_sass/_notebooks.scss +1 -32
  69. data/_sass/_package-docs.scss +6 -9
  70. data/_sass/_post-components.scss +23 -23
  71. data/_sass/_print.scss +1 -1
  72. data/_sass/_search-page.scss +75 -0
  73. data/_sass/_search.scss +17 -26
  74. data/_sass/_syntax-highlighting.scss +23 -1
  75. data/_sass/_theme.scss +1 -0
  76. data/_sass/_utilities.scss +87 -40
  77. data/_sass/_variables.scss +31 -29
  78. data/assets/css/main.scss +2 -1
  79. data/assets/js/dist/academic.js +1 -1
  80. data/assets/js/dist/analytics-dashboard.js +1 -1
  81. data/assets/js/dist/chunks/chunk-TNVD6UAM.js +1 -0
  82. data/assets/js/dist/comments.js +1 -1
  83. data/assets/js/dist/contact.js +1 -1
  84. data/assets/js/dist/core.js +1 -1
  85. data/assets/js/dist/corrections.js +5 -1
  86. data/assets/js/dist/loader.js +1 -1
  87. data/assets/js/dist/math.js +2 -1
  88. data/assets/js/dist/moderation.js +1 -1
  89. data/assets/js/dist/reactions.js +1 -1
  90. data/assets/js/dist/search.js +1 -1
  91. data/assets/js/dist/sources.json +20 -16
  92. data/assets/js/dist/subscriptions.js +1 -1
  93. data/assets/js/loader.js +6 -2
  94. data/lib/datalog/audit/checks.rb +269 -0
  95. data/lib/datalog/audit/known_keys.rb +61 -0
  96. data/lib/datalog/audit/site_reader.rb +118 -0
  97. data/lib/datalog/audit/source_file.rb +85 -0
  98. data/lib/datalog/audit.rb +145 -0
  99. data/lib/datalog/citations/bibtex.rb +200 -0
  100. data/lib/datalog/citations/entry.rb +333 -0
  101. data/lib/datalog/citations/markup.rb +79 -0
  102. data/lib/datalog/cli.rb +164 -89
  103. data/lib/datalog/critical_css.rb +126 -21
  104. data/lib/datalog/latex_speech/words.json +88 -0
  105. data/lib/datalog/latex_speech.rb +355 -0
  106. data/lib/datalog/packages/command.rb +51 -0
  107. data/lib/datalog/packages/refresh.rb +187 -0
  108. data/lib/datalog/packages.rb +177 -0
  109. data/lib/datalog/plugin_system.rb +5 -0
  110. data/lib/datalog/plugins/citations.rb +325 -109
  111. data/lib/datalog/site_config.rb +26 -0
  112. data/lib/datalog/social_cards/font.rb +286 -0
  113. data/lib/datalog/social_cards/fonts/IBMPlexSans-Regular.ttf +0 -0
  114. data/lib/datalog/social_cards/fonts/IBMPlexSerif-SemiBold.ttf +0 -0
  115. data/lib/datalog/social_cards/fonts/OFL.txt +92 -0
  116. data/lib/datalog/social_cards/geometry.rb +51 -0
  117. data/lib/datalog/social_cards/outline.rb +54 -0
  118. data/lib/datalog/social_cards/plain_text.rb +258 -0
  119. data/lib/datalog/social_cards/template.rb +322 -0
  120. data/lib/datalog/social_cards/template.svg +17 -0
  121. data/lib/datalog/social_cards/typesetter.rb +178 -0
  122. data/lib/datalog/social_cards.rb +369 -0
  123. data/lib/datalog/theme/updater.rb +307 -0
  124. data/lib/datalog/theme/version.rb +1 -1
  125. metadata +44 -7
  126. data/_includes/components/enhanced-code-block.html +0 -212
  127. data/_includes/components/performance-monitor.html +0 -170
  128. data/_includes/components/viz-table-fallback.html +0 -19
  129. data/_plugins/datalog_bibliography.rb +0 -21
  130. 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