datalog-theme 0.9.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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/CITATION.cff +2 -2
  4. data/_data/i18n/en.yml +24 -0
  5. data/_data/i18n/es.yml +24 -0
  6. data/_data/i18n/pt.yml +24 -0
  7. data/_includes/components/academic-dashboard.html +4 -4
  8. data/_includes/components/author-list.html +1 -1
  9. data/_includes/components/breadcrumbs.html +6 -4
  10. data/_includes/components/citation-tools.html +5 -53
  11. data/_includes/components/comments-thread.html +1 -1
  12. data/_includes/components/contact-form.html +1 -1
  13. data/_includes/components/correction-report.html +1 -1
  14. data/_includes/components/hero.html +1 -1
  15. data/_includes/components/moderation-inbox.html +1 -1
  16. data/_includes/components/post-hero.html +2 -2
  17. data/_includes/components/reactions.html +1 -1
  18. data/_includes/components/reading-list.html +3 -0
  19. data/_includes/components/reading-mode-toggle.html +2 -0
  20. data/_includes/components/reading-state-panel.html +0 -9
  21. data/_includes/components/reading-state-resume.html +17 -0
  22. data/_includes/components/subscribe-form.html +1 -1
  23. data/_includes/components/subscription-manage.html +2 -1
  24. data/_includes/components/visualization-card.html +1 -1
  25. data/_includes/components/webmentions.html +1 -1
  26. data/_includes/footer/nav-column.html +1 -1
  27. data/_includes/footer.html +2 -2
  28. data/_includes/head.html +13 -12
  29. data/_includes/header/navigation.html +2 -2
  30. data/_includes/header.html +1 -1
  31. data/_includes/layouts/default/article.html +2 -2
  32. data/_includes/meta/math-config.html +3 -0
  33. data/_includes/meta/person-json.html +7 -7
  34. data/_includes/meta/publisher.html +1 -1
  35. data/_includes/meta/schema.html +32 -30
  36. data/_includes/meta/scholarly.html +4 -3
  37. data/_includes/post/related-posts.html +1 -1
  38. data/_layouts/dataset.html +1 -1
  39. data/_layouts/home.html +2 -2
  40. data/_layouts/notebook.html +1 -1
  41. data/_layouts/package.html +5 -5
  42. data/_layouts/portfolio.html +1 -1
  43. data/_layouts/post.html +12 -7
  44. data/_layouts/project.html +3 -3
  45. data/_layouts/research.html +7 -67
  46. data/_plugins/authors.rb +22 -4
  47. data/_plugins/citation_exports.rb +93 -0
  48. data/_plugins/image_optimizer.rb +16 -14
  49. data/_plugins/licenses.rb +15 -3
  50. data/_plugins/math_preprocessor.rb +23 -1
  51. data/_plugins/references.rb +30 -8
  52. data/_plugins/reproducibility.rb +20 -3
  53. data/_plugins/responsive_content.rb +40 -0
  54. data/_plugins/revisions.rb +1 -0
  55. data/_plugins/scholarly.rb +5 -0
  56. data/_plugins/statements.rb +2 -2
  57. data/_sass/_base.scss +5 -0
  58. data/_sass/_layout.scss +18 -1
  59. data/_sass/_mathematical.scss +11 -1
  60. data/_sass/_print.scss +16 -8
  61. data/_sass/_syntax-highlighting.scss +1 -1
  62. data/assets/js/dist/chunks/chunk-VZ5WKQVA.js +1 -0
  63. data/assets/js/dist/chunks/chunk-WIRUK3OZ.js +1 -0
  64. data/assets/js/dist/comments.js +2 -2
  65. data/assets/js/dist/contact.js +1 -1
  66. data/assets/js/dist/corrections.js +1 -1
  67. data/assets/js/dist/moderation.js +1 -1
  68. data/assets/js/dist/reactions.js +1 -1
  69. data/assets/js/dist/reading-state.js +1 -1
  70. data/assets/js/dist/sources.json +16 -16
  71. data/assets/js/dist/subscriptions.js +1 -1
  72. data/assets/js/dist/visualizations.js +2 -2
  73. data/assets/js/dist/webmentions.js +1 -1
  74. data/lib/datalog/theme/version.rb +1 -1
  75. metadata +7 -4
  76. data/assets/js/dist/chunks/chunk-2DYDWUFX.js +0 -1
  77. data/assets/js/dist/chunks/chunk-V7734B2G.js +0 -1
