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) + } + } +}