the empty room: 404s happen in character
commit fba503ffd6f9984ba70ba76bfdcf6adbd4d717f5 · parent 30aaaf206d
· browse this tree
Ximianth, 2026-10-30 14:49:00 UTC
Unknown repos, stale refs, vanished paths, and submodule pointers render as a themed page on the browse surfaces: the bare pedestal (the same mark the footer uses for 'nothing more on display'), the specific reason in the museum-label register, and a button back to the entrance. Status stays 404. The raw and git-protocol endpoints keep terse plain text: their audience is tools. 400s and 500s stay plain everywhere -- faults are not rooms.
diff --git a/chrome.go b/chrome.go index b3a80ea..3730975 100644 --- a/chrome.go +++ b/chrome.go @@ -25,6 +25,7 @@ type pageData struct { // deeper pages are destinations, not cards. Desc string Canon string // the canonical URL for og:url, when a baseurl is configured + Status int // non-zero substitutes the response status (the empty-room 404) Crumbs []display.Crumb Arc []arcButton ArcDrop int // the deepest button drop, so the arc region reserves room for it (transforms take no layout space) @@ -264,6 +265,24 @@ article ul.entrance { text-overflow: ellipsis; } +/* The empty room: a 404 shows the bare pedestal, the reason in the museum-label + register, and the way back. The pedestal already means "nothing on display" + wherever else it stands empty; here that is the whole message. */ +article .emptyroom { text-align: center; margin: 3.5rem 0; } +article .emptyroom .pedestal { + height: 84px; + background-position: top center; + background-size: 140px 84px; + background-repeat: no-repeat; +} +article .emptyroom .nothing { + font-size: .8rem; + letter-spacing: .16em; + color: var(--ink-soft); + margin: 1.2rem 0 2.4rem; +} +article .emptyroom .back a.boxed { text-decoration: none; padding: .45rem 1.1rem; } + /* The tagline: the repo description, read as the exhibit's subtitle. */ article .tagline { text-align: center; @@ -466,7 +485,7 @@ func sketchCSS(scheme string) string { return fmt.Sprintf(` .flank { background-image: %s; } footer.ground .floor { background-image: %s; } -.stand .pedestal { background-image: %s; } +.stand .pedestal, .emptyroom .pedestal { background-image: %s; } .marquee .pedestal { background-image: %s; } /* Hand-ruled lines replace solid borders on the nav and headings. */ nav.crumbs, article h1, article h2 { @@ -549,6 +568,9 @@ func (s *server) renderPage(w http.ResponseWriter, pd pageData) { pd.ArcDrop = max(pd.ArcDrop, b.DY) } w.Header().Set("Content-Type", "text/html; charset=utf-8") + if pd.Status != 0 { + w.WriteHeader(pd.Status) + } if err := soclePageTmpl.Execute(w, pd); err != nil { // Headers are gone already; leave a trace in the body and the log. log.Print("socle: ", err) diff --git a/pages.go b/pages.go index 129e5e5..edf48c0 100644 --- a/pages.go +++ b/pages.go @@ -146,6 +146,26 @@ func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, snap *snapsh }{snap.face.siteName, rows}, pd) } +// ---- the empty room (404) ---- + +// A 404 is an address with nothing on display: one bare pedestal (the footer's +// "nothing more" mark, center-room), the reason in the museum-label register, +// and the way back to the entrance. +var notFoundTmpl = template.Must(template.New("notfound").Parse(`<div class="emptyroom"> +<div class="pedestal"></div> +<p class="nothing">{{.Reason}}</p> +<p class="back"><a class="boxed" href="/">to the entrance</a></p> +</div> +`)) + +func (s *server) notFoundPage(w http.ResponseWriter, snap *snapshot, reason string) { + s.render(w, snap, notFoundTmpl, struct{ Reason string }{reason}, pageData{ + Title: "nothing here · " + snap.face.siteName, + Status: http.StatusNotFound, + Crumbs: []display.Crumb{{Name: snap.face.siteName, Href: "/"}, {Name: "?"}}, + }) +} + // ---- repo home ---- // The repo home: the vitals come first -- the clone line and the language meter in one @@ -245,7 +265,7 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, snap *snapsho } branches, tags, err := rp.refs() if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } for _, b := range branches { @@ -260,20 +280,20 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, snap *snapsho var rm readmeInfo d.Readme, rm, err = s.renderReadme(rp, db, sha) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } d.ReadmeName, d.ReadmeSize, d.ShortSha = rm.name, humanSize(rm.size), shortSha(sha) stats, err := s.langStats(rp, sha) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } d.Langs = langSegments(stats) case errors.Is(err, errNotFound): d.Empty = true // an unborn HEAD: a freshly-init'd repo is fine to show, just bare default: - httpError(w, err) + s.pageError(w, snap, err) return } pd := pageData{ @@ -383,7 +403,7 @@ type treeData struct { func (s *server) serveTreeListing(w http.ResponseWriter, snap *snapshot, rp *repo, ref, sha, pth string) { entries, err := rp.lsTree(sha, pth) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } d := treeData{Title: pth, Kicker: rp.name} @@ -542,7 +562,7 @@ func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo, } size, err := rp.blobSize(sha, pth) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } plq := plaqueData{Path: pth, Size: humanSize(size), ShortSha: shortSha(sha)} @@ -565,7 +585,7 @@ func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo, } var buf bytes.Buffer if err := rp.catBlob(sha, pth, &buf); err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } if looksBinary(buf.Bytes()) { @@ -575,7 +595,7 @@ func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo, if strings.ToLower(path.Ext(pth)) == ".md" { body, err := display.Markdown(buf.Bytes()) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } s.render(w, snap, fileTmpl, struct { @@ -587,7 +607,7 @@ func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo, } var pre bytes.Buffer if err := preTmpl.Execute(&pre, buf.String()); err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } s.render(w, snap, fileTmpl, struct { diff --git a/serve.go b/serve.go index a6c0da1..bf11a3a 100644 --- a/serve.go +++ b/serve.go @@ -134,7 +134,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { rp, rest := matchRepo(repos, p) if rp == nil { - http.NotFound(w, r) + s.notFoundPage(w, snap, "no exhibit at this address") return } // The go toolchain's vanity-import probe outranks all other dispatch under a repo: @@ -196,9 +196,22 @@ func serveGoGetMeta(w http.ResponseWriter, rp *repo, baseURL string) { `, prefix, repoURL, prefix, repoURL, repoURL, repoURL, rp.name, prefix) } +// pageError is httpError for the browse surfaces: a not-found renders as the themed +// empty-room page, since a stale link deserves the same room as a mistyped one. +// Everything else falls through to the plain mapping -- faults are not rooms. +func (s *server) pageError(w http.ResponseWriter, snap *snapshot, err error) { + if errors.Is(err, errNotFound) { + s.notFoundPage(w, snap, err.Error()) + return + } + httpError(w, err) +} + // httpError maps a plumbing-layer error onto an HTTP response: // errInvalid is the client's fault (400), errNotFound speaks for itself (404), // and anything else is internal (500) -- logged in full, reported generically. +// The raw and git-protocol endpoints use this directly: their audience is tools, +// and tools want terse text, not a themed room. func httpError(w http.ResponseWriter, err error) { switch { case errors.Is(err, errInvalid): @@ -233,12 +246,12 @@ func (s *server) serveTree(w http.ResponseWriter, r *http.Request, snap *snapsho ref, path := splitRefPath(arg) sha, err := rp.resolveCommit(ref) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } typ, err := rp.entryType(sha, path) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } setCacheHeader(w, ref, sha) @@ -249,7 +262,7 @@ func (s *server) serveTree(w http.ResponseWriter, r *http.Request, snap *snapsho s.serveFileView(w, snap, rp, ref, sha, path) default: // A submodule pointer: there is nothing of it in this repo to show. - http.Error(w, fmt.Sprintf("path %q is a submodule pointer, not browsable here", path), http.StatusNotFound) + s.notFoundPage(w, snap, fmt.Sprintf("path %q is a submodule pointer, not browsable here", path)) } } @@ -296,12 +309,12 @@ func (s *server) serveLog(w http.ResponseWriter, r *http.Request, snap *snapshot ref := strings.TrimSuffix(arg, "/") sha, err := rp.resolveCommit(ref) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } cs, err := rp.commits(sha, logLimit) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } setCacheHeader(w, ref, sha) @@ -315,17 +328,17 @@ func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, snap *snaps ref := strings.TrimSuffix(arg, "/") sha, err := rp.resolveCommit(ref) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } c, err := rp.commit(sha) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } diff, err := rp.diff(c) if err != nil { - httpError(w, err) + s.pageError(w, snap, err) return } setCacheHeader(w, ref, sha) diff --git a/testdata/pages.md b/testdata/pages.md index cb75185..c523dc4 100644 --- a/testdata/pages.md +++ b/testdata/pages.md @@ -642,8 +642,34 @@ invalid: ref "main..dev" contains ".." [testmark]:# (err-unknown-repo/response) ```text status: 404 -Content-Type: text/plain; charset=utf-8 -X-Content-Type-Options: nosniff +Content-Type: text/html; charset=utf-8 -404 page not found +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>nothing here · test</title> +<link rel="stylesheet" href="/-/style.css"> +<link rel="icon" type="image/svg+xml" href="/-/sketch/favicon.svg"> +</head> +<body> +<div class="flank left"></div><div class="flank right"></div> +<div class="sheet"> +<nav class="crumbs"> +<a href="/">test</a> / ? +</nav> +<main><article><div class="emptyroom"> +<div class="pedestal"></div> +<p class="nothing">no exhibit at this address</p> +<p class="back"><a class="boxed" href="/">to the entrance</a></p> +</div> +</article></main> +</div> +<footer class="ground"> +<div class="floor"></div> +<p class="colophon">test · made with socle</p> +</footer> +</body> +</html> ```