sitename: the published identity comes from config, and the chrome wears it
commit 9d1cce7ca26408c451aae2c492931cbe2e9315e0 · parent 199e3974a0
· browse this tree
Ximianth, 2026-10-30 14:33:02 UTC
The hostname in the header was a misfeature: socle publishes under a name
that need not be the machine's. A new optional config key 'sitename' is the
displayed identity -- first crumb, index title, footer -- and seeds the theme,
falling back to the baseurl's host label, then the machine hostname.
The themed chrome (theme, sketch assets, stylesheet) moves into the snapshot
as a 'face', because the sitename comes from config and config reloads live;
regeneration is cheap and deterministic. -theme-seed still recolors without
renaming (and keeps test snapshots machine-independent).
The tool's own name leaves the chrome: no more 'socle @host' in the corner,
and the footer colophon now reads '{sitename} . made with socle'.
diff --git a/README.md b/README.md index 7daf147..26e9876 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ socle takes exactly one config file, via the required `-config` flag The format is YAML: ```yaml +sitename: parnassus baseurl: https://code.example repos: parnass: @@ -29,6 +30,13 @@ repos: description: "deploy scripts" ``` +- `sitename` (optional) is the display identity of this socle: + it appears in the header, page titles, the entrance masthead, and the footer, + and it seeds the accent color. + When unset, the identity derives from the `baseurl` host's first label when one is configured, + else from the machine hostname -- + but a published socle usually wants to say its own name, not its machine's. + - `baseurl` (optional) is the canonical outside address of this socle: an absolute http(s) URL naming only a host. When set, it becomes the displayed clone URL @@ -118,8 +126,8 @@ its look and its markdown dialect: GFM with callout boxes and server-rendered `dot` diagrams, heading anchors, and light/dark following the browser's `prefers-color-scheme`. -Each host wears an accent color derived from its hostname -(the `-theme-seed` flag substitutes any string), +Each socle wears an accent color derived from its sitename +(the `-theme-seed` flag recolors without renaming, mostly useful for tests), so several socle instances in several tabs stay recognizable at a glance. Known limits diff --git a/chrome.go b/chrome.go index 4617f35..b009eeb 100644 --- a/chrome.go +++ b/chrome.go @@ -18,13 +18,13 @@ import ( // pageData is everything the socle page template needs. type pageData struct { - Title string - ThemeName string - Crumbs []display.Crumb - Arc []arcButton - ArcDrop int // the deepest button drop, so the arc region reserves room for it (transforms take no layout space) - Dawn bool // true: the full dawn arc (repo home); false: the compact corner arc - Content template.HTML + Title string + SiteName string + Crumbs []display.Crumb + Arc []arcButton + ArcDrop int // the deepest button drop, so the arc region reserves room for it (transforms take no layout space) + Dawn bool // true: the full dawn arc (repo home); false: the compact corner arc + Content template.HTML } // arcButton is one round button on the arc. Buttons are for moves; prose links stay links. @@ -80,7 +80,7 @@ var soclePageTmpl = template.Must(template.New("socle").Parse(`<!doctype html> <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{if and (eq $i 0) $.ThemeName}}<span class="theme">@{{$.ThemeName}}</span>{{end}}{{end}} +{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{end}} </nav> {{if .Arc}}<nav class="arc{{if not .Dawn}} corner{{end}}" aria-label="page moves" style="--arc-drop:{{.ArcDrop}}px"> {{range .Arc}}<a class="v{{.Variant}}" href="{{.Href}}"{{if .Title}} title="{{.Title}}"{{end}} style="--dy:{{.DY}}px">{{.Label}}</a> @@ -89,17 +89,17 @@ var soclePageTmpl = template.Must(template.New("socle").Parse(`<!doctype html> </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">{{.ThemeName}} · socle</p> +<p class="colophon">{{.SiteName}} · made with socle</p> </footer> </body> </html> `)) // serveSketch hands out one generated sketch asset. The bytes are a pure function of -// the theme seed, so they are safe to cache long; a redeploy with a new seed changes -// nothing here (same URLs, new bytes), hence long-but-not-immutable. -func (s *server) serveSketch(w http.ResponseWriter, name string) { - b, ok := s.sketch[name] +// the theme seed, so they are safe to cache long; a redeploy or reload with a new seed +// changes nothing here (same URLs, new bytes), hence long-but-not-immutable. +func serveSketch(w http.ResponseWriter, snap *snapshot, name string) { + b, ok := snap.face.sketch[name] if !ok { http.Error(w, "no such sketch asset", http.StatusNotFound) return @@ -497,9 +497,8 @@ func buildStyle(theme display.Theme) []byte { return []byte(sb.String()) } -// renderPage fills in the site-wide theme and writes the full page. +// renderPage computes the arc's reserved height and writes the full page. func (s *server) renderPage(w http.ResponseWriter, pd pageData) { - pd.ThemeName = s.theme.Name for _, b := range pd.Arc { pd.ArcDrop = max(pd.ArcDrop, b.DY) } diff --git a/config.go b/config.go index 7422224..0548343 100644 --- a/config.go +++ b/config.go @@ -23,6 +23,12 @@ type repoConfig struct { // config is the top-level schema of the socle config file. type config struct { + // Sitename is the display identity of this socle: it appears in the header, + // page titles, the entrance masthead, the footer, and it seeds the theme color. + // Free-form text; optional. When empty the identity derives from the baseurl's + // host label when one is configured, else from the machine hostname -- + // but a published socle usually wants to say its own name, not its machine's. + Sitename string `yaml:"sitename"` // BaseURL is the canonical outside address of this socle, e.g. "https://code.example": // scheme and host only, since socle's URL space is rooted at "/". // When set, it becomes the displayed clone URL and enables the go-get vanity-import @@ -54,6 +60,7 @@ func loadConfig(path string) (*config, error) { return nil, fmt.Errorf("config %s: %w", path, err) } + cfg.Sitename = strings.TrimSpace(cfg.Sitename) if cfg.BaseURL != "" { normalized, err := validateBaseURL(cfg.BaseURL) if err != nil { diff --git a/main.go b/main.go index 94d6528..3b0383e 100644 --- a/main.go +++ b/main.go @@ -7,60 +7,59 @@ import ( "flag" "log" "net/http" - "os" "time" - - "vitrine/display" ) func main() { configPath := flag.String("config", "", "path to the socle config file (required; there is no default location)") listen := flag.String("listen", ":8080", "listen address") - themeSeed := flag.String("theme-seed", "", "theme name override (default: first dot-segment of the hostname)") + themeSeed := flag.String("theme-seed", "", "theme seed override (default: the sitename; see buildFace for how that resolves)") flag.Parse() if *configPath == "" { log.Fatal("socle: the -config flag is required") } - seed := *themeSeed - if seed == "" { - hostname, err := os.Hostname() - if err != nil { - log.Fatalf("socle: cannot determine hostname for theming (use -theme-seed to bypass): %v", err) - } - seed = display.HostLabel(hostname) - } cfg, err := loadConfig(*configPath) if err != nil { log.Fatal("socle: ", err) } + fc, err := buildFace(cfg, *themeSeed) + if err != nil { + log.Fatal("socle: ", err) + } repos, warnings := openAll(cfg) for _, w := range warnings { log.Print("socle: ", w) } - srv := newServer(repos, cfg.BaseURL, display.NewTheme(seed)) + srv := newServer(repos, cfg.BaseURL, fc) // Config changes apply live: reloads are fail-stable // (any problem logs loudly and the old repo set keeps serving), // while startup above stays fail-closed as ever. watchConfig(*configPath, 5*time.Second, func() { - reloadConfig(srv, *configPath) + reloadConfig(srv, *configPath, *themeSeed) }) log.Printf("socle: serving %d repos on %s", len(repos), *listen) log.Fatal(http.ListenAndServe(*listen, srv)) } -// reloadConfig loads the config file and swaps the server's repo set to it. +// reloadConfig loads the config file and swaps the server's snapshot to it, +// identity included (a sitename edit lands live, like everything else in the config). // Fail-stable, unlike startup: a config that cannot be loaded (parse or validation // failure) or that yields not one openable repo is rejected loudly and the previous // set keeps serving -- a showcase must not go dark from one typo mid-edit. // Per-repo open failures are the same warning-and-skip as at startup. -func reloadConfig(srv *server, path string) { +func reloadConfig(srv *server, path, themeSeed string) { cfg, err := loadConfig(path) if err != nil { log.Printf("socle: config reload REJECTED, keeping previous repos: %v", err) return } + fc, err := buildFace(cfg, themeSeed) + if err != nil { + log.Printf("socle: config reload REJECTED, keeping previous repos: %v", err) + return + } repos, warnings := openAll(cfg) for _, w := range warnings { log.Print("socle: ", w) @@ -69,6 +68,6 @@ func reloadConfig(srv *server, path string) { log.Printf("socle: config reload REJECTED, keeping previous repos: no configured repo could be opened") return } - srv.publish(repos, cfg.BaseURL) + srv.publish(repos, cfg.BaseURL, fc) log.Printf("socle: config reloaded: %d repos", len(repos)) } diff --git a/pages.go b/pages.go index 722ba75..5138212 100644 --- a/pages.go +++ b/pages.go @@ -21,19 +21,20 @@ import ( // with the gallery-sketchbook chrome coming from chrome.go and markdown rendering // from the display package. -const siteName = "socle" - // logLimit caps the commit log page; history older than this is reachable by // following parent shas (and a real pager can come later if it's ever missed). const logLimit = 100 // render executes one content-fragment template; a failure is an internal error (500). -func (s *server) render(w http.ResponseWriter, tmpl *template.Template, data any, pd pageData) { +// The snapshot supplies the site identity, so every page wears the face of the config +// it was served under. +func (s *server) render(w http.ResponseWriter, snap *snapshot, tmpl *template.Template, data any, pd pageData) { var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { httpError(w, err) return } + pd.SiteName = snap.face.siteName pd.Content = template.HTML(buf.String()) s.renderPage(w, pd) } @@ -78,8 +79,8 @@ type crumbLink = display.Crumb // repoCrumbs starts a breadcrumb trail: site, then the repo (its full name as one crumb, // slashes and all: intermediate name segments are not pages). -func repoCrumbs(name string, more ...crumbLink) []display.Crumb { - cs := []display.Crumb{{Name: siteName, Href: "/"}, {Name: name, Href: "/" + name + "/"}} +func repoCrumbs(site, name string, more ...crumbLink) []display.Crumb { + cs := []display.Crumb{{Name: site, Href: "/"}, {Name: name, Href: "/" + name + "/"}} cs = append(cs, more...) cs[len(cs)-1].Href = "" // the last crumb is the page being viewed return cs @@ -110,10 +111,10 @@ type indexRow struct { LangColor string // its linguist color; the placard dot only shows when one exists } -func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, repos map[string]*repo) { +func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, snap *snapshot) { var rows []indexRow - for _, name := range sortedNames(repos) { - rp := repos[name] + for _, name := range sortedNames(snap.repos) { + rp := snap.repos[name] row := indexRow{Name: name, Description: rp.description, Branch: rp.defaultBranch()} // Enrichment is best-effort, matching the caption's existing stance: // the entrance still stands when one repo cannot answer. @@ -126,9 +127,9 @@ func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, repos map[st } rows = append(rows, row) } - s.render(w, indexTmpl, rows, pageData{ - Title: siteName, - Crumbs: []display.Crumb{{Name: siteName}}, + s.render(w, snap, indexTmpl, rows, pageData{ + Title: snap.face.siteName, + Crumbs: []display.Crumb{{Name: snap.face.siteName}}, }) } @@ -209,11 +210,11 @@ func langSegments(stats []langStat) []langSeg { return segs } -func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, baseURL string) { +func (s *server) serveHome(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo) { // The clone URL: the configured canonical address when there is one, // else derived from the request (right whenever no rewriting proxy intervenes). - cloneURL := baseURL + "/" + rp.name - if baseURL == "" { + cloneURL := snap.baseURL + "/" + rp.name + if snap.baseURL == "" { scheme := "http" if r.TLS != nil { scheme = "https" @@ -261,7 +262,7 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, bas } pd := pageData{ Title: rp.name, - Crumbs: repoCrumbs(rp.name), + Crumbs: repoCrumbs(snap.face.siteName, rp.name), } if !d.Empty { pd.Dawn = true @@ -272,7 +273,7 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, bas {Label: "clone", Title: "the clone command", Href: "#clone"}, }) } - s.render(w, homeTmpl, d, pd) + s.render(w, snap, homeTmpl, d, pd) } // readmeInfo is what the home plaque says about the README it accompanies. @@ -348,7 +349,7 @@ type treeData struct { Rows []treeRow } -func (s *server) serveTreeListing(w http.ResponseWriter, rp *repo, ref, sha, pth string) { +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) @@ -392,15 +393,15 @@ func (s *server) serveTreeListing(w http.ResponseWriter, rp *repo, ref, sha, pth arcButton{Label: "log", Title: "commit log of " + shortSha(ref), Href: "/" + rp.name + "/log/" + ref}, arcButton{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"}, ) - s.render(w, treeTmpl, d, pageData{ + s.render(w, snap, treeTmpl, d, pageData{ Title: pageTitle(rp.name, ref, pth), - Crumbs: treeCrumbs(rp.name, ref, pth), + Crumbs: treeCrumbs(snap.face.siteName, rp.name, ref, pth), Arc: cornerArc(btns), }) } // treeCrumbs: site / repo / ref / path segments, each linking to its listing. -func treeCrumbs(name, ref, pth string, more ...crumbLink) []display.Crumb { +func treeCrumbs(site, name, ref, pth string, more ...crumbLink) []display.Crumb { cs := []crumbLink{{Name: ref, Href: treeHref(name, ref, "") + "/"}} if pth != "" { segs := strings.Split(pth, "/") @@ -408,7 +409,7 @@ func treeCrumbs(name, ref, pth string, more ...crumbLink) []display.Crumb { cs = append(cs, crumbLink{Name: seg, Href: treeHref(name, ref, strings.Join(segs[:i+1], "/")) + "/"}) } } - return repoCrumbs(name, append(cs, more...)...) + return repoCrumbs(site, name, append(cs, more...)...) } // humanSize renders a byte count the way a listing wants it: exact while it is small, @@ -490,12 +491,12 @@ type plaqueData struct { // serveFileView shows a blob as a page: markdown rendered, text preformatted, images inlined, // and anything binary or oversized as a link to the raw view. -func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth string) { +func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo, ref, sha, pth string) { name := path.Base(pth) raw := rawHref(rp.name, ref, pth) pd := pageData{ Title: pageTitle(rp.name, ref, pth), - Crumbs: treeCrumbs(rp.name, ref, parentDir(pth), crumbLink{Name: name}), + Crumbs: treeCrumbs(snap.face.siteName, rp.name, ref, parentDir(pth), crumbLink{Name: name}), Arc: cornerArc([]arcButton{ {Label: "raw", Title: "the raw bytes", Href: raw}, {Label: "up", Title: "containing directory", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"}, @@ -510,14 +511,14 @@ func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth st } plq := plaqueData{Path: pth, Size: humanSize(size), ShortSha: shortSha(sha)} if isImagePath(pth) { - s.render(w, imageTmpl, struct { + s.render(w, snap, imageTmpl, struct { Name, RawHref string Plaque plaqueData }{name, raw, plq}, pd) return } rawLink := func(kind string) { - s.render(w, rawLinkTmpl, struct { + s.render(w, snap, rawLinkTmpl, struct { Name, Kind, RawHref string Size int64 }{name, kind, raw, size}, pd) @@ -541,7 +542,7 @@ func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth st httpError(w, err) return } - s.render(w, fileTmpl, struct { + s.render(w, snap, fileTmpl, struct { Name string Body template.HTML Plaque plaqueData @@ -553,7 +554,7 @@ func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth st httpError(w, err) return } - s.render(w, fileTmpl, struct { + s.render(w, snap, fileTmpl, struct { Name string Body template.HTML Plaque plaqueData @@ -604,7 +605,7 @@ type logData struct { // renderLog writes the log page. Commit shas link to the tree at that commit: // commit-diff pages are a later stretch goal, and browsing the snapshot is useful today. -func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []commitInfo) { +func (s *server) renderLog(w http.ResponseWriter, snap *snapshot, rp *repo, ref string, cs []commitInfo) { d := logData{Title: shortSha(ref), Capped: len(cs) == logLimit, Limit: logLimit} for _, c := range cs { d.Rows = append(d.Rows, logRow{ @@ -615,9 +616,9 @@ func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []com Subject: c.subject, }) } - s.render(w, logTmpl, d, pageData{ + s.render(w, snap, logTmpl, d, pageData{ Title: rp.name + " log @ " + shortSha(ref), - Crumbs: repoCrumbs(rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}), + Crumbs: repoCrumbs(snap.face.siteName, rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}), Arc: cornerArc([]arcButton{ {Label: "files", Title: "browse the tree at " + shortSha(ref), Href: treeHref(rp.name, ref, "") + "/"}, {Label: "home", Title: "repo home", Href: "/" + rp.name + "/"}, @@ -650,7 +651,7 @@ type commitData struct { } // renderCommit writes the commit page: subject, metadata, the rest of the message, the diff. -func (s *server) renderCommit(w http.ResponseWriter, rp *repo, c commitDetail, diff []byte) { +func (s *server) renderCommit(w http.ResponseWriter, snap *snapshot, rp *repo, c commitDetail, diff []byte) { subject, rest, _ := strings.Cut(c.message, "\n") d := commitData{ Subject: subject, @@ -664,9 +665,9 @@ func (s *server) renderCommit(w http.ResponseWriter, rp *repo, c commitDetail, d d.Parents = append(d.Parents, refLink{shortSha(p), "/" + rp.name + "/commit/" + p}) } d.Diff, d.Truncated = diffHTML(diff) - s.render(w, commitTmpl, d, pageData{ + s.render(w, snap, commitTmpl, d, pageData{ Title: rp.name + " commit " + shortSha(c.hash), - Crumbs: repoCrumbs(rp.name, crumbLink{Name: "commit"}, crumbLink{Name: shortSha(c.hash)}), + Crumbs: repoCrumbs(snap.face.siteName, rp.name, crumbLink{Name: "commit"}, crumbLink{Name: shortSha(c.hash)}), Arc: cornerArc([]arcButton{ {Label: "files", Title: "browse the tree at this commit", Href: treeHref(rp.name, c.hash, "") + "/"}, {Label: "log", Title: "the log from this commit", Href: "/" + rp.name + "/log/" + c.hash}, diff --git a/serve.go b/serve.go index 5a23982..a6c0da1 100644 --- a/serve.go +++ b/serve.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "net/url" + "os" "strings" "sync/atomic" @@ -14,12 +15,59 @@ import ( ) // snapshot is the server's immutable view of one loaded config: -// the opened repo set, plus the canonical base URL (empty when unconfigured; -// see config.BaseURL for what each state means). -// The two travel together so a reload can never pair old repos with a new address. +// the opened repo set, the canonical base URL (empty when unconfigured; +// see config.BaseURL for what each state means), and the face. +// All of it travels together so a reload can never pair old repos with a new address +// or new identity. type snapshot struct { repos map[string]*repo baseURL string + face face +} + +// face is the config-derived visual identity: the displayed sitename, the theme it seeds, +// and the sketch assets and stylesheet drawn with that seed. +// It lives in the snapshot, not the server, because the sitename comes from the config +// and the config reloads live; rebuilding it is cheap and deterministic +// (an unchanged seed regenerates byte-identical assets). +type face struct { + siteName string + theme display.Theme + sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go + style []byte // the assembled /-/style.css body; see buildStyle +} + +// buildFace resolves the display identity from a config and draws its assets. +// seedOverride (the -theme-seed flag) decouples the color from the name when set; +// it is a process-lifetime override, so it applies across reloads too. +func buildFace(cfg *config, seedOverride string) (face, error) { + name := cfg.Sitename + if name == "" && cfg.BaseURL != "" { + u, err := url.Parse(cfg.BaseURL) + if err != nil { + // Unreachable in practice: validateBaseURL already parsed it. + return face{}, fmt.Errorf("deriving sitename from baseurl: %w", err) + } + name = display.HostLabel(u.Host) + } + if name == "" { + hostname, err := os.Hostname() + if err != nil { + return face{}, fmt.Errorf("cannot determine a sitename: no sitename or baseurl configured, and reading the hostname failed: %w", err) + } + name = display.HostLabel(hostname) + } + seed := seedOverride + if seed == "" { + seed = name + } + th := display.NewTheme(seed) + return face{ + siteName: name, + theme: th, + sketch: sketchAssets(seed), + style: buildStyle(th), + }, nil } // server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch. @@ -28,21 +76,15 @@ type snapshot struct { // (at the top of ServeHTTP) and works with that immutable snapshot for its whole duration. // A published snapshot is never mutated. type server struct { - snap atomic.Pointer[snapshot] - theme display.Theme // fixed for the process lifetime; see display/theme.go - sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go - style []byte // the assembled /-/style.css body; see buildStyle - langs *langCache // language composition per (repo, sha); see lang.go + snap atomic.Pointer[snapshot] + langs *langCache // language composition per (repo, sha); see lang.go } -func newServer(repos map[string]*repo, baseURL string, th display.Theme) *server { +func newServer(repos map[string]*repo, baseURL string, fc face) *server { s := &server{ - theme: th, - sketch: sketchAssets(th.Name), - style: buildStyle(th), - langs: newLangCache(langCacheCap), + langs: newLangCache(langCacheCap), } - s.publish(repos, baseURL) + s.publish(repos, baseURL, fc) return s } @@ -52,8 +94,8 @@ func newServer(repos map[string]*repo, baseURL string, th display.Theme) *server // The language-stats cache deliberately survives the swap: it is keyed (repo-name, commit-sha) // and a sha content-addresses the commit's tree, so an entry can be orphaned by a reload // but never wrong, and the cache's cap already bounds orphans. -func (s *server) publish(repos map[string]*repo, baseURL string) { - s.snap.Store(&snapshot{repos: repos, baseURL: baseURL}) +func (s *server) publish(repos map[string]*repo, baseURL string, fc face) { + s.snap.Store(&snapshot{repos: repos, baseURL: baseURL, face: fc}) } // ServeHTTP dispatches: the two server-wide paths first, then longest-prefix repo matching @@ -79,14 +121,14 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case p == "": - s.serveIndex(w, r, repos) + s.serveIndex(w, r, snap) return case p == "-/style.css": w.Header().Set("Content-Type", "text/css; charset=utf-8") - w.Write(s.style) + w.Write(snap.face.style) return case strings.HasPrefix(p, "-/sketch/"): - s.serveSketch(w, strings.TrimPrefix(p, "-/sketch/")) + serveSketch(w, snap, strings.TrimPrefix(p, "-/sketch/")) return } @@ -112,7 +154,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, r.URL.EscapedPath()+"/", http.StatusMovedPermanently) return } - s.serveHome(w, r, rp, snap.baseURL) + s.serveHome(w, r, snap, rp) return } @@ -123,13 +165,13 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { case verb == "git-upload-pack" && arg == "": s.serveUploadPack(w, r, rp) case verb == "tree": - s.serveTree(w, r, rp, arg) + s.serveTree(w, r, snap, rp, arg) case verb == "raw": s.serveRawBlob(w, r, rp, arg) case verb == "log": - s.serveLog(w, r, rp, arg) + s.serveLog(w, r, snap, rp, arg) case verb == "commit": - s.serveCommit(w, r, rp, arg) + s.serveCommit(w, r, snap, rp, arg) default: s.redirectIntoTree(w, r, rp, rest) } @@ -187,7 +229,7 @@ func splitRefPath(arg string) (ref, path string) { } // serveTree handles /<name>/tree/<ref>[/<path>]: a listing for trees, a file view for blobs. -func (s *server) serveTree(w http.ResponseWriter, r *http.Request, rp *repo, arg string) { +func (s *server) serveTree(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo, arg string) { ref, path := splitRefPath(arg) sha, err := rp.resolveCommit(ref) if err != nil { @@ -202,9 +244,9 @@ func (s *server) serveTree(w http.ResponseWriter, r *http.Request, rp *repo, arg setCacheHeader(w, ref, sha) switch typ { case "tree": - s.serveTreeListing(w, rp, ref, sha, path) + s.serveTreeListing(w, snap, rp, ref, sha, path) case "blob": - s.serveFileView(w, rp, ref, sha, path) + 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) @@ -250,7 +292,7 @@ func (s *server) serveRawBlob(w http.ResponseWriter, r *http.Request, rp *repo, } // serveLog handles /<name>/log/<ref>. -func (s *server) serveLog(w http.ResponseWriter, r *http.Request, rp *repo, arg string) { +func (s *server) serveLog(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo, arg string) { ref := strings.TrimSuffix(arg, "/") sha, err := rp.resolveCommit(ref) if err != nil { @@ -263,13 +305,13 @@ func (s *server) serveLog(w http.ResponseWriter, r *http.Request, rp *repo, arg return } setCacheHeader(w, ref, sha) - s.renderLog(w, rp, ref, cs) + s.renderLog(w, snap, rp, ref, cs) } // serveCommit handles /<name>/commit/<sha>: the commit's metadata, message, and diff. // Any ref is accepted where <sha> is written, but links always use full shas, // so the pages that matter get the immutable cache header. -func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, rp *repo, arg string) { +func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo, arg string) { ref := strings.TrimSuffix(arg, "/") sha, err := rp.resolveCommit(ref) if err != nil { @@ -287,7 +329,7 @@ func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, rp *repo, a return } setCacheHeader(w, ref, sha) - s.renderCommit(w, rp, c, diff) + s.renderCommit(w, snap, rp, c, diff) } // redirectIntoTree sends /<name>/<path> (any path that isn't a verb) into the tree at the diff --git a/serve_test.go b/serve_test.go index ea7c364..4f8f2d8 100644 --- a/serve_test.go +++ b/serve_test.go @@ -14,10 +14,21 @@ import ( ) // fixtureServer builds a server over the standard hermetic fixture repo, -// with a pinned theme seed so output stays deterministic. +// with a pinned identity so output stays deterministic. func fixtureServer(t *testing.T) *server { t.Helper() - return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "", display.NewTheme("test")) + return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "", testFace(t)) +} + +// testFace is the pinned identity every test server wears: sitename (and thus seed) "test", +// so page chrome and sketch assets stay deterministic regardless of the machine. +func testFace(t *testing.T) face { + t.Helper() + fc, err := buildFace(&config{Sitename: "test"}, "") + if err != nil { + t.Fatalf("building the test face: %v", err) + } + return fc } // do runs one request through the handler and returns the recorded response. @@ -208,7 +219,7 @@ func TestFileViewModes(t *testing.T) { if err != nil { t.Fatal(err) } - s := newServer(map[string]*repo{"media": rp}, "", display.NewTheme("test")) + s := newServer(map[string]*repo{"media": rp}, "", testFace(t)) for _, c := range []struct { target string want string @@ -252,7 +263,7 @@ func TestCommitPageShapes(t *testing.T) { if err != nil { t.Fatal(err) } - s := newServer(map[string]*repo{"merger": rp}, "", display.NewTheme("test")) + s := newServer(map[string]*repo{"merger": rp}, "", testFace(t)) rec := do(s, "/merger/commit/main", nil, nil) if rec.Code != 200 { t.Fatalf("status = %d", rec.Code) @@ -282,7 +293,7 @@ func TestGoGetMeta(t *testing.T) { wantImport := `<meta name="go-import" content="code.example/fixturama git https://code.example/fixturama">` wantSource := `<meta name="go-source" content="code.example/fixturama https://code.example/fixturama/ https://code.example/fixturama/tree/HEAD{/dir} https://code.example/fixturama/tree/HEAD{/dir}/{file}">` - s := newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "https://code.example", display.NewTheme("test")) + s := newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "https://code.example", testFace(t)) for _, target := range []string{ "/fixturama?go-get=1", // the slashless home: no redirect detour (it would drop the query) "/fixturama/?go-get=1", // the home itself @@ -319,3 +330,34 @@ func TestGoGetMeta(t *testing.T) { t.Errorf("no baseurl: home page must not carry a go-import tag") } } + +// TestFaceIdentityResolution pins how the displayed identity and the theme seed resolve: +// an explicit sitename wins, a baseurl's host label is the fallback, +// and -theme-seed decouples the color without touching the name. +func TestFaceIdentityResolution(t *testing.T) { + t.Parallel() + for _, c := range []struct { + name string + cfg config + override string + wantName string + wantTheme string + }{ + {name: "sitename wins over baseurl", cfg: config{Sitename: "parnassus", BaseURL: "https://code.example"}, wantName: "parnassus", wantTheme: "parnassus"}, + {name: "baseurl label when no sitename", cfg: config{BaseURL: "https://code.example"}, wantName: "code", wantTheme: "code"}, + {name: "seed override recolors but does not rename", cfg: config{Sitename: "parnassus"}, override: "zebra", wantName: "parnassus", wantTheme: "zebra"}, + } { + t.Run(c.name, func(t *testing.T) { + fc, err := buildFace(&c.cfg, c.override) + if err != nil { + t.Fatal(err) + } + if fc.siteName != c.wantName { + t.Errorf("siteName = %q, want %q", fc.siteName, c.wantName) + } + if fc.theme.Name != c.wantTheme { + t.Errorf("theme seed = %q, want %q", fc.theme.Name, c.wantTheme) + } + }) + } +} diff --git a/testdata/pages.md b/testdata/pages.md index 755f0c5..cfd6cd5 100644 --- a/testdata/pages.md +++ b/testdata/pages.md @@ -28,14 +28,14 @@ Content-Type: text/html; charset=utf-8 <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>socle</title> +<title>test</title> <link rel="stylesheet" href="/-/style.css"> </head> <body> <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -socle<span class="theme">@test</span> +test </nav> <main><article><h1>repositories</h1> <ul class="entrance"> @@ -50,7 +50,7 @@ socle<span class="theme">@test</span> </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -84,7 +84,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / fixturama +<a href="/">test</a> / fixturama </nav> <nav class="arc" aria-label="page moves" style="--arc-drop:67px"> <a class="va" href="/fixturama/tree/main/" title="browse the tree at main" style="--dy:67px">files</a> @@ -108,7 +108,7 @@ Content-Type: text/html; charset=utf-8 </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -142,7 +142,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / main +<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / main </nav> <nav class="arc corner" aria-label="page moves" style="--arc-drop:18px"> <a class="va" href="/fixturama/log/main" title="commit log of main" style="--dy:18px">log</a> @@ -162,7 +162,7 @@ Content-Type: text/html; charset=utf-8 </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -190,7 +190,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / docs +<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / docs </nav> <nav class="arc corner" aria-label="page moves" style="--arc-drop:51px"> <a class="va" href="/fixturama/tree/main/" title="parent directory" style="--dy:51px">up</a> @@ -209,7 +209,7 @@ Content-Type: text/html; charset=utf-8 </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -244,7 +244,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / README.md +<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / README.md </nav> <nav class="arc corner" aria-label="page moves" style="--arc-drop:109px"> <a class="va" href="/fixturama/raw/main/README.md" title="the raw bytes" style="--dy:109px">raw</a> @@ -261,7 +261,7 @@ Content-Type: text/html; charset=utf-8 </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -289,7 +289,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / <a href="/fixturama/tree/main/docs/">docs</a> / with space.txt +<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / <a href="/fixturama/tree/main/docs/">docs</a> / with space.txt </nav> <nav class="arc corner" aria-label="page moves" style="--arc-drop:109px"> <a class="va" href="/fixturama/raw/main/docs/with%20space.txt" title="the raw bytes" style="--dy:109px">raw</a> @@ -305,7 +305,7 @@ Content-Type: text/html; charset=utf-8 </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -334,7 +334,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/ced91a8519b59966124301e6383532f24223528b/">ced91a8519b59966124301e6383532f24223528b</a> / README.md +<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/ced91a8519b59966124301e6383532f24223528b/">ced91a8519b59966124301e6383532f24223528b</a> / README.md </nav> <nav class="arc corner" aria-label="page moves" style="--arc-drop:109px"> <a class="va" href="/fixturama/raw/ced91a8519b59966124301e6383532f24223528b/README.md" title="the raw bytes" style="--dy:109px">raw</a> @@ -351,7 +351,7 @@ Content-Type: text/html; charset=utf-8 </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -383,7 +383,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / log / main +<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / log / main </nav> <nav class="arc corner" aria-label="page moves" style="--arc-drop:18px"> <a class="va" href="/fixturama/tree/main/" title="browse the tree at main" style="--dy:18px">files</a> @@ -405,7 +405,7 @@ Content-Type: text/html; charset=utf-8 </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -440,7 +440,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / commit / ced91a8519 +<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / commit / ced91a8519 </nav> <nav class="arc corner" aria-label="page moves" style="--arc-drop:51px"> <a class="va" href="/fixturama/tree/ced91a8519b59966124301e6383532f24223528b/" title="browse the tree at this commit" style="--dy:51px">files</a> @@ -521,7 +521,7 @@ Ada Fixture, 2026-01-02 03:04:05 UTC</p> </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> @@ -552,7 +552,7 @@ Content-Type: text/html; charset=utf-8 <div class="flank left"></div><div class="flank right"></div> <div class="sheet"> <nav class="crumbs"> -<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / commit / 64ac67a35d +<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / commit / 64ac67a35d </nav> <nav class="arc corner" aria-label="page moves" style="--arc-drop:51px"> <a class="va" href="/fixturama/tree/64ac67a35d36079e167cb8f929fd57f859c8b5d1/" title="browse the tree at this commit" style="--dy:51px">files</a> @@ -576,7 +576,7 @@ Ada Fixture, 2026-01-02 03:04:05 UTC</p> </div> <footer class="ground"> <div class="floor"></div> -<p class="colophon">test · socle</p> +<p class="colophon">test · made with socle</p> </footer> </body> </html> diff --git a/watch_test.go b/watch_test.go index 58b9557..6e56861 100644 --- a/watch_test.go +++ b/watch_test.go @@ -13,8 +13,6 @@ import ( "sync/atomic" "testing" "time" - - "vitrine/display" ) // watchFixture is a server plus a watched config file in a temp dir, @@ -51,9 +49,9 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture { if len(warnings) > 0 { t.Fatalf("fixture repos should all open: %v", warnings) } - fx.srv = newServer(repos, cfg.BaseURL, display.NewTheme("test")) + fx.srv = newServer(repos, cfg.BaseURL, testFace(t)) watchConfig(fx.cfgPath, pollInterval, func() { - reloadConfig(fx.srv, fx.cfgPath) + reloadConfig(fx.srv, fx.cfgPath, "test") fx.checked.Add(1) }) return fx