From 2fd6b3aa5fbde04ee4ef6fc6bb986470293339f9 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 22:07:49 +0300 Subject: [PATCH] chrome: give the brand both halves, and share the login URL and time helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 because the theme colours ".navbar-light .navbar-brand a", which outranks .text-danger and would repaint a red 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. --- README.md | 18 +++++++++-- chrome/chrome.go | 34 ++++++++++++++++---- chrome/chrome_test.go | 60 +++++++++++++++++++++++++++++++++++- chrome/funcs.go | 57 ++++++++++++++++++++++++++++++++++ chrome/templates/chrome.tmpl | 19 +++++++++--- 5 files changed, 174 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9fca5337acce86f26ab0435485e287fb4996991a..97f7e563861cdadd65723ad3920e2d7e606e66f6 100644 --- a/README.md +++ b/README.md @@ -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): `:` members split on ASCII whitespace, `*` for every action of every service, the reserved `id:` 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. diff --git a/chrome/chrome.go b/chrome/chrome.go index 3d3c1a02e9b7a313bff1aa5aaf0504f067a1edd9..7e6d1c6e5787c147ce70face07ac5b509dbaa55d 100644 --- a/chrome/chrome.go +++ b/chrome/chrome.go @@ -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, diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go index b5f997472b258df41a23d9ab75979a308921468d..10190ea7eea8a5ad9ddbb87c3d9800b952e58e24 100644 --- a/chrome/chrome_test.go +++ b/chrome/chrome_test.go @@ -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, `compare`) + // The brand is two links: the site name to hub, the red label to us. + assert.Contains(t, out, `srht.example`) + 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") @@ -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, `srht.example`) + assert.Contains(t, out, `compare`) +} + // 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) diff --git a/chrome/funcs.go b/chrome/funcs.go index d93af10b012f0c0816a9d202dbba8d13341e10ee..773a227f95944fab644c86afbd61dc7236deb190 100644 --- a/chrome/funcs.go +++ b/chrome/funcs.go @@ -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) +} diff --git a/chrome/templates/chrome.tmpl b/chrome/templates/chrome.tmpl index 3552a59cd1ae480cc0d2f81990eb3b9f8eb1c9bf..aa7e599e0d3bc0dea96af108812adbb306e870da 100644 --- a/chrome/templates/chrome.tmpl +++ b/chrome/templates/chrome.tmpl @@ -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 rather than + becoming a red : the theme colors ".navbar-light .navbar-brand a", which + outranks .text-danger and would repaint the label white in dark mode. */}} - - {{.SiteName}} - {{.SiteLabel}} - + {{.SiteName}} + {{.SiteLabel}}