M chrome/chrome.go => chrome/chrome.go +121 -0
@@ 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),
A chrome/sections_test.go => chrome/sections_test.go +125 -0
@@ 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(`<nav>{{template "srht-nav" .}}</nav>{{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, `<div class="header-tabbed">`)
+ assert.Contains(t, out, `<ul class="nav nav-tabs">`)
+ assert.Contains(t, out, `<a class="nav-link active" href="/cache">cache</a>`)
+ assert.Contains(t, out, `<a class="nav-link" href="/images">images</a>`)
+}
+
+// The row is what the switcher cannot be: the switcher's active element names
+// the service, which is true of every page it serves.
+func TestActiveTabFollowsTheRequestPath(t *testing.T) {
+ svc := NewService(testConf(), "diff.sr.ht")
+ svc.Sections = testSections()
+
+ for _, tc := range []struct {
+ path string
+ active string // "" when no tab lights up
+ }{
+ {path: "/", active: "channels"},
+ // A listing row belongs to the section whose listing carries it.
+ {path: "/~alice/main", active: "channels"},
+ {path: "/images", active: "images"},
+ {path: "/images/alice/tool", active: "images"},
+ {path: "/mirrors/alpine/rules", active: "mirrors"},
+ // The prefix matches as text; the segment boundary is what keeps the
+ // tab dark.
+ {path: "/mirrorsomething"},
+ // A path in no section - the 404 - lights nothing rather than falling
+ // back to the first tab.
+ {path: "/nowhere"},
+ } {
+ t.Run(tc.path, func(t *testing.T) {
+ page := svc.Page(httptest.NewRequest("GET", tc.path, nil), "t", "alice")
+ if tc.active == "" {
+ assert.Empty(t, activeNames(page.Tabs))
+ return
+ }
+ assert.Equal(t, []string{tc.active}, activeNames(page.Tabs))
+ })
+ }
+}
+
+// The root must not swallow every path under it: it is "/" as a segment, and
+// "/" is the prefix of everything.
+func TestServiceRootMatchesOnlyItself(t *testing.T) {
+ root := Section{Name: "overview", Href: "/", Paths: []string{"/"}}
+
+ assert.True(t, root.matches("/"))
+ assert.False(t, root.matches("/images"))
+ assert.False(t, root.matches("/images/alice/tool"))
+}
+
+// A service that declared no sections renders exactly what it did before the
+// field existed, which is every service on the instance but one.
+func TestServiceWithoutSectionsRendersNoRow(t *testing.T) {
+ svc := NewService(testConf(), "diff.sr.ht")
+ page := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice")
+
+ assert.Empty(t, page.Tabs)
+ assert.NotContains(t, renderSections(t, page), "header-tabbed")
+}
+
+// Chrome for a reader with a session, the rule the switcher already follows.
+func TestAnonymousViewerGetsNoSectionRow(t *testing.T) {
+ svc := NewService(testConf(), "diff.sr.ht")
+ svc.Sections = testSections()
+ page := svc.Page(httptest.NewRequest("GET", "/cache", nil), "t", "")
+
+ assert.Empty(t, page.Tabs)
+ assert.NotContains(t, renderSections(t, page), "header-tabbed")
+}
M chrome/templates.go => chrome/templates.go +4 -4
@@ 8,10 8,10 @@ import (
//go:embed templates/chrome.tmpl
var templateFS embed.FS
-// Attach parses the shared chrome partials ("srht-nav", "srht-env-banner",
-// "srht-head-links", "srht-repo-list", "srht-repo-table") into t, so a service
-// layout can invoke them. Call it once per template set, before the layout
-// that references the partials is executed.
+// Attach parses the shared chrome partials ("srht-nav", "srht-sections",
+// "srht-env-banner", "srht-head-links", "srht-repo-list", "srht-repo-table")
+// into t, so a service layout can invoke them. Call it once per template set,
+// before the layout that references the partials is executed.
//
// It installs Funcs first, because the partials call them: an unknown function
// is a parse error, so a caller who had not merged Funcs would get a startup
M chrome/templates/chrome.tmpl => chrome/templates/chrome.tmpl +33 -0
@@ 96,6 96,39 @@
{{end}}
{{- end}}
+{{/*
+ srht-sections renders a service's own navigation row — the second row, below
+ the switcher — from Page.Tabs, and nothing at all when that is empty. Place it
+ immediately after the <nav> element the layout owns.
+
+ The markup is upstream's for this job, which is why the row costs no service
+ any CSS: .header-tabbed wrapping .nav.nav-tabs is what meta puts profile and
+ keys in and git puts a repo's tree and log in, and both classes arrive with
+ core.sr.ht's base.scss — nav.scss, and the dark half of it — which every
+ service here already imports.
+
+ Unlike srht-nav this partial owns its wrapper element. srht-nav renders the
+ navbar's inner content because the services disagreed about the <nav>'s own
+ classes; nobody disagrees about this row, and a service that had to write the
+ wrapper itself is a service that can write it differently — which is how a
+ shared chrome stops being shared.
+*/}}
+{{define "srht-sections" -}}
+{{if .Tabs}}
+<div class="header-tabbed">
+ <div class="container">
+ <ul class="nav nav-tabs">
+ {{range .Tabs}}
+ <li class="nav-item">
+ <a class="nav-link{{if .Active}} active{{end}}" href="{{.Href}}">{{.Name}}</a>
+ </li>
+ {{end}}
+ </ul>
+ </div>
+</div>
+{{end}}
+{{- end}}
+
{{define "srht-nav" -}}
{{/*
The brand carries a fixed min-width so the service switcher starts at the