@@ -38,7 +38,7 @@ schema_type: ScholarlyArticle
38
38
  {% assign show_publications = page.show_publications | default: false %}
39
39
 
40
40
  <div class="research-wrapper" itemscope itemtype="https://schema.org/ScholarlyArticle">
41
- <meta itemprop="headline" content="{{ page.title }}" />
41
+ <meta itemprop="headline" content="{{ page.title | escape }}" />
42
42
  {% if page.date %}
43
43
  <meta itemprop="datePublished" content="{{ page.date | date_to_xmlschema }}" />
44
44
  {% endif %}
@@ -56,7 +56,7 @@ schema_type: ScholarlyArticle
56
56
  {% endif %}
57
57
 
58
58
  <header class="research-header" aria-labelledby="research-title">
59
- <h1 id="research-title" class="research-title">{{ page.title }}</h1>
59
+ <h1 id="research-title" class="research-title">{{ page.title | escape }}</h1>
60
60
 
61
61
  <div class="research-authors" aria-label="Authors">
62
62
  <h2 class="visually-hidden">Authors</h2>
@@ -207,67 +207,7 @@ schema_type: ScholarlyArticle
207
207
  </section>
208
208
  {% endif %}
209
209
 
210
- <section class="research-citation" aria-labelledby="citation-exports-heading">
211
- <h2 id="citation-exports-heading">Citation exports</h2>
212
- {% capture bibtex_authors %}
213
- {% for author in authors %}
214
- {% assign author_name = author.name | default: author %}
215
- {{ author_name | strip }}{% unless forloop.last %} and {% endunless %}
216
- {% endfor %}
217
- {% endcapture %}
218
- {% capture bibtex_entry %}
219
- @article{ {{ citation_key }},
220
- title = { {{ page.title | strip }} },
221
- author = { {{ bibtex_authors | strip }} },
222
- {% if publication_name %}journal = { {{ publication_name | strip }} },{% endif %}
223
- {% if page.date %}year = { {{ page.date | date: '%Y' }} },{% endif %}
224
- {% if doi %}doi = { {{ doi }} },{% endif %}
225
- {% if page.publisher %}publisher = { {{ page.publisher }} },{% endif %}
226
- {% if page.volume %}volume = { {{ page.volume }} },{% endif %}
227
- {% if page.number %}number = { {{ page.number }} },{% endif %}
228
- {% if page.pages %}pages = { {{ page.pages }} },{% endif %}
229
- }
230
- {% endcapture %}
231
- {% capture ris_entry %}
232
- TY - JOUR
233
- TI - {{ page.title | strip }}
234
- {% if publication_name %}JO - {{ publication_name | strip }}
235
- {% endif %}
236
- {% if page.date %}PY - {{ page.date | date: '%Y/%m/%d' }}
237
- {% endif %}
238
- {% for author in authors %}{% assign author_name = author.name | default: author %}AU - {{ author_name | strip }}
239
- {% endfor %}
240
- {% if doi %}DO - {{ doi }}
241
- {% endif %}
242
- ER -
243
- {% endcapture %}
244
- {% capture endnote_entry %}
245
- %0 Journal Article
246
- %T {{ page.title | strip }}
247
- {% for author in authors %}{% assign author_name = author.name | default: author %}%A {{ author_name | strip }}
248
- {% endfor %}
249
- {% if publication_name %}%J {{ publication_name | strip }}
250
- {% endif %}
251
- {% if page.date %}%D {{ page.date | date: '%Y' }}
252
- {% endif %}
253
- {% if doi %}%R {{ doi }}
254
- {% endif %}
255
- {% endcapture %}
256
- <div class="citation-export-grid">
257
- <article>
258
- <h3>BibTeX</h3>
259
- <textarea readonly aria-label="BibTeX citation">{{ bibtex_entry | strip }}</textarea>
260
- </article>
261
- <article>
262
- <h3>RIS</h3>
263
- <textarea readonly aria-label="RIS citation">{{ ris_entry | strip }}</textarea>
264
- </article>
265
- <article>
266
- <h3>EndNote</h3>
267
- <textarea readonly aria-label="EndNote citation">{{ endnote_entry | strip }}</textarea>
268
- </article>
269
- </div>
270
- </section>
210
+ {% include components/citation-tools.html authors=authors journal=publication_name %}
271
211
 
