M chrome/chrome.go => chrome/chrome.go +65 -3
@@ 33,10 33,12 @@
package chrome
import (
+ "html/template"
"net/http"
"net/url"
"sort"
"strings"
+ "time"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-core/config"
@@ 135,6 137,11 @@ type Page struct {
StyleHref string // "" when the binary was built without a stylesheet
+ // FaviconHref is the icon for this page's <head>; "" renders no <link>.
+ // Guarded rather than emitted empty for the same reason StyleHref is:
+ // <link href=""> re-requests the page it is on.
+ FaviconHref template.URL
+
// Assets are the hashed hrefs of the extra build artefacts a layout links
// beyond the stylesheet — a vendored chart library, a front-end bundle —
// keyed by names the service picks. Read as {{index .Assets "uplot.js"}},
@@ 161,16 168,37 @@ type Page struct {
// ListItem is one project in a listing — a repository, a database, a space.
// Title is the display name ("~owner/name"); Visibility is the service's
// literal enum value ("PUBLIC"/"UNLISTED"/"PRIVATE", "" to render nothing —
-// the srht-repo-list partial shows it lowercase, non-public only).
+// the partials show it lowercase, non-public only).
+//
+// Updated and Meta are optional, and deliberately so. Four services wanted a
+// listing here and disagreed about its shape: bench and spec needed a
+// modification time, dolt has no timestamp in its schema at all, and cover's
+// index is a table of percentages and sparklines that no shared partial will
+// ever render. A required column would have pushed dolt back onto a local
+// copy; a zero Updated and a nil Meta render nothing, which is what keeps all
+// three of them consumers.
+//
+// Updated is a time.Time rather than a preformatted string so the partial can
+// render "3 hours ago" with the exact stamp in the title attribute, once,
+// instead of every service picking its own spelling — the drift RelTime and
+// AbsTime were hoisted to end.
type ListItem struct {
Href string
Title string
Visibility string
Description string
+ Updated time.Time
+ Meta []string
}
-// RepoList is the dot for the srht-repo-list partial: the items, and the
-// muted text shown when there are none.
+// RepoList is the dot for the srht-repo-list and srht-repo-table partials: the
+// items, and the muted text shown when there are none.
+//
+// Two partials over one type because the two shapes are not variants of each
+// other: srht-repo-list is the family's event-list cards, srht-repo-table the
+// same data as aligned columns for a service whose listing is long enough to
+// scan. Making the cards partial grow columns would have made it a worse cards
+// partial for the services that wanted cards.
type RepoList struct {
Items []ListItem
Empty string
@@ 192,6 220,11 @@ type Service struct {
// Assets holds the extra hashed asset hrefs every Page carries; see
// Page.Assets. Populate it at startup, next to StyleHref.
Assets map[string]string
+ // FaviconHref is the icon linked from every page's <head>. NewService sets
+ // it to DefaultFaviconHref; a service with a logo of its own overwrites it
+ // (through assets.Resolve, so a hashed icon earns the immutable lifetime),
+ // and "" renders no <link> at all.
+ FaviconHref template.URL
siteName string
environment string
@@ 201,12 234,40 @@ type Service struct {
nav []NavItem
}
+// DefaultFaviconHref is the icon a service gets without shipping one: the
+// brand's circle, inlined as a data: URI.
+//
+// A data: URI rather than a path into a static tree, because the alternative
+// fails in a way that is easy to miss. bench deliberately embeds no favicon
+// and its layout says why: a <link rel="icon"> pointing at an asset the binary
+// does not have is a 404 — a rendered error page, on every page load, for a
+// file no human asked for. A default that is a path would hand that to every
+// service that has not made a logo yet; a default that carries its own bytes
+// cannot 404. It also costs no request at all, which a 500-byte icon is not
+// worth making.
+//
+// The stroke follows the viewer's colour scheme, since a favicon sits on the
+// browser's chrome rather than on ours, and a near-black ring disappears into
+// a dark tab strip.
+//
+// The type is template.URL because html/template rewrites any href whose
+// scheme is not http, https or mailto to "#ZgotmplZ" — a data: URI reaches the
+// page only if the caller says it meant it. That is also the guard on a
+// service overriding this field: the value has to come from somewhere the
+// service vouches for, not from a request.
+const DefaultFaviconHref template.URL = "data:image/svg+xml," +
+ "%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E" +
+ "%3Cstyle%3Ecircle%7Bstroke:%23222%7D" +
+ "@media(prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23eee%7D%7D%3C/style%3E" +
+ "%3Ccircle%20cx='16'%20cy='16'%20r='11'%20fill='none'%20stroke-width='6'/%3E%3C/svg%3E"
+
// NewService reads the shared config once and caches everything Page needs.
// section must be this service's literal config section name.
func NewService(conf ini.File, section string) *Service {
env := config.GetString(conf, "sr.ht", "environment", "development")
return &Service{
Section: section,
+ FaviconHref: DefaultFaviconHref,
siteName: config.GetString(conf, "sr.ht", "site-name", "sr.ht"),
environment: env,
selfOrigin: strings.TrimRight(config.GetOrigin(conf, section, true), "/"),
@@ 267,6 328,7 @@ func (s *Service) Page(r *http.Request, title, username string) Page {
SelfOrigin: s.selfOrigin,
HubOrigin: s.hubOrigin,
StyleHref: s.StyleHref,
+ FaviconHref: s.FaviconHref,
Assets: s.Assets,
Environment: strings.ToUpper(s.environment),
ShowBanner: s.environment != "" && s.environment != "production",
M chrome/chrome_test.go => chrome/chrome_test.go +87 -0
@@ 183,6 183,58 @@ func TestRepoListCards(t *testing.T) {
assert.Equal(t, 2, strings.Count(out, "pull-right"))
}
+// TestOptionalColumnsRenderOnlyWhenCarried is the rule that keeps all four
+// listing services consumers: dolt has no timestamp in its schema, bench and
+// spec do, and neither may force a column of blanks on the other.
+func TestOptionalColumnsRenderOnlyWhenCarried(t *testing.T) {
+ at := time.Now().Add(-3 * time.Hour)
+
+ bare := renderList(t, RepoList{Items: []ListItem{{Href: "/a", Title: "~a/db"}}})
+ assert.NotContains(t, bare, "text-muted\">\n", "no empty metadata line")
+ assert.NotContains(t, bare, "ago")
+
+ rich := renderList(t, RepoList{Items: []ListItem{
+ {Href: "/a", Title: "~a/repo", Updated: at, Meta: []string{"12 runs"}},
+ }})
+ assert.Contains(t, rich, "3 hours ago")
+ assert.Contains(t, rich, AbsTime(at), "the exact stamp rides in the title")
+ assert.Contains(t, rich, "12 runs")
+}
+
+// renderTable executes the srht-repo-table partial alone against v.
+func renderTable(t *testing.T, v RepoList) string {
+ t.Helper()
+ tpl := MustAttach(template.New("t"))
+ tpl, err := tpl.Parse(`{{template "srht-repo-table" .}}`)
+ require.NoError(t, err)
+ var b strings.Builder
+ require.NoError(t, tpl.Execute(&b, v))
+ return b.String()
+}
+
+func TestRepoTableIsTheSameDotAsTheCards(t *testing.T) {
+ at := time.Now().Add(-2 * 24 * time.Hour)
+ list := RepoList{
+ Items: []ListItem{
+ {Href: "/a", Title: "~a/one", Visibility: "PRIVATE", Updated: at},
+ {Href: "/b", Title: "~a/two", Description: "described"},
+ },
+ Empty: "No repositories yet.",
+ }
+
+ out := renderTable(t, list)
+ assert.Equal(t, 2, strings.Count(out, "<tr>"))
+ assert.Contains(t, out, `<a href="/a">~a/one</a>`)
+ assert.Contains(t, out, ">private</small>")
+ assert.Contains(t, out, "2 days ago")
+ assert.Contains(t, out, "described")
+ // The second row carries no time, so it grows no cell for one.
+ assert.Equal(t, 1, strings.Count(out, "text-right"))
+
+ assert.Contains(t, renderTable(t, RepoList{Empty: "No repositories yet."}),
+ "No repositories yet.")
+}
+
func TestRepoListEmptyState(t *testing.T) {
out := renderList(t, RepoList{Empty: "No databases yet."})
assert.NotContains(t, out, "event-list")
@@ 201,6 253,41 @@ func TestLoginURLForMatchesTheNav(t *testing.T) {
"https%3A%2F%2Fcompare.example%2F~alice%2Fdemo%3Fa%3D1", svc.LoginURLFor(r))
}
+// TestHeadLinksAreGuardedAndTheFaviconSurvivesEscaping covers the two ways the
+// head links go wrong: an empty href that re-requests the page, and a data:
+// URI that html/template rewrites to #ZgotmplZ unless it is a template.URL.
+func TestHeadLinksAreGuardedAndTheFaviconSurvivesEscaping(t *testing.T) {
+ renderHead := func(t *testing.T, p Page) string {
+ t.Helper()
+ tpl := MustAttach(template.New("h"))
+ tpl, err := tpl.Parse(`{{template "srht-head-links" .}}`)
+ require.NoError(t, err)
+ var b strings.Builder
+ require.NoError(t, tpl.Execute(&b, p))
+ return b.String()
+ }
+
+ svc := NewService(testConf(), "compare.sr.ht")
+ svc.StyleHref = "/static/main.min.0badc0de.css"
+ out := renderHead(t, svc.Page(httptest.NewRequest("GET", "/", nil), "t", ""))
+
+ assert.Contains(t, out, `<link rel="stylesheet" href="/static/main.min.0badc0de.css">`)
+ // The "+" of image/svg+xml renders as the HTML entity +, which the
+ // browser decodes back before the href is ever parsed as a URL.
+ assert.Contains(t, out, `<link rel="icon" href="data:image/svg+xml,`,
+ "the default icon must survive the URL filter")
+ assert.Contains(t, out, "%3Csvg", "and carry its markup")
+ assert.NotContains(t, out, "ZgotmplZ", "a filtered href would render as this")
+
+ // A binary built without a stylesheet, and a service that switched the
+ // icon off, render neither link rather than an empty one.
+ bare := NewService(testConf(), "compare.sr.ht")
+ bare.FaviconHref = ""
+ out = renderHead(t, bare.Page(httptest.NewRequest("GET", "/", nil), "t", ""))
+ assert.NotContains(t, out, "<link")
+ assert.NotContains(t, out, `href=""`)
+}
+
// TestPageCarriesTheServiceAssets pins the slot three services grew their own
// copy of: the layout reads an extra hashed artefact off the chrome, not off
// the page's payload.
M chrome/templates.go => chrome/templates.go +11 -4
@@ 8,11 8,18 @@ import (
//go:embed templates/chrome.tmpl
var templateFS embed.FS
-// Attach parses the shared chrome partials ("srht-nav", "srht-env-banner")
-// 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-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
+// panic naming a template it never wrote. Installing them here also means the
+// partials keep working when a service overrides a helper afterwards — its own
+// Funcs call wins for its own templates.
func Attach(t *template.Template) (*template.Template, error) {
- return t.ParseFS(templateFS, "templates/chrome.tmpl")
+ return t.Funcs(Funcs()).ParseFS(templateFS, "templates/chrome.tmpl")
}
// MustAttach is Attach for the common wire-up-at-startup path, where a parse
M chrome/templates/chrome.tmpl => chrome/templates/chrome.tmpl +52 -0
@@ 8,6 8,17 @@
own classes.
*/}}
+{{/*
+ srht-head-links renders the two <link> elements every service's <head>
+ carries: the built stylesheet and the favicon. Both are guarded rather than
+ emitted empty, because <link href=""> re-requests the page it is on — one
+ extra page load per page load, for nothing.
+*/}}
+{{define "srht-head-links" -}}
+{{if .StyleHref}}<link rel="stylesheet" href="{{.StyleHref}}">{{end}}
+{{if .FaviconHref}}<link rel="icon" href="{{.FaviconHref}}">{{end}}
+{{- end}}
+
{{define "srht-env-banner" -}}
{{if .ShowBanner}}
<div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
@@ 36,6 47,12 @@
{{if .Description}}
<p>{{.Description}}</p>
{{end}}
+ {{if or (not .Updated.IsZero) .Meta}}
+ <small class="text-muted">
+ {{if not .Updated.IsZero}}<span title="{{abstime .Updated}}">{{reltime .Updated}}</span>{{end}}
+ {{range .Meta}}<span>{{.}}</span>{{end}}
+ </small>
+ {{end}}
</div>
{{end}}
</div>
@@ 44,6 61,41 @@
{{end}}
{{- end}}
+{{/*
+ srht-repo-table is the same listing as aligned columns, for a service whose
+ list is long enough to scan rather than read. Dot is a chrome.RepoList, so a
+ service can switch between the two partials without touching its handler.
+
+ The columns after the name render only when the data carries them: a service
+ with no timestamp in its schema (dolt) gets a two-column table rather than a
+ column of blanks.
+*/}}
+{{define "srht-repo-table" -}}
+{{if .Items}}
+<table class="table">
+ <tbody>
+ {{range .Items}}
+ <tr>
+ <td>
+ <a href="{{.Href}}">{{.Title}}</a>
+ {{if and .Visibility (ne .Visibility "PUBLIC")}}
+ <small class="text-muted">{{if eq .Visibility "UNLISTED"}}unlisted{{else}}private{{end}}</small>
+ {{end}}
+ {{if .Description}}<br><small class="text-muted">{{.Description}}</small>{{end}}
+ </td>
+ {{range .Meta}}<td class="text-muted">{{.}}</td>{{end}}
+ {{if not .Updated.IsZero}}
+ <td class="text-right text-muted" title="{{abstime .Updated}}">{{reltime .Updated}}</td>
+ {{end}}
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p class="text-muted">{{.Empty}}</p>
+{{end}}
+{{- end}}
+
{{define "srht-nav" -}}
{{/*
The brand carries a fixed min-width so the service switcher starts at the
M pages/pages.go => pages/pages.go +5 -1
@@ 264,10 264,14 @@ func Load(fsys fs.FS, opts Options) (Set, error) {
// doing, and Load already returns an error for everything else that can go
// wrong here.
func (o Options) base(funcs template.FuncMap) (*template.Template, error) {
- t, err := chrome.Attach(template.New(o.Layout).Funcs(funcs))
+ // Attach first, the service's funcs second: Attach installs chrome's own
+ // helpers so its partials can parse, and layering the service's map on top
+ // afterwards is what lets a service shadow one of them deliberately.
+ t, err := chrome.Attach(template.New(o.Layout))
if err != nil {
return nil, fmt.Errorf("pages: attach the shared chrome partials: %w", err)
}
+ t = t.Funcs(funcs)
if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPartialFile); err != nil {
return nil, fmt.Errorf("pages: parse the shared error partial: %w", err)
}