vitrine

support mounts deeper than one URL segment

commit 1a3eccc323a78ef200c1f7881abbc0510a39ce68 · parent 0d0727cbf9 · browse this tree
Ximianth, 2026-07-24 16:05:06 UTC

Mount names may now contain slashes.  Path levels above a mount serve
synthetic listings that reveal only the next segment of each mounted
subtree; unmounted siblings stay invisible and unreachable (still
enforced by os.Root at the mount boundary, plus longest-prefix
matching in dispatch).

This subsumes the old special-cased root index: '/' is now just the
synthetic listing for the empty prefix.  Breadcrumbs likewise
unified onto full URL paths.

Mounted the two 2026-07-24 syntax-research dirs from moonshot.meta;
the rest of that repo (worklogs, older research, input) stays private.
diff --git a/main.go b/main.go
index 793c96f..6119c10 100644
--- a/main.go
+++ b/main.go
@@ -12,11 +12,16 @@ import (
 	"net/http"
 )
 
-// roots maps a URL name to a filesystem directory.
-// Hardcoded for now; lift into a config file when we want more than a couple.
+// 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() {
diff --git a/render.go b/render.go
index 3fabbf6..f7379c6 100644
--- a/render.go
+++ b/render.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"bytes"
+	_ "embed"
 	"fmt"
 	"html/template"
 	"net/http"
@@ -10,13 +11,24 @@ import (
 
 	"github.com/yuin/goldmark"
 	gmext "github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	gmutil "github.com/yuin/goldmark/util"
 )
 
 // maxRenderSize caps how much we'll read into memory for rendered views.
 // Bigger files still work fine via ?fmt=raw, which streams.
 const maxRenderSize = 4 << 20
 
