diff options
| author | Jason A. Dönerfield <no-replay@sl0p.foo> | 2026-07-18 22:58:15 +0200 |
|---|---|---|
| committer | Jason A. Dönerfield <no-replay@sl0p.foo> | 2026-07-18 22:58:15 +0200 |
| commit | 3f7db81172a25d8fe8637f1e0bca86d32caffe1e (patch) | |
| tree | b846e73ee2123773758fefed1dc75f2bd94ae87b /filters | |
| parent | ui-tree: render folder README under the directory listing (diff) | |
| download | cgit-main.tar.gz cgit-main.tar.xz cgit-main.zip | |
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 '')
| -rwxr-xr-x | filters/html-converters/md2html | 101 |
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>") |
