commit 68ed76b65add4f265a9990ebad658e2ce2a73e57
parent b986866cce1ac2705d373d163a32ac75c4b78544
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 17 Aug 2026 23:29:24 +0200
Style markdown files in git browser
Diffstat:
2 files changed, 202 insertions(+), 3 deletions(-)
diff --git a/scripts/postprocess_stagit_site.py b/scripts/postprocess_stagit_site.py
@@ -19,7 +19,11 @@ TOPBAR = """
</div>"""
-def postprocess_html(html: str) -> str:
+LINE_RE = re.compile(r'(?P<line><a\s+[^>]*class="line"[^>]*>.*?</a>)(?P<body>.*)', re.S)
+BLOB_RE = re.compile(r'(<pre id="blob"[^>]*>)(.*?)(</pre>)', re.S)
+
+
+def postprocess_html(html: str, path: Path | None = None) -> str:
if '<div class="topbar">' not in html:
html = re.sub(r"<body([^>]*)>", r"<body\1>\n" + TOPBAR, html, count=1)
@@ -37,7 +41,134 @@ def postprocess_html(html: str) -> str:
count=1,
flags=re.S,
)
- return re.sub(r"<tr([^>]*)><td></td><td>", r"<tr\1><td>", html)
+ html = re.sub(r"<tr([^>]*)><td></td><td>", r"<tr\1><td>", html)
+
+ if path is not None and is_markdown_file_page(path):
+ html = style_markdown_blob(html)
+ return html
+
+
+def is_markdown_file_page(path: Path) -> bool:
+ return path.name.endswith(".md.html") or path.name.endswith(".markdown.html")
+
+
+def style_markdown_blob(html: str) -> str:
+ def replace_blob(match: re.Match[str]) -> str:
+ opening, blob, closing = match.groups()
+ if re.search(r'class="[^"]*\bmd-source\b', opening):
+ return match.group(0)
+ if 'class="' in opening:
+ opening = re.sub(r'class="([^"]*)"', r'class="\1 md-source"', opening, count=1)
+ else:
+ opening = opening[:-1] + ' class="md-source">'
+ return opening + style_markdown_lines(blob) + closing
+
+ return BLOB_RE.sub(replace_blob, html, count=1)
+
+
+def style_markdown_lines(blob: str) -> str:
+ lines = blob.splitlines(keepends=True)
+ in_fence = False
+ styled = []
+ for line in lines:
+ newline = ""
+ if line.endswith("\r\n"):
+ line, newline = line[:-2], "\r\n"
+ elif line.endswith("\n"):
+ line, newline = line[:-1], "\n"
+
+ match = LINE_RE.match(line)
+ if not match:
+ styled.append(line + newline)
+ continue
+
+ anchor = match.group("line")
+ body = match.group("body")
+ stripped = body.lstrip(" ")
+ leading = body[: len(body) - len(stripped)]
+ if stripped.startswith("```") or stripped.startswith("~~~"):
+ in_fence = not in_fence
+ body = leading + '<span class="md-fence">' + stripped + "</span>"
+ elif not in_fence:
+ body = style_markdown_line_body(body)
+ styled.append(anchor + body + newline)
+ return "".join(styled)
+
+
+def style_markdown_line_body(body: str) -> str:
+ if not body.strip():
+ return body
+
+ leading = body[: len(body) - len(body.lstrip(" "))]
+ text = body[len(leading) :]
+ heading = re.match(r"(#{1,6})(\s+)(.*)", text)
+ if heading:
+ level = len(heading.group(1))
+ return (
+ leading
+ + f'<span class="md-heading md-heading-{level}">'
+ + '<span class="md-marker">'
+ + heading.group(1)
+ + heading.group(2)
+ + "</span>"
+ + style_markdown_inline(heading.group(3))
+ + "</span>"
+ )
+
+ blockquote = re.match(r"(>)(\s?)(.*)", text)
+ if blockquote:
+ return (
+ leading
+ + '<span class="md-blockquote"><span class="md-marker">'
+ + blockquote.group(1)
+ + blockquote.group(2)
+ + "</span>"
+ + style_markdown_inline(blockquote.group(3))
+ + "</span>"
+ )
+
+ unordered = re.match(r"([-*+])(\s+)(.*)", text)
+ if unordered:
+ return leading + style_markdown_marker_line("md-list", unordered)
+
+ ordered = re.match(r"(\d+\.)(\s+)(.*)", text)
+ if ordered:
+ return leading + style_markdown_marker_line("md-list", ordered)
+
+ thematic_break = re.match(r"((?:[-*_]\s*){3,})$", text)
+ if thematic_break:
+ return leading + '<span class="md-rule">' + thematic_break.group(1) + "</span>"
+
+ return leading + style_markdown_inline(text)
+
+
+def style_markdown_marker_line(class_name: str, match: re.Match[str]) -> str:
+ return (
+ f'<span class="{class_name}"><span class="md-marker">'
+ + match.group(1)
+ + match.group(2)
+ + "</span>"
+ + style_markdown_inline(match.group(3))
+ + "</span>"
+ )
+
+
+def style_markdown_inline(text: str) -> str:
+ spans: list[str] = []
+
+ def stash(class_name: str, value: str) -> str:
+ spans.append(f'<span class="{class_name}">{value}</span>')
+ return f"\0{len(spans) - 1}\0"
+
+ text = re.sub(r"`([^`\n]+)`", lambda m: stash("md-code", m.group(1)), text)
+ text = re.sub(r"\*\*([^*\n]+)\*\*", r"<strong>\1</strong>", text)
+ text = re.sub(r"__([^_\n]+)__", r"<strong>\1</strong>", text)
+ text = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<em>\1</em>", text)
+
+ def restore(match: re.Match[str]) -> str:
+ return spans[int(match.group(1))]
+
+ return re.sub(r"\0(\d+)\0", restore, text)
def main() -> int:
@@ -52,7 +183,7 @@ def main() -> int:
for path in git_root.rglob("*.html"):
html = path.read_text(encoding="utf-8")
- path.write_text(postprocess_html(html), encoding="utf-8")
+ path.write_text(postprocess_html(html, path), encoding="utf-8")
return 0
diff --git a/www/assets/static-listing.css b/www/assets/static-listing.css
@@ -154,6 +154,74 @@ pre#blob,
table-layout: fixed;
}
+pre#blob {
+ --line-number-width: 5.25rem;
+ padding-left: var(--line-number-width);
+}
+
+pre#blob a.line {
+ display: inline-block;
+ width: var(--line-number-width);
+ margin-left: calc(-1 * var(--line-number-width));
+ padding-right: .75rem;
+ text-align: right;
+ user-select: none;
+}
+
+pre#blob.md-source {
+ line-height: 1.62;
+}
+
+pre#blob.md-source .md-heading {
+ display: inline-block;
+ margin: .45rem 0 .15rem;
+ color: var(--text);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-weight: 850;
+}
+
+pre#blob.md-source .md-heading-1 { font-size: 1.75rem; }
+pre#blob.md-source .md-heading-2 { font-size: 1.42rem; }
+pre#blob.md-source .md-heading-3 { font-size: 1.18rem; }
+pre#blob.md-source .md-heading-4,
+pre#blob.md-source .md-heading-5,
+pre#blob.md-source .md-heading-6 { font-size: 1.02rem; }
+
+pre#blob.md-source strong {
+ color: #f4f8fa;
+ font-weight: 850;
+}
+
+pre#blob.md-source em {
+ color: #d7e0e5;
+}
+
+pre#blob.md-source .md-code {
+ padding: .08rem .28rem;
+ border: 1px solid rgba(141, 233, 205, .22);
+ border-radius: 4px;
+ background: rgba(141, 233, 205, .08);
+ color: var(--cyan);
+}
+
+pre#blob.md-source .md-marker,
+pre#blob.md-source .md-fence {
+ color: #66747b;
+}
+
+pre#blob.md-source .md-blockquote {
+ color: #c9d4d9;
+}
+
+pre#blob.md-source .md-list {
+ color: #c6d1d6;
+}
+
+pre#blob.md-source .md-rule {
+ color: var(--line);
+ letter-spacing: .12rem;
+}
+
#blob td:first-child {
width: 1%;
white-space: nowrap;