Nimbin[12]?Web & Tools / Git-Server / commits / 85313b7

Git-Server git · main

gitsync + this read-only git browser

git php bash apache self-hosted · first commit 2026-10-05 · last commit 2026-10-06 (3 days ago) · synced 3 days ago

PHP 77.1% CSS 11.4% Markdown 8.6% Makefile 2.9%
git clone https://git.christianimmanuel.de/web-tools/Git-Server.gitwget https://git.christianimmanuel.de/web-tools/Git-Server/archive/Git-Server.tar.gz

Fix lfs-backup

Christian Immanuel · 2026-10-05 14:59 · 85313b7c643ea45171230a199e522269096ae109

 README.md     |  16 +-
 gitsync       |  77 +++++++-
 gitsync.conf  |   3 +-
 index.php     | 569 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 web/index.php |   2 +-
 5 files changed, 663 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index f5b45c7..4593501 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@ Prefix a config line with `!` to publish it under `/private` instead of the publ
 
 ```
 [Private]
-!~/LFS/lfs-config | My LFS config | lfs
+!~/Git/notes | My private notes | notes
 ```
 
 Everything below `/private` (pages, raw files, tarballs, `git clone`) is protected by HTTP Basic auth in
@@ -113,6 +113,20 @@ sudo htpasswd -cB /var/www/html/git.christianimmanuel.de/.htpasswd NAME    # -c
 ```
 
 Fetch with `wget --user=NAME --ask-password URL` or `git clone https://NAME@git.christianimmanuel.de/private/cat/name.git`.
+
+### lfs-backup config
+
+Publish exactly the files an `lfs-backup` config lists, always private:
+
+```
+[Private]
+!lfs-backup < /etc/pkgusr/backup.conf | My LFS system configuration | lfs, config
+```
+
+- Layout on the site: `SECTION/path`, e.g. `user-shell/.bashrc`, `system-boot/etc/fstab`.
+- Globs, directories, symlinks and `!` exclusions work like in `lfs-backup`.
+- Secret files (private keys, `psk=`/`password=` lines) are left out and listed. `-f` takes them.
+- gitsync runs as your user: files only root can read (`[root:*]`, host keys) are left out and listed.
 Apache enforces this in `-common.conf`; `index.php` additionally checks the login itself against `.htpasswd` (bcrypt), so pages, raw files and tarballs stay closed even with an outdated vhost.
 
 ## Scripts collection
diff --git a/gitsync b/gitsync
index 2dd4d62..b6852ec 100755
--- a/gitsync
+++ b/gitsync
@@ -3,7 +3,7 @@
 # 100% Vibecode but tested.
 set -eu
 
-VERSION="1.9.3"
+VERSION="1.10.1"
 CONF="${GITSYNC_CONF:-$HOME/.config/gitsync/gitsync.conf}"
 STAGE="${GITSYNC_STAGE:-$HOME/.cache/gitsync}"
 DRYRUN=0 LOCAL=0 VERBOSE=0 FORCE=0 LIST=0 QUIET=0 SSH_KEY=""
@@ -30,6 +30,8 @@ gitsync $VERSION - sync repos listed in $CONF
 Config: HOST=user@server, REMOTE_DIR=/var/www/html/site, then
 [Category] sections with lines:  /path | description | tag1, tag2
 Prefix a line with ! to publish it under /private (HTTP auth, no secret scan).
+A line  !NAME < /etc/pkgusr/backup.conf  publishes the files an lfs-backup config lists
+(private; secret files left out unless -f).
 Optional: SSH_KEY=~/.ssh/gitsync_ed25519 (key for the upload, no password prompt),
           SCRIPTS=~/Bash-Public (script collection, subdirs = groups),
           EXCLUDE=a,b (extra excludes), MAX_SIZE=50m (skip bigger files),
@@ -131,6 +133,69 @@ sync_plain() { # src dst desc tags
   write_meta "$dst/.gitsync.meta" "$3" "$4" "" "$created" "$modified" "$langs"
 }
 
