vitrine

serve images and other browser-native formats raw by default

commit b0ad0cdd90667011ccc4b70699d9cb9a8e65ae12 · parent 7875fa1e05 · browse this tree
Ximianth, 2026-10-09 02:16:25 UTC

Files whose extensions identify them as images (.png, .svg, .jpg, etc.),
fonts, or PDFs now bypass the HTML iframe wrapper and serve their raw
bytes directly.  The iframe adds nothing for formats the browser already
renders natively, and for SVG in particular it introduces unnecessary
indirection when the file is referenced by other pages or tools.

Text files and other non-recognized extensions still get the rendered
frame as before; ?fmt=raw remains available on everything regardless.
diff --git a/serve.go b/serve.go
index 7d1f7f2..f807c0a 100644
--- a/serve.go
+++ b/serve.go
@@ -320,11 +320,30 @@ func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, tr tree,
 		// the non-exposed case was rejected at the top of serveFromTree.)
 		// Rendering .git contents as pages was never meaningful anyway.
 		serveRaw(w, r, root, rel)
+	case serveRawByExtension(rel):
+		// Images and other binary formats that browsers render natively
+		// gain nothing from the HTML iframe wrapper; serve them directly.
+		serveRaw(w, r, root, rel)
 	default:
 		s.serveRendered(w, r, root, mount, rel, info.Size())
 	}
 }
 
+// serveRawByExtension reports whether a file should bypass the rendered
+// HTML frame and serve its raw bytes by default.  Images, fonts, and other
+// binary formats that browsers render natively gain nothing from the
+// iframe wrapper; serving them directly lets them display (or embed)
+// without an extra layer of indirection.
+func serveRawByExtension(rel string) bool {
+	switch strings.ToLower(path.Ext(rel)) {
+	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico",
+		".woff", ".woff2", ".ttf", ".otf",
+		".pdf":
+		return true
+	}
+	return false
+}
+
 // serveRaw sends the file bytes.
 // Markdown gets text/plain so browsers display it instead of downloading;
 // everything else gets stdlib content-type detection via ServeContent.
diff --git a/serve_test.go b/serve_test.go
index db50d1d..029acec 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -77,6 +77,41 @@ func TestNonMarkdownGetsIframe(t *testing.T) {
 	}
 }
 
+// Images and other browser-native binary formats serve raw by default,
+// bypassing the HTML iframe wrapper that text files get.
+func TestImageFormatsServeRawByDefault(t *testing.T) {
+	dir := t.TempDir()
+	for name, content := range map[string]string{
+		"icon.png":    "fakepng",
+		"drawing.svg": `<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>`,
+		"photo.jpg":   "fakejpg",
+		"notes.txt":   "plain text",
+	} {
+		if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	srv := exhibitServer(map[string]exhibit{"img": {Path: dir}}, theme{})
+
+	// These should serve raw bytes, no iframe.
+	for _, name := range []string{"icon.png", "drawing.svg", "photo.jpg"} {
+		rec := get(t, srv, "/img/"+name)
+		if rec.Code != http.StatusOK {
+			t.Fatalf("%s: status = %d", name, rec.Code)
+		}
+		body := rec.Body.String()
+		if strings.Contains(body, "<iframe") || strings.Contains(body, "<!doctype html>") {
+			t.Errorf("%s: got HTML frame, want raw bytes; body:\n%s", name, body)
+		}
+	}
+
+	// Plain text should still get the iframe.
+	rec := get(t, srv, "/img/notes.txt")
+	if !strings.Contains(rec.Body.String(), "<iframe") {
+		t.Errorf("notes.txt: expected iframe; body:\n%s", rec.Body.String())
+	}
+}
+
 // newRawTestServer mounts the same tree with raw:true, so files serve as bytes.
 func newRawTestServer(t *testing.T) *server {
 	t.Helper()