vitrine

add per-exhibit `raw` option: serve files as bytes, no HTML frame

commit 7875fa1e0591d4cc3cb57e3aed866a56c5e40262 · parent 496af3b480 · browse this tree
Ximianth, 2026-08-22 19:48:35 UTC

By default a file viewed without ?fmt=raw gets the rendered frame:
markdown becomes HTML, everything else is wrapped in an iframe.
Some trees are assets meant to be consumed directly (by tools, by
<img>/<link> refs, by download), where that chrome only gets in the way.

`raw: true` on an exhibit makes every file serve as its raw bytes, as if
?fmt=raw were always present (markdown included, as text/plain), turning
the exhibit into a plain static file server. Directories are unaffected:
they still render the normal listing.

Kept as a bool rather than a view-mode enum on purpose: `raw` and
`expose-git` are orthogonal axes (all four combinations are sensible), so
a sum type would encode a mutual exclusivity that does not exist. The one
axis that could become an enum is view-rendering alone (rendered vs raw);
the trigger to promote it is a third *view* mode appearing (e.g. a
download/attachment mode), which is a cheap localized change when it comes.
diff --git a/README.md b/README.md
index 74a3f05..1cd3c9e 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,7 @@ exhibits:
   Absolute paths also work.
   Trailing slashes on names and paths are accepted and normalized away.
 - `expose-git` (optional, default false): see below.
+- `raw` (optional, default false): see below.
 
 A bad config (parse error, unknown key, missing path, or nothing configured at all)
 is a startup error: vitrine refuses to run rather than serve the wrong thing.
@@ -165,6 +166,22 @@ digraph { rankdir=LR; a -> b -> c; b [shape=box]; }
 - Any other code language (or none) renders as an ordinary code block, unchanged.
 
 
+raw
+---
+
+By default, a file served without `?fmt=raw` gets the rendered view:
+markdown becomes HTML, and anything else is framed in an iframe.
+
+Setting `raw: true` on an exhibit drops that frame entirely:
+every file serves as its raw bytes, as if `?fmt=raw` were always present
+(markdown included -- it serves as `text/plain` like any other source file).
+This turns an exhibit into a plain static file server,
+which is what you want for a tree of assets meant to be consumed directly
+(by tools, by `<img>`/`<link>` references, or by download)
+rather than read through vitrine's chrome.
+
+Directories are unaffected: they still render the normal listing.
+
 expose-git
 ----------
 
diff --git a/config.go b/config.go
index 4b9cd81..199ff1d 100644
--- a/config.go
+++ b/config.go
@@ -20,6 +20,10 @@ type exhibit struct {
 	// ExposeGit makes the .git directory list and serve like any other content
 	// (it is hidden and unreachable by default).
 	ExposeGit bool `yaml:"expose-git"`
+	// Raw makes files serve as their raw bytes by default, as if every request
+	// carried ?fmt=raw: no rendered-markdown view, no iframe frame.
+	// Directories still render the normal listing.
+	Raw bool `yaml:"raw"`
 }
 
 // forward is one configured reverse-proxy mount:
diff --git a/config_test.go b/config_test.go
index 586edbc..021f8ab 100644
--- a/config_test.go
+++ b/config_test.go
@@ -38,6 +38,27 @@ exhibits:
 	}
 }
 
