vitrine

load exhibits from a YAML config file via required -config flag

commit 51e714cd19f0d54e52b77388d2bd735b3747be15 · parent 13311ca700 · browse this tree
Ximianth, 2026-07-28 14:07:04 UTC

The hardcoded roots map in main.go becomes a config file with a top-level
"exhibits" key: each entry maps a URL prefix (slashes allowed) to a path
plus options.  Relative paths resolve against the config file's directory,
which makes configs portable alongside synced trees.

gopkg.in/yaml.v3 with KnownFields(true): unknown keys are startup errors,
as are a missing path, an empty exhibits map, and any parse failure.
The expose-git option is parsed and threaded through to the server here,
but behavior lands in the next commit.
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..335c660
--- /dev/null
+++ b/config.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"gopkg.in/yaml.v3"
+)
+
+// exhibit is one configured mount: a directory served under a URL prefix.
+type exhibit struct {
+	// Path is the directory to serve.
+	// In the config file it may be relative (resolved against the config file's directory);
+	// after loadConfig it is always absolute.
+	Path string `yaml:"path"`
+	// ExposeGit makes the .git directory list and serve like any other content
+	// (it is hidden and unreachable by default).
+	ExposeGit bool `yaml:"expose-git"`
+}
+
+// config is the top-level schema of the vitrine config file.
+type config struct {
+	// Exhibits maps a URL prefix (which may contain slashes) to what it serves.
+	Exhibits map[string]exhibit `yaml:"exhibits"`
+}
+
+// loadConfig reads and validates the YAML config file at the given path.
+// Relative exhibit paths are resolved against the config file's directory,
+// so a config travels with the tree it describes.
+// Any parse or validation problem is an error: vitrine refuses to start on a bad config.
+func loadConfig(path string) (*config, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("config: %w", err)
+	}
+	defer f.Close()
+
+	var cfg config
+	dec := yaml.NewDecoder(f)
+	dec.KnownFields(true) // typo'd keys are errors, not silence
+	if err := dec.Decode(&cfg); err != nil {
+		return nil, fmt.Errorf("config %s: %w", path, err)
+	}
+
+	if len(cfg.Exhibits) == 0 {
+		return nil, fmt.Errorf("config %s: no exhibits configured; a vitrine with nothing on display is a misconfiguration", path)
+	}
+
+	base, err := filepath.Abs(filepath.Dir(path))
+	if err != nil {
+		return nil, fmt.Errorf("config %s: resolving directory: %w", path, err)
+	}
+	for name, ex := range cfg.Exhibits {
+		if ex.Path == "" {
+			return nil, fmt.Errorf("config %s: exhibit %q: missing required field %q", path, name, "path")
+		}
+		if !filepath.IsAbs(ex.Path) {
+			ex.Path = filepath.Join(base, ex.Path)
+			cfg.Exhibits[name] = ex
+		}
+	}
+	return &cfg, nil
+}
diff --git a/config_test.go b/config_test.go
new file mode 100644
index 0000000..aa3c7f6
--- /dev/null
+++ b/config_test.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// writeConfig drops config text into a temp dir and returns the file path.
+func writeConfig(t *testing.T, text string) string {
+	t.Helper()
+	p := filepath.Join(t.TempDir(), "vitrine.cfg")
+	if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	return p
+}
+
+func TestConfigValid(t *testing.T) {
+	p := writeConfig(t, `
+exhibits:
+  demo:
+    path: /srv/demo
+  meta/research/thing:
+    path: /srv/thing
+    expose-git: true
+`)
+	cfg, err := loadConfig(p)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := cfg.Exhibits["demo"]; got.Path != "/srv/demo" || got.ExposeGit {
+		t.Errorf("demo = %+v", got)
+	}
+	if got := cfg.Exhibits["meta/research/thing"]; got.Path != "/srv/thing" || !got.ExposeGit {
+		t.Errorf("meta/research/thing = %+v", got)
+	}
+}
+
+func TestConfigRelativePathResolvesAgainstConfigDir(t *testing.T) {
+	p := writeConfig(t, `
+exhibits:
+  demo:
+    path: sub/tree
+  updemo:
+    path: ../elsewhere
+`)
+	cfg, err := loadConfig(p)
+	if err != nil {
+		t.Fatal(err)
+	}
+	dir := filepath.Dir(p)
+	if want := filepath.Join(dir, "sub/tree"); cfg.Exhibits["demo"].Path != want {
+		t.Errorf("demo path = %q, want %q", cfg.Exhibits["demo"].Path, want)
+	}
+	if want := filepath.Join(filepath.Dir(dir), "elsewhere"); cfg.Exhibits["updemo"].Path != want {
+		t.Errorf("updemo path = %q, want %q", cfg.Exhibits["updemo"].Path, want)
+	}
+}
+
+func TestConfigUnknownFieldRejected(t *testing.T) {
+	p := writeConfig(t, `
+exhibits:
+  demo:
+    path: /srv/demo
+    expose-gti: true
+`)
+	if _, err := loadConfig(p); err == nil {
+		t.Fatal("want error for unknown field, got nil")
+	}
+}
+
+func TestConfigMissingPathRejected(t *testing.T) {
+	p := writeConfig(t, `
+exhibits:
+  demo:
+    expose-git: true
+`)
+	_, err := loadConfig(p)
+	if err == nil || !strings.Contains(err.Error(), "path") {
+		t.Fatalf("want missing-path error, got %v", err)
+	}
+}
+
+func TestConfigEmptyExhibitsRejected(t *testing.T) {
+	for name, text := range map[string]string{
+		"empty-map":  "exhibits: {}\n",
+		"absent-key": "# nothing here\n",
+	} {
+		p := writeConfig(t, text)
+		if _, err := loadConfig(p); err == nil {
+			t.Errorf("%s: want error for empty exhibits, got nil", name)
+		}
+	}
+}
+
+func TestConfigMissingFileRejected(t *testing.T) {
+	if _, err := loadConfig(filepath.Join(t.TempDir(), "nope.cfg")); err == nil {
+		t.Fatal("want error for missing file, got nil")
+	}
+}
diff --git a/go.mod b/go.mod
index e94e9e5..2ea9747 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,7 @@ module vitrine
 
 go 1.26.5
 
