From 3f7db81172a25d8fe8637f1e0bca86d32caffe1e Mon Sep 17 00:00:00 2001 From: "Jason A. Dönerfield" Date: Sat, 18 Jul 2026 22:58:15 +0200 Subject: md2html: auto-link bare repo paths in rendered markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect path tokens in prose (must contain a "/") and inline spans (matched verbatim, so bare Makefile/README link too) and turn them into links to the tree/ (or about/ for *.md) view — but ONLY when the token resolves to a path that actually exists in the repo, which we learn from `git ls-tree -r -t` at the default branch.
 code blocks, existing
 links (no nested anchors) and tag attributes are never touched;
tokens resolve relative to the rendered file dir or the repo root.
---
 filters/html-converters/md2html | 101 +++++++++++++++++++++++++++++++++++++++-
 1 file changed, 100 insertions(+), 1 deletion(-)

(limited to 'filters/html-converters/md2html')

diff --git a/filters/html-converters/md2html b/filters/html-converters/md2html
index d2906ec..65153f0 100755
--- a/filters/html-converters/md2html
+++ b/filters/html-converters/md2html
@@ -396,7 +396,7 @@ html = markdown.markdown(
 # against the repo's URL location (-> /sibling). The about view renders against
 # the configured readme ref, so about/ links carry no ?h=. If the repo context
 # env vars are absent we leave the references untouched.
-import os, re
+import os, re, subprocess
 from urllib.parse import urljoin
 repo = os.environ.get("CGIT_REPO_URL", "").strip("/")
 if repo:
@@ -470,6 +470,105 @@ if repo:
 	html = re.sub(r'(]*?\bsrc=")([^"]*)(")', rewrite_img, html, flags=re.I)
 	html = re.sub(r'(]*?\bhref=")([^"]*)(")', rewrite_href, html, flags=re.I)
 
+	# Auto-link bare repo paths. A README that merely *mentions* a file/dir in
+	# prose or an inline `code` span (e.g. `src/cpu.c`, tools/gbgif.py, Makefile)
+	# is far more navigable if those become links. To avoid linking garbage we
+	# only link tokens that resolve to a path that ACTUALLY EXISTS in the repo,
+	# so we ask git for the full file+dir list (at the repo's default branch) and
+	# match against it. Destinations follow the same routing as explicit links
+	# (*.md -> about/, everything else -> tree/). We never touch 
 code
+	# blocks, existing  links (no nested anchors), or tag attributes.
+	def dest_for(p, is_md=None):
+		if md_ext.search(p) if is_md is None else is_md:
+			return repo_root + "about/" + p
+		return repo_root + "tree/" + p + query
+
+	def _norm(path):
+		# collapse "."/".." with no base (repo-root-relative); like _resolve but
+		# without base_dir, used to also accept paths written from the repo root.
+		out, climbs = [], 0
+		for seg in path.split("/"):
+			if seg in ("", "."):
+				continue
+			if seg == "..":
+				if out:
+					out.pop()
+				else:
+					climbs += 1
+			else:
+				out.append(seg)
+		return climbs, "/".join(out)
+
+	known = set()
+	repo_path = os.environ.get("CGIT_REPO_PATH", "")
+	if repo_path and ("" in html or "/" in html):
+		try:
+			ls = subprocess.run(
+				["git", "-C", repo_path, "-c", "core.quotepath=false",
+				 "ls-tree", "-r", "-t", "-z", "--name-only", branch or "HEAD"],
+				stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5)
+			known = set(p for p in ls.stdout.decode("utf-8", "replace").split("\0") if p)
+		except Exception:
+			known = set()
+
+	if known:
+		def resolve_known(token):
+			t = token.strip()
+			# need a real name: reject blanks, tokens with spaces, and pure
+			# relative markers like "." / ".." / "./" (which would otherwise
+			# resolve to the base directory and mislink).
+			if not t or " " in t or not re.search(r"[A-Za-z0-9]", t):
+				return None
+			for climbs, cleaned in (_resolve(t), _norm(t)):
+				if not climbs and cleaned in known:
+					return cleaned
+			return None
+
+		def link(cleaned, text):
+			return '%s' % (dest_for(cleaned), text)
+
+		# A path token: word chars / . / - / _ , optionally multi-segment. In
+		# prose we require at least one "/" (a strong path signal) to avoid
+		# linking ordinary words that happen to match a top-level filename; in an
+		# inline code span the whole content is matched verbatim, so a bare
+		# `Makefile` or `README` still links.
+		prose_tok = re.compile(r"(?...
and existing ... so they're never touched + # (no linking inside code blocks, no nested anchors / re-linked hrefs). + masks = [] + def _mask(m): + masks.append(m.group(0)) + return "\x00%d\x00" % (len(masks) - 1) + masked = re.sub(r"", _mask, html, flags=re.I | re.S) + masked = re.sub(r"", _mask, masked, flags=re.I | re.S) + + # Walk text nodes only (odd list items are tags); inside an inline + # use verbatim matching, elsewhere use the stricter prose matcher. + parts = re.split(r"(<[^>]+>)", masked) + for i in range(0, len(parts), 2): + text = parts[i] + if not text: + continue + prev = parts[i - 1] if i > 0 else "" + if prev[:5].lower() == "") sys.stdout.write(html) sys.stdout.write("") -- cgit v1.3.1-sl0p