-var markdown = goldmark.New(goldmark.WithExtensions(gmext.GFM))
+var markdown = goldmark.New(
+	goldmark.WithExtensions(gmext.GFM),
+	goldmark.WithParserOptions(
+		parser.WithAutoHeadingID(),
+		parser.WithASTTransformers(gmutil.Prioritized(headingAnchors{}, 100)),
+	),
+)
+
+//go:embed style.css
+var styleCSS []byte
 
 type dirEntry struct {
 	Name string
@@ -37,33 +49,39 @@ type pageData struct {
 	Iframe  string        // iframe-passthrough mode: src URL
 }
 
-// crumbs builds a breadcrumb trail: home, root name, then each path segment.
-func crumbs(name, rel string) []crumb {
-	cs := []crumb{{Name: "repoview", Href: "/"}}
-	if name == "" {
-		return cs
-	}
-	href := "/" + name + "/"
-	cs = append(cs, crumb{Name: name, Href: href})
+// joinURL joins a mount name and a tree-relative path into one URL path
+// (no leading slash); rel of "." means the mount root itself.
+func joinURL(mount, rel string) string {
 	if rel == "." {
+		return mount
+	}
+	return mount + "/" + rel
+}
+
+// crumbs builds a breadcrumb trail from a URL path (no leading slash):
+// home, then each path segment.  The last segment gets no href;
+// it's the page being viewed.
+func crumbs(urlpath string) []crumb {
+	cs := []crumb{{Name: "repoview", Href: "/"}}
+	if urlpath == "" {
+		cs[0].Href = ""
 		return cs
 	}
-	for _, seg := range strings.Split(rel, "/") {
+	href := "/"
+	for _, seg := range strings.Split(urlpath, "/") {
 		href += seg + "/"
 		cs = append(cs, crumb{Name: seg, Href: href})
 	}
-	// The last segment may be a file; its correct href has no trailing slash,
-	// but browsers resolve it identically because we link the exact page anyway.
 	cs[len(cs)-1].Href = ""
 	return cs
 }
 
 // serveRendered shows a file as a page: markdown rendered to HTML,
 // anything else passed through in an iframe pointed at the raw view.
-func serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, name, rel string, size int64) {
+func serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, mount, rel string, size int64) {
 	pd := pageData{
-		Title:   name + "/" + rel,
-		Crumbs:  crumbs(name, rel),
+		Title:   mount + "/" + rel,
+		Crumbs:  crumbs(joinURL(mount, rel)),
 		RawHref: "?fmt=raw",
 	}
 	if strings.HasSuffix(rel, ".md") && size <= maxRenderSize {
@@ -73,7 +91,9 @@ func serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, name,
 			return
 		}
 		var buf bytes.Buffer
-		if err := markdown.Convert(src, &buf); err != nil {
+		// A fresh IDs registry per document keeps duplicate-heading suffixes deterministic.
+		ctx := parser.NewContext(parser.WithIDs(newSlugIDs()))
+		if err := markdown.Convert(src, &buf, parser.WithContext(ctx)); err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
@@ -90,18 +110,7 @@ var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>{{.Title}}</title>
-<style>
-body { font-family: sans-serif; max-width: 56rem; margin: 1rem auto; padding: 0 1rem; }
-nav { border-bottom: 1px solid #ccc; padding-bottom: .5rem; margin-bottom: 1rem; }
-nav .raw { float: right; }
-ul.listing { list-style: none; padding-left: 0; }
-ul.listing li { padding: .1rem 0; }
-iframe.passthrough { width: 100%; height: 75vh; border: 1px solid #ccc; }
-article pre { background: #f4f4f4; padding: .5rem; overflow-x: auto; }
-article code { background: #f4f4f4; }
-article table { border-collapse: collapse; }
-article th, article td { border: 1px solid #ccc; padding: .2rem .5rem; }
-</style>
+<link rel="stylesheet" href="/-/style.css">
 </head>
 <body>
 <nav>
diff --git a/serve.go b/serve.go
index 8b88259..e26828a 100644
--- a/serve.go
+++ b/serve.go
@@ -10,7 +10,7 @@ import (
 	"strings"
 )
 
-// server holds one opened os.Root per configured tree.
+// server holds one opened os.Root per configured mount.
 // os.Root guarantees all path resolution stays inside the tree
 // (it rejects ".." escapes and symlinks pointing outside).
 type server struct {
@@ -30,39 +30,98 @@ func newServer(roots map[string]string) (*server, error) {
 }
 
 func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	if r.URL.Path == "/" {
-		s.serveIndex(w, r)
+	// The "/-/" prefix is reserved for server-internal assets,
+	// keeping it distinct from any configured mount name.
+	if r.URL.Path == "/-/style.css" {
+		w.Header().Set("Content-Type", "text/css; charset=utf-8")
+		w.Write(styleCSS)
 		return
 	}
 
-	name, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
-	root, ok := s.trees[name]
-	if !ok {
-		http.NotFound(w, r)
+	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 {
+		if req == mount || strings.HasPrefix(req, mount+"/") {
+			rel := strings.TrimPrefix(strings.TrimPrefix(req, mount), "/")
+			if rel == "" {
+				rel = "."
+			}
+			s.serveFromTree(w, r, root, mount, rel)
+			return
+		}
+	}
+
+	// A request path *above* a mount gets a synthetic listing
+	// showing only the exposed next-level names.
+	if kids := s.mountChildren(req); kids != nil {
+		if req != "" && !strings.HasSuffix(r.URL.Path, "/") {
+			http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
+			return
+		}
+		title := "repoview"
+		if req != "" {
+			title = req + "/"
+		}
+		renderPage(w, pageData{Title: title, Crumbs: crumbs(req), Listing: kids})
 		return
 	}
-	rel := path.Clean("/" + rest)[1:] // normalize; "" becomes "."
-	if rel == "" {
-		rel = "."
+
+	http.NotFound(w, r)
+}
+
+// mountChildren returns the listing for a synthetic (above-mount) directory:
+// the deduplicated next path segments of all mounts under the given prefix.
+// Returns nil if no mount lives under this prefix.
+func (s *server) mountChildren(prefix string) []dirEntry {
+	seen := make(map[string]bool)
+	for mount := range s.trees {
+		rest := mount
+		if prefix != "" {
+			if !strings.HasPrefix(mount, prefix+"/") {
+				continue
+			}
+			rest = strings.TrimPrefix(mount, prefix+"/")
+		}
+		seg, _, _ := strings.Cut(rest, "/")
+		seen[seg] = true
 	}
+	if len(seen) == 0 {
+		return nil
+	}
+	names := make([]string, 0, len(seen))
+	for seg := range seen {
+		names = append(names, seg)
+	}
+	sort.Strings(names)
+	entries := make([]dirEntry, 0, len(names)+1)
+	if prefix != "" {
+		entries = append(entries, dirEntry{Name: "../", Href: "../"})
+	}
+	for _, seg := range names {
+		entries = append(entries, dirEntry{Name: seg + "/", Href: seg + "/"})
+	}
+	return entries
+}
 
+// 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) {
 	info, err := root.Stat(rel)
 	if err != nil {
 		http.NotFound(w, r)
 		return
 	}
-
 	switch {
 	case info.IsDir():
 		if !strings.HasSuffix(r.URL.Path, "/") {
 			http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
 			return
 		}
-		s.serveDir(w, r, root, name, rel)
+		s.serveDir(w, r, root, mount, rel)
 	case r.URL.Query().Get("fmt") == "raw":
 		serveRaw(w, r, root, rel)
 	default:
-		serveRendered(w, r, root, name, rel, info.Size())
+		serveRendered(w, r, root, mount, rel, info.Size())
 	}
 }
 
@@ -87,32 +146,16 @@ func serveRaw(w http.ResponseWriter, r *http.Request, root *os.Root, rel string)
 	http.ServeContent(w, r, info.Name(), info.ModTime(), f)
 }
 
-// serveIndex lists the configured roots.
-func (s *server) serveIndex(w http.ResponseWriter, r *http.Request) {
-	names := make([]string, 0, len(s.trees))
-	for name := range s.trees {
-		names = append(names, name)
-	}
-	sort.Strings(names)
-	entries := make([]dirEntry, len(names))
-	for i, name := range names {
-		entries[i] = dirEntry{Name: name + "/", Href: "/" + name + "/"}
-	}
-	renderPage(w, pageData{Title: "repoview", Crumbs: crumbs("", ""), Listing: entries})
-}
-
 // 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, name, rel string) {
+func (s *server) serveDir(w http.ResponseWriter, r *http.Request, root *os.Root, mount, rel string) {
 	ents, err := fs.ReadDir(root.FS(), rel)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 	var listing []dirEntry
-	if rel != "." {
-		listing = append(listing, dirEntry{Name: "../", Href: "../"})
-	}
+	listing = append(listing, dirEntry{Name: "../", Href: "../"})
 	for _, e := range ents {
 		if e.Name() == ".git" {
 			continue
@@ -124,8 +167,8 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, root *os.Root,
 		listing = append(listing, dirEntry{Name: n, Href: n})
 	}
 	renderPage(w, pageData{
-		Title:   name + "/" + strings.TrimSuffix(rel, "."),
-		Crumbs:  crumbs(name, rel),
+		Title:   mount + "/" + strings.TrimSuffix(rel, "."),
+		Crumbs:  crumbs(joinURL(mount, rel)),
 		Listing: listing,
 	})
 }
diff --git a/serve_test.go b/serve_test.go
index 0f34748..5207d6c 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -17,8 +17,8 @@ func newTestServer(t *testing.T) *server {
 		"README.md":     "# Hello\n\nsome *markdown* & <angle> stuff\n",
 		"sub/notes.txt": "plain text here\n",
 		"sub/deeper.md": "nested doc\n",
-		".git/config":   "should be hidden\n",
-	}
+		"dup.md":        "## Twice\n\n## Twice\n",
+		".git/config":   "should be hidden\n"}
 	for name, content := range files {
 		p := filepath.Join(dir, filepath.FromSlash(name))
 		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
@@ -110,7 +110,76 @@ func TestTraversalRejected(t *testing.T) {
 
 func TestRootIndexListsTrees(t *testing.T) {
 	rec := get(t, newTestServer(t), "/")
-	if !strings.Contains(rec.Body.String(), `href="/demo/"`) {
+	if !strings.Contains(rec.Body.String(), `href="demo/"`) {
 		t.Errorf("index missing tree link; body:\n%s", rec.Body.String())
 	}
 }
+
+// newNestedTestServer builds a server with a mount deeper than one URL segment,
+// alongside a sibling subtree that is deliberately NOT mounted.
+func newNestedTestServer(t *testing.T) *server {
+	t.Helper()
+	dir := t.TempDir()
+	for name, content := range map[string]string{
+		"research/latest/README.md": "# Fresh\n",
+		"research/old/secret.md":    "# Stale\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)
+		}
+	}
+	srv, err := newServer(map[string]string{
+		"meta/research/latest": filepath.Join(dir, "research/latest"),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	return srv
+}
+
+func TestNestedMountServesContent(t *testing.T) {
+	rec := get(t, newNestedTestServer(t), "/meta/research/latest/README.md")
+	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Fresh") {
+		t.Errorf("status = %d; body:\n%s", rec.Code, rec.Body.String())
+	}
+}
+
+func TestSyntheticListingsAboveMount(t *testing.T) {
+	srv := newNestedTestServer(t)
+	for url, want := range map[string]string{
+		"/":               `href="meta/"`,
+		"/meta/":          `href="research/"`,
+		"/meta/research/": `href="latest/"`,
+	} {
+		rec := get(t, srv, url)
+		if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
+			t.Errorf("%s: status = %d, want listing containing %s; body:\n%s", url, rec.Code, want, rec.Body.String())
+		}
+	}
+}
+
+func TestUnmountedSiblingsInvisible(t *testing.T) {
+	srv := newNestedTestServer(t)
+	rec := get(t, srv, "/meta/research/")
+	if strings.Contains(rec.Body.String(), "old") {
+		t.Errorf("unmounted sibling leaked into listing; body:\n%s", rec.Body.String())
+	}
+	rec = get(t, srv, "/meta/research/old/secret.md")
+	if rec.Code != http.StatusNotFound {
+		t.Errorf("unmounted sibling reachable: status = %d", rec.Code)
+	}
+}
+
+func TestNestedMountBreadcrumbsReachRoot(t *testing.T) {
+	rec := get(t, newNestedTestServer(t), "/meta/research/latest/README.md")
+	body := rec.Body.String()
+	for _, want := range []string{`href="/"`, `href="/meta/"`, `href="/meta/research/"`, `href="/meta/research/latest/"`} {
+		if !strings.Contains(body, want) {
+			t.Errorf("missing crumb %s; body:\n%s", want, body)
+		}
+	}
+}