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