aboutsummaryrefslogtreecommitdiffstats
path: root/filters/html-converters/md2html
diff options
context:
space:
mode:
authorJason A. Dönerfield <no-replay@sl0p.foo>2026-07-18 22:58:15 +0200
committerJason A. Dönerfield <no-replay@sl0p.foo>2026-07-18 22:58:15 +0200
commit3f7db81172a25d8fe8637f1e0bca86d32caffe1e (patch)
treeb846e73ee2123773758fefed1dc75f2bd94ae87b /filters/html-converters/md2html
parentui-tree: render folder README under the directory listing (diff)
downloadcgit-main.tar.gz
cgit-main.tar.xz
cgit-main.zip
md2html: auto-link bare repo paths in rendered markdownHEADmain
Detect path tokens in prose (must contain a "/") and inline <code> 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. <pre> code blocks, existing <a> links (no nested anchors) and tag attributes are never touched; tokens resolve relative to the rendered file dir or the repo root.
Diffstat (limited to 'filters/html-converters/md2html')
-rwxr-xr-xfilters/html-converters/md2html101
1 files changed, 100 insertions, 1 deletions
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'(<img\b[^>]*?\bsrc=")([^"]*)(")', rewrite_img, html, flags=re.I)
html = re.sub(r'(<a\b[^>]*?\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 <pre> code
+ # blocks, existing <a> 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 ("<code>" 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 '<a class="pathlink" href="%s">%s</a>' % (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"(?<![\w./-])([\w.\-]+(?:/[\w.\-]+)+)(?![\w./-])")
+
+ def linkify_prose(text):
+ def repl(m):
+ c = resolve_known(m.group(1))
+ return link(c, m.group(1)) if c else m.group(0)
+ return prose_tok.sub(repl, text)
+
+ def linkify_code(text):
+ c = resolve_known(text)
+ return link(c, text) if c else text
+
+ # Mask <pre>...</pre> and existing <a>...</a> 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"<pre\b.*?</pre>", _mask, html, flags=re.I | re.S)
+ masked = re.sub(r"<a\b.*?</a>", _mask, masked, flags=re.I | re.S)
+
+ # Walk text nodes only (odd list items are tags); inside an inline <code>
+ # 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() == "<code":
+ parts[i] = linkify_code(text)
+ else:
+ parts[i] = linkify_prose(text)
+ masked = "".join(parts)
+
+ html = re.sub(r"\x00(\d+)\x00", lambda m: masks[int(m.group(1))], masked)
+
sys.stdout.write("<div class='markdown-body'>")
sys.stdout.write(html)
sys.stdout.write("</div>")