+# --- lfs-backup projects:  !NAME < /etc/pkgusr/backup.conf ------------------
+# Publishes the files a backup.conf lists, privately. Layout: SECTION/path,
+# e.g. user-shell/.bashrc, system-boot/etc/fstab. Secret files are left out (-f takes them).
+is_secret() { # file
+  printf '%s\n' "$1" | grep -qiE "$SECRET_FILES" && return 0
+  grep -qiIE -e "$SECRET_RE" -e '^[[:space:]]*(psk|password|private_key_passwd)[[:space:]]*=' "$1" 2>/dev/null
+}
+
+sync_backup() { # backup.conf dst desc tags
+  local conf="$1" dst="$2" line sec="" home="" rhome p m f rel pat d skip n=0 ex=() secrets=() unread=()
+  rhome=$(getent passwd root | cut -d: -f6); rhome="${rhome:-/root}"
+  while IFS= read -r line || [ -n "$line" ]; do
+    line=$(trim "$line"); [ "${line:0:1}" = "!" ] && ex+=("${line#!}")
+  done < "$conf"
+  rm -rf "$dst"; mkdir -p "$dst"
+  shopt -s dotglob
+  while IFS= read -r line || [ -n "$line" ]; do
+    line=$(trim "$line")
+    case "$line" in
+      ''|\#*|\!*) continue ;;
+      \[user:*\])   sec="user-${line:6:-1}";   home="$HOME" ;;
+      \[root:*\])   sec="root-${line:6:-1}";   home="$rhome"
+                    [ -r "$rhome" ] && [ -x "$rhome" ] || unread+=("[root:${line:6:-1}]") ;;
+      \[system:*\]) sec="system-${line:8:-1}"; home="" ;;
+      \[*)          warn "$(basename "$conf"): unknown section $line"; sec="" ;;
+      *)
+        [ -n "$sec" ] || continue
+        case "$sec" in root-*) [ -r "$rhome" ] && [ -x "$rhome" ] || continue ;; esac
+        p="$line"; [ -n "$home" ] && p="${p/#\~/$home}"
+        m=$(compgen -G "$p" || true)
+        if [ -z "$m" ]; then
+          d=$(dirname "$p"); [ -d "$d" ] && { [ -r "$d" ] && [ -x "$d" ] || unread+=("$line"); }
+          continue
+        fi
+        while IFS= read -r m; do
+          while IFS= read -r -d '' f; do
+            skip=0
+            for pat in "${ex[@]:-}"; do
+              [ -n "$pat" ] || continue
+              pat="${pat/#\~/${home:-$HOME}}"
+              # shellcheck disable=SC2053
+              [[ "$f" == $pat ]] && { skip=1; break; }
+            done
+            [ "$skip" = 1 ] && continue
+            if [ ! -r "$f" ]; then unread+=("$f"); continue; fi
+            if [ "$FORCE" = 0 ] && is_secret "$f"; then secrets+=("$f"); continue; fi
+            rel="${f#"$home"/}"; rel="${rel#/}"
+            mkdir -p "$dst/$sec/$(dirname "$rel")"
+            cp -L --preserve=timestamps "$f" "$dst/$sec/$rel" && n=$((n + 1))
+          done < <(find -L "$m" -type f -print0 2>/dev/null)
+        done <<< "$m" ;;
+    esac
+  done < "$conf"
+  shopt -u dotglob
+  [ "${#secrets[@]}" -gt 0 ] && warn "$(basename "$dst"): ${#secrets[@]} secret file(s) left out (-f takes them): $(printf '%s\n' "${secrets[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)"
+  [ "${#unread[@]}" -gt 0 ] && warn "$(basename "$dst"): not readable as $(id -un), left out: $(printf '%s\n' "${unread[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)"
+  [ "$VERBOSE" = 1 ] && log "$(basename "$dst"): $n files"
+  write_meta "$dst/.gitsync.meta" "$3" "$4" "" \
+    "$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | head -n1 | cut -d. -f1)" \
+    "$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | tail -n1 | cut -d. -f1)" \
+    "$(find "$dst" -type f -printf '%s %p\n' | lang_stats)"
+}
+
 HOST="" REMOTE_DIR="" SCRIPTS="" cat="" cslug="" BLOCKED=0
 mkdir -p "$STAGE"
 : > "$STAGE/.categories.new"
@@ -158,6 +223,16 @@ while IFS= read -r line || [ -n "$line" ]; do
       IFS='|' read -r path desc tags <<< "$line"
       path=$(trim "$path"); desc=$(trim "${desc:-}"); tags=$(trim "${tags:-}")
       tags=$(printf '%s' "$tags" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | tr '[:upper:]' '[:lower:]' | paste -sd, - || true)
+      if [[ "$path" == *"<"* ]]; then   # NAME < backup.conf: always private
+        bconf=$(trim "${path#*<}"); bconf="${bconf/#\~/$HOME}"; name=$(trim "${path%%<*}")
+        [ -n "$name" ] || name=$(basename "$bconf" .conf)
+        [ -r "$bconf" ] || { warn "missing or not readable: $bconf"; continue; }
+        if [ "$LIST" = 1 ]; then printf '%-22s %-45s %-40s %s  [private, backup.conf]\n' "[$cat]" "$name < $bconf" "$desc" "$tags"; continue; fi
+        dest="$STAGE/.private/$cslug/$name"; mkdir -p "$STAGE/.private/$cslug"
+        log "$cat / $name (backup.conf, private)"
+        [ "$DRYRUN" = 1 ] || sync_backup "$bconf" "$dest" "$desc" "$tags"
+        KEEP+=("$dest"); continue
+      fi
       path="${path/#\~/$HOME}"
       name=$(basename "$path")
       if [ ! -d "$path" ]; then warn "missing: $path"; continue; fi
diff --git a/gitsync.conf b/gitsync.conf
index a635f8b..6535d37 100644
--- a/gitsync.conf
+++ b/gitsync.conf
@@ -65,7 +65,8 @@ SCRIPTS=~/Bash-Public
 
 [Private]
 # ! = only reachable via /private with the password from .htpasswd; not scanned for secrets
-!~/LFS/lfs-config                   | My LFS system configuration                                | lfs, config, dotfiles
+# NAME < backup.conf = the files your lfs-backup config lists (secret files left out, -f takes them)
+!lfs-backup < /etc/pkgusr/backup.conf | My LFS system configuration                                | lfs, config, dotfiles
 
 [Mirrors & Forks]
 ~/Git/jhalfs                        | jhalfs LFS build automation (mirror)                       | lfs, mirror, bash
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..661c598
--- /dev/null
+++ b/index.php
@@ -0,0 +1,569 @@
+<?php
+declare(strict_types=1);
+/* git.christianimmanuel.de - read-only git browser. 100% Vibecode but tested. Works in lynx. */
+const VERSION = '1.10.0';
+const SITE = 'Nimbin[12]?';
+const LEGAL = 'https://christianimmanuel.de';
+$ROOT   = __DIR__ . '/repos';
+$PREFIX = '';                                   /* '/private' when browsing the protected area */
+$HOST   = $_SERVER['HTTP_HOST'] ?? 'git.christianimmanuel.de';
+$SCHEME = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+$BASE   = "$SCHEME://$HOST";
+$LANG   = ['c'=>'c','h'=>'c','cpp'=>'cpp','cc'=>'cpp','hpp'=>'cpp','py'=>'python','sh'=>'bash','bash'=>'bash',
+           'php'=>'php','js'=>'javascript','ts'=>'typescript','html'=>'xml','xml'=>'xml','css'=>'css','json'=>'json',
+           'yml'=>'yaml','yaml'=>'yaml','rs'=>'rust','go'=>'go','java'=>'java','vim'=>'vim','mk'=>'makefile',
+           'makefile'=>'makefile','ini'=>'ini','conf'=>'ini','toml'=>'ini','sql'=>'sql','lua'=>'lua','glsl'=>'glsl',
+           'vert'=>'glsl','frag'=>'glsl','s'=>'x86asm','asm'=>'x86asm','diff'=>'diff','patch'=>'diff'];
+$MIME   = ['png'=>'image/png','jpg'=>'image/jpeg','jpeg'=>'image/jpeg','gif'=>'image/gif','svg'=>'image/svg+xml',
+           'webp'=>'image/webp','pdf'=>'application/pdf','ico'=>'image/x-icon'];
+$LCOLOR = ['C'=>'#555555','C++'=>'#f34b7d','Python'=>'#3572A5','Shell'=>'#89e051','JavaScript'=>'#f1e05a','TypeScript'=>'#3178c6',
+           'PHP'=>'#4F5D95','HTML'=>'#e34c26','CSS'=>'#663399','Rust'=>'#dea584','Go'=>'#00ADD8','Java'=>'#b07219',
+           'Vim Script'=>'#199f4b','Lua'=>'#000080','GLSL'=>'#5686a5','Assembly'=>'#6E4C13','Markdown'=>'#083fa1',
+           'Makefile'=>'#427819','Ruby'=>'#701516','Perl'=>'#0298c3','C#'=>'#178600','Kotlin'=>'#A97BFF','Swift'=>'#F05138','TeX'=>'#3D6117'];
+
+function h(?string $s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
+function valid(string $s): bool { return $s !== '.' && $s !== '..' && preg_match('/^[A-Za-z0-9._ +-]+$/', $s) === 1; }
+function git(string $dir, string ...$args): string {
+    $cmd = 'git -c safe.directory=* -C ' . escapeshellarg($dir);
+    foreach ($args as $a) $cmd .= ' ' . escapeshellarg($a);
+    return (string)shell_exec($cmd . ' 2>/dev/null');
+}
+function safe_path(array $segs): ?string {
+    foreach ($segs as $s) if (!valid($s)) return null;
+    return implode('/', $segs);
+}
+function ago(int $t): string {
+    if ($t <= 0) return '';
+    $d = time() - $t;
+    foreach ([31536000=>'year', 2592000=>'month', 86400=>'day', 3600=>'hour', 60=>'minute'] as $s => $n)
+        if ($d >= $s) { $v = intdiv($d, $s); return "$v $n" . ($v > 1 ? 's' : '') . ' ago'; }
+    return 'just now';
+}
+function ext(string $name): string {
+    $b = strtolower(basename($name));
+    return $b === 'makefile' ? 'makefile' : strtolower(pathinfo($b, PATHINFO_EXTENSION));
+}
+function fmt_size(int $b): string {
+    if ($b < 1024) return "$b B";
+    if ($b < 1048576) return round($b / 1024, 1) . ' KB';
+    return round($b / 1048576, 1) . ' MB';
+}
+function lcolor(string $l): string { global $LCOLOR; return $LCOLOR[$l] ?? '#' . substr(md5($l), 0, 6); }
+
+/* ---------- data ---------- */
+function categories(): array {
+    global $ROOT; $cats = [];
+    $f = "$ROOT/.categories";
+    if (is_file($f)) foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $l) {
+        [$slug, $title] = array_pad(explode('|', $l, 2), 2, '');
+        if (valid($slug) && is_dir("$ROOT/$slug")) $cats[$slug] = $title !== '' ? $title : $slug;
+    }
+    if (is_dir($ROOT)) foreach (scandir($ROOT) as $d)
+        if ($d[0] !== '.' && is_dir("$ROOT/$d") && !isset($cats[$d])) $cats[$d] = $d;
+    return $cats;
+}
+function load_repo(string $cat, string $name): ?array {
+    global $ROOT, $BASE, $PREFIX, $SCHEME, $HOST;
+    if (!valid($cat) || !valid($name)) return null;
+    if (is_dir("$ROOT/$cat/$name.git"))   { $dir = "$ROOT/$cat/$name.git"; $isgit = true;  $mf = "$dir/gitsync.meta"; }
+    elseif (is_dir("$ROOT/$cat/$name"))   { $dir = "$ROOT/$cat/$name";     $isgit = false; $mf = "$dir/.gitsync.meta"; }
+    else return null;
+    $m = ['description'=>'', 'tags'=>'', 'origin'=>'', 'created'=>'0', 'modified'=>'0', 'languages'=>'', 'synced'=>'0'];
+    if (is_file($mf)) foreach (file($mf, FILE_IGNORE_NEW_LINES) as $l)
+        if (str_contains($l, '=')) { [$k, $v] = explode('=', $l, 2); $m[$k] = trim($v); }
+    $langs = []; $total = 0;
+    foreach (array_filter(explode(',', $m['languages'])) as $p) { [$l, $b] = array_pad(explode(':', $p), 2, '0'); $langs[$l] = (int)$b; $total += (int)$b; }
+    foreach ($langs as $l => $b) $langs[$l] = $total ? $b / $total * 100 : 0;
+    $url = $PREFIX . '/' . rawurlencode($cat) . '/' . rawurlencode($name);
+    return ['cat'=>$cat, 'name'=>$name, 'dir'=>$dir, 'git'=>$isgit, 'desc'=>$m['description'], 'url'=>$url,
+            'tags'=>array_values(array_filter(explode(',', $m['tags']))), 'origin'=>$m['origin'],
+            'created'=>(int)$m['created'], 'modified'=>(int)$m['modified'] ?: ($isgit ? 0 : (int)filemtime($dir)), 'synced'=>(int)$m['synced'],
+            'langs'=>$langs, 'private'=>$PREFIX !== '',
+            'clone'=>($PREFIX ? "$SCHEME://USER@$HOST" : $BASE) . "$url.git", 'archive'=>"$BASE$url/archive/" . rawurlencode($name) . '.tar.gz'];
+}
+function repos_in(string $cat): array {
+    global $ROOT; $out = [];
+    foreach (scandir("$ROOT/$cat") as $d) {
+        if ($d[0] === '.' || !is_dir("$ROOT/$cat/$d")) continue;
+        $r = load_repo($cat, preg_replace('/\.git$/', '', $d));
+        if ($r) $out[$r['name']] = $r;
+    }
+    ksort($out, SORT_NATURAL | SORT_FLAG_CASE);
+    return $out;
+}
+function parse_commit(string $l): ?array {
+    if ($l === '') return null;
+    [$H, $h, $an, $at, $s] = array_pad(explode("\x1f", $l), 5, '');
+    return ['H'=>$H, 'h'=>$h, 'author'=>$an, 'time'=>(int)$at, 'subject'=>$s];
+}
+const LOGFMT = '--format=%H%x1f%h%x1f%an%x1f%at%x1f%s';
+function last_commit(array $r): ?array { return $r['git'] ? parse_commit(trim(git($r['dir'], 'log', '-1', LOGFMT))) : null; }
+function branch(array $r): string { $b = trim(git($r['dir'], 'symbolic-ref', '--short', 'HEAD')); return $b !== '' ? $b : 'HEAD'; }
+function obj_type(array $r, string $p): string {
+    if ($r['git']) return trim(git($r['dir'], 'cat-file', '-t', 'HEAD:' . $p));
+    $f = "{$r['dir']}/$p";
+    return is_dir($f) ? 'tree' : (is_file($f) ? 'blob' : '');
+}
+function tree(array $r, string $p): array {
+    $items = [];
+    if ($r['git']) {
+        $spec = $p === '' ? 'HEAD' : "HEAD:$p";
+        foreach (explode("\n", trim(git($r['dir'], 'ls-tree', '-l', $spec))) as $l) {
+            if ($l === '' || !preg_match('/^(\d+) (\w+) (\w+) +(-|\d+)\t(.+)$/', $l, $m)) continue;
+            $items[] = ['name'=>$m[5], 'dir'=>$m[2] === 'tree', 'size'=>$m[4] === '-' ? 0 : (int)$m[4]];
+        }
+    } else {
+        $base = rtrim("{$r['dir']}/$p", '/');
+        foreach (scandir($base) as $f) {
+            if ($f === '.' || $f === '..' || $f === '.gitsync.meta') continue;
+            $items[] = ['name'=>$f, 'dir'=>is_dir("$base/$f"), 'size'=>is_file("$base/$f") ? (int)filesize("$base/$f") : 0];
+        }
+    }
+    usort($items, fn($a, $b) => [$b['dir'], strtolower($a['name'])] <=> [$a['dir'], strtolower($b['name'])]);
+    return $items;
+}
+function blob(array $r, string $p): string {
+    return $r['git'] ? git($r['dir'], 'cat-file', 'blob', "HEAD:$p") : (string)file_get_contents("{$r['dir']}/$p");
+}
+function find_readme(array $r): ?string {
+    foreach (tree($r, '') as $i) if (!$i['dir'] && preg_match('/^readme(\.(md|markdown|txt))?$/i', $i['name'])) return $i['name'];
+    return null;
+}
+function activity_days(array $r): array {           /* day => commits, last 53 weeks */
+    $days = [];
+    foreach (explode("\n", trim(git($r['dir'], 'log', '--all', '--since=53 weeks ago', '--format=%at'))) as $t)
+        if ($t !== '') { $k = date('Y-m-d', (int)$t); $days[$k] = ($days[$k] ?? 0) + 1; }
+    return $days;
+}
+
+/* ---------- markdown (server side, keeps lynx happy) ---------- */
+function md_inline(string $s, string $rawbase): string {
+    $s = h($s);
+    $s = preg_replace_callback('/`([^`]+)`/', fn($m) => '<code>' . $m[1] . '</code>', $s);
+    $fix = fn($u) => preg_match('#^([a-z]+:|/|\#)#i', $u) ? $u : $rawbase . ltrim($u, './');
+    $s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/', fn($m) => '<img src="' . h($fix($m[2])) . '" alt="' . $m[1] . '">', $s);
+    $s = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/', fn($m) => '<a href="' . h($fix($m[2])) . '">' . $m[1] . '</a>', $s);
+    $s = preg_replace('#(?<![">])\bhttps?://[^\s<]+#', '<a href="$0">$0</a>', $s);
+    $s = preg_replace('/(\*\*|__)(.+?)\1/', '<b>$2</b>', $s);
+    $s = preg_replace('/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/', '<i>$2</i>', $s);
+    $s = preg_replace('/~~(.+?)~~/', '<s>$1</s>', $s);
+    return $s;
+}
+function md(string $src, string $rawbase): string {
+    $lines = explode("\n", str_replace("\r", '', $src)); $n = count($lines); $out = ''; $i = 0;
+    $list = ''; $para = [];
+    $close_list = function () use (&$list, &$out) { if ($list) { $out .= "</$list>"; $list = ''; } };
+    $flush = function () use (&$para, &$out, $rawbase) { if ($para) { $out .= '<p>' . md_inline(implode("\n", $para), $rawbase) . '</p>'; $para = []; } };
+    while ($i < $n) {
+        $l = $lines[$i];
+        if (preg_match('/^\s*(```|~~~)\s*(\w*)/', $l, $m)) {
+            $flush(); $close_list(); $buf = []; $i++;
+            while ($i < $n && !preg_match('/^\s*' . preg_quote($m[1]) . '/', $lines[$i])) $buf[] = $lines[$i++];
+            $i++; $out .= '<pre><code' . ($m[2] ? ' class="language-' . h($m[2]) . '"' : '') . '>' . h(implode("\n", $buf)) . "</code></pre>"; continue;
+        }
+        if (preg_match('/^(#{1,6})\s+(.*?)\s*#*$/', $l, $m)) { $flush(); $close_list(); $k = strlen($m[1]); $out .= "<h$k>" . md_inline($m[2], $rawbase) . "</h$k>"; }
+        elseif (preg_match('/^\s*[-*+]\s+(.*)/', $l, $m)) { $flush(); if ($list !== 'ul') { $close_list(); $out .= '<ul>'; $list = 'ul'; } $out .= '<li>' . md_inline($m[1], $rawbase) . '</li>'; }
+        elseif (preg_match('/^\s*\d+[.)]\s+(.*)/', $l, $m)) { $flush(); if ($list !== 'ol') { $close_list(); $out .= '<ol>'; $list = 'ol'; } $out .= '<li>' . md_inline($m[1], $rawbase) . '</li>'; }
+        elseif (preg_match('/^>\s?(.*)/', $l, $m)) { $flush(); $close_list(); $out .= '<blockquote>' . md_inline($m[1], $rawbase) . '</blockquote>'; }
+        elseif (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $l)) { $flush(); $close_list(); $out .= '<hr>'; }
+        elseif (str_starts_with(trim($l), '|')) {
+            $flush(); $close_list(); $rows = [];
+            while ($i < $n && str_starts_with(trim($lines[$i]), '|')) { $rows[] = array_map('trim', explode('|', trim(trim($lines[$i]), '|'))); $i++; }
+            $out .= '<table class="md">';
+            foreach ($rows as $ri => $cells) {
+                if ($ri === 1 && preg_match('/^:?-+:?$/', $cells[0] ?? 'x')) continue;
+                $tag = $ri === 0 ? 'th' : 'td';
+                $out .= '<tr>' . implode('', array_map(fn($c) => "<$tag>" . md_inline($c, $rawbase) . "</$tag>", $cells)) . '</tr>';
+            }
+            $out .= '</table>'; continue;
+        }
+        elseif (preg_match('/^(    |\t)(.*)/', $l, $m) && !$para && !$list) {
+            $buf = []; while ($i < $n && preg_match('/^(    |\t)(.*)/', $lines[$i], $m2)) { $buf[] = $m2[2]; $i++; }
+            $out .= '<pre><code>' . h(implode("\n", $buf)) . '</code></pre>'; continue;
+        }
+        elseif (trim($l) === '') { $flush(); $close_list(); }
+        else $para[] = $l;
+        $i++;
+    }
+    $flush(); $close_list();
+    return $out;
+}
+
+/* ---------- html ---------- */
+function page_start(string $title, array $crumbs = []): void {
+    global $HOST;
+    echo "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">";
+    echo '<title>' . h($title) . ' · ' . h(SITE) . '</title><link rel="stylesheet" href="/style.css">';
+    echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">';
+    echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)">';
+    $path0 = (string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
+    $onscripts = str_starts_with($path0, '/scripts'); $onhome = $path0 === '/' || str_starts_with($path0, '/search'); $onpriv = str_starts_with($path0, '/private');
+    echo '</head><body><header><a class="brand" href="/">' . h(SITE) . '</a>';
+    echo '<nav class="topnav"><a class="' . ($onscripts || $onhome || $onpriv ? '' : 'on') . '" href="/projects">projects</a>';
+    if (is_dir($GLOBALS['ROOT'] . '/.scripts')) echo '<a class="' . ($onscripts ? 'on' : '') . '" href="/scripts">scripts</a>';
+    if (is_dir(__DIR__ . '/repos/.private')) echo '<a class="' . ($onpriv ? 'on' : '') . '" href="/private">private</a>';
+    echo '</nav>';
+    if ($crumbs) echo '<span class="crumbs">';
+    $i = 0; foreach ($crumbs as $text => $href) echo ($i++ ? ' <span class="sep">/</span> ' : '') . ($href ? '<a href="' . h($href) . '">' . h((string)$text) . '</a>' : '<b>' . h((string)$text) . '</b>');
+    if ($crumbs) echo '</span>';
+    echo '<form class="search" action="/search" method="get"><input type="text" name="q" placeholder="search projects and scripts" value="' . h($_GET['q'] ?? '') . '"><input type="submit" value="search"></form>';
+    echo '</header><main>';
+}
+function page_end(): void {
+    global $ROOT; $sy = is_file("$ROOT/.synced") ? (int)file_get_contents("$ROOT/.synced") : 0;
+    echo '</main><footer>' . ($sy ? 'last synced ' . date('Y-m-d H:i', $sy) . ' (' . ago($sy) . ') · ' : '') . '<a href="' . LEGAL . '">Impressum</a> · <a href="' . LEGAL . '">Datenschutz</a> · <a href="' . LEGAL . '">christianimmanuel.de</a><br>read-only git browser · 100% Vibecode but tested · v' . VERSION . '</footer>';
+    echo '<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script><script>
+document.querySelectorAll("pre code").forEach(e=>hljs.highlightElement(e));
+document.querySelectorAll(".cmd").forEach(c=>{const b=document.createElement("button");b.className="copy";b.textContent="copy";b.onclick=()=>{navigator.clipboard.writeText(c.querySelector("code").textContent);b.textContent="copied";setTimeout(()=>b.textContent="copy",1500);};c.appendChild(b);});
+const tt=document.createElement("a");tt.href="#";tt.className="totop";tt.textContent="↑";tt.title="back to top";tt.onclick=e=>{e.preventDefault();window.scrollTo({top:0,behavior:"smooth"});};document.body.appendChild(tt);
+addEventListener("scroll",()=>tt.classList.toggle("show",scrollY>400),{passive:true});
+document.querySelectorAll(".get").forEach(g=>{const cmds=[...g.querySelectorAll(".cmd")];if(cmds.length<2)return;const sw=document.createElement("div");sw.className="getopts";cmds.forEach((c,i)=>{const b=document.createElement("button");b.textContent=c.dataset.label;b.className=i?"":"on";b.onclick=()=>{cmds.forEach((x,j)=>{x.style.display=j===i?"":"none";sw.children[j].className=j===i?"on":"";});};sw.appendChild(b);if(i)c.style.display="none";});g.prepend(sw);});
+</script></body></html>';
+}
+function not_found(): void { http_response_code(404); page_start('404'); echo '<h1>404</h1><p>Not found.</p>'; page_end(); exit; }
+function hue(string $t): int { return hexdec(substr(md5($t), 0, 2)) * 360 >> 8; }
+function tag_links(array $tags, string $cls = 'tag'): string {
+    return implode(' ', array_map(fn($t) => '<a class="' . $cls . '" style="--h:' . hue($t) . '" href="/projects?tag=' . rawurlencode($t) . '">' . h($t) . '</a>', $tags));
+}
+function lang_bar(array $langs, bool $text): string {
+    if (!$langs) return '';
+    $bar = '<span class="langbar">';
+    foreach ($langs as $l => $p) if ($p >= 0.5) $bar .= '<span style="width:' . round($p, 1) . '%;background:' . lcolor($l) . '" title="' . h($l) . '"></span>';
+    $bar .= '</span>';
+    if ($text) { $parts = []; foreach ($langs as $l => $p) if ($p >= 1) $parts[] = '<span class="lang"><span class="dot" style="background:' . lcolor($l) . '"></span>' . h($l) . ' ' . round($p, 1) . '%</span>';
+                 $bar .= '<span class="langs">' . implode(' ', $parts) . '</span>'; }
+    return $bar;
+}
+function cmd_box(string $cmd, string $label): string {
+    return '<span class="cmd" data-label="' . h($label) . '"><code>' . h($cmd) . '</code></span>';
+}
+function activity_svg(array $days, string $label): string {
+    $total = array_sum($days);
+    $start = strtotime('monday this week') - 52 * 7 * 86400;
+    $max = max(1, ...array_values($days ?: [0]));
+    $svg = '<svg class="activity" viewBox="0 0 ' . (53 * 13 + 30) . ' 110" role="img" aria-label="' . h($label) . '">';
+    for ($w = 0; $w < 53; $w++) for ($d = 0; $d < 7; $d++) {
+        $t = $start + ($w * 7 + $d) * 86400;
+        if ($t > time()) continue;
+        $k = date('Y-m-d', $t); $c = $days[$k] ?? 0;
+        $lvl = $c === 0 ? 0 : min(4, (int)ceil($c / $max * 4));
+        $svg .= '<rect x="' . ($w * 13 + 28) . '" y="' . ($d * 13 + 14) . '" width="11" height="11" rx="2" class="l' . $lvl . '"><title>' . $k . ': ' . $c . ' commits</title></rect>';
+        if ($d === 0 && date('j', $t) <= 7) $svg .= '<text x="' . ($w * 13 + 28) . '" y="10">' . date('M', $t) . '</text>';
+    }
+    foreach ([1=>'Mon', 3=>'Wed', 5=>'Fri'] as $d => $n) $svg .= '<text x="0" y="' . ($d * 13 + 23) . '">' . $n . '</text>';
+    return '<div class="activitybox"><p class="meta">' . h($label) . ': <b>' . $total . '</b> commits in the last year</p>' . $svg . '</svg></div>';
+}
+function repo_header(array $r, string $active): void {
+    echo '<div class="repohead"><h1><a href="' . h($r['url']) . '">' . h($r['name']) . '</a> <span class="badge">' . ($r['git'] ? 'git · ' . h(branch($r)) : 'files') . '</span></h1>';
+    if ($r['desc'] !== '') echo '<p class="desc">' . h($r['desc']) . '</p>';
+    echo '<p class="meta">';
+    if ($r['tags']) echo tag_links($r['tags']) . ' · ';
+    if ($r['created']) echo ($r['git'] ? 'first commit ' : 'oldest file ') . date('Y-m-d', $r['created']) . ' · ';
+    if ($r['modified']) echo ($r['git'] ? 'last commit ' : 'last change ') . date('Y-m-d', $r['modified']) . ' (' . ago($r['modified']) . ')';
+    if ($r['synced']) echo ' · synced ' . ago($r['synced']);
+    if ($r['origin'] !== '') echo ' · upstream: <a href="' . h($r['origin']) . '">' . h(preg_replace('#^https?://#', '', $r['origin'])) . '</a>';
+    echo '</p>' . lang_bar($r['langs'], true);
+    echo '<nav class="tabs">';
+    foreach (['files'=>$r['url'], 'commits'=>$r['url'] . '/commits'] as $t => $u) {
+        if ($t === 'commits' && !$r['git']) continue;
+        echo '<a class="' . ($t === $active ? 'on' : '') . '" href="' . h($u) . '">' . $t . '</a>';
+    }
+    echo '</nav><div class="get">';
+    if ($r['git']) echo cmd_box('git clone ' . $r['clone'], 'git clone');
+    echo cmd_box('wget ' . ($r['private'] ? '--user=USER --ask-password ' : '') . $r['archive'], 'wget tar.gz');
+    echo '</div></div>';
+}
+function tree_link(array $r, string $p, string $kind): string {
+    return $r['url'] . "/$kind/" . implode('/', array_map('rawurlencode', $p === '' ? [] : explode('/', $p)));
+}
+function render_tree(array $r, string $p): void {
+    $items = tree($r, $p);
+    echo '<table class="tree">';
+    if ($p !== '') echo '<tr><td class="ico">..</td><td><a href="' . h(dirname($p) === '.' ? $r['url'] : tree_link($r, dirname($p), 'tree')) . '">parent directory</a></td><td></td></tr>';
+    foreach ($items as $i) {
+        $sub = ($p === '' ? '' : "$p/") . $i['name'];
+        $href = tree_link($r, $sub, $i['dir'] ? 'tree' : 'blob');
+        echo '<tr><td class="ico">' . ($i['dir'] ? 'd' : '-') . '</td><td><a href="' . h($href) . '">' . h($i['name']) . ($i['dir'] ? '/' : '') . '</a></td><td class="size">' . ($i['dir'] ? '' : fmt_size($i['size'])) . '</td></tr>';
+    }
+    if (!$items) echo '<tr><td colspan="3"><i>empty</i></td></tr>';
+    echo '</table>';
+}
+function render_readme(array $r): void {
+    $f = find_readme($r);
+    if ($f === null) return;
+    $c = blob($r, $f);
+    echo '<section class="readme"><h3>' . h($f) . '</h3>';
+    if (preg_match('/\.(md|markdown)$/i', $f)) echo '<div class="markdown">' . md($c, rtrim(tree_link($r, '', 'raw'), '/') . '/') . '</div>';
+    else echo '<pre>' . h($c) . '</pre>';
+    echo '</section>';
+}
+function commit_row(array $r, array $c): void {
+    echo '<tr><td><a class="hash" href="' . h($r['url'] . '/commit/' . $c['H']) . '">' . h($c['h']) . '</a></td><td>' . h($c['subject']) . '</td><td class="meta">' . h($c['author']) . ' · ' . ago($c['time']) . '</td></tr>';
+}
+function card(array $r): void {
+    echo '<div class="card"><div class="top"><a class="name" href="' . h($r['url']) . '">' . h($r['name']) . '</a><span class="badge">' . ($r['git'] ? 'git' : 'files') . '</span></div>';
+    if ($r['desc'] !== '') echo '<p class="desc">' . h($r['desc']) . '</p>';
+    if ($r['tags']) echo '<p class="tags">' . tag_links($r['tags'], 'tagt') . '</p>';
+    echo lang_bar($r['langs'], false);
+    echo '<p class="meta">' . ($r['modified'] ? ($r['git'] ? 'last commit ' : 'last change ') . ago($r['modified']) : '');
+    $top = array_key_first($r['langs']); if ($top) echo ' · ' . h($top);
+    echo '</p></div>';
+}
+
+/* ---------- scripts collection ---------- */
+function script_hint(string $file): string {
+    $out = ''; $in = false;
+    foreach (file($file, FILE_IGNORE_NEW_LINES) as $l) {
+        if (str_starts_with($l, '###') && !str_starts_with($l, '######')) { $out .= substr($l, 4) . "\n"; $in = true; }
+        elseif ($in) break;
+    }
+    return trim($out);
+}
+function script_interp(string $file): string {
+    $l = (string)fgets(fopen($file, 'r'));
+    if (!str_starts_with($l, '#!')) return 'text';
+    $i = basename(trim(explode(' ', trim(substr($l, 2)))[0]));
+    if ($i === 'env') { $p = preg_split('/\s+/', trim(substr($l, 2))); $i = basename($p[1] ?? 'sh'); }
+    return preg_replace('/[0-9.]+$/', '', $i) ?: $i;
+}
+function script_groups(): array {
+    global $ROOT; $g = [];
+    $base = "$ROOT/.scripts";
+    if (!is_dir($base)) return $g;
+    foreach (scandir($base) as $d) {
+        if ($d[0] === '.' || !is_dir("$base/$d") || !valid($d)) continue;
+        $files = [];
+        foreach (scandir("$base/$d") as $f) if ($f[0] !== '.' && is_file("$base/$d/$f") && valid($f)) $files[] = $f;
+        if ($files) $g[$d] = $files;
+    }
+    return $g;
+}
+function script_url(string $g, string $f, string $kind = ''): string {
+    return '/scripts/' . ($kind ? "$kind/" : '') . rawurlencode($g) . '/' . rawurlencode($f);
+}
+function scripts_page(array $seg): void {
+    global $ROOT, $BASE, $LANG;
+    $base = "$ROOT/.scripts";
+    if (!is_dir($base)) not_found();
+    $groups = script_groups();
+    if (isset($seg[1]) && $seg[1] === 'raw' && isset($seg[2], $seg[3]) && valid($seg[2]) && valid($seg[3]) && is_file("$base/$seg[2]/$seg[3]")) {
+        header('Content-Type: text/plain; charset=utf-8'); header('X-Content-Type-Options: nosniff');
+        readfile("$base/$seg[2]/$seg[3]"); exit;
+    }
+    if (isset($seg[1], $seg[2]) && valid($seg[1]) && valid($seg[2]) && is_file("$base/$seg[1]/$seg[2]")) {   /* one script */
+        [$g, $f] = [$seg[1], $seg[2]]; $c = (string)file_get_contents("$base/$g/$f");
+        page_start($f, [$g => '/scripts#' . rawurlencode($g), $f => '']);
+        echo '<div class="repohead"><h1>' . h($f) . ' <span class="badge">' . h($g) . '</span></h1>';
+        $hint = script_hint("$base/$g/$f"); if ($hint) echo '<p class="desc hint">' . nl2br(h($hint)) . '</p>';
+        echo '<div class="get">' . cmd_box("wget $BASE" . script_url($g, $f, 'raw') . " && chmod 740 " . $f, 'wget') . '</div></div>';
+        echo '<div class="filehead"><b>' . h($f) . '</b> <span class="meta">' . fmt_size(strlen($c)) . ' · ' . substr_count($c, "\n") . ' lines</span> <a class="btn" href="' . h(script_url($g, $f, 'raw')) . '">raw</a></div>';
+        echo '<pre><code class="language-bash">' . h($c) . '</code></pre>';
+        page_end(); exit;
+    }
+    if (isset($seg[1])) not_found();
+    page_start('Scripts');
+    echo '<h1>Scripts</h1><p class="meta">Standalone shell scripts. Every entry shows its header comment; grab one with the wget line or view the source.</p>';
+    echo '<p class="catnav">' . implode(' ', array_map(fn($g) => '<a href="#' . h($g) . '">' . h($g) . ' <small>' . count($groups[$g]) . '</small></a>', array_keys($groups))) . '</p>';
+    foreach ($groups as $g => $files) {
+        echo '<h2 id="' . h($g) . '">' . h($g) . ' <small>' . count($files) . '</small></h2><div class="cards">';
+        foreach ($files as $f) {
+            $hint = script_hint("$base/$g/$f"); $short = trim(explode("\n", $hint)[0]);
+            $c = (string)file_get_contents("$base/$g/$f");
+            echo '<div class="card"><div class="top"><a class="name" href="' . h(script_url($g, $f)) . '">' . h($f) . '</a><span class="badge">' . h(script_interp("$base/$g/$f")) . '</span></div>';
+            if ($short !== '') echo '<p class="desc">' . h($short) . '</p>';
+            echo '<p class="meta">' . substr_count($c, "\n") . ' lines · ' . fmt_size(strlen($c)) . ' · ' . date('Y-m-d', (int)filemtime("$base/$g/$f")) . '</p></div>';
+        }
+        echo '</div>';
+    }
+    page_end(); exit;
+}
+
+/* ---------- routing ---------- */
+$path = rawurldecode((string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH));
+$seg  = array_values(array_filter(explode('/', $path), fn($s) => $s !== ''));
+if ($seg && $seg[0] === 'private') {           /* protected area */
+    /* Apache should already have asked for the password (<Location /private>); this is the safety net
+       in case the vhost is old: verify HTTP Basic auth against .htpasswd (bcrypt entries, htpasswd -B). */
+    $ok = false; $u = $_SERVER['PHP_AUTH_USER'] ?? ''; $pw = $_SERVER['PHP_AUTH_PW'] ?? '';
+    if (!empty($_SERVER['REMOTE_USER'])) $ok = true;                       /* Apache did the auth */
+    elseif ($u !== '' && is_file(__DIR__ . '/.htpasswd'))
+        foreach (file(__DIR__ . '/.htpasswd', FILE_IGNORE_NEW_LINES) as $l) { [$n, $h] = array_pad(explode(':', $l, 2), 2, ''); if ($n === $u && $h !== '' && password_verify($pw, $h)) { $ok = true; break; } }
+    if (!$ok) { header('WWW-Authenticate: Basic realm="private"'); http_response_code(401); echo '401 - private area, login required'; exit; }
+    $ROOT .= '/.private'; $PREFIX = '/private'; array_shift($seg);
+    if (!is_dir($ROOT)) not_found();
+    if (!$seg) {
+        page_start('Private', ['private' => '']);
+        echo '<h1>Private</h1><p class="meta">Only for logged-in users. Clone with <code>git clone https://USER@' . h($HOST) . '/private/…</code>, download with <code>wget --user=USER --ask-password …</code>.</p>';
+        $n = 0;
+        foreach (categories() as $slug => $title) { $rs = repos_in($slug); if (!$rs) continue;
+            echo '<h2 id="' . h($slug) . '">' . h($title) . ' <small>' . count($rs) . '</small></h2><div class="cards">'; foreach ($rs as $r) { $n++; card($r); } echo '</div>'; }
+        if (!$n) echo '<p>Nothing here.</p>';
+        page_end(); exit;
+    }
+    if (count($seg) === 1) not_found();
+}
+$cats = categories();
+
+
+function latest_commits(array $all, int $n = 12): array {
+    $out = [];
+    foreach ($all as $rs) foreach ($rs as $r) if ($r['git'])
+        foreach (explode("\n", trim(git($r['dir'], 'log', '-5', LOGFMT))) as $l) if ($c = parse_commit($l)) { $c['repo'] = $r; $out[] = $c; }
+    usort($out, fn($a, $b) => $b['time'] <=> $a['time']);
+    return array_slice($out, 0, $n);
+}
+if (!$seg) {                                  /* landing page */
+    $all = []; foreach ($cats as $slug => $title) $all[$slug] = repos_in($slug);
+    $repos = array_merge(...array_values($all ?: [[]]));
+    $groups = script_groups(); $nscripts = array_sum(array_map('count', $groups));
+    page_start(SITE);
+    echo '<h1>' . h(SITE) . '</h1>';
+    echo '<div class="cards intro"><div class="card"><div class="top"><a class="name" href="/projects">Projects</a><span class="badge">' . count($repos) . '</span></div><p class="desc">Git repositories and plain file dumps, sorted by category, with tags, languages and commit history.</p><p class="meta">' . implode(' · ', array_map(fn($t) => h($t), $cats)) . '</p></div>';
+    if ($nscripts) echo '<div class="card"><div class="top"><a class="name" href="/scripts">Scripts</a><span class="badge">' . $nscripts . '</span></div><p class="desc">Standalone shell scripts with a wget line each.</p><p class="meta">' . implode(' · ', array_map(fn($g) => h($g), array_keys($groups))) . '</p></div></div>';
+    else echo '</div>';
+    $commits = latest_commits($all);
+    if ($commits) {
+        echo '<h2>Latest commits</h2><table class="commits feed">';
+        foreach ($commits as $c) echo '<tr><td><a class="hash" href="' . h($c['repo']['url'] . '/commit/' . $c['H']) . '">' . h($c['h']) . '</a></td><td><a class="repo" href="' . h($c['repo']['url']) . '">' . h($c['repo']['name']) . '</a> ' . h($c['subject']) . '</td><td class="meta">' . ago($c['time']) . '</td></tr>';
+        echo '</table>';
+    }
+    usort($repos, fn($a, $b) => $b['modified'] <=> $a['modified']);
+    $recent = array_slice(array_filter($repos, fn($r) => $r['modified'] > 0), 0, 6);
+    if ($recent) { echo '<h2>Recently updated</h2><div class="cards recent">'; foreach ($recent as $r) card($r); echo '</div>'; }
+    $days = []; foreach ($repos as $r) if ($r['git']) foreach (activity_days($r) as $k => $c) $days[$k] = ($days[$k] ?? 0) + $c;
+    if ($days) echo activity_svg($days, 'All repositories');
+    page_end(); exit;
+}
+if ($seg[0] === 'search') {                    /* global search */
+    $q = strtolower(trim($_GET['q'] ?? ''));
+    page_start("search: $q");
+    echo '<h1>Search</h1>';
+    if ($q === '') { echo '<p class="meta">Type something into the search box.</p>'; page_end(); exit; }
+    echo '<p class="meta">results for <b>' . h($q) . '</b></p>';
+    $n = 0;
+    foreach ($cats as $slug => $title) {
+        $rs = array_filter(repos_in($slug), fn($r) => str_contains(strtolower($r['name'] . ' ' . $r['desc'] . ' ' . implode(' ', $r['tags']) . ' ' . implode(' ', array_keys($r['langs']))), $q));
+        if (!$rs) continue;
+        echo '<h2>' . h($title) . ' <small>' . count($rs) . '</small></h2><div class="cards">'; foreach ($rs as $r) { $n++; card($r); } echo '</div>';
+    }
+    $base = "$ROOT/.scripts";
+    foreach (script_groups() as $g => $files) {
+        $hits = array_filter($files, fn($f) => str_contains(strtolower($f . ' ' . script_hint("$base/$g/$f")), $q));
+        if (!$hits) continue;
+        echo '<h2>scripts / ' . h($g) . ' <small>' . count($hits) . '</small></h2><div class="cards">';
+        foreach ($hits as $f) { $n++; $hint = script_hint("$base/$g/$f"); $short = trim(explode("\n", $hint)[0]);
+            echo '<div class="card"><div class="top"><a class="name" href="' . h(script_url($g, $f)) . '">' . h($f) . '</a><span class="badge">' . h(script_interp("$base/$g/$f")) . '</span></div>' . ($short !== '' ? '<p class="desc">' . h($short) . '</p>' : '') . '</div>'; }
+        echo '</div>';
+    }
+    if (!$n) echo '<p>Nothing found.</p>';
+    page_end(); exit;
+}
+if ($seg[0] === 'scripts') scripts_page($seg);
+if ($seg && $seg[0] === 'projects' && count($seg) === 1) {   /* projects index */
+    $q = strtolower(trim($_GET['q'] ?? '')); $tag = strtolower(trim($_GET['tag'] ?? ''));
+    $all = []; foreach ($cats as $slug => $title) $all[$slug] = repos_in($slug);
+    $tagcount = []; foreach ($all as $rs) foreach ($rs as $r) foreach ($r['tags'] as $t) $tagcount[$t] = ($tagcount[$t] ?? 0) + 1;
+    arsort($tagcount);
+    page_start($tag ? "tag: $tag" : 'Projects');
+    echo '<h1>Projects</h1>';
+    $nav = []; foreach ($all as $slug => $rs) if ($rs) $nav[] = '<a href="#' . h($slug) . '">' . h($cats[$slug]) . '</a>';
+    if ($nav && !$q && !$tag) echo '<p class="catnav">' . implode(' ', $nav) . '</p>';
+    $alltags = isset($_GET['tags']); $shown = $alltags ? $tagcount : array_slice($tagcount, 0, 18, true);
+    if ($tag && !isset($shown[$tag])) $shown[$tag] = $tagcount[$tag] ?? 0;
+    if ($tagcount) echo '<p class="tagcloud">' . implode(' ', array_map(fn($t, $c) => '<a class="tag' . ($t === $tag ? ' on' : '') . '" style="--h:' . hue($t) . '" href="/projects?tag=' . rawurlencode($t) . '">' . h($t) . ' <small>' . $c . '</small></a>', array_keys($shown), $shown))
+        . (!$alltags && count($tagcount) > count($shown) ? ' <a class="tag more" href="/projects?tags">+' . (count($tagcount) - count($shown)) . ' more</a>' : '') . '</p>';
+    if ($q || $tag) echo '<p class="meta">filter: <b>' . h($q ?: "tag $tag") . '</b> · <a href="/projects">show all</a></p>';
+    $n = 0; $days = [];
+    foreach ($all as $slug => $rs) {
+        $rs = array_filter($rs, function ($r) use ($q, $tag) {
+            if ($tag && !in_array($tag, $r['tags'])) return false;
+            return !$q || str_contains(strtolower($r['name'] . ' ' . $r['desc'] . ' ' . implode(' ', $r['tags']) . ' ' . implode(' ', array_keys($r['langs']))), $q);
+        });
+        if (!$rs) continue;
+        echo '<h2 id="' . h($slug) . '">' . h($cats[$slug]) . ' <small>' . count($rs) . '</small></h2><div class="cards">';
+        foreach ($rs as $r) {
+            $n++; card($r);
+        }
+        echo '</div>';
+    }
+    if (!$n) echo '<p>Nothing found.</p>';
+    page_end(); exit;
+}
+
+$cat = $seg[0];
+$name = preg_replace('/\.git$/', '', $seg[1] ?? '');
+if (count($seg) === 1) {                      /* category → index anchor */
+    if (!isset($cats[$cat])) not_found();
+    header('Location: /projects#' . rawurlencode($cat)); exit;
+}
+$r = load_repo($cat, $name);
+if (!$r) not_found();
+$title = $cats[$cat] ?? $cat;
+$crumbs = [$title => ($PREFIX ?: '/projects') . '#' . rawurlencode($cat), $r['name'] => $r['url']];
+$action = $seg[2] ?? 'tree';
+$sub = safe_path(array_slice($seg, 3));
+if ($sub === null) not_found();
+
+if ($action === 'archive') {
+    header('Content-Type: application/gzip');
+    header('Content-Disposition: attachment; filename="' . $r['name'] . '.tar.gz"');
+    if ($r['git']) passthru('git -c safe.directory=* -C ' . escapeshellarg($r['dir']) . ' archive --format=tar.gz --prefix=' . escapeshellarg($r['name'] . '/') . ' HEAD');
+    else passthru('tar -C ' . escapeshellarg(dirname($r['dir'])) . ' --exclude=.gitsync.meta -czf - ' . escapeshellarg($r['name']));
+    exit;
+}
+if ($action === 'raw') {
+    if (obj_type($r, $sub) !== 'blob') not_found();
+    header('Content-Type: ' . ($MIME[ext($sub)] ?? 'text/plain; charset=utf-8'));
+    header('X-Content-Type-Options: nosniff');
+    echo blob($r, $sub); exit;
+}
+if ($action === 'commits' && $r['git']) {
+    page_start($r['name'] . ' commits', $crumbs + ['commits' => '']);
+    repo_header($r, 'commits');
+    echo activity_svg(activity_days($r), $r['name']);
+    echo '<table class="commits">';
+    foreach (explode("\n", trim(git($r['dir'], 'log', '-100', LOGFMT))) as $l) if ($c = parse_commit($l)) commit_row($r, $c);
+    echo '</table>';
+    page_end(); exit;
+}
+if ($action === 'commit' && $r['git'] && preg_match('/^[0-9a-f]{4,64}$/', $sub)) {
+    $c = parse_commit(trim(git($r['dir'], 'log', '-1', LOGFMT, $sub)));
+    if (!$c) not_found();
+    page_start($c['h'], $crumbs + ['commits' => $r['url'] . '/commits', $c['h'] => '']);
+    repo_header($r, 'commits');
+    echo '<div class="commit"><h2>' . h($c['subject']) . '</h2><p class="meta">' . h($c['author']) . ' · ' . date('Y-m-d H:i', $c['time']) . ' · <code>' . h($c['H']) . '</code></p>';
+    $body = trim(git($r['dir'], 'log', '-1', '--format=%b', $sub));
+    if ($body !== '') echo '<pre class="body">' . h($body) . '</pre>';
+    echo '<pre class="diff">';
+    foreach (explode("\n", git($r['dir'], 'show', '--format=', '--stat', '-p', $sub)) as $l) {
+        $cls = match (true) { str_starts_with($l, '+++') || str_starts_with($l, '---') => 'h', str_starts_with($l, '+') => 'a',
+                              str_starts_with($l, '-') => 'd', str_starts_with($l, '@@') => 'r', str_starts_with($l, 'diff ') => 'f', default => '' };
+        echo $cls ? '<span class="' . $cls . '">' . h($l) . "</span>\n" : h($l) . "\n";
+    }
+    echo '</pre></div>';
+    page_end(); exit;
+}
+if ($action === 'blob') {
+    if (obj_type($r, $sub) !== 'blob') not_found();
+    $c = blob($r, $sub); $e = ext($sub);
+    page_start(basename($sub), $crumbs + [$sub => '']);
+    repo_header($r, 'files');
+    $raw = tree_link($r, $sub, 'raw');
+    echo '<div class="filehead"><b>' . h($sub) . '</b> <span class="meta">' . fmt_size(strlen($c)) . ' · ' . substr_count($c, "\n") . ' lines</span> <a class="btn" href="' . h($raw) . '">raw</a></div>';
+    if (isset($MIME[$e]) && $MIME[$e] !== 'application/pdf') echo '<p><img class="preview" src="' . h($raw) . '" alt=""></p>';
+    elseif (strlen($c) > 1048576) echo '<p><i>File too large to display.</i></p>';
+    elseif (str_contains(substr($c, 0, 8000), "\0")) echo '<p><i>Binary file.</i></p>';
+    elseif (in_array($e, ['md', 'markdown'])) echo '<div class="markdown readme">' . md($c, rtrim(tree_link($r, dirname($sub) === '.' ? '' : dirname($sub), 'raw'), '/') . '/') . '</div>';
+    else echo '<pre><code class="' . (isset($LANG[$e]) ? 'language-' . $LANG[$e] : 'nohighlight') . '">' . h($c) . '</code></pre>';
+    page_end(); exit;
+}
+if ($action === 'tree') {                     /* repo overview / subtree */
+    if ($sub !== '' && obj_type($r, $sub) !== 'tree') not_found();
+    page_start($r['name'] . ($sub ? "/$sub" : ''), $crumbs + ($sub ? [$sub => ''] : []));
+    repo_header($r, 'files');
+    if ($sub === '' && ($c = last_commit($r))) { echo '<table class="commits last">'; commit_row($r, $c); echo '</table>'; }
+    render_tree($r, $sub);
+    if ($sub === '') render_readme($r);
+    page_end(); exit;
+}
+not_found();
diff --git a/web/index.php b/web/index.php
index 74aa30d..47e36b3 100644
--- a/web/index.php
+++ b/web/index.php
@@ -1,7 +1,7 @@
 <?php
 declare(strict_types=1);
 /* git.christianimmanuel.de - read-only git browser. 100% Vibecode but tested. Works in lynx. */
-const VERSION = '1.9.3';
+const VERSION = '1.10.1';
 const SITE = 'Nimbin[12]?';
 const LEGAL = 'https://christianimmanuel.de';
 $ROOT   = __DIR__ . '/repos';