From 514838a75a4b7dcac5ffbd85d078f00e50bc9aee Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 15 Aug 2026 22:12:46 +0300 Subject: [PATCH] chrome: give a service its own navigation row The switcher lists the instance's services, each on an origin of its own. Three artifacts pages rode ExtraNav into it, so that row's membership changed from service to service and its active element - spent naming "artifacts" - said nothing about which of the three the reader was on. Sections is the second row instead, the one upstream draws as .header-tabbed wrapping .nav.nav-tabs and meta puts profile and keys in. A service declares its sections at startup; Page marks the one the request's path stands in, so a page cannot forget to declare itself, and a path in no section lights nothing rather than the first tab. Both classes arrive with core.sr.ht's nav.scss, so the row costs no service any CSS. ExtraNav is deprecated with it: bench and cov abandoned it when the instance deployed a tokens.sr.ht and the word appeared in the navbar twice, and artifacts was its last user. --- chrome/chrome.go | 121 +++++++++++++++++++++++++++++++++ chrome/sections_test.go | 125 +++++++++++++++++++++++++++++++++++ chrome/templates.go | 8 +-- chrome/templates/chrome.tmpl | 33 +++++++++ 4 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 chrome/sections_test.go diff --git a/chrome/chrome.go b/chrome/chrome.go index c6888d7244ee123403066c524c91679319382315..c47987d097a648d3bfa3d79055ae0980c904b980 100644 --- a/chrome/chrome.go +++ b/chrome/chrome.go @@ -61,6 +61,95 @@ type NavItem struct { Active bool // highlights the current service } +// Section is one entry in a service's own navigation row — the second row, the +// one the family draws below the switcher and upstream spells .header-tabbed +// wrapping .nav.nav-tabs (meta's profile/security/keys, git's summary/tree/log). +// +// It is deliberately not a NavItem. The switcher's entries are services, each +// on an origin of its own, and its active element is spent naming the service +// the reader is in — which is true of every page that service serves and +// therefore says nothing about which one. These are pages of one service, so +// they carry a path rather than an origin, and their active element is the only +// thing in the chrome that can say where inside the service the reader stands. +// artifacts kept its three sections in ExtraNav before this type existed, and +// that row was the sum of both mistakes: a switcher whose membership changed +// from service to service, and no active element anywhere below it. +type Section struct { + // Name is the tab's text; Href is where it leads, as a path on this + // service. + Name string + Href string + // Paths are matched as path segments: a page stands in the section when + // its path equals one of them, or continues one after a slash. "/mirrors" + // therefore covers "/mirrors" and "/mirrors/alpine/rules" but not + // "/mirrorsomething", and the service root "/" matches only itself. + Paths []string + // Prefixes are matched as literal string prefixes, for the shapes a + // segment boundary cannot express. "/~" is one: a channel, a repository + // and a database are all spelled "/~owner/name", and every such page + // belongs to the section whose listing carries it. + Prefixes []string +} + +// SectionTab is a Section as one rendered page sees it: the entry, plus whether +// this page is the one standing in it. +type SectionTab struct { + Name string + Href string + Active bool +} + +// sectionTabs is the row as a request's own path sees it. +// +// Deriving the active entry from the path is what makes a page that forgot to +// declare its section impossible: a service has a dozen render paths and one of +// them is the error page, reached from every other. +// +// A path in no section — the 404 that "/nowhere" renders — lights nothing +// rather than falling back to the first tab, because a row whose active element +// is always lit would be claiming the reader is somewhere they are not. +func sectionTabs(sections []Section, path string) []SectionTab { + if len(sections) == 0 { + return nil + } + tabs := make([]SectionTab, 0, len(sections)) + for _, section := range sections { + tabs = append(tabs, SectionTab{ + Name: section.Name, + Href: section.Href, + Active: section.matches(path), + }) + } + return tabs +} + +// matches answers whether a path stands in this section; see Section.Paths and +// Section.Prefixes for the two rules and why both exist. +func (s Section) matches(path string) bool { + for _, segment := range s.Paths { + if path == segment { + return true + } + // The service root is the one segment with nothing under it: trimming + // its slash leaves "", and "" + "/" is the prefix of every path on the + // service, so a root declared this way would light its tab on every + // page and darken it nowhere. Pages below the root belong to whichever + // section claims them - through Prefixes, as "/~" does - or to none. + if segment == "/" { + continue + } + if strings.HasPrefix(path, strings.TrimSuffix(segment, "/")+"/") { + return true + } + } + for _, prefix := range s.Prefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + // 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 @@ -125,6 +214,13 @@ type Page struct { Nav []NavItem ExtraNav []NavItem // service-specific entries appended after the switcher + // Tabs is this service's own navigation row, below the switcher, with the + // entry the request's path stands in marked Active. Empty for a service + // that declared no Sections, and empty for an anonymous viewer — the rule + // the switcher already follows, since a row of destinations is chrome for + // someone with a session rather than a second front door. + Tabs []SectionTab + 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 @@ -216,7 +312,21 @@ type Service struct { StyleHref string // ExtraNav holds service-specific switcher entries (e.g. a /tokens link), // rendered after the shared network entries, for authenticated viewers. + // + // Deprecated: it has no correct use left. Its two historical ones both + // turned out to be mistakes with the same shape — putting a page of one + // service into the row that lists the instance's services. bench and cov + // rode it for a local /tokens until the instance deployed a tokens.sr.ht + // and the word appeared in the navbar twice; artifacts rode it for three + // sections that are Sections now. A service's own pages belong in Sections; + // the switcher is the instance's, not the service's. ExtraNav []NavItem + // Sections is this service's own navigation row, rendered below the + // switcher. Declare it at startup, in the order the tabs should print; + // Page marks the one the request stands in. A service that declares none + // renders no row at all, which is every service that had none before this + // field existed. + Sections []Section // Assets holds the extra hashed asset hrefs every Page carries; see // Page.Assets. Populate it at startup, next to StyleHref. Assets map[string]string @@ -313,12 +423,23 @@ func (s *Service) Page(r *http.Request, title, username string) Page { profileURL = s.hubOrigin + "/~" + username } + // The section row follows the switcher's own rule, which the partial states + // in markup: chrome for a reader with a session. Not a permission check — + // a service's public sections stay reachable by their addresses either way + // — but a row printed for a logged-out visitor would be offering tabs it + // cannot know are answerable, and the switcher beside it would be empty. + var tabs []SectionTab + if username != "" { + tabs = sectionTabs(s.Sections, r.URL.Path) + } + return Page{ Title: title, SiteName: s.siteName, SiteLabel: strings.TrimSuffix(s.Section, ".sr.ht"), Nav: s.nav, ExtraNav: s.ExtraNav, + Tabs: tabs, Username: username, LoginURL: s.LoginURLFor(r), LogoutURL: s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.selfOrigin), diff --git a/chrome/sections_test.go b/chrome/sections_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5de70ac40d70389aeaeb6cf798c7d5490ae52fc1 --- /dev/null +++ b/chrome/sections_test.go @@ -0,0 +1,125 @@ +package chrome + +import ( + "html/template" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testSections is the row artifacts declares, which is the shape that motivated +// the type: three sections with a path of their own, and a first one standing +// for the service root plus the listing rows reached from it. +func testSections() []Section { + return []Section{ + {Name: "channels", Href: "/", Paths: []string{"/"}, Prefixes: []string{"/~"}}, + {Name: "images", Href: "/images", Paths: []string{"/images"}}, + {Name: "cache", Href: "/cache", Paths: []string{"/cache"}}, + {Name: "mirrors", Href: "/mirrors", Paths: []string{"/mirrors"}}, + } +} + +// renderSections executes a layout invoking the section partial, the way a +// service's own layout does. +func renderSections(t *testing.T, v any) string { + t.Helper() + tpl := MustAttach(template.New("layout")) + tpl, err := tpl.Parse(`{{template "srht-sections" .}}`) + require.NoError(t, err) + var b strings.Builder + require.NoError(t, tpl.Execute(&b, v)) + return b.String() +} + +// activeNames is the tabs a path lights up, which for a well-formed row is +// never more than one. +func activeNames(tabs []SectionTab) []string { + var names []string + for _, tab := range tabs { + if tab.Active { + names = append(names, tab.Name) + } + } + return names +} + +func TestSectionRowRendersUpstreamMarkup(t *testing.T) { + svc := NewService(testConf(), "diff.sr.ht") + svc.Sections = testSections() + page := svc.Page(httptest.NewRequest("GET", "/cache", nil), "t", "alice") + + out := renderSections(t, page) + // The classes are the whole reason the row needs no stylesheet of its own: + // both come from core.sr.ht's nav.scss through every service's base import. + assert.Contains(t, out, `
`) + assert.Contains(t, out, `