// Package chrome is the shared page chrome for the custom services of a // self-hosted SourceHut instance (compare, spec, dolt, cover, bench, ...). // // Every one of those services renders the same top strip: the brand (circle // icon + site name + red service label), the service switcher derived from the // shared config.ini, and the login box against meta.sr.ht's unified login. // Before this package each service carried its own copy of the nav-building // code and markup, and the copies drifted (hardcoded brand labels, string vs // const active checks, divergent hub handling). This package is the one copy. // // Usage: // // svc := chrome.NewService(conf, "compare.sr.ht") // svc.StyleHref = cssHref // after discovering the hashed stylesheet // page := svc.Page(r, "My title", username) // // and in the layout, after chrome.Attach(t) has parsed the shared partials: // // {{template "srht-env-banner" .}} // // // The template dot must expose the Page fields — either a Page itself or a // service view struct that embeds it (promoted fields resolve in templates). // // Unified policy decisions, deliberately baked in rather than parameterized: // the switcher renders only for authenticated viewers; paste, pages and hub // never appear in it (hub is the brand's business); the profile link prefers // hub's ~username page when hub is configured; the brand is always circle + // site name + red service label, with the name linking to hub and the label to // the service's own root. package chrome import ( "html/template" "net/http" "net/url" "sort" "strings" "time" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/config" ) // navCanonical is the SourceHut service-switcher order, mirroring upstream // core.sr.ht's _network_order. Services not listed here (including the custom // ones) sort alphabetically after these. var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"} // navExcluded are service sections that never appear in the switcher: paste // and pages have no top-level UI worth linking, and hub is not a sibling // service but the network's front page. var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true} // NavItem is one entry in the service switcher (or a service-specific extra). type NavItem struct { Name string // link text, e.g. "git" Origin string // href Active bool // highlights the current service } // BuildNav derives the service switcher from the shared config: every section // whose name ends in ".sr.ht" (with a configured origin) except the excluded // ones, ordered canonically then alphabetically, with the section named by // active marked as the current service. // // The ".sr.ht" suffix is the whole membership rule — it is what core.sr.ht's // own _network does, and it is why a custom service's section must be named // literally ".sr.ht" no matter what host it is served from. func BuildNav(conf ini.File, active string) []NavItem { var items []NavItem for section := range conf { if !strings.HasSuffix(section, ".sr.ht") { continue } short := strings.TrimSuffix(section, ".sr.ht") if navExcluded[short] { continue } origin := config.GetOrigin(conf, section, true) if origin == "" { continue } items = append(items, NavItem{ Name: short, Origin: origin, Active: section == active, }) } sort.SliceStable(items, func(i, j int) bool { ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name) if ci != cj { return ci < cj } return items[i].Name < items[j].Name }) return items } // canonIndex returns a service's position in navCanonical, or a sentinel past // the end for services that are not canonically ordered. func canonIndex(name string) int { for i, n := range navCanonical { if n == name { return i } } return len(navCanonical) } // Page is the chrome every rendered page shares. Services embed it in their // own view struct and add page payload (and service-specific chrome fields) // next to it. // // Embedding names the field Page, so a view struct that wants "Page" for its // own payload — a pagination counter, most often — has to rename that field // (PageNum, say). The collision is a compile error, not a silent shadow. type Page struct { Title string SiteName string SiteLabel string // red brand suffix: the service's short name Nav []NavItem ExtraNav []NavItem // service-specific entries appended after the switcher Username string // "" for an anonymous viewer LoginURL string // meta login with return_to back to the current URL LogoutURL string // meta logout with return_to to this service's root RegisterURL string ProfileURL string // hub's ~username page when hub is configured, else meta profile MetaOrigin string SelfOrigin string HubOrigin string StyleHref string // "" when the binary was built without a stylesheet // FaviconHref is the icon for this page's ; "" renders no . // Guarded rather than emitted empty for the same reason StyleHref is: // re-requests the page it is on. FaviconHref template.URL // Assets are the hashed hrefs of the extra build artefacts a layout links // beyond the stylesheet — a vendored chart library, a front-end bundle — // keyed by names the service picks. Read as {{index .Assets "uplot.js"}}, // guarded on emptiness exactly like StyleHref. // // They belong to the chrome for the reason StyleHref does: the hash in the // name is a property of this binary, not of any page. A page that had to // be handed its own asset URLs is a page that can be written without them // and silently render nothing where the chart was. // // The map is the Service's, shared by every Page it builds: written once // at startup, read-only afterwards. A handler must not write to it. Assets map[string]string Environment string // uppercased; banner text ShowBanner bool // true outside production // ContainerClass selects the width of the page's content wrapper: the // centered Bootstrap "container" by default; services override it to // "container-fluid" for full-bleed pages (diff views, annotated source). ContainerClass string } // ListItem is one project in a listing — a repository, a database, a space. // Title is the display name ("~owner/name"); Visibility is the service's // literal enum value ("PUBLIC"/"UNLISTED"/"PRIVATE", "" to render nothing — // the partials show it lowercase, non-public only). // // Updated and Meta are optional, and deliberately so. Four services wanted a // listing here and disagreed about its shape: bench and spec needed a // modification time, dolt has no timestamp in its schema at all, and cover's // index is a table of percentages and sparklines that no shared partial will // ever render. A required column would have pushed dolt back onto a local // copy; a zero Updated and a nil Meta render nothing, which is what keeps all // three of them consumers. // // Updated is a time.Time rather than a preformatted string so the partial can // render "3 hours ago" with the exact stamp in the title attribute, once, // instead of every service picking its own spelling — the drift RelTime and // AbsTime were hoisted to end. type ListItem struct { Href string Title string Visibility string Description string Updated time.Time Meta []string } // RepoList is the dot for the srht-repo-list and srht-repo-table partials: the // items, and the muted text shown when there are none. // // Two partials over one type because the two shapes are not variants of each // other: srht-repo-list is the family's event-list cards, srht-repo-table the // same data as aligned columns for a service whose listing is long enough to // scan. Making the cards partial grow columns would have made it a worse cards // partial for the services that wanted cards. type RepoList struct { Items []ListItem Empty string } // Service is the static half of the chrome, built once at startup. The // exported fields may be adjusted between NewService and the first Page call // (they are read, never written, by Page). type Service struct { // Section is the literal config section, e.g. "compare.sr.ht". Section string // StyleHref is the href of the built stylesheet (the hashed // main.min..css); the zero value renders a bare page rather than // failing, matching how the services degrade without CSS. StyleHref string // ExtraNav holds service-specific switcher entries (e.g. a /tokens link), // rendered after the shared network entries, for authenticated viewers. ExtraNav []NavItem // Assets holds the extra hashed asset hrefs every Page carries; see // Page.Assets. Populate it at startup, next to StyleHref. Assets map[string]string // FaviconHref is the icon linked from every page's . NewService sets // it to DefaultFaviconHref; a service with a logo of its own overwrites it // (through assets.Resolve, so a hashed icon earns the immutable lifetime), // and "" renders no at all. FaviconHref template.URL siteName string environment string selfOrigin string metaOrigin string hubOrigin string nav []NavItem } // DefaultFaviconHref is the icon a service gets without shipping one: the // brand's circle, inlined as a data: URI. // // A data: URI rather than a path into a static tree, because the alternative // fails in a way that is easy to miss. bench deliberately embeds no favicon // and its layout says why: a pointing at an asset the binary // does not have is a 404 — a rendered error page, on every page load, for a // file no human asked for. A default that is a path would hand that to every // service that has not made a logo yet; a default that carries its own bytes // cannot 404. It also costs no request at all, which a 500-byte icon is not // worth making. // // The stroke follows the viewer's colour scheme, since a favicon sits on the // browser's chrome rather than on ours, and a near-black ring disappears into // a dark tab strip. // // The type is template.URL because html/template rewrites any href whose // scheme is not http, https or mailto to "#ZgotmplZ" — a data: URI reaches the // page only if the caller says it meant it. That is also the guard on a // service overriding this field: the value has to come from somewhere the // service vouches for, not from a request. const DefaultFaviconHref template.URL = "data:image/svg+xml," + "%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E" + "%3Cstyle%3Ecircle%7Bstroke:%23222%7D" + "@media(prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23eee%7D%7D%3C/style%3E" + "%3Ccircle%20cx='16'%20cy='16'%20r='11'%20fill='none'%20stroke-width='6'/%3E%3C/svg%3E" // NewService reads the shared config once and caches everything Page needs. // section must be this service's literal config section name. func NewService(conf ini.File, section string) *Service { env := config.GetString(conf, "sr.ht", "environment", "development") return &Service{ Section: section, FaviconHref: DefaultFaviconHref, siteName: config.GetString(conf, "sr.ht", "site-name", "sr.ht"), environment: env, selfOrigin: strings.TrimRight(config.GetOrigin(conf, section, true), "/"), metaOrigin: strings.TrimRight(config.GetOrigin(conf, "meta.sr.ht", true), "/"), hubOrigin: strings.TrimRight(config.GetOrigin(conf, "hub.sr.ht", true), "/"), nav: BuildNav(conf, section), } } // SelfOrigin returns the service's own external origin, as resolved from the // config section given to NewService. func (s *Service) SelfOrigin() string { return s.selfOrigin } // MetaOrigin returns meta.sr.ht's external origin. func (s *Service) MetaOrigin() string { return s.metaOrigin } // HubOrigin returns hub.sr.ht's external origin, or "" when the instance has // no hub. func (s *Service) HubOrigin() string { return s.hubOrigin } // SiteName returns the instance's brand text. func (s *Service) SiteName() string { return s.siteName } // Environment returns the configured environment as written in the config // (lowercase); Page uppercases it for the banner. func (s *Service) Environment() string { return s.environment } // LoginURLFor is meta.sr.ht's login with return_to pointing back at the URL // being served — the same link the nav's "Log in" carries. Exported for the // handlers that gate a page behind login and only need somewhere to redirect, // so they do not have to build a whole Page to read one field off it. func (s *Service) LoginURLFor(r *http.Request) string { return s.metaOrigin + "/login?return_to=" + url.QueryEscape(s.selfOrigin+r.URL.RequestURI()) } // Page builds the chrome for one request. Login return_to is the current full // URL (so the viewer lands back where they were); logout return_to is this // service's origin. username is the caller's *authoritative* identity — pass // "" for viewers whose cookie grants nothing, and the nav offers login. func (s *Service) Page(r *http.Request, title, username string) Page { profileURL := s.metaOrigin + "/profile" if s.hubOrigin != "" && username != "" { profileURL = s.hubOrigin + "/~" + username } return Page{ Title: title, SiteName: s.siteName, SiteLabel: strings.TrimSuffix(s.Section, ".sr.ht"), Nav: s.nav, ExtraNav: s.ExtraNav, Username: username, LoginURL: s.LoginURLFor(r), LogoutURL: s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.selfOrigin), RegisterURL: s.metaOrigin, ProfileURL: profileURL, MetaOrigin: s.metaOrigin, SelfOrigin: s.selfOrigin, HubOrigin: s.hubOrigin, StyleHref: s.StyleHref, FaviconHref: s.FaviconHref, Assets: s.Assets, Environment: strings.ToUpper(s.environment), ShowBanner: s.environment != "" && s.environment != "production", ContainerClass: "container", } }