272
212
  <section class="research-body" itemprop="articleBody">
273
213
  {% if methodology_content %}
@@ -347,7 +287,7 @@ ER -
347
287
  <li>
348
288
  <article class="appendix-item">
349
289
  {% if appendix.title %}
350
- <h3>{{ appendix.title }}</h3>
290
+ <h3>{{ appendix.title | escape }}</h3>
351
291
  {% endif %}
352
292
  {% if appendix.content %}
353
293
  {{ appendix.content | markdownify }}
@@ -393,7 +333,7 @@ ER -
393
333
  {% for publication in group_items %}
394
334
  <li class="publication-list__item">
395
335
  <article>
396
- <h4 class="publication-title">{{ publication.title }}</h4>
336
+ <h4 class="publication-title">{{ publication.title | escape }}</h4>
397
337
  {% assign publication_authors = publication.authors | arrayify %}
398
338
  {% if publication_authors and publication_authors.size > 0 %}
399
339
  <p class="publication-authors">{{ publication_authors | join: ', ' }}</p>
@@ -416,7 +356,7 @@ ER -
416
356
  {% for publication in publication_entries %}
417
357
  <li class="publication-list__item">
418
358
  <article>
419
- <h4 class="publication-title">{{ publication.title }}</h4>
359
+ <h4 class="publication-title">{{ publication.title | escape }}</h4>
420
360
  {% assign publication_authors = publication.authors | arrayify %}
421
361
  {% if publication_authors and publication_authors.size > 0 %}
422
362
  <p class="publication-authors">{{ publication_authors | join: ', ' }}</p>
@@ -456,7 +396,7 @@ ER -
456
396
  <li>
457
397
  <article class="version-entry">
458
398
  <header>
459
- <strong>{{ version.version | default: version.title | default: version }}</strong>
399
+ <strong>{{ version.version | default: version.title | default: version | escape }}</strong>
460
400
  {% if version.date %}<span class="version-date">{{ version.date | localize_date: date_format }}</span>{% endif %}
461
401
  </header>
462
402
  {% if version.notes %}
data/_plugins/authors.rb CHANGED
@@ -27,13 +27,13 @@ module Datalog
27
27
  def authors(page, site)
28
28
  entries = entries(value(page, "authors"))
29
29
  entries = entries(value(page, "author")) if entries.empty?
30
- records = entries.filter_map { |entry| resolve(entry, site) }
30
+ records = entries.filter_map { |entry| resolve(entry, site, page) }
31
31
  records = [site_author(site)].compact if records.empty?
32
32
  apply_page_affiliation(records, page)
33
33
  end
34
34
 
35
35
  def contributors(page, site)
36
- entries(value(page, "contributors")).filter_map { |entry| resolve(entry, site) }
36
+ entries(value(page, "contributors")).filter_map { |entry| resolve(entry, site, page) }
37
37
  end
38
38
 
39
39
  # A list, one entry, or names in a string separated by semicolons.
@@ -46,9 +46,15 @@ module Datalog
46
46
  end
47
47
 
