Repositories / gitweb2.git

gitweb2.git

Clone (read-only): git clone http://git.guha-anderson.com/git/gitweb2.git

Branch

Add README previews and pygments rendering

Author
Arjun Guha <a.guha@northeastern.edu>
Date
2026-04-19 08:41:20 -0400
Commit
ca5d9448b5d8b4f2bd3f22ccba673da333188734
README.md
index 16df590..adc2924 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ The app uses Ktor Native with the CIO engine for HTTP and the local
 Options:
 
 ```sh
-./build/bin/native/releaseExecutable/gitweb2.kexe ~/repos --host 127.0.0.1 --port 8080
+./build/bin/native/releaseExecutable/gitweb2.kexe ~/repos --host 127.0.0.1 --port 8080 --pygments "uv run --with pygments pygmentize"
 ```
 
 Press `Ctrl+C` to stop the server.
build.gradle.kts
index b6178bb..5f56cd4 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -32,11 +32,13 @@ kotlin {
 
     nativeTarget.apply {
         binaries {
+            all {
+                linkerOpts("-L${pkgConfigValue("--variable=libdir", "libgit2")}")
+                linkerOpts(pkgConfig("--libs", "libgit2"))
+            }
             executable {
                 baseName = "gitweb2"
                 entryPoint = "main"
-                linkerOpts("-L${pkgConfigValue("--variable=libdir", "libgit2")}")
-                linkerOpts(pkgConfig("--libs", "libgit2"))
             }
         }
     }
src/main/kotlin/Main.kt
index af82df9..e937133 100644
--- a/src/main/kotlin/Main.kt
+++ b/src/main/kotlin/Main.kt
@@ -15,6 +15,9 @@ import io.ktor.server.routing.get
 import io.ktor.server.routing.routing
 import io.ktor.utils.io.charsets.Charsets
 import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.ByteVar
+import kotlinx.cinterop.allocArray
+import kotlinx.cinterop.memScoped
 import kotlinx.cinterop.pointed
 import kotlinx.cinterop.staticCFunction
 import kotlinx.cinterop.toKString
@@ -30,9 +33,12 @@ import platform.posix.SIGINT
 import platform.posix.SIGTERM
 import platform.posix.access
 import platform.posix.closedir
+import platform.posix.fgets
 import platform.posix.getenv
 import platform.posix.opendir
+import platform.posix.pclose
 import platform.posix.pause
+import platform.posix.popen
 import platform.posix.readdir
 import platform.posix.signal
 import platform.posix._exit
@@ -41,25 +47,28 @@ import kotlin.system.exitProcess
 private const val DefaultHost = "127.0.0.1"
 private const val DefaultPort = 8080
 private const val MaxScanDepth = 5
+private const val DefaultPygmentsCommand = "pygmentize"
 
 fun runGitWeb2(args: Array<String>) {
     val config = parseArgs(args) ?: return
-    GitWebServer(config.root, config.host, config.port).start()
+    GitWebServer(config.root, config.host, config.port, config.pygmentsCommand).start()
 }
 
-private data class Config(val root: String, val host: String, val port: Int)
+private data class Config(val root: String, val host: String, val port: Int, val pygmentsCommand: String)
 
 private data class RepoInfo(val key: String, val name: String, val path: String)
 
-private data class Response(val status: Int, val body: String)
+internal data class Response(val status: Int, val body: String)
 
-private class GitWebServer(
+internal class GitWebServer(
     private val root: String,
     private val host: String,
     private val port: Int,
+    private val pygmentsCommand: String = DefaultPygmentsCommand,
 ) {
     private val repos: List<RepoInfo> = discoverRepos(root)
     private val reposByKey: Map<String, RepoInfo> = repos.associateBy { it.key }
+    private val fileRenderer = FileRenderer(pygmentsCommand)
 
     fun start() {
         println("gitweb2 serving ${repos.size} repositories from $root at http://$host:$port/")
@@ -171,7 +180,8 @@ private class GitWebServer(
         path: String,
         branches: List<Branch>,
     ): String {
-        val listing = git.tree(path, git.branchCommit(branch))
+        val commit = git.branchCommit(branch)
+        val listing = git.tree(path, commit)
         return buildString {
             append(repoHeader(repo, branch, path, branches, "code"))
             append("<div class=\"panel\"><table><tbody>")
@@ -179,12 +189,25 @@ private class GitWebServer(
             if (path.isNotBlank()) {
                 append("<tr><td><a href=\"${repoUrl(repo, branch, parent)}\">..</a></td><td class=\"kind\">tree</td></tr>")
             }
-            for (entry in listing.entries) {
+            for (entry in listing.entries.entriesForDisplay()) {
                 val child = joinPath(path, entry.name)
                 append("<tr><td><a href=\"${repoUrl(repo, branch, child)}\">${html(entry.name)}</a></td>")
                 append("<td class=\"kind\">${entry.kind.name.lowercase()}</td></tr>")
             }
             append("</tbody></table></div>")
+            val readme = listing.entries.entriesForDisplay().firstOrNull { isReadme(it.name) && it.kind == TreeEntryKind.Blob }
+            if (readme != null) {
+                val readmePath = joinPath(path, readme.name)
+                val blob = git.blob(readmePath, commit)
+                if (!blob.isBinary) {
+                    append("<section class=\"readme\">")
+                    append("<h2>${html(readme.name)}</h2>")
+                    append("<div class=\"panel file\">")
+                    append(fileRenderer.render(joinPath(repo.path, readmePath), blob.text()))
+                    append("</div>")
+                    append("</section>")
+                }
+            }
         }
     }
 
@@ -201,7 +224,7 @@ private class GitWebServer(
         if (blob.isBinary) {
             append("<p class=\"notice\">Binary file</p>")
         } else {
-            append("<pre><code>${html(blob.text())}</code></pre>")
+            append(fileRenderer.render(joinPath(repo.path, path), blob.text()))
         }
         append("</div>")
     }
@@ -279,6 +302,7 @@ main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 56px; }
 .hero, .repo-head { border-bottom: 1px solid var(--line); margin-bottom: 20px; padding-bottom: 16px; }
 .eyebrow, .repo-head p, .file-meta, .kind, small, .commits span, .repo-list span { color: var(--muted); }
 h1 { margin: 4px 0 8px; font-size: 28px; line-height: 1.2; overflow-wrap: anywhere; }
+h2 { margin: 0 0 10px; font-size: 20px; line-height: 1.25; overflow-wrap: anywhere; }
 .repo-list, .commits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
 .repo-list li, .commits li { border-top: 1px solid var(--line); }
 .repo-list li:first-child, .commits li:first-child { border-top: 0; }
@@ -296,6 +320,10 @@ tr:first-child td { border-top: 0; }
 .toolbar a.active, .tabs a.active { border-color: var(--accent); color: var(--accent); font-weight: 600; }
 .file pre { margin: 0; padding: 16px; overflow: auto; background: var(--wash); }
 .file code { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }
+.highlight { margin: 0; padding: 16px; overflow: auto; background: var(--wash); }
+.highlight pre { margin: 0; padding: 0; background: transparent; }
+.highlight, .highlight pre { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; }
+.readme { margin-top: 24px; }
 .file-meta { padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; }
 .notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--wash); }
 @media (max-width: 640px) {
@@ -309,6 +337,49 @@ tr:first-child td { border-top: 0; }
 </html>
 """.trimIndent()
 
+internal class FileRenderer(private val pygmentsCommand: String = DefaultPygmentsCommand) {
+    fun render(filePath: String, text: String): String =
+        renderWithPygments(filePath, text) ?: plain(text)
+
+    private fun renderWithPygments(filePath: String, text: String): String? {
+        val lexer = readCommandOutput("$pygmentsCommand -N ${shellQuote(filePath)} 2>/dev/null")?.trim()
+        val lexerOption = if (lexer.isNullOrBlank()) "-g" else "-l ${shellQuote(lexer)}"
+        val command = "printf %s ${shellQuote(text)} | $pygmentsCommand -f html -O nowrap,noclasses=True $lexerOption 2>/dev/null"
+        val highlighted = readCommandOutput(command) ?: return null
+        if (highlighted.isBlank()) return null
+        return "<div class=\"highlight\">$highlighted</div>"
+    }
+
+    private fun plain(text: String): String =
+        "<pre><code>${html(text)}</code></pre>"
+}
+
+private fun List<kotlinx.git.TreeEntry>.entriesForDisplay(): List<kotlinx.git.TreeEntry> =
+    sortedBy { it.name.lowercase() }
+
+private fun isReadme(name: String): Boolean =
+    name == "README.md" || name == "README.txt" || name == "README"
+
+private fun shellQuote(value: String): String =
+    "'" + value.replace("'", "'\"'\"'") + "'"
+
+private fun readCommandOutput(command: String): String? {
+    val stream = popen(command, "r") ?: return null
+    return try {
+        memScoped {
+            val buffer = allocArray<ByteVar>(4096)
+            buildString {
+                while (true) {
+                    val line = fgets(buffer, 4096, stream) ?: break
+                    append(line.toKString())
+                }
+            }
+        }
+    } finally {
+        pclose(stream)
+    }
+}
+
 private fun discoverRepos(root: String): List<RepoInfo> {
     val found = mutableListOf<RepoInfo>()
     fun scan(path: String, relative: String, depth: Int) {
@@ -431,21 +502,23 @@ private fun normalizeRoot(path: String): String {
 
 private fun parseArgs(args: Array<String>): Config? {
     if (args.isEmpty() || args[0] == "-h" || args[0] == "--help") {
-        println("usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080]")
+        println("usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080] [--pygments pygmentize]")
         return null
     }
     var host = DefaultHost
     var port = DefaultPort
+    var pygmentsCommand = DefaultPygmentsCommand
     var index = 1
     while (index < args.size) {
         when (args[index]) {
             "--host" -> host = args.getOrNull(++index) ?: die("missing --host value")
             "--port" -> port = args.getOrNull(++index)?.toIntOrNull() ?: die("missing or invalid --port value")
+            "--pygments" -> pygmentsCommand = args.getOrNull(++index) ?: die("missing --pygments value")
             else -> die("unknown argument ${args[index]}")
         }
         index++
     }
-    return Config(root = args[0], host = host, port = port)
+    return Config(root = args[0], host = host, port = port, pygmentsCommand = pygmentsCommand)
 }
 
 private fun die(message: String): Nothing {
src/nativeTest/kotlin/gitweb/FileRendererTest.kt
new file mode 100644
index 0000000..5e08dd5
--- /dev/null
+++ b/src/nativeTest/kotlin/gitweb/FileRendererTest.kt
@@ -0,0 +1,56 @@
+package gitweb
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.toKString
+import platform.posix.getenv
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalForeignApi::class)
+class FileRendererTest {
+    private val repoRoot: String = getenv("PWD")!!.toKString()
+
+    @Test
+    fun filesCanBeRenderedThroughCustomPygmentsCommand() {
+        val html = FileRenderer("uv run --with pygments pygmentize").render(
+            "$repoRoot/src/main/kotlin/Main.kt",
+            """
+            private class Example {
+                fun answer(): Int = 42
+            }
+            """.trimIndent(),
+        )
+
+        assertContains(html, "<div class=\"highlight\">")
+        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">private</span>")
+        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">class</span>")
+        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">fun</span>")
+        assertContains(html, "Example")
+    }
+
+    @Test
+    fun pygmentsUsesProvidedBlobTextEvenWhenThePathIsNotOnDisk() {
+        val html = FileRenderer("uv run --with pygments pygmentize").render(
+            "$repoRoot/not-checked-out/Example.kt",
+            """
+            private class BlobOnly {
+                fun answer(): Int = 42
+            }
+            """.trimIndent(),
+        )
+
+        assertContains(html, "<div class=\"highlight\">")
+        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">private</span>")
+        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">class</span>")
+        assertContains(html, "<span style=\"color: #008000; font-weight: bold\">fun</span>")
+        assertContains(html, "BlobOnly")
+    }
+
+    @Test
+    fun filesFallBackToEscapedPlainTextWhenPygmentsIsUnavailable() {
+        val html = FileRenderer("__missing_pygmentize__").render("$repoRoot/README.md", "<plain>")
+
+        assertEquals("<pre><code>&lt;plain&gt;</code></pre>", html)
+    }
+}
src/nativeTest/kotlin/gitweb/GitWebServerTest.kt
new file mode 100644
index 0000000..ea21114
--- /dev/null
+++ b/src/nativeTest/kotlin/gitweb/GitWebServerTest.kt
@@ -0,0 +1,48 @@
+package gitweb
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.toKString
+import platform.posix.getenv
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalForeignApi::class)
+class GitWebServerTest {
+    private val repoRoot: String = getenv("PWD")!!.toKString()
+    private val repoName: String = repoRoot.trimEnd('/').substringAfterLast('/')
+
+    @Test
+    fun directoryEntriesStayAlphabetical() {
+        val response = GitWebServer(repoRoot, "127.0.0.1", 0).route("/repo/$repoName")
+
+        assertEquals(200, response.status)
+        val buildIndex = response.body.indexOf(">build.gradle.kts</a>")
+        val settingsIndex = response.body.indexOf(">settings.gradle.kts</a>")
+        val readmeIndex = response.body.indexOf(">README.md</a>")
+
+        assertNotEquals(-1, buildIndex)
+        assertNotEquals(-1, settingsIndex)
+        assertNotEquals(-1, readmeIndex)
+        assertTrue(readmeIndex > buildIndex, "README.md should be listed after build.gradle.kts")
+        assertTrue(readmeIndex < settingsIndex, "README.md should be listed before settings.gradle.kts")
+    }
+
+    @Test
+    fun directoriesRenderTheirReadmeBelowTheListing() {
+        val response = GitWebServer(repoRoot, "127.0.0.1", 0, "uv run --with pygments pygmentize").route("/repo/$repoName")
+
+        assertEquals(200, response.status)
+        val listingIndex = response.body.indexOf(">README.md</a>")
+        val renderedIndex = response.body.indexOf("<h2>README.md</h2>")
+
+        assertNotEquals(-1, listingIndex)
+        assertNotEquals(-1, renderedIndex)
+        assertTrue(renderedIndex > listingIndex, "Rendered README should appear below the directory listing")
+        assertContains(response.body, "<div class=\"highlight\">")
+        assertContains(response.body, "gitweb2")
+        assertContains(response.body, "A Kotlin/Native, read-only web viewer")
+    }
+}