foxygit / foxygit Log in
A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
commit 4dd80d9fdb338666594d516588da333d706f2641
Author:     mrfox <jens.kristoffersson.se@gmail.com>
AuthorDate: Wed Aug 12 21:26:24 2026 +0200
Commit:     mrfox <jens.kristoffersson.se@gmail.com>
CommitDate: Wed Aug 12 21:26:24 2026 +0200

    Extend markdown rendering, add image viewing, and a repo language bar

    Markdown was too bare-bones for real READMEs: no images, relative links got
    dropped, section anchors didn't exist, and mixed HTML (common in GFM READMEs
    for centering images/badges) just got escaped as literal text. Adds images,
    GFM tables, ordered/nested lists, blockquotes, hr, heading anchors so TOC and
    cross-doc section links work, and a small allow-listed raw-HTML passthrough.

    Image files now render as <img> on the blob page instead of "binary file",
    and the blob toolbar gained a download button (raw endpoint supports &dl=1
    for Content-Disposition: attachment).

    The repo index page now shows a GitHub-style language bar with a percentage
    legend under each repo, computed from a single `git ls-tree -r -l` per repo.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 assets/base.css      |  53 ++++-
 inc/functions.php    | 553 ++++++++++++++++++++++++++++++++++++++++++++++-----
 index.php            |  25 ++-
 views/blob.php       |  19 +-
 views/repo-index.php |  14 ++
 5 files changed, 607 insertions(+), 57 deletions(-)

diff --git a/assets/base.css b/assets/base.css
index 77dd20c..d5dfe91 100644
--- a/assets/base.css
+++ b/assets/base.css
@@ -196,18 +196,43 @@ pre {

 .readme .box-header { color: var(--fg); font-weight: 600; }
 .readme .body { padding: 24px 32px; }
-.readme h1, .readme h2, .readme h3, .readme h4 { color: var(--fg); margin: 24px 0 12px; }
+.readme h1, .readme h2, .readme h3, .readme h4, .readme h5, .readme h6 {
+  color: var(--fg); margin: 24px 0 12px; scroll-margin-top: 16px;
+}
 .readme h1 { border-bottom: 1px solid var(--line); padding-bottom: .3em; font-size: 26px; }
 .readme h2 { border-bottom: 1px solid var(--line); padding-bottom: .3em; font-size: 20px; }
 .readme h1:first-child, .readme h2:first-child { margin-top: 0; }
 .readme p { margin: 12px 0; }
-.readme ul { margin: 12px 0; padding-left: 22px; }
+.readme ul, .readme ol { margin: 12px 0; padding-left: 22px; }
 .readme li { margin: 4px 0; }
+.readme li > ul, .readme li > ol { margin: 4px 0; }
 .readme code { background: var(--surface); padding: 2px 6px; border-radius: 6px; font-size: 85%; }
 .readme pre { background: var(--pre-bg); }
 .readme pre code { background: none; padding: 0; font-size: 100%; }
+.readme img { max-width: 100%; height: auto; }
+.readme del { color: var(--dim); }
+.readme hr { border: 0; border-top: 1px solid var(--line); margin: 24px 0; }
+.readme blockquote {
+  margin: 12px 0;
+  padding: 1px 1em;
+  color: var(--dim);
+  border-left: .25em solid var(--line);
+}
+.readme blockquote > *:first-child { margin-top: 0; }
+.readme blockquote > *:last-child { margin-bottom: 0; }
+.readme table { display: block; width: max-content; max-width: 100%; overflow: auto; margin: 12px 0; border-collapse: collapse; }
+.readme table th, .readme table td { border: 1px solid var(--line); padding: 6px 12px; }
+.readme table tr:nth-child(2n) td { background: var(--surface); }
 .license-line { color: var(--dim); font-size: 12px; margin: 0 0 16px; }

+/* --------------------------------------------------------------- blob view */
+
+.blob-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
+.blob-toolbar .desc { margin: 0; }
+.blob-toolbar .spacer { flex: 1; }
+.img-frame { padding: 32px; text-align: center; }
+.img-frame img { max-width: 100%; max-height: 70vh; border-radius: 4px; }
+
 /* ------------------------------------------------------------- repo list

    Real <table> markup, restyled as a stack of bordered cards via CSS grid
@@ -229,6 +254,30 @@ pre {
 #repo-list td:nth-child(1) { grid-row: 1; font-size: 16px; font-weight: 600; }
 #repo-list td:nth-child(2) { grid-column: 1 / 2; grid-row: 2; color: var(--dim); font-size: 13px; }
 #repo-list td:nth-child(3) { grid-column: 2 / 3; grid-row: 1; color: var(--dim); font-size: 12px; white-space: nowrap; }
+#repo-list td:nth-child(4) { grid-column: 1 / -1; grid-row: 3; }
+
+/* the language bar: a row of proportionally-sized colored segments, GitHub-style,
+   plus a wrapping text legend (colored dot + name + percentage) underneath. */
+.lang-bar {
+  display: flex;
+  width: 25%;
+  height: 6px;
+  border-radius: 3px;
+  overflow: hidden;
+  background: var(--surface);
+  margin-top: 8px;
+}
+.lang-bar span { display: block; height: 100%; }
+.lang-legend {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px 14px;
+  margin-top: 6px;
+  font-size: 12px;
+  color: var(--dim);
+}
+.lang-legend-item { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
+.lang-legend-item i { width: 9px; height: 9px; display: inline-block; flex: none; }

 /* ------------------------------------------------------------------ log */

diff --git a/inc/functions.php b/inc/functions.php
index b94e119..a24e294 100644
--- a/inc/functions.php
+++ b/inc/functions.php
@@ -118,6 +118,17 @@ function safe_ref(string $ref): bool {
         && !preg_match('~[\x00-\x1f]~', $ref);
 }

+const IMAGE_EXTENSIONS    = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'];
+const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdown', 'mkd'];
+
+function is_image_ext(string $path): bool {
+    return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), IMAGE_EXTENSIONS, true);
+}
+
+function is_markdown_ext(string $path): bool {
+    return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), MARKDOWN_EXTENSIONS, true);
+}
+
 /** Last-activity time for a repo without shelling out to git — reads mtimes
  *  of HEAD / the branch it points at / packed-refs. Used for the repo index
  *  so listing many repos doesn't spawn a `git log` per repo on every hit. */
