~bigbes/sr-ht-ecore

514838a75a4b7dcac5ffbd85d078f00e50bc9aee — Eugene Blikh 2 days ago 2e37ec7
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.
4 files changed, 283 insertions(+), 4 deletions(-)

M chrome/chrome.go
A chrome/sections_test.go
M chrome/templates.go
M chrome/templates/chrome.tmpl
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