datalog-theme 0.9.0 → 0.9.2

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 +30 -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 +24 -16
  49. data/_plugins/licenses.rb +15 -3
  50. data/_plugins/math_preprocessor.rb +65 -3
  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
@@ -420,13 +422,19 @@ module Jekyll
420
422
 
421
423
  def original_variant(src, width, height)
422
424
  {
423
- "url" => src,
425
+ "url" => encode_image_path(src),
424
426
  "width" => width,
425
427
  "height" => height,
426
428
  "format" => normalize_format(File.extname(src))
427
429
  }
428
430
  end
429
431
 
432
+ # These are filesystem paths, not already escaped URLs. Encode them once
433
+ # when creating the manifest so both Markdown and the include use valid srcsets.
434
+ def encode_image_path(path)
435
+ path.split("/", -1).map { |part| URI.encode_www_form_component(part).gsub("+", "%20") }.join("/")
436
+ end
437
+
430
438
  # Variant URLs are site paths. A site served below a baseurl needs it in
431
439
  # front of them, as relative_url adds it in templates.
432
440
  def build_srcset(variants, baseurl = "")
@@ -440,16 +448,16 @@ module Jekyll
440
448
  end
441
449
 
442
450
  def normalize_src(src, site)
443
- return src if src.nil? || src.empty?
444
- return src if src.start_with?("http://", "https://", "data:")
451
+ # Relative image URLs belong to the page, not to the source root. Leave
452
+ # them alone rather than offer another image's dimensions or variants.
453
+ return unless src&.start_with?("/") && !src.start_with?("//")
445
454
 
446
455
  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}"
