#!/usr/bin/env python3 import markdown import sys import io import re from pygments.formatters import HtmlFormatter from markdown.extensions.toc import TocExtension from markdown.extensions import Extension from markdown.preprocessors import Preprocessor # Python-Markdown (unlike CommonMark/GitHub) refuses to let a list interrupt a # paragraph unless there's a blank line between them, so a very common README # shape renders as one run-on paragraph instead of a list: # # Passing: # - foo # - bar # # This preprocessor restores the GitHub behavior: when a bullet ( - * + ) or a # "1."/"1)" ordered-list marker immediately follows a paragraph line, insert a # blank line so the list starts. We deliberately only interrupt on "1" for # ordered lists (matching CommonMark) so prose like "... the year\n1985. was # ..." is not turned into a list. Fenced code, indented code, and lines that # already follow a list item are left untouched. _MD_FENCE = re.compile(r'^(\s{0,3})(`{3,}|~{3,})') _MD_BULLET = re.compile(r'^ {0,3}[-*+][ \t]+\S') _MD_ORDERED1 = re.compile(r'^ {0,3}1[.)][ \t]+\S') _MD_ANYITEM = re.compile(r'^ {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]') class _ListInterruptPreprocessor(Preprocessor): def run(self, lines): out, in_fence, fence = [], False, None # Track whether we're already inside a list. A bullet that merely # continues an existing list must NOT get a blank line inserted before # it: that would turn a tight list into a loose one (every item wrapped # in

), which is exactly what happens when a preceding item wraps onto # an indented continuation line and we mistake the next bullet for a # paragraph->list transition. We only want to interrupt a real paragraph. in_list = False for line in lines: m = _MD_FENCE.match(line) if m: tok = m.group(2)[0] if not in_fence: in_fence, fence = True, tok elif fence == tok: in_fence, fence = False, None out.append(line) continue if in_fence: out.append(line) continue if not line.strip(): # a blank line doesn't end a list (loose lists / lazy # continuation), so leave in_list untouched out.append(line) continue is_item = bool(_MD_ANYITEM.match(line)) indented = line[:1] in (' ', '\t') # a non-indented, non-item line after a list closes that list if in_list and not is_item and not indented: in_list = False if out: prev = out[-1] if (prev.strip() and not in_list and not _MD_ANYITEM.match(prev) and not prev.startswith((' ', '\t')) and (_MD_BULLET.match(line) or _MD_ORDERED1.match(line))): out.append('') if is_item: in_list = True out.append(line) return out class ListInterruptExtension(Extension): def extendMarkdown(self, md): # priority 21: just above the built-in 'normalize_whitespace' (20) so we # operate on clean lines before block parsing. md.preprocessors.register( _ListInterruptPreprocessor(md), 'list_interrupt', 21) sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8') sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') sys.stdout.write(''' ''') # Render to a string (not straight to stdout) so we can post-process it. # Note: you may want to run this through bleach for sanitization html = markdown.markdown( sys.stdin.read(), output_format="html5", extensions=[ "markdown.extensions.fenced_code", "markdown.extensions.codehilite", "markdown.extensions.tables", "markdown.extensions.sane_lists", ListInterruptExtension(), TocExtension(anchorlink=True)], extension_configs={ "markdown.extensions.codehilite":{"css_class":"highlight"}}) # A README's relative paths point at repo content, but a browser resolves them # against the page the readme is shown on (//about/... or the summary page # //), not against the file's own location — so they 404 or hit the wrong # file. cgit serves this content through three commands, so rewrite relative # references to absolute cgit URLs (absolute so they work on the about page and # the summary page alike): # -> //plain/?h= (raw bytes) # *.md -> //about/ (rendered markdown) # other/dir -> //tree/?h= (browsable blob view) # cgit hands the about-filter the path of the file being rendered as argv[1] # (README.md, or docs/foo.md for a rendered subpage), so relative links resolve # against *that* file's directory and links inside a rendered subpage work too. # A href that climbs out of the repo with ".." (e.g. ../sibling) is resolved # 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, subprocess from urllib.parse import urljoin repo = os.environ.get("CGIT_REPO_URL", "").strip("/") if repo: branch = os.environ.get("CGIT_REPO_DEFBRANCH", "").strip() repo_root = "/" + repo + "/" plain = repo_root + "plain/" query = ("?h=" + branch) if branch else "" # directory of the file being rendered; relative links resolve against it. # argv[1] is the repo-relative readme/subpage path cgit passes the filter. cur = sys.argv[1] if len(sys.argv) > 1 else "" base_dir = "" if cur.startswith("/") else os.path.dirname(cur) # markdown files are rendered via the about view; everything else is a blob md_ext = re.compile(r"\.(?:md|markdown|mdown|mkd)$", re.I) # leave absolute, protocol-relative, root-relative, anchor, query-only and # scheme (mailto:, http:, data:, ...) URLs alone skip = re.compile(r"^(?:[a-z][a-z0-9+.\-]*:|//|/|[#?])", re.I) def _split_frag(url): # split off a trailing #fragment (kept verbatim on the rewritten URL) if "#" in url: path, frag = url.split("#", 1) return path, "#" + frag return url, "" def _resolve(path): # resolve `path` (relative to base_dir) into a repo-root-relative path, # collapsing "." / ".."; returns (climbs, cleaned) where `climbs` counts # ".." segments that escaped above the repo root. combined = (base_dir + "/" + path) if base_dir else path out, climbs = [], 0 for seg in combined.split("/"): if seg in ("", "."): continue if seg == "..": if out: out.pop() else: climbs += 1 else: out.append(seg) return climbs, "/".join(out) def rewrite_img(m): url = m.group(2) if not url or skip.match(url): return m.group(0) path, frag = _split_frag(url) climbs, cleaned = _resolve(path) if climbs or not cleaned: return m.group(0) return m.group(1) + plain + cleaned + query + frag + m.group(3) def rewrite_href(m): url = m.group(2) if not url or skip.match(url): return m.group(0) path, frag = _split_frag(url) climbs, cleaned = _resolve(path) if climbs: # escapes the repo (sibling repo etc.): resolve against repo location dest = urljoin(repo_root, "../" * climbs + cleaned) elif not cleaned: dest = repo_root + (query or "") elif md_ext.search(cleaned): # rendered through the about-filter (renders against the readme ref) dest = repo_root + "about/" + cleaned else: dest = repo_root + "tree/" + cleaned + query return m.group(1) + dest + frag + m.group(3) 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("")