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 @@ Origin string // href 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 @@ 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 @@ // 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. + // + // 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 @@ if s.hubOrigin != "" && username != "" { 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, `