// Package chrome is the shared page chrome for the custom services of a
// self-hosted SourceHut instance (diff, 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, "diff.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" .}}
// <nav class="container navbar navbar-light navbar-expand-sm">
// {{template "srht-nav" .}}
// </nav>
//
// 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 "<name>.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 <head>; "" renders no <link>.
// Guarded rather than emitted empty for the same reason StyleHref is:
// <link href=""> 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. "diff.sr.ht".
Section string
// StyleHref is the href of the built stylesheet (the hashed
// main.min.<sha>.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 <head>. 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 <link> 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 <link rel="icon"> 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",
}
}