Module: OKF::Markdown::Links

Defined in:
lib/okf/markdown/links.rb

Overview

Markdown cross-link extraction and resolution — the single source of truth for "which concepts does this body point at". Shared by OKF::Bundle::Graph (to build edges) and OKF::Bundle::Validator (to warn on broken cross-links, §6.1), so both agree on what counts as a link and where it resolves.

Constant Summary collapse

FENCE =
/\A(```|~~~)/.freeze
CODE_SPAN =

Inline code span: a run of N backticks, the shortest content (which may hold shorter backtick runs, per CommonMark), then a matching run of N backticks. Links inside inline code render as literal text, not edges, so these are blanked before scanning — the inline analogue of FENCE.

/(`+).*?\1/.freeze
/(?<!!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.freeze
/(?<!!)\[([^\]]*)\]\[([^\]]*)\]/.freeze
DEFINITION =

Reference definition: [label]: target (optionally followed by a "title"). A label may not begin with ^: that is a footnote definition (§5.1 per-claim attribution), which would otherwise read as a reference definition nothing ever uses.

/\A[ \t]{0,3}\[([^\^\]][^\]]*)\]:[ \t]*(\S+)/.freeze
FOOTNOTE_REFERENCE =

In-prose footnote reference [^label] — §5.1 joins it on a sources.id; (?<!!) skips an image whose alt text happens to start with a caret.

/(?<!!)\[\^([^\]\s]+)\]/.freeze
FOOTNOTE_DEFINITION =

A footnote definition line ([^label]: prose) — never a reference.

/\A[ \t]{0,3}\[\^([^\]\s]+)\]:/.freeze
SCHEME_NAME =

The URI scheme grammar, in one source string the citation item regexes compose from — schemes are case-insensitive (RFC 3986), and answering the case question in two places had HTTP:// counted as provenance by Citations and as prose by this module.

"[a-zA-Z][a-zA-Z0-9+.-]*"
SCHEME =
%r{\A#{SCHEME_NAME}://}.freeze
MAILTO =

mailto has no ://, so SCHEME cannot see it — and a scheme name is as case-insensitive here as everywhere else. This guard sat inline and case-sensitive in three places; MAILTO:user@example.md then passed both gates and resolved as a relative path.

/\Amailto:/i.freeze

Class Method Summary collapse

Class Method Details

.each_prose_line(text) ⇒ Object

Yield each line outside a fenced code block, with inline code spans blanked. Both exclusions mirror the rendered document: fenced and inline code are literal text, so a link written inside them is not a cross-link.



110
111
112
113
114
115
116
117
118
119
# File 'lib/okf/markdown/links.rb', line 110

def each_prose_line(text)
  in_fence = false
  text.each_line do |line|
    if FENCE.match?(line.strip)
      in_fence = !in_fence
      next
    end
    yield line.gsub(CODE_SPAN, " ") unless in_fence
  end
end

.extract(body) ⇒ Object

Raw link targets in body, in document order, skipping fenced code blocks and image links. Handles both inline links and standard reference-style links (resolving [text][label] against its [label]: target definition). Targets keep any #anchorresolve strips it.



52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/okf/markdown/links.rb', line 52

def extract(body)
  text = body.to_s
  definitions = reference_definitions(text)
  found = []
  each_prose_line(text) do |line|
    line.scan(INLINE_LINK) { |_text, target| found << target }
    line.scan(REFERENCE_LINK).each do |label, explicit|
      key = (explicit.empty? ? label : explicit).strip.downcase
      target = definitions[key]
      found << target if target
    end
  end
  found
end

.footnote_definitions(text) ⇒ Object

The footnote labels a body defines ([^label]: …), deduplicated. A label with a definition is a self-contained GFM content footnote; §5.1's keyed attribution never reserves the whole label space, so the provenance checks treat only undefined, unmatched labels as dangling.



98
99
100
101
102
103
104
105
# File 'lib/okf/markdown/links.rb', line 98

def footnote_definitions(text)
  labels = []
  each_prose_line(text) do |line|
    match = FOOTNOTE_DEFINITION.match(line)
    labels << match[1] if match
  end
  labels.uniq
end

.footnote_references(text) ⇒ Object

The distinct footnote labels referenced in prose (§5.1), in document order. A definition's own leading token is not a reference — a source cited only by [^a]: itself is still uncited — but the prose after it is prose like any other: [^a]: see also [^b] cites b, and skipping the whole line made that citation invisible to both provenance checks. Labels are deduplicated so one unmatched label yields one finding, not one per use.



86
87
88
89
90
91
92
# File 'lib/okf/markdown/links.rb', line 86

def footnote_references(text)
  labels = []
  each_prose_line(text) do |line|
    line.sub(FOOTNOTE_DEFINITION, "").scan(FOOTNOTE_REFERENCE) { |captures| labels << captures.first }
  end
  labels.uniq
end

.reference_definitions(text) ⇒ Object

Map every reference definition (+[label]: target+) to its target, keyed by the lowercased label. Definitions may appear anywhere, so they are collected before uses are resolved.



70
71
72
73
74
75
76
77
# File 'lib/okf/markdown/links.rb', line 70

def reference_definitions(text)
  definitions = {}
  each_prose_line(text) do |line|
    match = DEFINITION.match(line)
    definitions[match[1].strip.downcase] = match[2] if match
  end
  definitions
end

.resolve(raw, from:, bundle:) ⇒ Object

Resolve a raw link target to a bundle-relative .md path, or nil when the target is not an in-scope markdown cross-link (external scheme, mailto, non-+.md+, directory, or empty). A relative link that escapes the bundle root is returned verbatim, so the validator can flag it "not found" and the graph can drop it.

Parameters:

  • from (String)

    bundle-relative path of the source file, e.g. "features/x.md"

  • bundle (String)

    path to the bundle root



129
130
131
132
133
134
# File 'lib/okf/markdown/links.rb', line 129

def resolve(raw, from:, bundle:)
  target = raw.to_s.split("#", 2).first.to_s
  return nil unless target.end_with?(".md")

  resolve_path(raw, from: from, bundle: bundle)
end

.resolve_path(raw, from:, bundle:) ⇒ Object

The path arithmetic under #resolve without its .md gate — the resolver for §6.2's path-valued frontmatter fields (resource, sources.resource, computation, executor.resource, attester.resource), which accept any file. Body cross-links stay .md-only through #resolve; keeping the gate there and not here is what stops the two rules from trading places.



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/okf/markdown/links.rb', line 141

def resolve_path(raw, from:, bundle:)
  target = raw.to_s.split("#", 2).first.to_s
  return nil if target.empty? || target.end_with?("/")
  return nil if target.match?(SCHEME) || target.match?(MAILTO)
  return target.sub(%r{\A/+}, "") if target.start_with?("/")

  bundle_abs = File.expand_path(bundle)
  source_dir = File.dirname(File.expand_path(from, bundle_abs))
  candidate = File.expand_path(target, source_dir)
  if candidate == bundle_abs || candidate.start_with?("#{bundle_abs}/")
    Pathname.new(candidate).relative_path_from(Pathname.new(bundle_abs)).to_s
  else
    target
  end
end