+func TestConfigRawField(t *testing.T) {
+	p := writeConfig(t, `
+exhibits:
+  assets:
+    path: /srv/assets
+    raw: true
+  docs:
+    path: /srv/docs
+`)
+	cfg, err := loadConfig(p)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !cfg.Exhibits["assets"].Raw {
+		t.Errorf("assets: raw = false, want true")
+	}
+	if cfg.Exhibits["docs"].Raw {
+		t.Errorf("docs: raw = true, want default false")
+	}
+}
+
 func TestConfigRelativePathResolvesAgainstConfigDir(t *testing.T) {
 	p := writeConfig(t, `
 exhibits:
diff --git a/serve.go b/serve.go
index 1e0e46a..7d1f7f2 100644
--- a/serve.go
+++ b/serve.go
@@ -40,6 +40,7 @@ type mountSet struct {
 type tree struct {
 	root      *os.Root
 	exposeGit bool
+	raw       bool
 }
 
 func newServer(cfg *config, th theme) *server {
@@ -76,7 +77,7 @@ func (s *server) setMounts(cfg *config) {
 			log.Printf("exhibit %q skipped: %v", name, err)
 			continue
 		}
-		mounts.trees[name] = tree{root: root, exposeGit: ex.ExposeGit}
+		mounts.trees[name] = tree{root: root, exposeGit: ex.ExposeGit, raw: ex.Raw}
 	}
 	// Run update-server-info only for exhibits that actually opened above.
 	// Re-running on every successful reload is deliberate and idempotent.
@@ -307,6 +308,10 @@ func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, tr tree,
 		s.serveDir(w, r, tr, mount, rel)
 	case r.URL.Query().Get("fmt") == "raw":
 		serveRaw(w, r, root, rel)
+	case tr.raw:
+		// This exhibit opts out of the rendered frame entirely: every file
+		// serves as its raw bytes, as if ?fmt=raw were always present.
+		serveRaw(w, r, root, rel)
 	case hasGitSegment(rel):
 		// Files inside an exposed .git serve raw always: git's dumb-HTTP
 		// client fetches info/refs, HEAD, objects, etc. as plain GETs with
diff --git a/serve_test.go b/serve_test.go
index 825dedd..db50d1d 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -77,6 +77,66 @@ func TestNonMarkdownGetsIframe(t *testing.T) {
 	}
 }
 
+// newRawTestServer mounts the same tree with raw:true, so files serve as bytes.
+func newRawTestServer(t *testing.T) *server {
+	t.Helper()
+	dir := t.TempDir()
+	for name, content := range map[string]string{
+		"README.md":     "# Hello\n\nsome *markdown* & <angle> stuff\n",
+		"sub/notes.txt": "plain text here\n",
+	} {
+		p := filepath.Join(dir, filepath.FromSlash(name))
+		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	return exhibitServer(map[string]exhibit{"demo": {Path: dir, Raw: true}}, newTheme("testhost"))
+}
+
+// On a raw mount, markdown serves as its plain bytes, not rendered HTML:
+// the whole point is that the mount behaves like a static file server.
+func TestRawMountMarkdownServesBytes(t *testing.T) {
+	rec := get(t, newRawTestServer(t), "/demo/README.md")
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d", rec.Code)
+	}
+	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
+		t.Errorf("content-type = %q, want text/plain", ct)
+	}
+	if got := rec.Body.String(); got != "# Hello\n\nsome *markdown* & <angle> stuff\n" {
+		t.Errorf("body = %q; want exact file bytes", got)
+	}
+	if strings.Contains(rec.Body.String(), "<article>") {
+		t.Errorf("markdown was rendered on a raw mount; body:\n%s", rec.Body.String())
+	}
+}
+
+// On a raw mount, non-markdown files skip the iframe frame and serve raw too.
+func TestRawMountNonMarkdownSkipsIframe(t *testing.T) {
+	rec := get(t, newRawTestServer(t), "/demo/sub/notes.txt")
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d", rec.Code)
+	}
+	if strings.Contains(rec.Body.String(), "<iframe") {
+		t.Errorf("raw mount still framed the file in an iframe; body:\n%s", rec.Body.String())
+	}
+	if got := rec.Body.String(); got != "plain text here\n" {
+		t.Errorf("body = %q; want exact file bytes", got)
+	}
+}
+
+// Directories on a raw mount still render the normal vitrine listing.
+func TestRawMountDirStillListsWithFrame(t *testing.T) {
+	rec := get(t, newRawTestServer(t), "/demo/")
+	body := rec.Body.String()
+	if !strings.Contains(body, `href="README.md"`) || !strings.Contains(body, "<ul class=\"listing\"") {
+		t.Errorf("raw mount did not render a normal listing; body:\n%s", body)
+	}
+}
+
 func TestDirListingHidesGit(t *testing.T) {
 	rec := get(t, newTestServer(t), "/demo/")
 	body := rec.Body.String()