From d5dafc4797187114334960932ec28ae3a79fd668 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:52:10 +0800 Subject: [PATCH 1/2] docs(xref): emit link: macro so cross-doc refs survive compat-mode The resolver rewrote cross-doc <> to <>, but -a compat-mode (set for the legacy asciidoc-py source) disables that inter-document syntax and parses it as an in-page anchor, so links landed on the current page (tools.html#tutorial.adoc#cha:hal-tutorial). Emit a link: macro instead, which compat-mode leaves intact, with .adoc rewritten to outfilesuffix. link: has no auto reftext, so the index now also captures each target's reftext for bare <> uses. Cache v2. --- docs/src/extensions/xref_resolver.rb | 70 ++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/docs/src/extensions/xref_resolver.rb b/docs/src/extensions/xref_resolver.rb index 0cd6a75921a..5bddfd0644b 100644 --- a/docs/src/extensions/xref_resolver.rb +++ b/docs/src/extensions/xref_resolver.rb @@ -1,8 +1,12 @@ # docs/src/extensions/xref_resolver.rb # # Asciidoctor preprocessor that resolves bare <> cross-doc -# references to qualified <> form, the way -# asciidoc-py used to do via objects/xref_.links. +# references to a link: macro pointing at the target's output file. +# +# We emit link: rather than the inter-document xref form (<>) +# 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 \ @@ -44,6 +48,38 @@ 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 = 2 + + # 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 + def self.build_index(root, exclude_re = nil) cache_key = [root, exclude_re&.source].compact.join('|') key = File.expand_path(cache_key) @@ -61,31 +97,36 @@ 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 maps anchor => { 'file' => abspath, 'text' => reftext|nil }. idx = {} paths.each do |path| - path.each_line do |line| + lines = path.readlines + lines.each_with_index do |line, i| 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'] != target 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' => target, 'text' => reftext } end end end 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 @@ -101,6 +142,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") @@ -109,12 +152,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 <> label, else captured reftext, else anchor. + text = tail.empty? ? (target['text'] || anchor) : tail.sub(/\A,\s*/, '') + "link:#{out}##{anchor}[#{text}]" else full end From a633132104f6dd1640e1b1c39476609d2f91ef5c Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:48:43 +0800 Subject: [PATCH 2/2] docs(xref): resolve anchors in included partials to their page An anchor in an include::d partial has no .html of its own; it renders into the page that includes it. The resolver pointed such links at the partial (halshow.html) instead of the including page (tutorial.html). Build the include graph and redirect each anchor to its topmost non-Master_ ancestor. Cache bumped to v3. --- docs/src/extensions/xref_resolver.rb | 35 ++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/src/extensions/xref_resolver.rb b/docs/src/extensions/xref_resolver.rb index 5bddfd0644b..4e9123a1908 100644 --- a/docs/src/extensions/xref_resolver.rb +++ b/docs/src/extensions/xref_resolver.rb @@ -49,7 +49,10 @@ 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 = 2 + 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. @@ -80,6 +83,18 @@ def self.lookahead_reftext(lines, i) 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) @@ -104,26 +119,36 @@ def self.build_index(root, exclude_re = nil) end end - # idx maps anchor => { 'file' => abspath, 'text' => reftext|nil }. + # idx: anchor => { 'file' => abspath, 'text' => reftext|nil }. + # parents: included file => its includers. idx = {} + parents = Hash.new { |h, k| h[k] = [] } paths.each do |path| + 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]['file'] != target + if idx[anchor] && idx[anchor]['file'] != abspath warn "xref_resolver: duplicate anchor '#{anchor}' in " \ "#{path} (already in #{idx[anchor]['file']})" next end reftext = inline_reftext(line, anchor) || lookahead_reftext(lines, i) - idx[anchor] = { 'file' => target, 'text' => reftext } + 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('version' => INDEX_VERSION, 'mtime' => mtime_max, 'index' => idx))