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
- INLINE_LINK =
/(?<!!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/.freeze
- REFERENCE_LINK =
Reference-style use: full [text][label] or collapsed [label][]; (?<!!) skips images. group 1 = text/label, group 2 = the explicit label (empty if collapsed).
/(?<!!)\[([^\]]*)\]\[([^\]]*)\]/.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.mdthen passed both gates and resolved as a relative path. /\Amailto:/i.freeze
Class Method Summary collapse
-
.each_prose_line(text) ⇒ Object
Yield each line outside a fenced code block, with inline code spans blanked.
-
.extract(body) ⇒ Object
Raw link targets in
body, in document order, skipping fenced code blocks and image links. -
.footnote_definitions(text) ⇒ Object
The footnote labels a body defines (
[^label]: …), deduplicated. -
.footnote_references(text) ⇒ Object
The distinct footnote labels referenced in prose (§5.1), in document order.
-
.reference_definitions(text) ⇒ Object
Map every reference definition (+[label]: target+) to its target, keyed by the lowercased label.
-
.resolve(raw, from:, bundle:) ⇒ Object
Resolve a raw link target to a bundle-relative
.mdpath, ornilwhen the target is not an in-scope markdown cross-link (external scheme, mailto, non-+.md+, directory, or empty). -
.resolve_path(raw, from:, bundle:) ⇒ Object
The path arithmetic under #resolve without its
.mdgate — the resolver for §6.2's path-valued frontmatter fields (resource, sources.resource, computation, executor.resource, attester.resource), which accept any file.
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 #anchor — resolve 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.
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.(bundle) source_dir = File.dirname(File.(from, bundle_abs)) candidate = File.(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 |