48
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)
49
+ def resolve(entry, site, page = {})
50
50
  own = entry.is_a?(Hash) ? present(entry) : {}
51
- key = own["id"] || own["name"] || entry.to_s.strip
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
52
58
  record = known(key, site).merge(own)
53
59
  record["name"] ||= key
54
60
  return if record["name"].to_s.strip.empty?
@@ -63,6 +69,11 @@ module Datalog
63
69
  record = value(data, key.to_s)
64
70
  return present(record) if record.is_a?(Hash)
65
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
+
66
77
  author = site_author_profile(site)
67
78
  author && author["name"] == key ? author : {}
68
79
  end
@@ -85,6 +96,13 @@ module Datalog
85
96
  record["image"] ||= record["avatar"] || record["photo"]
86
97
  record["orcid"] = "https://orcid.org/#{record['orcid']}" if record["orcid"].to_s.match?(ORCID_ID)
87
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
88
106
  record["same_as"] = profiles(record)
89
107
  record
90
108
  end
@@ -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)
@@ -8,6 +8,7 @@ require "nokogiri"
8
8
  require "open3"
9
9
  require "tmpdir"
10
10
  require "tempfile"
11
+ require "uri"
11
12
 
12
13
  module Jekyll
13
14
  module ImageOptimizer
@@ -76,18 +77,19 @@ module Jekyll
76
77
  img["loading"] ||= "lazy"
77
78
  end
78
79
  img["decoding"] ||= "async"
79
-
80
80
  normalized_src = normalize_src(img["src"], site)
81
81
  picture_entry = manifest[normalized_src]
82
82
 
83
83
  if picture_entry
84
84
  offer_variants(img, picture_entry, image_config, baseurl)
85
- img["width"] ||= picture_entry["width"].to_s if picture_entry["width"]
86
- img["height"] ||= picture_entry["height"].to_s if picture_entry["height"]
85
+ if !img["width"] && !img["height"] && picture_entry["width"] && picture_entry["height"]
86
+ img["width"] = picture_entry["width"].to_s
87
+ img["height"] = picture_entry["height"].to_s
88
+ end
87
89
  else
88
- next if img["width"] && img["height"]
90
+ next if img["width"] || img["height"]
89
91
 
90
- source = image_source_path(site, img["src"])
92
+ source = image_source_path(site, normalized_src)
91
93
  next unless source && File.exist?(source)
92
94
 
93
95
  width, height = FastImage.size(source)
@@ -270,7 +272,7 @@ module Jekyll
270
272
  relative_path = static_file.relative_path.sub(%r{^/}, "")
271
273
  dir = File.dirname(relative_path)
272
274
  responsive_dir = dir == "." ? "responsive" : File.join(dir, "responsive")
273
- base = File.basename(relative_path, File.extname(relative_path))
275
+ base = File.basename(relative_path)
274
276
  Jekyll::ResponsiveImageStaticFile.new(site, responsive_dir, "#{base}-#{target_width}w.#{extension_for(format)}",
275
277
  cached)
276
278
  end
@@ -440,16 +442,16 @@ module Jekyll
440
442
  end
441
443
 
442
444
  def normalize_src(src, site)
443
- return src if src.nil? || src.empty?
444
- return src if src.start_with?("http://", "https://", "data:")
445
+ # Relative image URLs belong to the page, not to the source root. Leave
446
+ # them alone rather than offer another image's dimensions or variants.
447
+ return unless src&.start_with?("/") && !src.start_with?("//")
445
448
 
446
449
  baseurl = site&.baseurl.to_s
