// 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, and this brand links to the
// service's own root instead); the profile link prefers hub's ~username page
// when hub is configured; the brand is always circle + site name + red label.
package chrome
import (
"net/http"
"net/url"
"sort"
"strings"
"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.
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
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 srht-repo-list partial shows it lowercase, non-public only).
type ListItem struct {
Href string
Title string
Visibility string
Description string
}
// RepoList is the dot for the srht-repo-list partial: the items, and the
// muted text shown when there are none.
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
siteName string
environment string
selfOrigin string
metaOrigin string
hubOrigin string
nav []NavItem
}
// 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,
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 }
// 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 {
current := s.selfOrigin + r.URL.RequestURI()
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.metaOrigin + "/login?return_to=" + url.QueryEscape(current),
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,
Environment: strings.ToUpper(s.environment),
ShowBanner: s.environment != "" && s.environment != "production",
ContainerClass: "container",
}
}