Module: Theoj::MystFrontmatter

Defined in:
lib/theoj/myst_frontmatter.rb

Overview

Fill gaps in a paper's front matter from the project's myst.yml.

A NeuroLibre submission declares its title, authors, and affiliations in myst.yml for the living preprint, so paper.md need not repeat them. The deposit path still wants them in the shape paper.md front matter uses: affiliations numbered by index, and each author's affiliations as a comma-joined string of those indices.

This is a port of inara's data/filters/myst-frontmatter.lua, which is the canonical statement of the mapping; full-stack-server's api/myst_frontmatter.py mirrors the same rules on the Python side. Keep the three in step -- an author whose affiliations resolve differently depending on which service is looking is worse than one that fails outright.

The fallback is best-effort by design: a missing, unreadable, or malformed myst.yml leaves the metadata exactly as it was. It must never itself be the reason a deposit fails.

Constant Summary collapse

MYST_FILE =
"myst.yml".freeze
NAME_PARTS =

Parts of a myst.yml affiliation, joined into one name string. Department precedes institution to match the convention in existing NeuroLibre front matter.

%w[
  department institution address city region postal_code country
].freeze
ALIASES =

MyST accepts these aliases for two of the parts.

{ "institution" => "name", "region" => "state" }.freeze
SCALAR_KEYS =

Keys that fill individually, unlike authors and affiliations.

%w[title date tags bibliography].freeze

Class Method Summary collapse

Class Method Details

.config_text(paper_path, search_root: nil) ⇒ Object

The contents of the nearest myst.yml at or above the paper, or nil.

paper_path - path to the paper (paper.md, paper.tex ...). search_root - the directory the walk may climb to, normally the root of a cloned repository. When nil the walk stops at the first directory holding a .git, which is the repository root for a plain checkout; failing that, at the paper's own directory. myst.yml sits at the project root while the paper is often nested (content/paper.md), so the walk has to happen -- but it must never wander out of the tree the caller meant.



116
117
118
119
# File 'lib/theoj/myst_frontmatter.rb', line 116

def config_text(paper_path, search_root: nil)
  path = config_path(paper_path, search_root)
  path.nil? ? nil : File.read(path)
end

.merge(front_matter, myst_text) ⇒ Object

Returns the paper's metadata with any gap filled from myst.yml.

front_matter - the already-parsed paper.md front matter, or nil for a paper that has none. myst_text - the raw contents of myst.yml, or nil. Parsed here rather than in the caller so a malformed file is tolerated in one place.



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/theoj/myst_frontmatter.rb', line 48

def merge(front_matter, myst_text)
   = front_matter.is_a?(Hash) ? front_matter.dup : {}
  return  if myst_text.to_s.strip.empty?

  fallback = (parse_project(myst_text))

  # Authors and affiliations are filled as a pair. An affiliation index
  # only means something relative to the list that defines it, so mixing
  # front matter authors with myst.yml affiliations would silently attach
  # authors to the wrong institutions.
  if blank?(["authors"]) || blank?(["affiliations"])
    unless blank?(fallback["authors"])
      unless blank?(["authors"])
        warn "[neurolibre] #{MYST_FILE}: the paper names authors but no " \
             "affiliations, so its author list is replaced by the one in " \
             "#{MYST_FILE} rather than merged -- an affiliation index only " \
             "means something relative to the list that defines it."
      end
      ["authors"] = fallback["authors"]
      ["affiliations"] = fallback["affiliations"] || []
    end
  end

  SCALAR_KEYS.each do |key|
    [key] = fallback[key] if blank?([key]) && fallback.key?(key)
  end

  
end

.project_metadata(project) ⇒ Object

Returns paper metadata derived from a myst.yml project mapping, holding only the keys the project actually defines so the caller can treat it as a set of defaults. Junk input yields an empty hash.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/theoj/myst_frontmatter.rb', line 81

def (project)
  return {} unless project.is_a?(Hash)

   = {}
  ["title"] = project["title"] unless blank?(project["title"])
  unless blank?(project["date"])
    # `date: 2024-01-15` -- unquoted ISO, the MyST-canonical form --
    # parses to a Date. This value ends up in a deposit payload, and
    # nothing downstream reads it structurally, so the string form is the
    # right shape.
    date = project["date"]
    ["date"] = date.is_a?(String) ? date : date.to_s
  end
  ["tags"] = project["keywords"] unless blank?(project["keywords"])
  ["bibliography"] = project["bibliography"] unless blank?(project["bibliography"])

  affiliations, index_of = build_affiliations(project)
  authors = build_authors(project, affiliations, index_of)

  ["authors"] = authors unless authors.empty?
  ["affiliations"] = affiliations unless affiliations.empty?
  
end