@@ -140,13 +151,116 @@ function repo_description(string $path): string {
     return trim($desc);
 }

+/** File extension -> [language name, GitHub-linguist-style color]. Deliberately
+ *  excludes "data"/markup formats (JSON, YAML, Markdown, XML, ...) — same as
+ *  GitHub's language bar, so a repo's lockfiles/docs don't dominate the count. */
+const LANGUAGE_COLORS = [
+    'js' => ['JavaScript', '#f1e05a'], 'mjs' => ['JavaScript', '#f1e05a'], 'cjs' => ['JavaScript', '#f1e05a'],
+    'jsx' => ['JavaScript', '#f1e05a'],
+    'ts' => ['TypeScript', '#3178c6'], 'tsx' => ['TypeScript', '#3178c6'],
+    'py' => ['Python', '#3572A5'], 'pyw' => ['Python', '#3572A5'],
+    'php' => ['PHP', '#4F5D95'], 'phtml' => ['PHP', '#4F5D95'],
+    'rb' => ['Ruby', '#701516'],
+    'go' => ['Go', '#00ADD8'],
+    'rs' => ['Rust', '#dea584'],
+    'java' => ['Java', '#b07219'],
+    'kt' => ['Kotlin', '#A97BFF'], 'kts' => ['Kotlin', '#A97BFF'],
+    'swift' => ['Swift', '#F05138'],
+    'c' => ['C', '#555555'], 'h' => ['C', '#555555'],
+    'cpp' => ['C++', '#f34b7d'], 'cc' => ['C++', '#f34b7d'], 'cxx' => ['C++', '#f34b7d'], 'hpp' => ['C++', '#f34b7d'],
+    'cs' => ['C#', '#178600'],
+    'sh' => ['Shell', '#89e051'], 'bash' => ['Shell', '#89e051'], 'zsh' => ['Shell', '#89e051'],
+    'html' => ['HTML', '#e34c26'], 'htm' => ['HTML', '#e34c26'],
+    'css' => ['CSS', '#563d7c'],
+    'scss' => ['SCSS', '#c6538c'], 'sass' => ['SCSS', '#c6538c'],
+    'vue' => ['Vue', '#41b883'],
+    'lua' => ['Lua', '#000080'],
+    'pl' => ['Perl', '#0298c3'], 'pm' => ['Perl', '#0298c3'],
+    'dart' => ['Dart', '#00B4AB'],
+    'm' => ['Objective-C', '#438eff'],
+    'scala' => ['Scala', '#c22d40'],
+    'hs' => ['Haskell', '#5e5086'],
+    'ex' => ['Elixir', '#6e4a7e'], 'exs' => ['Elixir', '#6e4a7e'],
+    'erl' => ['Erlang', '#B83998'],
+    'clj' => ['Clojure', '#db5855'], 'cljs' => ['Clojure', '#db5855'],
+    'r' => ['R', '#198CE7'],
+    'jl' => ['Julia', '#a270ba'],
+    'elm' => ['Elm', '#60B5CC'],
+    'vim' => ['Vim Script', '#199f4b'],
+    'ps1' => ['PowerShell', '#012456'],
+    'asm' => ['Assembly', '#6E4C13'], 's' => ['Assembly', '#6E4C13'],
+    'zig' => ['Zig', '#ec915c'],
+    'nim' => ['Nim', '#ffc200'],
+    'ml' => ['OCaml', '#3be133'],
+    'fs' => ['F#', '#b845fc'],
+    'groovy' => ['Groovy', '#4298b8'],
+    'coffee' => ['CoffeeScript', '#244776'],
+    'sql' => ['SQL', '#e38c00'],
+];
+
+/** A handful of extensionless files that are unambiguously one language. */
+const LANGUAGE_FILENAMES = [
+    'Dockerfile' => ['Dockerfile', '#384d54'],
+    'Makefile'   => ['Makefile', '#427819'],
+    'Rakefile'   => ['Ruby', '#701516'],
+    'Gemfile'    => ['Ruby', '#701516'],
+];
+
+/** GitHub-style "language bar" data for a repo: bytes-of-code per language at
+ *  $ref, as percentages of the recognized-language total (LANGUAGE_COLORS/
+ *  LANGUAGE_FILENAMES only — unrecognized files, same as GitHub's linguist,
+ *  don't count toward the total). Sorted by size descending; anything past
+ *  the top 5 languages is folded into a trailing "Other" bucket so a
+ *  polyglot repo's bar/legend stays readable. Empty array for an empty repo
+ *  or one with nothing recognized. */
+function repo_language_stats(string $repo, string $ref = 'HEAD'): array {
+    $raw = git_raw($repo, ['ls-tree', '-r', '-l', $ref]);
+    if ($raw === '') return [];
+
+    $byLang = [];   // language name -> ['bytes' => int, 'color' => hex]
+    foreach (explode("\n", $raw) as $line) {
+        // <mode> blob <sha> <size>\t<path>
+        if (!preg_match('~^\d+\s+blob\s+[0-9a-f]+\s+(\S+)\t(.+)$~', $line, $m)) continue;
+        $size = $m[1] === '-' ? 0 : (int) $m[1];
+        if ($size <= 0) continue;
+
+        $path = $m[2];
+        $lang = LANGUAGE_FILENAMES[basename($path)] ?? LANGUAGE_COLORS[strtolower(pathinfo($path, PATHINFO_EXTENSION))] ?? null;
+        if ($lang === null) continue;
+
+        [$name, $color] = $lang;
+        $byLang[$name] ??= ['bytes' => 0, 'color' => $color];
+        $byLang[$name]['bytes'] += $size;
+    }
+    if (!$byLang) return [];
+
+    $total = array_sum(array_column($byLang, 'bytes'));
+    $stats = [];
+    foreach ($byLang as $name => $info) {
+        $stats[] = ['name' => $name, 'color' => $info['color'], 'bytes' => $info['bytes'], 'pct' => $info['bytes'] / $total * 100];
+    }
+    usort($stats, fn($a, $b) => $b['bytes'] <=> $a['bytes']);
+
+    if (count($stats) > 6) {
+        $rest      = array_slice($stats, 5);
+        $restBytes = array_sum(array_column($rest, 'bytes'));
+        $stats     = array_slice($stats, 0, 5);
+        $stats[]   = ['name' => 'Other', 'color' => '#8b949e', 'bytes' => $restBytes, 'pct' => $restBytes / $total * 100];
+    }
+    return $stats;
+}
+
 function list_repos(): array {
     $out = [];
     foreach (glob(REPO_BASE . '/*') ?: [] as $p) {
         if (!is_dir($p)) continue;
         if (git($p, ['rev-parse', '--git-dir']) === []) continue;
         $name = basename($p);
-        $out[$name] = ['desc' => repo_description($p), 'mtime' => repo_last_activity($p)];
+        $out[$name] = [
+            'desc'      => repo_description($p),
+            'mtime'     => repo_last_activity($p),
+            'languages' => repo_language_stats($p),
+        ];
     }
     uasort($out, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
     return $out;
@@ -168,8 +282,11 @@ function find_root_file(string $repo, string $ref, string $pattern): ?string {
 }

 /** README + LICENSE from a ref's root tree, ready to hand to views/tree.php.
- *  README is rendered to HTML; oversized or binary READMEs are skipped (null). */
-function root_docs(string $repo, string $ref): array {
+ *  README is rendered to HTML; oversized or binary READMEs are skipped (null).
+ *  $repoName is the URL-facing repo name (not the filesystem path in $repo) —
+ *  needed so relative links/images inside the README can resolve to real
+ *  foxygit URLs (see resolve_md_url()). */
+function root_docs(string $repo, string $repoName, string $ref): array {
     $out = ['readmeName' => null, 'readmeHtml' => null, 'licenseName' => null];

     $out['licenseName'] = find_root_file($repo, $ref, '~^(licen[sc]e|copying)(\.(md|txt))?$~i');
@@ -185,13 +302,148 @@ function root_docs(string $repo, string $ref): array {
     if (strpos($content, "\0") !== false) return $out;   // binary, not a readable README

     $out['readmeName'] = $readme;
-    $out['readmeHtml'] = markdown_to_html($content);
+    $out['readmeHtml'] = markdown_to_html($content, $repoName, $ref, '');
     return $out;
 }

-/** Inline markdown: escapes first, code/links/bold/italic applied on top via
- *  placeholders so nothing gets double-escaped and link hrefs are scheme-checked. */
-function inline_md(string $text): string {
+/** Absolute-URL schemes markdown links/images are allowed to point at.
+ *  Everything else with a scheme (javascript:, data:, ...) gets dropped. */
+const MD_SAFE_SCHEMES = ['http', 'https', 'mailto', 'ftp', 'tel', 'xmpp', 'irc', 'ircs'];
+
+/** Resolve a relative markdown link/image path against the directory
+ *  containing the file being rendered ('' for repo root), collapsing "."
+ *  and ".." segments the way a browser would for a relative <a href>. */
+function resolve_relative_link(string $baseDir, string $rel): string {
+    $parts = $baseDir === '' ? [] : explode('/', $baseDir);
+    foreach (explode('/', $rel) as $seg) {
+        if ($seg === '' || $seg === '.') continue;
+        if ($seg === '..') { array_pop($parts); continue; }
+        $parts[] = $seg;
+    }
+    return implode('/', $parts);
+}
+
+/** Turn a markdown link/image target into an href foxygit can actually serve.
+ *  Absolute URLs pass through unchanged (scheme allow-listed via
+ *  MD_SAFE_SCHEMES); '#anchor' and '/root-relative' targets pass through as-is;
+ *  everything else is a path relative to $baseDir (the directory of the file
+ *  being rendered) and gets rewritten into a link within this same repo+ref —
+ *  ?a=raw for images (so <img src> gets real bytes), ?a=tree for links (so
+ *  clicking through lands on that file's own foxygit page, anchor preserved).
+ *  Returns null when the target should be dropped entirely (unsafe scheme, or
+ *  no repo context to resolve a relative path against). */
+function resolve_md_url(string $url, string $repoName, string $ref, string $baseDir, bool $isImage): ?string {
+    if ($url === '') return null;
+    if (preg_match('~^([a-zA-Z][a-zA-Z0-9+.-]*):~', $url, $m)) {
+        return in_array(strtolower($m[1]), MD_SAFE_SCHEMES, true) ? $url : null;
+    }
+    if (str_starts_with($url, '//')) return $url;           // protocol-relative
+    if ($url[0] === '#' || $url[0] === '/') return $url;    // in-page anchor / site-root-relative
+    if ($repoName === '') return null;
+
+    [$path, $frag] = array_pad(explode('#', $url, 2), 2, '');
+    $resolved = resolve_relative_link($baseDir, rawurldecode($path));
+    if ($resolved === '') return null;
+
+    $action = $isImage ? 'raw' : 'tree';
+    $href   = '?r=' . $repoName . '&a=' . $action . '&ref=' . $ref . '&blob=' . $resolved;
+    return $frag !== '' ? $href . '#' . $frag : $href;
+}
+
+/** GitHub-style heading slug: strip markdown syntax and punctuation, lowercase,
+ *  spaces to hyphens. Caller is responsible for de-duplicating across a document
+ *  (GitHub appends -1, -2, ... to repeats) — see markdown_to_html(). */
+function md_slug(string $text): string {
+    $text = preg_replace('~`([^`]+)`~', '$1', $text);
+    $text = preg_replace('~!?\[([^\]]*)\]\([^)]*\)~', '$1', $text);
+    $text = preg_replace('#[*_~]+#', '', $text);
+    $text = strtolower(trim($text));
+    $text = preg_replace('~[^\p{L}\p{N}\s_-]~u', '', $text);
+    $text = preg_replace('~\s+~', '-', $text);
+    return $text ?? '';
+}
+
+/** Split one GFM table row into its cell strings ("| a | b |" and "a | b" alike). */
+function md_table_cells(string $line): array {
+    $line = trim($line);
+    if (str_starts_with($line, '|')) $line = substr($line, 1);
+    if (str_ends_with($line, '|')) $line = substr($line, 0, -1);
+    $cells = preg_split('~(?<!\\\\)\|~', $line) ?: [''];
+    return array_map(fn($c) => str_replace('\\|', '|', trim($c)), $cells);
+}
+
+/** README authors routinely mix raw HTML into markdown — wrapping an image in
+ *  a centering <div>, a <sub> caption, a <details> spoiler. GitHub renders
+ *  that HTML as-is; foxygit's parser previously escaped it all as plain text
+ *  (safe, but exactly the "image tag shows up as literal text" bug). This is
+ *  a small allow-list of tags/attributes rendered for real; anything else stays
+ *  escaped. src/href go through resolve_md_url() same as markdown links/images.
+ *  See the inline pass in inline_md() and the block pass in markdown_to_html(). */
+const MD_HTML_TAGS = [
+    'div' => ['align'], 'p' => ['align'], 'span' => ['align'], 'center' => [],
+    'details' => [], 'summary' => [],
+    'br' => [], 'hr' => [],
+    'b' => [], 'strong' => [], 'i' => [], 'em' => [],
+    'sub' => [], 'sup' => [], 'kbd' => [], 'small' => [],
+    'img' => ['src', 'alt', 'title', 'width', 'height', 'align'],
+    'a'   => ['href', 'title'],
+];
+const MD_HTML_VOID_TAGS  = ['img', 'br', 'hr'];
+/** Tags allowed to open a *block* (alone on their own source line) in
+ *  markdown_to_html() — their content is re-parsed as markdown (headings,
+ *  images, fenced code, ...), not just run through inline_md() as plain text. */
+const MD_HTML_BLOCK_TAGS = ['div', 'p', 'center', 'details', 'summary'];
+
+/** Parse name="value"/name='value' pairs out of a raw HTML tag's attribute text. */
+function md_html_attrs(string $attrString): array {
+    $attrs = [];
+    preg_match_all(
+        '~([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*)"|([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*\'([^\']*)\'~',
+        $attrString, $ms, PREG_SET_ORDER
+    );
+    foreach ($ms as $mm) {
+        if ($mm[1] !== '') $attrs[strtolower($mm[1])] = $mm[2];
+        else $attrs[strtolower($mm[3])] = $mm[4];
+    }
+    return $attrs;
+}
+
+/** Render one allow-listed HTML opening tag with sanitized attributes, or null
+ *  if $tag isn't one foxygit renders raw (caller falls back to escaping the
+ *  original source text instead — never render a tag we don't recognize). */
+function md_render_html_open(string $tag, string $attrString, string $repoName, string $ref, string $baseDir): ?string {
+    if (!array_key_exists($tag, MD_HTML_TAGS)) return null;
+    $attrs = md_html_attrs($attrString);
+    $out = "<$tag";
+    foreach (MD_HTML_TAGS[$tag] as $name) {
+        if (!isset($attrs[$name])) continue;
+        $val = $attrs[$name];
+        if ($name === 'src') {
+            $resolved = resolve_md_url($val, $repoName, $ref, $baseDir, true);
+            if ($resolved === null) continue;
+            $val = $resolved;
+        } elseif ($name === 'href') {
+            $resolved = resolve_md_url($val, $repoName, $ref, $baseDir, false);
+            if ($resolved === null) continue;
+            $val = $resolved;
+        } elseif ($name === 'align') {
+            if (!in_array($val, ['left', 'right', 'center', 'justify'], true)) continue;
+        } elseif ($name === 'width' || $name === 'height') {
+            if (!preg_match('~^\d{1,4}%?$~', $val)) continue;
+        }
+        $out .= ' ' . $name . '="' . h($val) . '"';
+    }
+    if ($tag === 'a') $out .= ' rel="nofollow noopener"';
+    if ($tag === 'img') $out .= ' loading="lazy"';
+    return $out . '>';
+}
+
+/** Inline markdown: code/images/links/autolinks applied first via placeholders
+ *  (each escapes its own inner content) so nothing gets double-escaped, then
+ *  the remaining plain text is escaped and bold/italic/strikethrough applied
+ *  on top of that. $repoName/$ref/$baseDir give relative links & images
+ *  somewhere real to point at — see resolve_md_url(). */
+function inline_md(string $text, string $repoName = '', string $ref = '', string $baseDir = ''): string {
     $store = [];
     $put = function (string $html) use (&$store): string {
         $key = "\x02" . count($store) . "\x03";
@@ -199,67 +451,278 @@ function inline_md(string $text): string {
         return $key;
     };

+    // code spans first so nothing inside `...` is ever treated as markdown
     $text = preg_replace_callback('~`([^`]+)`~', function ($m) use ($put) {
         return $put('<code>' . h($m[1]) . '</code>');
     }, $text);

-    $text = preg_replace_callback('~\[([^\]]+)\]\(([^)\s]+)\)~', function ($m) use ($put) {
-        $url = $m[2];
-        if (!preg_match('~^(https?://|mailto:|/|#)~i', $url)) return h($m[1]); // drop unsafe/unknown schemes
-        return $put('<a href="' . h($url) . '" rel="nofollow noopener">' . h($m[1]) . '</a>');
+    // images: ![alt](src "title")
+    $text = preg_replace_callback('~!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)~',
+        function ($m) use ($put, $repoName, $ref, $baseDir) {
+            $src = resolve_md_url($m[2], $repoName, $ref, $baseDir, true);
+            if ($src === null) return '';
+            $titleAttr = ($m[3] ?? '') !== '' ? ' title="' . h($m[3]) . '"' : '';
+            return $put('<img src="' . h($src) . '" alt="' . h($m[1]) . '" loading="lazy"' . $titleAttr . '>');
+        }, $text);
+
+    // links: [text](href "title")
+    $text = preg_replace_callback('~\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)~',
+        function ($m) use ($put, $repoName, $ref, $baseDir) {
+            $href = resolve_md_url($m[2], $repoName, $ref, $baseDir, false);
+            if ($href === null) return h($m[1]);
+            $titleAttr = ($m[3] ?? '') !== '' ? ' title="' . h($m[3]) . '"' : '';
+            return $put('<a href="' . h($href) . '" rel="nofollow noopener"' . $titleAttr . '>' . h($m[1]) . '</a>');
+        }, $text);
+
+    // autolinks: <https://example.com> and bare http(s):// URLs in running text
+    $text = preg_replace_callback('~<((?:https?|mailto):[^\s>]+)>~i', function ($m) use ($put) {
+        return $put('<a href="' . h($m[1]) . '" rel="nofollow noopener">' . h($m[1]) . '</a>');
+    }, $text);
+    $text = preg_replace_callback('~\bhttps?://[^\s<>"]+~i', function ($m) use ($put) {
+        $url = rtrim($m[0], '.,;:!?)');
+        return $put('<a href="' . h($url) . '" rel="nofollow noopener">' . h($url) . '</a>');
     }, $text);

+    // a small allow-listed set of raw HTML tags mixed inline with markdown text
+    // (<sub>, <kbd>, <br>, an <img>/<a> written as HTML instead of markdown, ...);
+    // anything not in MD_HTML_TAGS is left as literal source for h() to escape below.
+    $text = preg_replace_callback(
+        '~<(/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s+[a-zA-Z][a-zA-Z0-9-]*(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'))?)*)\s*/?>~',
+        function ($m) use ($put, $repoName, $ref, $baseDir) {
+            $tag = strtolower($m[2]);
+            if (!array_key_exists($tag, MD_HTML_TAGS)) return $m[0];
+            if ($m[1] === '/') {
+                return in_array($tag, MD_HTML_VOID_TAGS, true) ? '' : $put("</$tag>");
+            }
+            $open = md_render_html_open($tag, $m[3], $repoName, $ref, $baseDir);
+            return $open === null ? $m[0] : $put($open);
+        }, $text);
+
     $text = h($text);
     $text = preg_replace('~\*\*([^*]+)\*\*~', '<strong>$1</strong>', $text);
     $text = preg_replace('~(?<!\*)\*([^*\n]+)\*(?!\*)~', '<em>$1</em>', $text);
-
-    return strtr($text, $store);
+    $text = preg_replace('#~~(.+?)~~#', '<del>$1</del>', $text);
+
+    // strtr() with an array does one single pass over $text — it never re-scans
+    // the replacement HTML it just spliced in. That's fine for one placeholder
+    // level, but a badge-style link wrapping an image (`[![alt](img)](href)`)
+    // nests an image placeholder *inside* the link's stashed HTML, so it needs
+    // a second pass to resolve. Loop until nothing changes (bounded by nesting
+    // depth, which here is at most 2: link > image).
+    while (strpos($text, "\x02") !== false) {
+        $next = strtr($text, $store);
+        if ($next === $text) break;   // remaining \x02 bytes are literal source text, not our placeholders
+        $text = $next;
+    }
+    return $text;
 }

-/** Small, dependency-free markdown → HTML: headings, paragraphs, "-"/"*"
- *  lists, fenced code blocks, and the inline formatting from inline_md(). */
-function markdown_to_html(string $md): string {
+/** Small, dependency-free markdown → HTML: headings (with GitHub-style anchor
+ *  ids so in-page/cross-doc section links work), paragraphs (with hard line
+ *  breaks), ordered/unordered lists (nested by indentation), blockquotes,
+ *  horizontal rules, GFM tables, fenced code blocks, and the inline formatting
+ *  from inline_md(). $repoName/$ref/$baseDir are threaded through to
+ *  inline_md() so relative links/images resolve — see resolve_md_url(). */
+function markdown_to_html(string $md, string $repoName = '', string $ref = '', string $baseDir = ''): string {
     $blocks = [];
-    $md = preg_replace_callback('~```[^\n]*\n(.*?)```~s', function ($m) use (&$blocks) {
-        $key = "\x02fence" . count($blocks) . "\x03";
-        $blocks[$key] = '<pre><code>' . h(rtrim($m[1], "\n")) . '</code></pre>';
+    $md = preg_replace_callback('~```([^\n`]*)\n(.*?)```~s', function ($m) use (&$blocks) {
+        $lang  = trim($m[1]);
+        $class = $lang !== '' ? ' class="language-' . h($lang) . '"' : '';
+        $key   = "\x02fence" . count($blocks) . "\x03";
+        $blocks[$key] = "<pre><code$class>" . h(rtrim($m[2], "\n")) . '</code></pre>';
         return $key;
     }, $md);

-    $html   = [];
-    $inList = false;
-    $para   = [];
-
-    $flushPara = function () use (&$para, &$html) {
-        if ($para) {
-            $html[] = '<p>' . inline_md(implode(' ', $para)) . '</p>';
-            $para = [];
+    $lines = explode("\n", $md);
+    $n     = count($lines);
+    $html  = [];
+    $slugs = [];
+    $para  = [];       // list of ['text' => ..., 'break' => bool hard-break-after]
+    $listStack = [];   // stack of ['type' => 'ul'|'ol', 'indent' => int]
+
+    $flushPara = function () use (&$para, &$html, $repoName, $ref, $baseDir) {
+        if (!$para) return;
+        $joined = '';
+        $last   = count($para) - 1;
+        foreach ($para as $i => $p) {
+            $joined .= $p['text'];
+            if ($i < $last) $joined .= $p['break'] ? "\x02br\x03" : ' ';
+        }
+        $text = inline_md($joined, $repoName, $ref, $baseDir);
+        $text = str_replace("\x02br\x03", "<br>\n", $text);
+        $html[] = '<p>' . $text . '</p>';
+        $para = [];
+    };
+    // A list item's <li> is left unclosed while it might still gain a nested
+    // sub-list (indented lines right after it) — closeCurrentLi() closes it
+    // once we know no more nesting is coming (a sibling item, a dedent, or
+    // the list ending), so nested <ul>/<ol> end up *inside* their parent <li>.
+    $closeCurrentLi = function () use (&$listStack, &$html) {
+        if ($listStack && end($listStack)['liOpen']) {
+            $html[] = '</li>';
+            $listStack[count($listStack) - 1]['liOpen'] = false;
         }
     };
-    $closeList = function () use (&$inList, &$html) {
-        if ($inList) { $html[] = '</ul>'; $inList = false; }
+    $closeLists = function (int $downTo = 0) use (&$listStack, &$html, $closeCurrentLi) {
+        while (count($listStack) > $downTo) {
+            $closeCurrentLi();
+            $html[] = '</' . array_pop($listStack)['type'] . '>';
+        }
     };

-    foreach (explode("\n", $md) as $line) {
-        $trim = rtrim($line);
-        if (preg_match('~^\x02fence\d+\x03$~', trim($trim))) {
-            $flushPara(); $closeList();
-            $html[] = trim($trim);
-        } elseif (trim($trim) === '') {
-            $flushPara(); $closeList();
-        } elseif (preg_match('~^(#{1,6})\s+(.*)$~', trim($trim), $m)) {
-            $flushPara(); $closeList();
-            $level  = strlen($m[1]);
-            $html[] = "<h$level>" . inline_md($m[2]) . "</h$level>";
-        } elseif (preg_match('~^[-*]\s+(.*)$~', trim($trim), $m)) {
+    for ($i = 0; $i < $n; $i++) {
+        $line     = rtrim($lines[$i]);
+        $trimmed  = ltrim($line);
+        $indent   = strlen($line) - strlen($trimmed);
+        $hardBreak = (bool) preg_match('~(?:[ \t]{2,}|\\\\)$~', $lines[$i]);
+
+        if (preg_match('~^\x02fence\d+\x03$~', $trimmed)) {
+            $flushPara(); $closeLists();
+            $html[] = $trimmed;
+            continue;
+        }
+
+        if ($trimmed === '') {
+            $flushPara(); $closeLists();
+            continue;
+        }
+
+        if (preg_match('~^(?:-{3,}|\*{3,}|_{3,})$~', str_replace(' ', '', $trimmed))) {
+            $flushPara(); $closeLists();
+            $html[] = '<hr>';
+            continue;
+        }
+
+        if (preg_match('~^(#{1,6})\s+(.*?)\s*#*\s*$~', $trimmed, $m)) {
+            $flushPara(); $closeLists();
+            $level = strlen($m[1]);
+            $slug  = md_slug($m[2]);
+            if ($slug !== '') {
+                $count = $slugs[$slug] ?? 0;
+                $slugs[$slug] = $count + 1;
+                if ($count > 0) $slug .= '-' . $count;
+            }
+            $idAttr = $slug !== '' ? ' id="' . h($slug) . '"' : '';
+            $html[] = "<h$level$idAttr>" . inline_md($m[2], $repoName, $ref, $baseDir) . "</h$level>";
+            continue;
+        }
+
+        // a block-level HTML wrapper alone on its own line (README pattern:
+        // <div align="center"> ... </div> around an image, a caption, a whole
+        // banner section) — its content is re-parsed as markdown recursively,
+        // same trick as blockquotes above, so headings/images/fences inside
+        // still render instead of becoming inert once wrapped in HTML.
+        if (preg_match('~^<(' . implode('|', MD_HTML_BLOCK_TAGS) . ')((?:\s[^<>]*)?)>$~i', $trimmed, $m)) {
+            $flushPara(); $closeLists();
+            $tag  = strtolower($m[1]);
+            $open = md_render_html_open($tag, $m[2], $repoName, $ref, $baseDir) ?? h($trimmed);
+
+            $depth = 1;
+            $inner = [];
+            $i++;
+            while ($i < $n && $depth > 0) {
+                $t = trim(rtrim($lines[$i]));
+                if (preg_match('~^<' . $tag . '(?:\s[^<>]*)?>$~i', $t)) {
+                    $depth++; $inner[] = $lines[$i]; $i++; continue;
+                }
+                if (preg_match('~^</' . $tag . '\s*>$~i', $t)) {
+                    $depth--;
+                    if ($depth === 0) break;
+                    $inner[] = $lines[$i]; $i++; continue;
+                }
+                $inner[] = $lines[$i]; $i++;
+            }
+            $html[] = $open . markdown_to_html(implode("\n", $inner), $repoName, $ref, $baseDir) . "</$tag>";
+            continue;
+        }
+
+        if ($trimmed[0] === '>') {
+            $flushPara(); $closeLists();
+            $quoteLines = [];
+            while ($i < $n) {
+                $t = trim(rtrim($lines[$i]));
+                if ($t === '' || $t[0] !== '>') break;
+                $quoteLines[] = preg_replace('~^>\s?~', '', $t);
+                $i++;
+            }
+            $i--;
+            $html[] = '<blockquote>' . markdown_to_html(implode("\n", $quoteLines), $repoName, $ref, $baseDir) . '</blockquote>';
+            continue;
+        }
+
+        if (strpos($trimmed, '|') !== false && isset($lines[$i + 1])
+            && preg_match('~^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$~', $lines[$i + 1])) {
+            $flushPara(); $closeLists();
+            $headerCells = md_table_cells($trimmed);
+            $aligns = array_map(function ($c) {
+                $c = trim($c);
+                $left  = str_starts_with($c, ':');
+                $right = str_ends_with($c, ':');
+                if ($left && $right) return 'center';
+                if ($right) return 'right';
+                if ($left) return 'left';
+                return '';
+            }, md_table_cells($lines[$i + 1]));
+
+            $rows = [];
+            $j = $i + 2;
+            while ($j < $n && trim($lines[$j]) !== '' && strpos($lines[$j], '|') !== false) {
+                $rows[] = md_table_cells($lines[$j]);
+                $j++;
+            }
+            $i = $j - 1;
+
+            $cellHtml = fn($tag, $cell, $align) => "<$tag" . ($align !== '' ? ' style="text-align:' . $align . '"' : '')
+                . '>' . inline_md(trim($cell), $repoName, $ref, $baseDir) . "</$tag>";
+
+            $out = '<table><thead><tr>';
+            foreach ($headerCells as $ci => $cell) $out .= $cellHtml('th', $cell, $aligns[$ci] ?? '');
+            $out .= '</tr></thead><tbody>';
+            foreach ($rows as $row) {
+                $out .= '<tr>';
+                foreach ($headerCells as $ci => $_) $out .= $cellHtml('td', $row[$ci] ?? '', $aligns[$ci] ?? '');
+                $out .= '</tr>';
+            }
+            $out .= '</tbody></table>';
+            $html[] = $out;
+            continue;
+        }
+
+        if (preg_match('~^([-*+])\s+(.*)$~', $trimmed, $mUl)) {
+            $flushPara();
+            $type = 'ul'; $content = $mUl[2];
+        } elseif (preg_match('~^\d{1,9}[.)]\s+(.*)$~', $trimmed, $mOl)) {
             $flushPara();
-            if (!$inList) { $html[] = '<ul>'; $inList = true; }
-            $html[] = '<li>' . inline_md($m[1]) . '</li>';
+            $type = 'ol'; $content = $mOl[1];
         } else {
-            $para[] = trim($trim);
+            $type = null;
         }
+
+        if ($type !== null) {
+            while ($listStack && end($listStack)['indent'] > $indent) {
+                $closeCurrentLi();
+                $html[] = '</' . array_pop($listStack)['type'] . '>';
+            }
+            if (!$listStack || end($listStack)['indent'] < $indent) {
+                // deeper than the current level -> nests inside the still-open parent <li>
+                $html[] = "<$type>";
+                $listStack[] = ['type' => $type, 'indent' => $indent, 'liOpen' => false];
+            } elseif (end($listStack)['type'] !== $type) {
+                $closeCurrentLi();
+                $html[] = '</' . array_pop($listStack)['type'] . '>';
+                $html[] = "<$type>";
+                $listStack[] = ['type' => $type, 'indent' => $indent, 'liOpen' => false];
+            } else {
+                $closeCurrentLi();
+            }
+            $html[] = '<li>' . inline_md($content, $repoName, $ref, $baseDir);
+            $listStack[count($listStack) - 1]['liOpen'] = true;
+            continue;
+        }
+
+        $para[] = ['text' => $trimmed, 'break' => $hardBreak];
     }
-    $flushPara(); $closeList();
+    $flushPara(); $closeLists();

     return strtr(implode("\n", $html), $blocks);
 }
diff --git a/index.php b/index.php
index 05e7c55..8f69640 100644
--- a/index.php
+++ b/index.php
@@ -89,9 +89,10 @@ if ($action === 'raw') {                                 // raw/plain file downl
             if ($detected) $mime = $detected;
         }
     }
+    $disposition = isset($_GET['dl']) ? 'attachment' : 'inline';
     header('Content-Type: ' . $mime);
     header('Content-Length: ' . (string) strlen($content));
-    header('Content-Disposition: inline; filename="' . basename($blob) . '"');
+    header('Content-Disposition: ' . $disposition . '; filename="' . basename($blob) . '"');
     header('X-Content-Type-Options: nosniff');
     echo $content;
     exit;
@@ -203,14 +204,24 @@ if (isset($_GET['blob'])) {                             // single file contents
         render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
         exit;
     }
-    $size     = (int) $sizeOut[0];
-    $tooLarge = $size > MAX_BLOB_BYTES;
-    $content  = $tooLarge ? '' : git_bytes($repo, ['show', $spec]);
-    $isBinary = !$tooLarge && strpos($content, "\0") !== false;
+    $size       = (int) $sizeOut[0];
+    $isImage    = is_image_ext($blob);
+    $isMarkdown = is_markdown_ext($blob);
+    // images are shown via <img src="raw-url">, never dumped inline as text,
+    // so the size cap (meant to protect the "paste it into a <pre>" path) doesn't apply to them
+    $tooLarge   = !$isImage && $size > MAX_BLOB_BYTES;
+    $content    = ($tooLarge || $isImage) ? '' : git_bytes($repo, ['show', $spec]);
+    $isBinary   = !$tooLarge && !$isImage && strpos($content, "\0") !== false;
+
+    $dir          = dirname($blob);
+    $renderedHtml = ($isMarkdown && !$tooLarge && !$isBinary)
+        ? markdown_to_html($content, $repoName, $ref, $dir === '.' ? '' : $dir)
+        : null;

     render('blob', [
         'repoName' => $repoName, 'ref' => $ref, 'blob' => $blob, 'size' => $size,
-        'tooLarge' => $tooLarge, 'isBinary' => $isBinary, 'content' => $content,
+        'tooLarge' => $tooLarge, 'isBinary' => $isBinary, 'isImage' => $isImage,
+        'content' => $content, 'renderedHtml' => $renderedHtml,
     ]);
     render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
     exit;
@@ -222,7 +233,7 @@ $treeish = $path === '' ? $ref : "$ref:$path";
 $rawTree = git_bytes($repo, ['ls-tree', '--long', '-z', $treeish]);

 // README/LICENSE belong to the repo root, so they only show on the top-level tree
-$docs = $path === '' ? root_docs($repo, $ref) : ['readmeName' => null, 'readmeHtml' => null, 'licenseName' => null];
+$docs = $path === '' ? root_docs($repo, $repoName, $ref) : ['readmeName' => null, 'readmeHtml' => null, 'licenseName' => null];

 render('tree', [
     'repoName' => $repoName, 'ref' => $ref, 'path' => $path,
diff --git a/views/blob.php b/views/blob.php
index 6540632..a8d7217 100644
--- a/views/blob.php
+++ b/views/blob.php
@@ -1,13 +1,26 @@
 <?php
 /** @var string $repoName @var string $ref @var string $blob @var int $size
- *  @var bool $tooLarge @var bool $isBinary @var string $content */
+ *  @var bool $tooLarge @var bool $isBinary @var bool $isImage
+ *  @var string $content @var ?string $renderedHtml */
 $rawHref = '?r=' . $repoName . '&a=raw&ref=' . $ref . '&blob=' . $blob;
+$dlHref  = $rawHref . '&dl=1';
 ?>
-<p class="desc">/<?= h($blob) ?> · <?= h(format_size((string) $size)) ?> · <a href="<?= h($rawHref) ?>">raw</a></p>
-<?php if ($tooLarge): ?>
+<div class="blob-toolbar">
+  <p class="desc">/<?= h($blob) ?> · <?= h(format_size((string) $size)) ?></p>
+  <span class="spacer"></span>
+  <a class="desc" href="<?= h($rawHref) ?>">raw</a>
+  <a class="icon-btn" href="<?= h($dlHref) ?>" download title="Download" aria-label="Download <?= h(basename($blob)) ?>">
+    <svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"></path><path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"></path></svg>
+  </a>
+</div>
+<?php if ($isImage): ?>
+<div class="box img-frame"><img src="<?= h($rawHref) ?>" alt="<?= h($blob) ?>"></div>
+<?php elseif ($tooLarge): ?>
 <p class="desc">File too large to display inline — use the raw link above.</p>
 <?php elseif ($isBinary): ?>
 <p class="desc">Binary file — use the raw link above.</p>
+<?php elseif ($renderedHtml !== null): ?>
+<div class="box readme"><div class="body"><?= $renderedHtml ?></div></div>
 <?php else: ?>
 <pre><?= h($content) ?></pre>
 <?php endif; ?>
diff --git a/views/repo-index.php b/views/repo-index.php
index 3487ee1..dc82373 100644
--- a/views/repo-index.php
+++ b/views/repo-index.php
@@ -11,6 +11,20 @@
     </td>
     <td><?= h($meta['desc']) ?></td>
     <td><?= $meta['mtime'] !== 0 ? h(date('Y-m-d', $meta['mtime'])) : '' ?></td>
+    <td>
+      <?php if ($meta['languages']): ?>
+      <div class="lang-bar">
+        <?php foreach ($meta['languages'] as $lang): ?>
+          <span style="width:<?= h(number_format($lang['pct'], 2, '.', '')) ?>%;background:<?= h($lang['color']) ?>" title="<?= h($lang['name']) ?> <?= h(number_format($lang['pct'], 1, '.', '')) ?>%"></span>
+        <?php endforeach; ?>
+      </div>
+      <div class="lang-legend">
+        <?php foreach ($meta['languages'] as $lang): ?>
+          <span class="lang-legend-item"><i style="background:<?= h($lang['color']) ?>"></i><?= h($lang['name']) ?> <?= h(number_format($lang['pct'], 1, '.', '')) ?>%</span>
+        <?php endforeach; ?>
+      </div>
+      <?php endif; ?>
+    </td>
   </tr>
 <?php endforeach; ?>
 </table>