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: 
+ $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 (`[](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>