456
+ normalized = URI::DEFAULT_PARSER.unescape(src.split(/[?#]/, 2).first)
457
+ if !baseurl.empty? && (normalized == baseurl || normalized.start_with?("#{baseurl.chomp('/')}/"))
458
+ normalized = normalized.delete_prefix(baseurl.chomp("/"))
459
+ end
460
+ normalized
453
461
  end
454
462
 
455
463
  def image_config(site)
@@ -475,7 +483,7 @@ class Jekyll::ResponsiveImageStaticFile < Jekyll::StaticFile
475
483
  def initialize(site, relative_dir, name, cached_path)
476
484
  @cached_path = cached_path
477
485
  super(site, site.source, "/#{relative_dir}", name)
478
- @url = "/#{relative_dir}/#{name}"
486
+ @url = Jekyll::ImageOptimizer.encode_image_path("/#{relative_dir}/#{name}")
479
487
  end
480
488
 
481
489
  def path
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
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
+ require "kramdown"
5
+ require "kramdown-parser-gfm"
4
6
 
5
7
  module MathPreprocessor
6
8
  DISPLAY_PATTERNS = [
@@ -46,7 +48,7 @@ module MathPreprocessor
46
48
  # so fenced blocks, highlight tags, <pre>/<code> elements and inline code
47
49
  # spans are set aside before looking for math and put back afterwards.
48
50
  CODE_PATTERNS = [
49
- /^([ \t]*)(`{3,}|~{3,})[^\n]*\n.*?(?:^\1\2[ \t]*$|\z)/m,
51
+ /^[ \t]*(?<fence>(?<marker>`|~)\k<marker>{2,})[^\n]*\n.*?(?:^[ \t]*\k<fence>\k<marker>*[ \t]*$|\z)/m,
50
52
  /\{%-?\s*highlight\b.*?\{%-?\s*endhighlight\s*-?%\}/m,
51
53
  %r{<(pre|code)\b[^>]*>.*?</\1>}mi,
52
54
  /(?<!`)(`+)(?!`)(?:(?!\n[ \t]*\n).)+?(?<!`)\1(?!`)/m
@@ -56,6 +58,31 @@ module MathPreprocessor
56
58
  # an escape: a raw NUL byte in the source stopped RuboCop from parsing the file.
57
59
  PLACEHOLDER = /\x00(\d+)\x00/
58
60
 
61
+ # Indentation alone does not identify code: list continuations and continued
62
+ # paragraphs can have the same indentation. Let the Markdown parser identify
63
+ # the code blocks, retaining their original source lines for masking.
64
+ class IndentedCodeParser < Kramdown::Parser::GFM
65
+ attr_reader :code_ranges
66
+
67
+ def initialize(source, options)
68
+ super
69
+ @code_ranges = []
70
+ end
71
+
72
+ def parse_codeblock
73
+ first = @src.current_line_number - 1
74
+ super.tap do |parsed|
75
+ @code_ranges << (first...(@src.current_line_number - 1)) if parsed
76
+ end
77
+ end
78
+
79
+ def self.ranges(source)
80
+ parser = new(source, {})
81
+ parser.parse
82
+ parser.code_ranges
83
+ end
84
+ end
85
+
59
86
  class Processor
60
87
  attr_reader :expressions
61
88
 
@@ -68,9 +95,10 @@ module MathPreprocessor
68
95
  return @content unless @content&.match?(/\$|\\\(|\\\[|\\begin\{/)
69
96
 
70
97
  @segments = []
71
- processed = CODE_PATTERNS.reduce(@content.dup) do |text, pattern|
98
+ processed = CODE_PATTERNS.reduce(mask_indented_code(@content)) do |text, pattern|
72
99
  text.gsub(pattern) { |match| mask(match) }
73
100
  end
101
+ processed = normalize_inline_dollars(processed)
74
102
  processed = apply_patterns(processed, DISPLAY_PATTERNS, display: true)
75
103
  processed = apply_patterns(processed, INLINE_PATTERNS, display: false)
76
104
  # A segment set aside can contain the placeholder of an earlier one.
@@ -85,6 +113,37 @@ module MathPreprocessor
85
113
  "\x00#{@segments.size - 1}\x00"
86
114
  end
87
115
 
116
+ def mask_indented_code(text)
117
+ return text unless text.match?(/^(?: {4}|\t)/)
118
+
119
+ lines = text.lines
120
+ IndentedCodeParser.ranges(text).reverse_each do |range|
121
+ block = lines[range].join
122
+ # Keep the final line boundary visible to subsequent block patterns.
123
+ ending = block.end_with?("\n") ? "\n" : ""
124
+ lines[range] = [mask(block.delete_suffix(ending)) + ending]
125
+ end
126
+ lines.join
127
+ end
128
+
129
+ # Kramdown accepts $$...$$ inside prose as inline math. A div there is
130
+ # escaped by Markdown and leaks its attributes into the visible article.
131
+ # Normalize before wrapping; code is already masked and standalone or
132
+ # multiline display equations retain their original delimiters.
133
+ def normalize_inline_dollars(text)
134
+ text.gsub(DISPLAY_PATTERNS.first[:regex]) do |expression|
135
+ match = Regexp.last_match
136
+ body = match[:body]
137
+ next expression if body.include?("\n") || body.strip.empty?
138
+
139
+ before = match.pre_match.split("\n", -1).last.to_s
140
+ after = match.post_match.split("\n", 2).first.to_s
141
+ next expression unless before.match?(/\S/) || after.match?(/\S/)
142
+
143
+ "$#{body.strip}$"
144
+ end
145
+ end
146
+
88
147
  def apply_patterns(text, patterns, display: false)
89
148
  patterns.reduce(text) do |result, pattern|
90
149
  result.gsub(pattern[:regex]) do |match|
@@ -118,6 +177,9 @@ module MathPreprocessor
118
177
  "aria-label" => alt_text,
119
178
  "tabindex" => "0"
120
179
  }
180
+ # Kramdown must not interpret TeX's escaped delimiters or underscores as
181
+ # Markdown inside an inline HTML span.
182
+ attributes["markdown"] = "0" unless display
121
183
 
122
184
  attribute_string = attributes.map do |key, value|
123
185
  next if value.nil? || value.strip.empty?
@@ -125,7 +187,7 @@ module MathPreprocessor
125
187
  %(#{key}="#{CGI.escapeHTML(value)}")
126
188
  end.compact.join(" ")
127
189
 
128
- inner = "#{open}#{latex}#{close}"
190
+ inner = CGI.escapeHTML("#{open}#{latex}#{close}")
129
191
  "<#{tag} #{attribute_string}>#{inner}</#{tag}>"
130
192
  end
131
193
 
@@ -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.