vitrine

config: path shorthand is an explicit '%' placeholder, not an omitted field

commit c6ea0e9b8ffdfbd60f76e368f81eb772c6b76bc9 · parent 46fd58b7d4 · browse this tree
Ximianth, 2026-07-30 11:14:21 UTC

Review feedback on the previous commit: a bare exhibit key with no
value reads like an accident in yaml, not a request.  The shorthand
for 'path mirrors the exhibit name' is now the explicit placeholder
'path: "%"', and a missing path is a config error again (with the
error message teaching the placeholder).
diff --git a/README.md b/README.md
index 35961af..2a86879 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ The format is YAML:
 ```yaml
 exhibits:
   moonshot-1:
+    path: "%"
   meta/research/some-topic:
     path: ../elsewhere/some-topic
   some-repo:
@@ -28,8 +29,8 @@ exhibits:
 - `exhibits` maps URL prefixes to directories.
   A prefix may contain slashes; the intermediate URL levels then serve synthetic listings
   that reveal only the mounted subtrees.
-- `path` (optional) is the directory to serve.
-  When omitted, it defaults to the exhibit's own name --
+- `path` (required) is the directory to serve.
+  The placeholder `"%"` means "same as the exhibit name" --
   the common case where the URL space mirrors the directory layout.
   Relative paths resolve against the config file's directory,
   which makes a config portable alongside the trees it describes.
@@ -37,7 +38,7 @@ exhibits:
   Trailing slashes on names and paths are accepted and normalized away.
 - `expose-git` (optional, default false): see below.
 
-A bad config (parse error, unknown key, or zero exhibits)
+A bad config (parse error, unknown key, missing path, or zero exhibits)
 is a startup error: vitrine refuses to run rather than serve the wrong thing.
 
 Config changes apply live: vitrine watches the config file
diff --git a/config.go b/config.go
index 7301c2b..40f4146 100644
--- a/config.go
+++ b/config.go
@@ -67,12 +67,17 @@ func loadConfig(path string) (*config, error) {
 		if _, dup := normalized[cleanName]; dup {
 			return nil, fmt.Errorf("config %s: exhibit %q: duplicate of %q after trimming slashes", path, name, cleanName)
 		}
-		// An omitted path means "same as the exhibit name":
+		// The path "%" is a placeholder meaning "same as the exhibit name":
 		// the near-universal case where the URL prefix mirrors the directory layout
 		// relative to the config file.
-		if ex.Path == "" {
+		// (Explicit placeholder rather than an omittable field:
+		// a bare key with no value reads like an accident in yaml.)
+		if ex.Path == "%" {
 			ex.Path = cleanName
 		}
+		if ex.Path == "" {
+			return nil, fmt.Errorf("config %s: exhibit %q: missing required field %q (use %q to serve the path matching the exhibit name)", path, name, "path", "path: %")
+		}
 		ex.Path = strings.TrimRight(ex.Path, "/")
 		if ex.Path == "" {
 			// Only "/" (or "///"...) trims to nothing: serving the root filesystem
diff --git a/config_test.go b/config_test.go
index b748803..4362231 100644
--- a/config_test.go
+++ b/config_test.go
@@ -71,10 +71,11 @@ exhibits:
 	}
 }
 
-func TestConfigOmittedPathMeansExhibitName(t *testing.T) {
+func TestConfigPercentPathMeansExhibitName(t *testing.T) {
 	p := writeConfig(t, `
 exhibits:
   meta/research/thing:
+    path: "%"
     expose-git: true
 `)
 	cfg, err := loadConfig(p)
@@ -87,12 +88,26 @@ exhibits:
 	}
 }
 
+func TestConfigMissingPathRejected(t *testing.T) {
+	p := writeConfig(t, `
+exhibits:
+  demo:
+    expose-git: true
+  bare-key:
+`)
+	_, err := loadConfig(p)
+	if err == nil || !strings.Contains(err.Error(), "path") {
+		t.Fatalf("want missing-path error, got %v", err)
+	}
+}
+
 func TestConfigTrailingSlashesNormalized(t *testing.T) {
 	p := writeConfig(t, `
 exhibits:
   demo/:
     path: /srv/demo/
   shorthand/:
+    path: "%"
 `)
 	cfg, err := loadConfig(p)
 	if err != nil {