From b739f927762948a7337f77b310312699dbf0f049 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 7 Aug 2026 07:53:23 +0300 Subject: [PATCH] chrome: shared page chrome for the instance's custom services Extract the nav/chrome idiom that compare, spec, dolt and cover each carried as a private copy (and bench was about to fork as a sixth): BuildNav over the shared config.ini with the canonical ordering and paste/pages/hub exclusions, a per-request Page with login/logout/profile URLs against meta's unified login, embedded srht-nav / srht-env-banner partials (circle brand + red service label + switcher + login box), and the generic dict/shortsha template helpers. Policy decisions the copies had drifted on are baked in: switcher only for authenticated viewers, hub excluded in the builder rather than the template, profile link prefers hub's ~username page, environment name uppercased for the banner. Service-specific needs surfaced by the cover survey get seams instead of copies: ExtraNav for extra switcher entries, ContainerClass for full-bleed pages, the config section as an explicit parameter for the active check. --- .gitignore | 2 + README.md | 50 ++++++++ chrome/chrome.go | 217 +++++++++++++++++++++++++++++++++++ chrome/chrome_test.go | 155 +++++++++++++++++++++++++ chrome/funcs.go | 44 +++++++ chrome/templates.go | 26 +++++ chrome/templates/chrome.tmpl | 57 +++++++++ go.mod | 15 +++ go.sum | 14 +++ 9 files changed, 580 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 chrome/chrome.go create mode 100644 chrome/chrome_test.go create mode 100644 chrome/funcs.go create mode 100644 chrome/templates.go create mode 100644 chrome/templates/chrome.tmpl create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a0090b46d76d38eb81ad66d6fd2af66977a287ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# macOS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d63a1abdede05a023dae71a8702c40542f28eb52 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# sr-ht-ecore + +Extended core for the custom services of a self-hosted SourceHut instance +(compare, spec, dolt, cover, bench, ...). Everything these services share that +is *ours* — not upstream's — lives here, so the sr-ht-core fork can stay a +clean mirror of upstream core-go, and so the services stop carrying drifting +copies of the same code. + +## Packages + +- `chrome` — the shared page chrome: service-switcher nav built from the + shared config.ini (`chrome.BuildNav`), per-request `chrome.Page` with + login/logout/profile URLs against meta.sr.ht's unified login, embedded + `srht-nav` / `srht-env-banner` template partials (circle brand + red service + label + switcher + login box), and the generic template helpers (`dict`, + `shortsha`). + +## Usage + +```go +svc := chrome.NewService(conf, "compare.sr.ht") +svc.StyleHref = cssHref // after discovering the hashed stylesheet + +t := chrome.MustAttach(template.New("layout").Funcs(chrome.Funcs())) +// ... parse the service's own templates into t ... + +page := svc.Page(r, "Page title", username) // username "" = anonymous +``` + +In the layout: + +```html +{{template "srht-env-banner" .}} + +``` + +The template dot must expose the `chrome.Page` fields — either a `Page` +itself, or a service view struct that embeds one (promoted fields resolve in +templates). + +## Policy + +The chrome bakes in the instance-wide decisions instead of parameterizing +them: the switcher renders only for authenticated viewers; paste, pages and +hub never appear in it; the brand is always circle + site name + red service +label and links to the service's own root; the profile link prefers hub's +`~username` page when hub is configured. Service-specific nav entries go +through `Service.ExtraNav`; per-page width through `Page.ContainerClass`. diff --git a/chrome/chrome.go b/chrome/chrome.go new file mode 100644 index 0000000000000000000000000000000000000000..f523cc96c5ba9e17f0334b7b2bdda1a745830d18 --- /dev/null +++ b/chrome/chrome.go @@ -0,0 +1,217 @@ +// 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 +} + +// 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", + } +} diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e469474095d5a5ff328ca14c653325d1eddb9121 --- /dev/null +++ b/chrome/chrome_test.go @@ -0,0 +1,155 @@ +package chrome + +import ( + "html/template" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vaughan0/go-ini" +) + +// testConf synthesizes the shared instance config the way the services read +// it: one section per service plus the [sr.ht] block. +func testConf() ini.File { + return ini.File{ + "sr.ht": { + "site-name": "srht.example", + "environment": "production", + }, + "meta.sr.ht": {"origin": "https://meta.example"}, + "git.sr.ht": {"origin": "https://git.example"}, + "todo.sr.ht": {"origin": "https://todo.example"}, + "builds.sr.ht": {"origin": "https://builds.example"}, + "hub.sr.ht": {"origin": "https://hub.example"}, + "paste.sr.ht": {"origin": "https://paste.example"}, + "pages.sr.ht": {"origin": "https://pages.example"}, + "compare.sr.ht": {"origin": "https://compare.example"}, + "dolt.sr.ht": {"origin": "https://dolt.example"}, + "ghost.sr.ht": {}, // no origin -> must not appear + "webhooks": {"private-key": "x"}, + } +} + +func TestBuildNavOrderExclusionsActive(t *testing.T) { + nav := BuildNav(testConf(), "compare.sr.ht") + + var names []string + for _, it := range nav { + names = append(names, it.Name) + } + // Canonical services first in canonical order, customs alphabetical after; + // hub/paste/pages and the origin-less section excluded. + assert.Equal(t, []string{"git", "todo", "builds", "meta", "compare", "dolt"}, names) + + for _, it := range nav { + assert.Equal(t, it.Name == "compare", it.Active, "active flag for %s", it.Name) + } +} + +func TestPageURLsAndIdentity(t *testing.T) { + svc := NewService(testConf(), "compare.sr.ht") + r := httptest.NewRequest("GET", "/~alice/demo?a=1", nil) + + p := svc.Page(r, "t", "alice") + assert.Equal(t, "srht.example", p.SiteName) + assert.Equal(t, "compare", p.SiteLabel) + assert.Equal(t, "https://meta.example/login?return_to="+ + "https%3A%2F%2Fcompare.example%2F~alice%2Fdemo%3Fa%3D1", p.LoginURL) + assert.Equal(t, "https://meta.example/logout?return_to="+ + "https%3A%2F%2Fcompare.example", p.LogoutURL) + // Hub is configured, so the profile link prefers hub's ~username page. + assert.Equal(t, "https://hub.example/~alice", p.ProfileURL) + assert.Equal(t, "container", p.ContainerClass) + assert.False(t, p.ShowBanner, "production must not show the env banner") +} + +func TestPageProfileFallsBackToMeta(t *testing.T) { + conf := testConf() + delete(conf, "hub.sr.ht") + svc := NewService(conf, "compare.sr.ht") + r := httptest.NewRequest("GET", "/", nil) + + assert.Equal(t, "https://meta.example/profile", svc.Page(r, "t", "alice").ProfileURL) + // Anonymous viewers get the meta profile link regardless of hub. + assert.Equal(t, "https://meta.example/profile", + NewService(testConf(), "compare.sr.ht").Page(r, "t", "").ProfileURL) +} + +func TestPageEnvBanner(t *testing.T) { + conf := testConf() + conf["sr.ht"]["environment"] = "staging" + svc := NewService(conf, "compare.sr.ht") + p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "") + + assert.True(t, p.ShowBanner) + assert.Equal(t, "STAGING", p.Environment) +} + +// render executes a minimal layout that invokes both shared partials against v. +func render(t *testing.T, v any) string { + t.Helper() + tpl := template.New("layout") + tpl = MustAttach(tpl) + tpl, err := tpl.Parse(`{{template "srht-env-banner" .}}`) + require.NoError(t, err) + var b strings.Builder + require.NoError(t, tpl.Execute(&b, v)) + return b.String() +} + +func TestNavTemplateLoggedIn(t *testing.T) { + svc := NewService(testConf(), "compare.sr.ht") + svc.ExtraNav = []NavItem{{Name: "tokens", Origin: "/tokens"}} + p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice") + + out := render(t, p) + assert.Contains(t, out, "icon icon-circle") + assert.Contains(t, out, `compare`) + assert.Contains(t, out, `href="https://git.example"`) + assert.Contains(t, out, `href="/tokens"`, "extra nav entries must render") + assert.Contains(t, out, "Logged in as") + assert.NotContains(t, out, "ENVIRONMENT") +} + +func TestNavTemplateAnonymous(t *testing.T) { + svc := NewService(testConf(), "compare.sr.ht") + p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "") + + out := render(t, p) + // The switcher renders only for authenticated viewers. + assert.NotContains(t, out, `href="https://git.example"`) + assert.Contains(t, out, "Log in") + assert.Contains(t, out, "Register") +} + +// TestNavTemplateEmbeddedPage guards the documented consumption pattern: a +// service view struct embedding Page resolves the promoted fields. +func TestNavTemplateEmbeddedPage(t *testing.T) { + type viewData struct { + Page + Data any + } + svc := NewService(testConf(), "dolt.sr.ht") + v := viewData{Page: svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice")} + + out := render(t, v) + assert.Contains(t, out, `dolt`) + assert.Contains(t, out, "Logged in as") +} + +func TestFuncs(t *testing.T) { + assert.Equal(t, "12345678", ShortSHA("1234567890abcdef")) + assert.Equal(t, "abc", ShortSHA("abc")) + + m, err := Dict("a", 1, "b", "x") + require.NoError(t, err) + assert.Equal(t, map[string]any{"a": 1, "b": "x"}, m) + + _, err = Dict("a") + require.Error(t, err) + _, err = Dict(1, "v") + require.Error(t, err) +} diff --git a/chrome/funcs.go b/chrome/funcs.go new file mode 100644 index 0000000000000000000000000000000000000000..d93af10b012f0c0816a9d202dbba8d13341e10ee --- /dev/null +++ b/chrome/funcs.go @@ -0,0 +1,44 @@ +package chrome + +import ( + "fmt" + "html/template" +) + +// Funcs returns the template helpers every service was carrying its own copy +// of. Merge into a service's FuncMap before its own helpers, so a service can +// still shadow a name deliberately. +func Funcs() template.FuncMap { + return template.FuncMap{ + "dict": Dict, + "shortsha": ShortSHA, + } +} + +// Dict builds a map from alternating key/value arguments, so a partial that +// needs several fields can be invoked with an inline context: +// {{template "x" (dict "A" .A "B" .B)}}. An odd argument count or a non-string +// key is a template authoring error and surfaces as a render error. +func Dict(kv ...any) (map[string]any, error) { + if len(kv)%2 != 0 { + return nil, fmt.Errorf("dict: expected an even number of arguments, got %d", len(kv)) + } + m := make(map[string]any, len(kv)/2) + for i := 0; i < len(kv); i += 2 { + k, ok := kv[i].(string) + if !ok { + return nil, fmt.Errorf("dict: key %d is not a string", i) + } + m[k] = kv[i+1] + } + return m, nil +} + +// ShortSHA abbreviates an object id to its first 8 characters (or returns it +// unchanged if shorter), the convention used everywhere commits are listed. +func ShortSHA(s string) string { + if len(s) > 8 { + return s[:8] + } + return s +} diff --git a/chrome/templates.go b/chrome/templates.go new file mode 100644 index 0000000000000000000000000000000000000000..55f08ce44518298adf40466758b09eef19fbf721 --- /dev/null +++ b/chrome/templates.go @@ -0,0 +1,26 @@ +package chrome + +import ( + "embed" + "html/template" +) + +//go:embed templates/chrome.tmpl +var templateFS embed.FS + +// Attach parses the shared chrome partials ("srht-nav", "srht-env-banner") +// into t, so a service layout can invoke them. Call it once per template set, +// before the layout that references the partials is executed. +func Attach(t *template.Template) (*template.Template, error) { + return t.ParseFS(templateFS, "templates/chrome.tmpl") +} + +// MustAttach is Attach for the common wire-up-at-startup path, where a parse +// failure is a programmer error. +func MustAttach(t *template.Template) *template.Template { + t, err := Attach(t) + if err != nil { + panic(err) + } + return t +} diff --git a/chrome/templates/chrome.tmpl b/chrome/templates/chrome.tmpl new file mode 100644 index 0000000000000000000000000000000000000000..0ef328d936b7774668f65db871c231668a1aeb47 --- /dev/null +++ b/chrome/templates/chrome.tmpl @@ -0,0 +1,57 @@ +{{/* + Shared chrome partials. The dot must expose the chrome.Page fields — either + a Page itself or a view struct embedding one. + + srht-env-banner renders the non-production warning strip; place it first in + . srht-nav renders the navbar INNER content (brand + switcher + login + box); the service's layout owns the