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)