-require github.com/yuin/goldmark v1.8.4 // indirect
+require (
+	github.com/yuin/goldmark v1.8.4
+	gopkg.in/yaml.v3 v3.0.1
+)
diff --git a/go.sum b/go.sum
index a63e17b..5ed7a65 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,6 @@
 github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
 github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
index c0da3cf..8356003 100644
--- a/main.go
+++ b/main.go
@@ -1,9 +1,12 @@
 // Command vitrine is a tiny read-only web viewer for local source trees.
 //
-// Each configured root is served under its name as a URL prefix,
+// Each configured exhibit is served under its name as a URL prefix,
 // e.g. "/moonshot-1/crates/iri/filetests/verify-errors.md".
 // The default view is rendered (markdown becomes HTML, directories become listings);
 // appending "?fmt=raw" serves the file bytes directly.
+//
+// Exhibits come from a YAML config file given via the required -config flag;
+// see config.go for the schema and README.md for examples.
 package main
 
 import (
@@ -12,23 +15,19 @@ import (
 	"net/http"
 )
 
-// roots maps a mount name to a filesystem directory.
-// Mount names may contain slashes: the tree is then served under that
-// deeper URL path, and the intermediate path levels serve synthetic
-// listings that reveal only the mounted subtrees.
-// Hardcoded for now; lift into a config file when this grows tiresome.
-var roots = map[string]string{
-	"moonshot-1":     "/home/exedev/fusion/moonshot-1",
-	"moonshot-1-mem": "/home/exedev/fusion/moonshot-1-mem",
-	"moonshot.meta/research/2026-07-24--language-design": "/home/exedev/fusion/moonshot.meta/research/2026-07-24--language-design",
-	"moonshot.meta/research/2026-07-24--syntax-sketch":   "/home/exedev/fusion/moonshot.meta/research/2026-07-24--syntax-sketch",
-}
-
 func main() {
 	addr := flag.String("addr", ":8000", "listen address")
+	configPath := flag.String("config", "", "path to config file (required)")
 	flag.Parse()
 
-	srv, err := newServer(roots)
+	if *configPath == "" {
+		log.Fatal("the -config flag is required; there is no default config location")
+	}
+	cfg, err := loadConfig(*configPath)
+	if err != nil {
+		log.Fatal(err)
+	}
+	srv, err := newServer(cfg.Exhibits)
 	if err != nil {
 		log.Fatal(err)
 	}
diff --git a/serve.go b/serve.go
index ba1aec8..172f9d8 100644
--- a/serve.go
+++ b/serve.go
@@ -10,21 +10,27 @@ import (
 	"strings"
 )
 
-// server holds one opened os.Root per configured mount.
+// server holds one opened tree per configured exhibit.
+type server struct {
+	trees map[string]tree
+}
+
+// tree is one exhibit as served: an opened os.Root plus its options.
 // os.Root guarantees all path resolution stays inside the tree
 // (it rejects ".." escapes and symlinks pointing outside).
-type server struct {
-	trees map[string]*os.Root
+type tree struct {
+	root      *os.Root
+	exposeGit bool
 }
 
-func newServer(roots map[string]string) (*server, error) {
-	s := &server{trees: make(map[string]*os.Root)}
-	for name, dir := range roots {
-		root, err := os.OpenRoot(dir)
+func newServer(exhibits map[string]exhibit) (*server, error) {
+	s := &server{trees: make(map[string]tree)}
+	for name, ex := range exhibits {
+		root, err := os.OpenRoot(ex.Path)
 		if err != nil {
-			return nil, fmt.Errorf("opening root %q: %w", name, err)
+			return nil, fmt.Errorf("opening exhibit %q: %w", name, err)
 		}
-		s.trees[name] = root
+		s.trees[name] = tree{root: root, exposeGit: ex.ExposeGit}
 	}
 	return s, nil
 }
@@ -41,13 +47,13 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	req := path.Clean("/" + r.URL.Path)[1:] // normalized, no leading slash; "" at root
 
 	// A request path inside a mount is served from that tree.
-	for mount, root := range s.trees {
+	for mount, tr := range s.trees {
 		if req == mount || strings.HasPrefix(req, mount+"/") {
 			rel := strings.TrimPrefix(strings.TrimPrefix(req, mount), "/")
 			if rel == "" {
 				rel = "."
 			}
-			s.serveFromTree(w, r, root, mount, rel)
+			s.serveFromTree(w, r, tr, mount, rel)
 			return
 		}
 	}
@@ -105,7 +111,8 @@ func (s *server) mountChildren(prefix string) []dirEntry {
 }
 
 // serveFromTree dispatches a request that resolved inside a mounted tree.
-func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, root *os.Root, mount, rel string) {
+func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, tr tree, mount, rel string) {
+	root := tr.root
 	info, err := root.Stat(rel)
 	if err != nil {
 		http.NotFound(w, r)
@@ -117,7 +124,7 @@ func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, root *os.
 			http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
 			return
 		}
-		s.serveDir(w, r, root, mount, rel)
+		s.serveDir(w, r, tr, mount, rel)
 	case r.URL.Query().Get("fmt") == "raw":
 		serveRaw(w, r, root, rel)
 	default:
@@ -148,8 +155,8 @@ func serveRaw(w http.ResponseWriter, r *http.Request, root *os.Root, rel string)
 
 // serveDir renders a directory listing.
 // The .git directory is omitted: it's not useful to browse and clutters the view.
-func (s *server) serveDir(w http.ResponseWriter, r *http.Request, root *os.Root, mount, rel string) {
-	ents, err := fs.ReadDir(root.FS(), rel)
+func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount, rel string) {
+	ents, err := fs.ReadDir(tr.root.FS(), rel)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
diff --git a/serve_test.go b/serve_test.go
index 5207d6c..c820ef6 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -28,7 +28,7 @@ func newTestServer(t *testing.T) *server {
 			t.Fatal(err)
 		}
 	}
-	srv, err := newServer(map[string]string{"demo": dir})
+	srv, err := newServer(map[string]exhibit{"demo": {Path: dir}})
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -132,8 +132,8 @@ func newNestedTestServer(t *testing.T) *server {
 			t.Fatal(err)
 		}
 	}
-	srv, err := newServer(map[string]string{
-		"meta/research/latest": filepath.Join(dir, "research/latest"),
+	srv, err := newServer(map[string]exhibit{
+		"meta/research/latest": {Path: filepath.Join(dir, "research/latest")},
 	})
 	if err != nil {
 		t.Fatal(err)