447
- normalized = src.dup
448
- normalized = normalized.delete_prefix(baseurl) if baseurl && !baseurl.empty? && normalized.start_with?(baseurl)
449
- site_url = site&.config&.fetch("url", "").to_s
450
- normalized = normalized.delete_prefix(site_url) if !site_url.empty? && normalized.start_with?(site_url)
451
- normalized = normalized.gsub(%r{^/+}, "")
452
- "/#{normalized}"
450
+ normalized = URI::DEFAULT_PARSER.unescape(src.split(/[?#]/, 2).first)
451
+ if !baseurl.empty? && (normalized == baseurl || normalized.start_with?("#{baseurl.chomp('/')}/"))
452
+ normalized = normalized.delete_prefix(baseurl.chomp("/"))
453
+ end
454
+ normalized
453
455
  end
454
456
 
455
457
  def image_config(site)
data/_plugins/licenses.rb CHANGED
@@ -76,16 +76,27 @@ module Datalog
76
76
  # The page's own value, else the site's; `false` declines the site's.
77
77
  def setting(page, page_key, site, site_key)
78
78
  value = Authors.value(page, page_key)
79
- return value unless value.nil?
80
- return if OWN_LICENSE.include?(Authors.value(page, "collection").to_s)
79
+ own_only = OWN_LICENSE.include?(Authors.value(page, "collection").to_s)
80
+ fallback = Authors.value(site, site_key) unless own_only
81
+ return fallback if value.nil? || value == true
81
82
 
82
- Authors.value(site, site_key)
83
+ if value.is_a?(Hash) && !Authors.present(value).keys.intersect?(%w[id name url])
84
+ defaults = fallback.is_a?(Hash) ? Authors.present(fallback) : { "name" => fallback }
85
+ return defaults.merge(Authors.present(value))
86
+ end
87
+
88
+ value
83
89
  end
84
90
 
85
91
  def resolve(value, page, site)
86
92
  return if value.nil? || value == false || (value.is_a?(String) && value.strip.empty?)
93
+ unless value.is_a?(String) || value.is_a?(Hash)
94
+ raise Jekyll::Errors::FatalException, "#{Authors.value(page, 'path')} license must be a name, a map or false"
95
+ end
87
96
 
88
97
  given = value.is_a?(Hash) ? Authors.present(value) : { "name" => value.to_s.strip }
98
+ return unless given.keys.intersect?(%w[id name url])
99
+
89
100
  id = LOOKUP[key(given["id"] || given["name"])]
90
101
  name, url = KNOWN[id] if id
91
102
  # A name that is not itself an identifier is the label the page chose.
@@ -114,6 +125,7 @@ module Datalog
114
125
 
115
126
  text = value.to_s.strip
116
127
  return text.to_i if text.match?(/\A\d{4}\z/)
128
+ return text if text.match?(/\A\d{4}[-–]\d{4}\z/)
117
129
 
118
130
  Time.parse(text).year unless text.empty?
119
131
  rescue ArgumentError
@@ -71,6 +71,7 @@ module MathPreprocessor
71
71
  processed = CODE_PATTERNS.reduce(@content.dup) do |text, pattern|
72
72
  text.gsub(pattern) { |match| mask(match) }
73
73
  end
74
+ processed = normalize_inline_dollars(processed)
74
75
  processed = apply_patterns(processed, DISPLAY_PATTERNS, display: true)
75
76
  processed = apply_patterns(processed, INLINE_PATTERNS, display: false)
76
77
  # A segment set aside can contain the placeholder of an earlier one.
@@ -85,6 +86,24 @@ module MathPreprocessor
85
86
  "\x00#{@segments.size - 1}\x00"
86
87
  end
87
88
 
89
+ # Kramdown accepts $$...$$ inside prose as inline math. A div there is
90
+ # escaped by Markdown and leaks its attributes into the visible article.
91
+ # Normalize before wrapping; code is already masked and standalone or
92
+ # multiline display equations retain their original delimiters.
93
+ def normalize_inline_dollars(text)
94
+ text.gsub(DISPLAY_PATTERNS.first[:regex]) do |expression|
95
+ match = Regexp.last_match
96
+ body = match[:body]
97
+ next expression if body.include?("\n") || body.strip.empty?
98
+
99
+ before = match.pre_match.split("\n", -1).last.to_s
100
+ after = match.post_match.split("\n", 2).first.to_s
101
+ next expression unless before.match?(/\S/) || after.match?(/\S/)
102
+
103
+ "$#{body.strip}$"
104
+ end
105
+ end
106
+
88
107
  def apply_patterns(text, patterns, display: false)
89
108
  patterns.reduce(text) do |result, pattern|
90
109
  result.gsub(pattern[:regex]) do |match|
@@ -118,6 +137,9 @@ module MathPreprocessor
118
137
  "aria-label" => alt_text,
119
138
  "tabindex" => "0"
120
139
  }
140
+ # Kramdown must not interpret TeX's escaped delimiters or underscores as
141
+ # Markdown inside an inline HTML span.
142
+ attributes["markdown"] = "0" unless display
121
143
 
122
144
  attribute_string = attributes.map do |key, value|
123
145
  next if value.nil? || value.strip.empty?
@@ -125,7 +147,7 @@ module MathPreprocessor
125
147
  %(#{key}="#{CGI.escapeHTML(value)}")
126
148
  end.compact.join(" ")
127
149
 
128
- inner = "#{open}#{latex}#{close}"
150
+ inner = CGI.escapeHTML("#{open}#{latex}#{close}")
129
151
  "<#{tag} #{attribute_string}>#{inner}</#{tag}>"
130
152
  end
131
153
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
+ require "strscan"
4
5
 
5
6
  module Datalog
6
7
  # Numbered figures and tables, and references to them, within a page:
@@ -41,7 +42,8 @@ module Datalog
41
42
  ID = /\A[A-Za-z][\w.:-]*\z/
42
43
  # A label given in place of the number, as in "Theorem A".
43
44
  CUSTOM_LABEL = /\A[[:alnum:]][[:alnum:].'*-]*\z/
44
- TARGET = /data-ref-target="([^"]+)" data-ref-kind="([a-z]+)"(?: data-ref-number="([^"]+)")?/
45
+ TARGET = /data-ref-target="([^"]+)"[ ]data-ref-kind="([a-z]+)"(?:[ ]data-ref-number="([^"]+)")?
46
+ ([ ]data-ref-numbered="true")?/x
45
47
  # A label ends with a full stop unless the tag chose another ending.
46
48
  LABEL = %r{<span class="datalog-ref-label" data-ref-for="([^"]+)"(?: data-ref-end="([^"]*)")?></span>}
47
49
  LINK = %r{<a class="datalog-ref" href="#([^"]+)" data-ref="\1">[^<]*</a>}
