~bigbes/sr-ht-ecore

b739f927762948a7337f77b310312699dbf0f049 — Eugene Blikh 11 days ago
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.
A  => .gitignore +2 -0
@@ 1,2 @@
# macOS
.DS_Store

A  => README.md +50 -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" .}}
<nav class="container navbar navbar-light navbar-expand-sm">
  {{template "srht-nav" .}}
</nav>
```

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`.

A  => chrome/chrome.go +217 -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" .}}
//	<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, 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 "<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.
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.<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

	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",
	}
}

A  => chrome/chrome_test.go +155 -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" .}}<nav>{{template "srht-nav" .}}</nav>`)
	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, `<span class="text-danger">compare</span>`)
	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, `<span class="text-danger">dolt</span>`)
	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)
}

A  => chrome/funcs.go +44 -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
}

A  => chrome/templates.go +26 -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
}

A  => chrome/templates/chrome.tmpl +57 -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
  <body>. srht-nav renders the navbar INNER content (brand + switcher + login
  box); the service's layout owns the <nav> element itself so it can keep its
  own classes.
*/}}

{{define "srht-env-banner" -}}
{{if .ShowBanner}}
<div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
  {{.Environment}} ENVIRONMENT
</div>
{{end}}
{{- end}}

{{define "srht-nav" -}}
<span class="navbar-brand">
  <span class="icon icon-circle" aria-hidden="true"><svg width="22" height="22" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"/></svg></span>
  <a class="navbar-brand" href="/">
    {{.SiteName}}
    <span class="text-danger">{{.SiteLabel}}</span>
  </a>
</span>
<ul class="navbar-nav">
  {{if .Username}}
  {{range .Nav}}
  <li class="nav-item {{if .Active}}active{{end}}">
    <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
  </li>
  {{end}}
  {{range .ExtraNav}}
  <li class="nav-item {{if .Active}}active{{end}}">
    <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
  </li>
  {{end}}
  {{end}}
</ul>
<div class="login">
  {{if .Username}}
  <span class="navbar-text">
    Logged in as
    <a href="{{.ProfileURL}}">{{.Username}}</a>
    &mdash;
    <a href="{{.LogoutURL}}">Log out</a>
  </span>
  {{else}}
  <span class="navbar-text">
    <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
    &mdash;
    <a href="{{.RegisterURL}}">Register</a>
  </span>
  {{end}}
</div>
{{- end}}

A  => go.mod +15 -0
@@ 1,15 @@
module sourcecraft.dev/bigbes/sr-ht-ecore

go 1.24

require (
	github.com/stretchr/testify v1.10.0
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
	sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
)

require (
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

A  => go.sum +14 -0
@@ 1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=