#!/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 (/ -> /
]*?\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 ("
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"" 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"(?...", _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("")