@@ -51,7 +53,12 @@ module Datalog
51
53
  content = document.content
52
54
  return unless content&.include?("data-ref")
53
55
 
54
- targets = targets(content.scan(TARGET), document)
56
+ # Feeds and listings embed content that has already been numbered on its
57
+ # own page. Only process this document's remaining placeholders.
58
+ targets = targets(content.scan(TARGET).reject { |entry| entry[3] }, document)
59
+ content = content.gsub(TARGET) do |target|
60
+ Regexp.last_match(4) ? target : %(#{target} data-ref-numbered="true")
61
+ end
55
62
  content = content.gsub(LABEL) do
56
63
  id, ending = Regexp.last_match.captures
57
64
  text = "#{CGI.escapeHTML(targets.fetch(id))}#{ending || '.'}"
@@ -113,7 +120,8 @@ module Datalog
113
120
 
114
121
  content.gsub(LINK) do
115
122
  id = Regexp.last_match(1)
116
- %(<a class="datalog-ref" href="##{id}" data-ref="#{id}">#{CGI.escapeHTML(targets[id])}</a>)
123
+ label = CGI.escapeHTML(targets[id])
124
+ %(<a class="datalog-ref" href="##{id}" data-ref="#{id}" data-ref-numbered="true">#{label}</a>)
117
125
  end
118
126
  end
119
127
 
@@ -140,10 +148,24 @@ module Datalog
140
148
  end
141
149
 
142
150
  # key="value", key='value' or key=variable.
143
- def attributes(markup, context)
144
- markup.scan(/(\w+)=(?:"([^"]*)"|'([^']*)'|([\w.\[\]-]+))/).to_h do |key, double, single, variable|
145
- [key, double || single || context[variable].to_s]
151
+ def attributes(markup, context, tag = "reference")
152
+ scanner = StringScanner.new(markup)
153
+ result = {}
154
+ until scanner.eos?
155
+ scanner.skip(/\s+/)
156
+ break if scanner.eos?
157
+
158
+ unless scanner.scan(/(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([\w.\[\]-]+))(?=\s|\z)/m)
159
+ page = context.registers[:page] || {}
160
+ raise Liquid::ArgumentError,
161
+ "#{page['path'] || page['url']} {% #{tag} %} has invalid attributes near #{scanner.rest.inspect}"
162
+ end
163
+ # Ruby 3.2's StringScanner#captures returns "" for unmatched groups.
164
+ # values_at preserves nil so quoted literals stay distinct from variables.
165
+ key, double, single, variable = scanner.values_at(1, 2, 3, 4)
166
+ result[key] = variable ? context[variable].to_s : (double || single).gsub(/\\([\\"'])/, '\\1')
146
167
  end
168
+ result
147
169
  end
148
170
 
149
171
  def markdown(context, text)
@@ -166,7 +188,7 @@ module Datalog
166
188
  end
167
189
 
168
190
  def render(context)
169
- attributes = References.attributes(@markup, context)
191
+ attributes = References.attributes(@markup, context, "figure")
170
192
  id = References.validate_id(attributes["id"], "figure")
171
193
  src = attributes["src"].to_s
172
194
  raise Liquid::ArgumentError, "{% figure id=\"#{id}\" %} needs a src, the image it shows" if src.empty?
@@ -193,7 +215,7 @@ module Datalog
193
215
 
194
216
  # The body holds the caption and then a Markdown table.
195
217
  def render(context)
196
- id = References.validate_id(References.attributes(@markup, context)["id"], "table")
218
+ id = References.validate_id(References.attributes(@markup, context, "table")["id"], "table")
197
219
  html = References.markdown(context, super)
198
220
  tables = html.scan(/<table\b/).size
199
221
  unless tables == 1
@@ -96,9 +96,22 @@ module Datalog
96
96
  def host_url(url, kind, *parts)
97
97
  return unless url
98
98
 
99
- host = URI.parse(url).host.to_s.sub(/\Awww\./, "")
99
+ uri = URI.parse(url)
100
+ host = uri.host.to_s.downcase.sub(/\Awww\./, "")
100
101
  pattern = HOSTS.dig(host, kind)
101
- "#{url.chomp('/')}#{format(pattern, *parts)}" if pattern
102
+ return unless pattern
103
+
104
+ root = if host == "github.com"
105
+ uri.path.split("/").reject(&:empty?).first(2).join("/")
106
+ else
107
+ uri.path.sub(%r{/-/.*}, "").delete_prefix("/")
108
+ end
109
+ root = root.chomp("/").delete_suffix(".git")
110
+ # A ref is one route argument; a file retains its directory separators.
111
+ encoded = parts.each_with_index.map do |part, index|
112
+ index.zero? ? URI.encode_www_form_component(part).gsub("+", "%20") : encode_path(part)
113
+ end
114
+ "#{uri.scheme}://#{host}/#{root}#{format(pattern, *encoded)}"
102
115
  rescue URI::InvalidURIError
103
116
  nil
104
117
  end
@@ -125,7 +138,11 @@ module Datalog
125
138
 
126
139
  def doi_url(doi)
127
140
  bare = bare_doi(doi)
128
- "https://doi.org/#{bare}" if bare
141
+ "https://doi.org/#{encode_path(bare)}" if bare
142
+ end
143
+
144
+ def encode_path(value)
145
+ value.to_s.split("/", -1).map { |part| URI.encode_www_form_component(part).gsub("+", "%20") }.join("/")
129
146
  end
130
147
 
131
148
  # What a link reads: the address without its scheme, or the site path.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Datalog
6
+ # A table keeps its native semantics and desktop width while its enclosing
7
+ # region can scroll with either touch or the keyboard on a narrow screen.
8
+ module ResponsiveContent
9
+ module_function
10
+
11
+ def process(document)
12
+ return unless document.output_ext == ".html" && document.content.to_s.include?("<table")
13
+
14
+ html = Nokogiri::HTML5.fragment(document.content)
15
+ tables = html.css("table").to_a
16
+ tables.reject! do |table|
17
+ table["class"].to_s.split.include?("rouge-table") || table.ancestors(".content-table").any?
18
+ end
19
+ return if tables.empty?
20
+
21
+ locale = I18n.locale_code(document.site, document.data["lang"])
22
+ label = I18n.lookup(document.site, locale, "references.table") || "Table"
23
+ tables.each_with_index do |table, index|
24
+ wrapper = Nokogiri::XML::Node.new("div", html)
25
+ wrapper["class"] = "content-table"
26
+ wrapper["role"] = "region"
27
+ wrapper["tabindex"] = "0"
28
+ caption = table.at_css("caption")&.text.to_s.strip
29
+ wrapper["aria-label"] = caption.empty? ? "#{label} #{index + 1}" : caption
30
+ table.add_previous_sibling(wrapper)
31
+ wrapper.add_child(table)
32
+ end
33
+ document.content = html.to_html
34
+ end
35
+ end
36
+ end
37
+
38
+ Jekyll::Hooks.register %i[pages documents], :post_convert do |document|
39
+ Datalog::ResponsiveContent.process(document)
40
+ end
@@ -34,6 +34,7 @@ module Datalog
34
34
 
35
35
  def normalize!(document)
36
36
  data = document.data
37
+ data["last_modified_at"] ||= to_time(data["updated"]) if data["updated"]
37
38
  return unless data.key?("revisions")
38
39
 
39
40
  revisions = entries(data["revisions"], document)
@@ -40,6 +40,11 @@ module Datalog
40
40
  end
41
41
 
42
42
  module ScholarlyFilters
43
+ # JSON embedded in a script must not contain an HTML closing tag.
44
+ def jsonify_script(value)
45
+ jsonify(value).gsub("<", '\\u003c').gsub("\u2028", '\\u2028').gsub("\u2029", '\\u2029')
46
+ end
47
+
43
48
  # A Liquid filter's name cannot end with "?".
44
49
  def scholarly(page) # rubocop:disable Naming/PredicateMethod
45
50
  Scholarly.scholarly?(page, @context["site"])
@@ -28,7 +28,7 @@ module Datalog
28
28
  end
29
29
 
30
30
  def render(context)
31
- attributes = References.attributes(@markup, context)
31
+ attributes = References.attributes(@markup, context, @kind)
32
32
  id = References.validate_id(attributes["id"], @kind)
33
33
  custom = attributes["label"]
34
34
  References.validate_label(custom, @kind)
@@ -52,7 +52,7 @@ module Datalog
52
52
  end
53
53
 
54
54
  def render(context)
55
- attributes = References.attributes(@markup, context)
55
+ attributes = References.attributes(@markup, context, "proof")
56
56
  target = attributes["for"]
57
57
  body = References.markdown(context, super)
58
58
  heading_id = "proof-#{target ? References.validate_id(target, 'proof') : Statements.next_proof(context)}"
data/_sass/_base.scss CHANGED
@@ -47,6 +47,11 @@ iframe {
47
47
  max-width: 100%;
48
48
  }
49
49
 
50
+ img,
51
+ video {
52
+ height: auto;
53
+ }
54
+
50
55
  a {
51
56
  color: $color-accent-primary;
52
57
  text-decoration-thickness: 0.12em;