A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
<?php
declare(strict_types=1);
/*
* All the "content" logic: talking to git, validating input, and turning
* raw git output into plain PHP arrays. Nothing in this file prints HTML —
* that's what views/ is for. The one exception is h(), colorize_diff() and
* markdown_to_html(), which are pure string->string transforms (same
* category as htmlspecialchars() itself) rather than page markup.
*/
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
/** Every *.css file in THEME_DIR is a theme (just CSS custom properties — see themes/foxygit-dark.css). */
function available_themes(): array {
$out = [];
foreach (glob(THEME_DIR . '/*.css') ?: [] as $f) {
$out[] = basename($f, '.css');
}
sort($out);
return $out !== [] ? $out : [THEME_DEFAULT];
}
function current_theme(): string {
$themes = available_themes();
$requested = $_GET['theme'] ?? $_COOKIE['foxygit_theme'] ?? THEME_DEFAULT;
if (is_string($requested) && in_array($requested, $themes, true)) return $requested;
return in_array(THEME_DEFAULT, $themes, true) ? THEME_DEFAULT : $themes[0];
}
/** A theme is "light" if its name ends in -light; everything else counts as dark.
* Purely a naming convention — it's what pairs foxygit-dark with foxygit-light. */
function theme_is_dark(string $theme): bool {
return !str_ends_with($theme, '-light');
}
/** The opposite-mode sibling of a theme (foxygit-dark <-> foxygit-light), or
* null when the theme has no counterpart on disk — the sun/moon toggle hides
* itself in that case rather than linking somewhere that doesn't exist. */
function theme_counterpart(string $theme): ?string {
if (str_ends_with($theme, '-dark')) {
$alt = substr($theme, 0, -strlen('-dark')) . '-light';
} elseif (str_ends_with($theme, '-light')) {
$alt = substr($theme, 0, -strlen('-light')) . '-dark';
} else {
return null;
}
return in_array($alt, available_themes(), true) ? $alt : null;
}
/** Rebuild the current query string with $overrides applied — lets the theme
* controls switch theme without losing which repo/view/ref you're looking at. */
function current_query(array $overrides = []): string {
$params = array_filter($_GET, 'is_string');
return '?' . http_build_query(array_merge($params, $overrides));
}
/** Run git inside $repo with an argument array. Each arg is shell-escaped.
* Line-based: use for commands whose output you want to read as text lines
* (log, refs, ls-tree, cat-file -s, ...). Not binary-safe — see git_bytes(). */
function git(string $repo, array $args): array {
$cmd = 'git -c core.quotepath=false -C ' . escapeshellarg($repo);
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
exec($cmd . ' 2>/dev/null', $out);
return $out;
}
function git_raw(string $repo, array $args): string {
return implode("\n", git($repo, $args));
}
/** Binary-safe capture of git's stdout, byte for byte (no line splitting/
* rejoining). Use for blob content and raw downloads — exec() above would
* mangle trailing newlines and can misrepresent binary content. */
function git_bytes(string $repo, array $args): string {
$cmd = 'git -c core.quotepath=false -C ' . escapeshellarg($repo);
foreach ($args as $a) {
$cmd .= ' ' . escapeshellarg($a);
}
$proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($proc)) return '';
$data = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($proc);
return $data === false ? '' : $data;
}
/** Cache-busting URL for a static asset — appends the file's mtime as ?v=.
* Without it an edited stylesheet keeps being served from the visitor's
* browser cache (and, for this site, from Cloudflare's edge cache, which
* holds on to .css for a long time), so CSS changes appear not to deploy. */
function asset_url(string $rel): string {
$mtime = @filemtime(__DIR__ . '/../' . $rel);
return $mtime !== false ? $rel . '?v=' . $mtime : $rel;
}
/** Validate + resolve a repo name to an absolute path INSIDE REPO_BASE. */
function resolve_repo(?string $name): ?string {
if ($name === null || !valid_repo_name($name)) {
return null; // reject junk / traversal chars
}
$base = realpath(REPO_BASE);
$path = realpath(REPO_BASE . '/' . $name);
if ($base === false || $path === false) return null;
if (strncmp($path, $base . '/', strlen($base) + 1) !== 0) return null; // must be under base
if (!is_dir($path)) return null;
if (git($path, ['rev-parse', '--git-dir']) === []) return null; // must be a git repo
return $path;
}
/** Same name rule the SSH-side git-shell-commands/{create,delete} enforce:
* no path separators, no leading dot (hidden dirs) or dash (looks like a
* flag), plain ASCII identifier characters only. */
function valid_repo_name(string $name): bool {
return $name !== '' && preg_match('~^[A-Za-z0-9._-]+$~', $name) === 1
&& $name[0] !== '.' && $name[0] !== '-';
}
/** Create a new bare repo under REPO_BASE, pre-configured so it accepts
* push over authenticated HTTPS (see server/git-http-backend-auth) the
* same way server/git-shell-commands/create does for SSH.
* @return true|string true on success, an error message otherwise. */
function create_bare_repo(string $name) {
if (!valid_repo_name($name)) return 'Invalid repo name.';
$dest = REPO_BASE . '/' . $name . '.git';
if (file_exists($dest)) return "A repo named \"$name\" already exists.";
$cmd = 'git init --bare -- ' . escapeshellarg($dest) . ' 2>&1';
exec($cmd, $out, $status);
if ($status !== 0) return 'git init failed: ' . implode(' ', $out);
exec('git -C ' . escapeshellarg($dest) . ' config http.receivepack true 2>&1', $out2, $status2);
if ($status2 !== 0) return 'repo created, but enabling HTTPS push failed: ' . implode(' ', $out2);
return true;
}
/** Permanently deletes a bare repo under REPO_BASE.
* @return true|string true on success, an error message otherwise. */
function delete_bare_repo(string $name) {
if (!valid_repo_name($name)) return 'Invalid repo name.';
$dest = REPO_BASE . '/' . $name . '.git';
$real = realpath($dest);
$base = realpath(REPO_BASE);
if ($real === false || $base === false || strncmp($real, $base . '/', strlen($base) + 1) !== 0) {
return 'No such repo.';
}
exec('rm -rf -- ' . escapeshellarg($real) . ' 2>&1', $out, $status);
if ($status !== 0) return 'delete failed: ' . implode(' ', $out);
return true;
}
/** Accept only a plausible ref/path so we never feed dangerous junk to git.
* Blocks: empty, overlong, a leading '-' (which git would read as a flag
* rather than a revision/path — the actual injection risk), '..' (path
* traversal) and raw control characters. Otherwise permissive on purpose:
* real filenames use plenty of punctuation and non-ASCII characters (e.g.
* åäö), and an ASCII-only whitelist would just make those files unreachable. */
function safe_ref(string $ref): bool {
return $ref !== ''
&& strlen($ref) <= 200
&& $ref[0] !== '-'
&& strpos($ref, '..') === false
&& !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. */
function repo_last_activity(string $path): int {
$t = [@filemtime($path . '/HEAD') ?: 0, @filemtime($path . '/packed-refs') ?: 0];
$head = @file_get_contents($path . '/HEAD');
if ($head !== false && preg_match('~^ref:\s*(\S+)~', trim($head), $m)) {
$t[] = @filemtime($path . '/' . $m[1]) ?: 0;
}
return max($t);
}
/** The repo's description, straight from the standard git `description` file
* (the same one `git init` seeds with "Unnamed repository..." — that
* placeholder is treated as "no description set", not real content).
* Set one with `ssh git@host describe <repo> <text>` — see server/. */
function repo_description(string $path): string {
$desc = @file_get_contents($path . '/description');
if ($desc === false || strpos($desc, 'Unnamed repository') !== false) return '';
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),
'languages' => repo_language_stats($p),
];
}
uasort($out, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
return $out;
}
/** Branch names for the branch pills in the repo subnav (log/tree views). */
function list_branches(string $repo): array {
return git($repo, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
}
/** Find a root-level file matching $pattern in $ref's tree (e.g. README, LICENSE). */
function find_root_file(string $repo, string $ref, string $pattern): ?string {
$out = git($repo, ['ls-tree', '--name-only', '-z', $ref]);
$blob = $out[0] ?? '';
foreach (explode("\0", $blob) as $name) {
if ($name !== '' && preg_match($pattern, $name)) return $name;
}
return null;
}
/** 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).
* $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');
$readme = find_root_file($repo, $ref, '~^readme(\.(md|markdown|mdown|mkd|txt))?$~i');
if ($readme === null) return $out;
$sizeOut = git($repo, ['cat-file', '-s', "$ref:$readme"]);
$size = isset($sizeOut[0]) ? (int) $sizeOut[0] : PHP_INT_MAX;
if ($size > MAX_BLOB_BYTES) return $out;
$content = git_bytes($repo, ['show', "$ref:$readme"]);
if (strpos($content, "\0") !== false) return $out; // binary, not a readable README
$out['readmeName'] = $readme;
$out['readmeHtml'] = markdown_to_html($content, $repoName, $ref, '');
return $out;
}
/** 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";
$store[$key] = $html;
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);
// 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);
$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 (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) {
$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);
$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;
}
};
$closeLists = function (int $downTo = 0) use (&$listStack, &$html, $closeCurrentLi) {
while (count($listStack) > $downTo) {
$closeCurrentLi();
$html[] = '</' . array_pop($listStack)['type'] . '>';
}
};
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();
$type = 'ol'; $content = $mOl[1];
} else {
$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(); $closeLists();
return strtr(implode("\n", $html), $blocks);
}
function colorize_diff(string $diff): string {
$lines = [];
foreach (explode("\n", $diff) as $line) {
$e = h($line);
if ($line === '') { $lines[] = ''; continue; }
if (strpos($line, '@@') === 0) $lines[] = '<span class="hunk">' . $e . '</span>';
elseif ($line[0] === '+') $lines[] = '<span class="add">' . $e . '</span>';
elseif ($line[0] === '-') $lines[] = '<span class="del">' . $e . '</span>';
else $lines[] = $e;
}
return implode("\n", $lines);
}
/** Turn `%H\x1f%h\x1f%an\x1f%at\x1f%s` log lines into plain arrays for views/log.php. */
function parse_log_lines(array $lines): array {
$out = [];
foreach ($lines as $l) {
[$full, $short, $an, $at, $subj] = array_pad(explode("\x1f", $l), 5, '');
$out[] = [
'hash' => $full,
'short' => $short,
'author' => $an,
'at' => $at !== '' ? (int) $at : null,
'subject' => $subj,
];
}
return $out;
}
/** Group already-parsed log entries into ['date' => 'YYYY-MM-DD', 'entries' => [...]]
* buckets, most recent day first, preserving each day's original commit order. */
function group_log_by_date(array $entries): array {
$groups = [];
foreach ($entries as $e) {
$day = $e['at'] !== null ? date('Y-m-d', $e['at']) : '';
if (!isset($groups[$day])) $groups[$day] = ['date' => $day, 'entries' => []];
$groups[$day]['entries'][] = $e;
}
return array_values($groups);
}
/** Turn `%(refname:short)\x1f%(refname)` for-each-ref lines into plain arrays for views/refs.php. */
function parse_refs(array $lines): array {
$out = [];
foreach ($lines as $r) {
[$short, $full] = array_pad(explode("\x1f", $r), 2, '');
$out[] = ['short' => $short, 'kind' => strpos($full, 'refs/tags/') === 0 ? 'tag' : 'branch'];
}
return $out;
}
/** Turn `ls-tree --long -z` output into plain arrays for views/tree.php. */
function parse_tree(string $rawTree, string $path): array {
$entries = $rawTree === '' ? [] : explode("\0", rtrim($rawTree, "\0"));
$out = [];
foreach ($entries as $e) {
// <mode> <type> <object> <size>\t<name>
if (!preg_match('~^(\d+)\s+(\w+)\s+[0-9a-f]+\s+(\S+)\t(.+)$~', $e, $m)) continue;
[, $mode, $type, $size, $name] = $m;
$out[] = [
'mode' => $mode,
'isTree' => $type === 'tree',
'size' => $size === '-' ? '' : $size,
'name' => $name,
'child' => $path === '' ? $name : "$path/$name",
];
}
return $out;
}
/** Turn `%H\x1f%an\x1f%ae\x1f%at\x1f%s\x1f%b\x1e`-delimited log output into
* plain arrays for views/atom.php and views/atom-tags.php. */
function parse_atom_log(string $raw, string $tag = ''): array {
$out = [];
foreach ($raw === '' ? [] : explode("\x1e", $raw) as $rec) {
$rec = ltrim($rec, "\n");
if ($rec === '') continue;
[$hash, $an, $ae, $at, $subj, $body] = array_pad(explode("\x1f", $rec, 6), 6, '');
$out[] = [
'hash' => $hash, 'author' => $an, 'email' => $ae,
'at' => (int) $at, 'subject' => $subj, 'body' => $body, 'tag' => $tag,
];
}
return $out;
}
const ATOM_LOG_FORMAT = '%H%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1e';
function atom_commit_entries(string $repo, string $ref): array {
return parse_atom_log(git_raw($repo, ['log', '-n', (string) ATOM_COUNT, '--pretty=format:' . ATOM_LOG_FORMAT, $ref]));
}
function atom_tag_entries(string $repo): array {
$out = [];
foreach (git($repo, ['for-each-ref', '--sort=-creatordate', '--format=%(refname:short)', 'refs/tags']) as $tag) {
$raw = git_raw($repo, ['log', '-1', '--pretty=format:' . ATOM_LOG_FORMAT, $tag]);
$entries = parse_atom_log($raw, $tag);
if ($entries) $out[] = $entries[0];
}
return $out;
}
/** The <content type="text"> body of one atom entry: "commit HASH\nAuthor: ...\n\nsubject\n\nbody". */
function atom_content_text(array $entry): string {
$text = "commit {$entry['hash']}\n";
if ($entry['author'] !== '') $text .= 'Author: ' . $entry['author'] . ($entry['email'] !== '' ? " <{$entry['email']}>" : '') . "\n";
if ($entry['at'] > 0) $text .= 'Date: ' . date('D M j H:i:s Y O', $entry['at']) . "\n";
return $text . "\n" . $entry['subject'] . ($entry['body'] !== '' ? "\n\n" . rtrim($entry['body']) : '');
}
/** The clone URL: avoids "repo.git.git" if $repoName already ends in .git. */
function clone_url(string $repoName): string {
return CLONE_BASE . (str_ends_with($repoName, '.git') ? $repoName : $repoName . '.git');
}
/** Byte count as a human-readable size: "218 B", "1.06 KB", "4.2 MB".
* Takes a string because that's what ls-tree gives us, and '' (directories,
* which have no size) passes straight through as ''. Trailing zeros are
* trimmed so round numbers read "1 KB", not "1.00 KB". */
function format_size(string $bytes): string {
if ($bytes === '' || !ctype_digit($bytes)) return '';
$n = (int) $bytes;
if ($n < 1024) return $n . ' B';
$units = ['KB', 'MB', 'GB', 'TB', 'PB'];
$i = -1;
$v = (float) $n;
do {
$v /= 1024;
$i++;
// round first: 1048575 B is 1023.999 KB, which would print as "1024 KB"
// once rounded to 2 decimals -- step up a unit instead.
} while (round($v, 2) >= 1024 && $i < count($units) - 1);
return rtrim(rtrim(number_format($v, 2, '.', ''), '0'), '.') . ' ' . $units[$i];
}
/** For each tree entry, the most recent commit that touched its path -- the
* "last commit message / updated 4 months ago" columns in views/tree.php.
*
* One `git log` call total, not one per entry: git log accepts several
* pathspecs at once and walks history a single time checking commits
* against all of them, versus N separate walks for N single-path calls.
* --name-only lists, per commit, which of our target paths it touched;
* we scan newest-first and take each entry's first (= most recent) match.
* A directory entry's "touched" files are anything under it (prefix match)
* since git tracks blobs, never bare directory paths, in that list. */
function annotate_last_commits(string $repo, string $ref, array $entries): array {
if (!$entries) return $entries;
$paths = array_map(fn($e) => $e['child'], $entries);
$raw = git_raw($repo, array_merge(
['log', $ref, '--name-only', '--pretty=format:%x1e%H%x1f%s%x1f%at'],
['--'], $paths
));
$answers = []; // child path -> ['hash', 'subject', 'at']
$remaining = count($entries);
foreach ($raw === '' ? [] : explode("\x1e", $raw) as $record) {
if ($remaining === 0) break;
if ($record === '') continue;
$lines = explode("\n", $record);
$header = array_shift($lines);
[$hash, $subject, $at] = array_pad(explode("\x1f", $header), 3, '');
foreach ($entries as $e) {
$child = $e['child'];
if (isset($answers[$child])) continue;
foreach ($lines as $file) {
if ($file === $child || ($e['isTree'] && str_starts_with($file, $child . '/'))) {
$answers[$child] = ['hash' => $hash, 'subject' => $subject, 'at' => (int) $at];
$remaining--;
break;
}
}
}
}
foreach ($entries as &$e) {
$info = $answers[$e['child']] ?? null;
$e['lastHash'] = $info['hash'] ?? '';
$e['lastSubject'] = $info['subject'] ?? '';
$e['lastAt'] = $info['at'] ?? null;
}
unset($e);
return $entries;
}
/** "4 months ago", "2 days ago", "just now" -- coarse, GitHub-style relative time. */
function format_relative_time(int $timestamp): string {
$diff = time() - $timestamp;
if ($diff < 60) return 'just now';
foreach ([
31536000 => 'year', 2592000 => 'month', 604800 => 'week',
86400 => 'day', 3600 => 'hour', 60 => 'minute',
] as $secs => $unit) {
$n = intdiv($diff, $secs);
if ($n >= 1) return $n . ' ' . $unit . ($n === 1 ? '' : 's') . ' ago';
}
return 'just now';
}
/** Truncate to $max chars with a trailing ellipsis; multibyte-safe without
* needing the mbstring extension (not guaranteed installed) -- PCRE's `u`
* modifier is built into PHP core, so splitting into UTF-8 characters this
* way needs nothing extra. Full text belongs in a title="" attribute
* wherever this is used for display. */
function truncate(string $s, int $max): string {
$chars = preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY);
if ($chars === false || count($chars) <= $max) return $s;
return implode('', array_slice($chars, 0, $max - 1)) . '…';
}
/** Repo name as shown to a reader. Bare repos are directories called `name.git`,
* but that suffix is just noise in headings and listings — so it's dropped for
* display only. URLs and clone_url() keep using the real directory name. */
function repo_display_name(string $repoName): string {
return str_ends_with($repoName, '.git') ? substr($repoName, 0, -strlen('.git')) : $repoName;
}