Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 83 additions & 14 deletions docs/src/extensions/xref_resolver.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# docs/src/extensions/xref_resolver.rb
#
# Asciidoctor preprocessor that resolves bare <<anchor,Title>> cross-doc
# references to qualified <<relpath/file.adoc#anchor,Title>> form, the way
# asciidoc-py used to do via objects/xref_<lang>.links.
# references to a link: macro pointing at the target's output file.
#
# We emit link: rather than the inter-document xref form (<<file.adoc#id>>)
# because the docs build with -a compat-mode, which disables that syntax and
# renders it as a literal in-page anchor. link: carries no auto reftext, so
# we capture the target's reftext (heading or [[id,reftext]]) for bare uses.
#
# Wire-up (in Submakefile):
# asciidoctor -r $(DOC_SRCDIR)/extensions/xref_resolver.rb \
Expand Down Expand Up @@ -44,6 +48,53 @@ class XrefResolver < Asciidoctor::Extensions::Preprocessor

CACHE_DIR = ENV['LINUXCNC_DOCS_XREF_CACHE'] || '/tmp/lcnc-xref-cache'

# Bump when the index structure changes so stale caches are ignored.
INDEX_VERSION = 3

# include::path[] directive (literal path only; skip ones with attributes).
INCLUDE_DEF = /^include::([^\[{]+)\[/

# Section title ("== Heading") / block title (".Heading"), used as reftext
# for a preceding anchor.
SECTION_TITLE = /^=+\s+(\S.*?)\s*$/
BLOCK_TITLE = /^\.(?!\.)(\S.*?)\s*$/

# Reference text carried inline on an anchor: [[id,reftext]].
def self.inline_reftext(line, anchor)
m = /\[\[#{Regexp.escape(anchor)}\s*,\s*([^\]]+?)\s*\]\]/.match(line)
m && m[1]
end

# First heading/block title after an anchor (skipping blanks and block
# attributes), used as its reftext. nil if body content comes first.
def self.lookahead_reftext(lines, i)
j = i + 1
while j < lines.length
l = lines[j]
if l.strip.empty? || l.start_with?('[')
j += 1
next
end
if (m = SECTION_TITLE.match(l)) || (m = BLOCK_TITLE.match(l))
return m[1]
end
return nil
end
nil
end

# Page an anchor renders into: walk the include graph (parents maps a file
# to its includers) up to the topmost non-Master_ ancestor. An include::d
# partial has no .html of its own; Master_* files only build the PDFs.
def self.html_master(file, parents, seen = nil)
seen ||= {}
return file if seen[file]
seen[file] = true
includers = (parents[file] || []).reject { |p| File.basename(p).start_with?('Master_') }
return file if includers.empty?
html_master(includers.first, parents, seen)
end

def self.build_index(root, exclude_re = nil)
cache_key = [root, exclude_re&.source].compact.join('|')
key = File.expand_path(cache_key)
Expand All @@ -61,31 +112,46 @@ def self.build_index(root, exclude_re = nil)
mtime_max = paths.map { |p| p.mtime.to_f }.max || 0.0
if File.exist?(cache_file)
cached = JSON.parse(File.read(cache_file)) rescue nil
if cached && cached['mtime'].to_f >= mtime_max
if cached && cached['version'] == INDEX_VERSION &&
cached['mtime'].to_f >= mtime_max
INDEX_CACHE[key] = cached['index']
return cached['index']
end
end

# idx: anchor => { 'file' => abspath, 'text' => reftext|nil }.
# parents: included file => its includers.
idx = {}
parents = Hash.new { |h, k| h[k] = [] }
paths.each do |path|
path.each_line do |line|
abspath = path.expand_path.to_s
dir = File.dirname(abspath)
lines = path.readlines
lines.each_with_index do |line, i|
if (inc = INCLUDE_DEF.match(line))
parents[File.expand_path(inc[1].strip, dir)] << abspath
next
end
line.scan(ANCHOR_DEF) do |a, b, c, d|
anchor = a || b || c || d
next unless anchor
target = path.expand_path.to_s
if idx[anchor] && idx[anchor] != target
if idx[anchor] && idx[anchor]['file'] != abspath
warn "xref_resolver: duplicate anchor '#{anchor}' in " \
"#{path} (already in #{idx[anchor]})"
"#{path} (already in #{idx[anchor]['file']})"
next
end
idx[anchor] = target
reftext = inline_reftext(line, anchor) || lookahead_reftext(lines, i)
idx[anchor] = { 'file' => abspath, 'text' => reftext }
end
end
end

# Redirect partial anchors to the page they render into.
idx.each_value { |e| e['file'] = html_master(e['file'], parents) }

FileUtils.mkdir_p(CACHE_DIR)
File.write(cache_file, JSON.dump('mtime' => mtime_max, 'index' => idx))
File.write(cache_file, JSON.dump('version' => INDEX_VERSION,
'mtime' => mtime_max, 'index' => idx))
INDEX_CACHE[key] = idx
end

Expand All @@ -101,6 +167,8 @@ def process(document, reader)
idx = self.class.build_index(File.expand_path(root), exclude_re)
src_dir = Pathname.new(File.dirname(File.expand_path(docfile)))
self_path = File.expand_path(docfile)
# Output extension (.html for the html build, .pdf for asciidoctor-pdf).
suffix = document.attr('outfilesuffix') || '.html'

# Join lines first so multi-line <<...\n...>> is matched as one.
joined = reader.lines.join("\n")
Expand All @@ -109,12 +177,13 @@ def process(document, reader)
full = Regexp.last_match(0)
anchor = Regexp.last_match(1)
tail = Regexp.last_match(2) || ''
# Pass through if line already looks qualified.
next full if full.include?('.adoc#')
target = idx[anchor]
if target && target != self_path
relpath = Pathname.new(target).relative_path_from(src_dir).to_s
"<<#{relpath}##{anchor}#{tail}>>"
if target && target['file'] != self_path
relpath = Pathname.new(target['file']).relative_path_from(src_dir).to_s
out = relpath.sub(/\.adoc\z/, suffix)
# explicit <<id,Title>> label, else captured reftext, else anchor.
text = tail.empty? ? (target['text'] || anchor) : tail.sub(/\A,\s*/, '')
"link:#{out}##{anchor}[#{text}]"
else
full
end
Expand Down
Loading