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()