~bigbes/sr-ht-ecore

2fd6b3aa5fbde04ee4ef6fc6bb986470293339f9 — Eugene Blikh 9 days ago 1741159
chrome: give the brand both halves, and share the login URL and time helpers

The brand was upstream's minus its hub link: core.sr.ht points the whole
brand at hub when the instance has one and drops the red service label,
while this package kept the label and pointed at the service root. Hub is
excluded from the switcher, so that left the chrome with no route to hub at
all. Split the brand in two — the site name to hub (to the service root on
an instance without one), the label to the service root — and neither half
has to lose. The label keeps its own <span class="text-danger"> because the
theme colours ".navbar-light .navbar-brand a", which outranks .text-danger
and would repaint a red <a> white in dark mode.

LoginURLFor exports the line Page already computed. A handler that gates a
page behind login was building an entire Page to read one field off it.

RelTime/AbsTime come up from the services, where the copies had already
drifted over what to print for a future instant: "in 3 hours" on one
service, "just now" on the next, for the same timestamp.
5 files changed, 174 insertions(+), 14 deletions(-)

M README.md
M chrome/chrome.go
M chrome/chrome_test.go
M chrome/funcs.go
M chrome/templates/chrome.tmpl
M README.md => README.md +15 -3
@@ 13,7 13,7 @@ copies of the same code.
  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`).
  `shortsha`, `reltime`, `abstime`).
- `grants` — the grant vocabulary of tokens.sr.ht (SPEC ch. 3):
  `<service>:<action>` members split on ASCII whitespace, `*` for every action
  of every service, the reserved `id:<n>` member a registered token carries,


@@ 92,6 92,18 @@ for the length of a tokens.sr.ht restart.
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
label, where the name links to hub (or to the service root on an instance
without one) and the label links to the service 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`.

The brand is two links rather than upstream's one because upstream has to
choose between them: with hub configured it points the whole brand at hub and
drops the service label, without hub it keeps the label and points at the
service root. Both halves are load-bearing — hub is excluded from the
switcher, so the brand is the only route to it, and chrome that does not name
its own service is worse chrome.

Note that embedding names the field `Page`: a view struct that wants `Page`
for its own payload (a pagination counter, usually) must rename that field.
The collision is a compile error, not a silent shadow.

M chrome/chrome.go => chrome/chrome.go +28 -6
@@ 26,9 26,10 @@
//
// 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.
// 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 (


@@ 110,6 111,10 @@ func canonIndex(name string) int {
// 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


@@ 201,13 206,30 @@ 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 {
	current := s.selfOrigin + r.URL.RequestURI()

	profileURL := s.metaOrigin + "/profile"
	if s.hubOrigin != "" && username != "" {
		profileURL = s.hubOrigin + "/~" + username


@@ 220,7 242,7 @@ func (s *Service) Page(r *http.Request, title, username string) Page {
		Nav:            s.nav,
		ExtraNav:       s.ExtraNav,
		Username:       username,
		LoginURL:       s.metaOrigin + "/login?return_to=" + url.QueryEscape(current),
		LoginURL:       s.LoginURLFor(r),
		LogoutURL:      s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.selfOrigin),
		RegisterURL:    s.metaOrigin,
		ProfileURL:     profileURL,

M chrome/chrome_test.go => chrome/chrome_test.go +59 -1
@@ 5,6 5,7 @@ import (
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"


@@ 107,7 108,9 @@ func TestNavTemplateLoggedIn(t *testing.T) {

	out := render(t, p)
	assert.Contains(t, out, "icon icon-circle")
	assert.Contains(t, out, `<span class="text-danger">compare</span>`)
	// The brand is two links: the site name to hub, the red label to us.
	assert.Contains(t, out, `<a href="https://hub.example">srht.example</a>`)
	assert.Contains(t, out, `<a href="/"><span class="text-danger">compare</span></a>`)
	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")


@@ 125,6 128,19 @@ func TestNavTemplateAnonymous(t *testing.T) {
	assert.Contains(t, out, "Register")
}

// TestNavBrandWithoutHub covers the instance that runs no hub: the site name
// has nowhere else to point, so it falls back to the service root and the
// brand becomes two links to the same place rather than a dead one.
func TestNavBrandWithoutHub(t *testing.T) {
	conf := testConf()
	delete(conf, "hub.sr.ht")
	svc := NewService(conf, "compare.sr.ht")

	out := render(t, svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice"))
	assert.Contains(t, out, `<a href="/">srht.example</a>`)
	assert.Contains(t, out, `<a href="/"><span class="text-danger">compare</span></a>`)
}

// TestNavTemplateEmbeddedPage guards the documented consumption pattern: a
// service view struct embedding Page resolves the promoted fields.
func TestNavTemplateEmbeddedPage(t *testing.T) {


@@ 173,10 189,52 @@ func TestRepoListEmptyState(t *testing.T) {
	assert.Contains(t, out, "No databases yet.")
}

// TestLoginURLForMatchesTheNav guards the whole reason the accessor exists: a
// handler redirecting to login must land the viewer exactly where the nav's
// "Log in" would have.
func TestLoginURLForMatchesTheNav(t *testing.T) {
	svc := NewService(testConf(), "compare.sr.ht")
	r := httptest.NewRequest("GET", "/~alice/demo?a=1", nil)

	assert.Equal(t, svc.Page(r, "t", "").LoginURL, svc.LoginURLFor(r))
	assert.Equal(t, "https://meta.example/login?return_to="+
		"https%3A%2F%2Fcompare.example%2F~alice%2Fdemo%3Fa%3D1", svc.LoginURLFor(r))
}

func TestServiceAccessors(t *testing.T) {
	svc := NewService(testConf(), "compare.sr.ht")
	assert.Equal(t, "https://compare.example", svc.SelfOrigin())
	assert.Equal(t, "https://meta.example", svc.MetaOrigin())
	assert.Equal(t, "https://hub.example", svc.HubOrigin())
	assert.Equal(t, "srht.example", svc.SiteName())
	assert.Equal(t, "production", svc.Environment())

	conf := testConf()
	delete(conf, "hub.sr.ht")
	assert.Empty(t, NewService(conf, "compare.sr.ht").HubOrigin())
}

func TestRelTimeFacesBothDirections(t *testing.T) {
	now := time.Now()
	assert.Equal(t, "just now", RelTime(now))
	assert.Equal(t, "just now", RelTime(now.Add(30*time.Second)))
	assert.Equal(t, "3 hours ago", RelTime(now.Add(-3*time.Hour)))
	assert.Equal(t, "in 3 hours", RelTime(now.Add(3*time.Hour+time.Minute)))
	assert.Equal(t, "1 minute ago", RelTime(now.Add(-time.Minute-time.Second)))
	assert.Equal(t, "2 years ago", RelTime(now.Add(-2*365*24*time.Hour)))

	assert.Equal(t, "2026-08-08 12:34:56 UTC",
		AbsTime(time.Date(2026, 8, 8, 12, 34, 56, 0, time.UTC)))
}

func TestFuncs(t *testing.T) {
	assert.Equal(t, "12345678", ShortSHA("1234567890abcdef"))
	assert.Equal(t, "abc", ShortSHA("abc"))

	for _, name := range []string{"dict", "shortsha", "reltime", "abstime"} {
		assert.Contains(t, Funcs(), name)
	}

	m, err := Dict("a", 1, "b", "x")
	require.NoError(t, err)
	assert.Equal(t, map[string]any{"a": 1, "b": "x"}, m)

M chrome/funcs.go => chrome/funcs.go +57 -0
@@ 3,6 3,7 @@ package chrome
import (
	"fmt"
	"html/template"
	"time"
)

// Funcs returns the template helpers every service was carrying its own copy


@@ 12,6 13,8 @@ func Funcs() template.FuncMap {
	return template.FuncMap{
		"dict":     Dict,
		"shortsha": ShortSHA,
		"reltime":  RelTime,
		"abstime":  AbsTime,
	}
}



@@ 42,3 45,57 @@ func ShortSHA(s string) string {
	}
	return s
}

// RelTime is the coarse "3 hours ago" a listing wants, and AbsTime the exact
// UTC stamp an investigation wants. Both exist, and both are shared, because
// every service on the instance shows the same two columns and had grown its
// own spelling of them: the copies disagreed about the future, printing "in 3
// hours" on one service and "just now" on the next for the same instant.
//
// A future instant gets the same arithmetic as a past one. "in 3 weeks"
// answers "do I have to deal with this today" without the reader working it
// out from a calendar stamp.
func RelTime(t time.Time) string {
	d := time.Since(t)
	switch {
	case d < -time.Minute:
		return "in " + coarse(-d)
	case d < time.Minute:
		// Covers both an instant that has just passed and one about to, which
		// is also what two machines with unsynchronised clocks produce for the
		// same instant.
		return "just now"
	default:
		return coarse(d) + " ago"
	}
}

// AbsTime is the unambiguous stamp, one hover away from a RelTime.
func AbsTime(t time.Time) string {
	return t.UTC().Format("2006-01-02 15:04:05 UTC")
}

// coarse names a positive duration in its largest whole unit. The direction is
// the caller's to add, so that "in 3 weeks" and "3 weeks ago" cannot end up
// counting in different units.
func coarse(d time.Duration) string {
	switch {
	case d < time.Hour:
		return plural(int(d/time.Minute), "minute")
	case d < 24*time.Hour:
		return plural(int(d/time.Hour), "hour")
	case d < 30*24*time.Hour:
		return plural(int(d/(24*time.Hour)), "day")
	case d < 365*24*time.Hour:
		return plural(int(d/(30*24*time.Hour)), "month")
	default:
		return plural(int(d/(365*24*time.Hour)), "year")
	}
}

func plural(n int, unit string) string {
	if n == 1 {
		return "1 " + unit
	}
	return fmt.Sprintf("%d %ss", n, unit)
}

M chrome/templates/chrome.tmpl => chrome/templates/chrome.tmpl +15 -4
@@ 51,13 51,24 @@
  of the red service label ("dolt" vs "compare") when hopping between
  services. Sized for the longest label on the instance; inline because the
  services build their CSS from the shared core tree and ecore ships none.

  Two links, not one. Upstream core.sr.ht makes the whole brand a single link
  and has to choose: with hub configured it points the site name at hub and
  drops the service label entirely, without hub it keeps the label and points
  at the service root. Neither half is expendable — hub is excluded from the
  switcher, so the brand is the only route to it, and a page that does not
  name the service it belongs to is worse chrome. So the site name goes to
  hub (falling back to the service root when the instance has no hub) and the
  red label goes to the service root.

  The label stays wrapped in its own <span class="text-danger"> rather than
  becoming a red <a>: the theme colors ".navbar-light .navbar-brand a", which
  outranks .text-danger and would repaint the label white in dark mode.
*/}}
<span class="navbar-brand" style="min-width: 15rem">
  <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>
  <a href="{{if .HubOrigin}}{{.HubOrigin}}{{else}}/{{end}}">{{.SiteName}}</a>
  <a href="/"><span class="text-danger">{{.SiteLabel}}</span></a>
</span>
<ul class="navbar-nav">
  {{if .Username}}