diff --git a/chrome/chrome.go b/chrome/chrome.go index b02c8298c195e9c00009fb4f5b4fc77fc5e2ca60..49f09983efd3d3362273b42789f9a26d77f8244b 100644 --- a/chrome/chrome.go +++ b/chrome/chrome.go @@ -33,10 +33,12 @@ // the service's own root. 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 @@ HubOrigin string StyleHref string // "" when the binary was built without a stylesheet + // FaviconHref is the icon for this page's ; "" renders no . + // Guarded rather than emitted empty for the same reason StyleHref is: + // 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 @@ // 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 @@ ExtraNav []NavItem // 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 . 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 at all. + FaviconHref template.URL siteName string environment string @@ -201,12 +234,40 @@ hubOrigin string 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 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 @@ MetaOrigin: s.metaOrigin, 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", diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go index ab5f5f7911302607cbbd9421ba529438f369ae54..a0eb033053176bd4c4e46e3a34a9ce7074c26d94 100644 --- a/chrome/chrome_test.go +++ b/chrome/chrome_test.go @@ -183,6 +183,58 @@ assert.Equal(t, 1, strings.Count(out, ">private")) 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, "")) + assert.Contains(t, out, `~a/one`) + assert.Contains(t, out, ">private") + 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") @@ -199,6 +251,41 @@ assert.Equal(t, svc.Page(r, "t", "").LoginURL, svc.LoginURLFor(r)) assert.Equal(t, "https://meta.example/login?return_to="+ "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, ``) + // 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, ` element itself so it can keep its own classes. */}} +{{/* + srht-head-links renders the two elements every service's + carries: the built stylesheet and the favicon. Both are guarded rather than + emitted empty, because re-requests the page it is on — one + extra page load per page load, for nothing. +*/}} +{{define "srht-head-links" -}} +{{if .StyleHref}}{{end}} +{{if .FaviconHref}}{{end}} +{{- end}} + {{define "srht-env-banner" -}} {{if .ShowBanner}}
@@ -36,9 +47,50 @@ {{if .Description}}

{{.Description}}

{{end}} + {{if or (not .Updated.IsZero) .Meta}} + + {{if not .Updated.IsZero}}{{reltime .Updated}}{{end}} + {{range .Meta}}{{.}}{{end}} + + {{end}}
{{end}} +{{else}} +

{{.Empty}}

+{{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}} + + + {{range .Items}} + + + {{range .Meta}}{{end}} + {{if not .Updated.IsZero}} + + {{end}} + + {{end}} + +
+ {{.Title}} + {{if and .Visibility (ne .Visibility "PUBLIC")}} + {{if eq .Visibility "UNLISTED"}}unlisted{{else}}private{{end}} + {{end}} + {{if .Description}}
{{.Description}}{{end}} +
{{.}}{{reltime .Updated}}
{{else}}

{{.Empty}}

{{end}} diff --git a/pages/pages.go b/pages/pages.go index 8ba8a793875e33d70740d3f926e9a41999b21c69..c8b24d4cd318b99486a53950c4b4b02366fa5d41 100644 --- a/pages/pages.go +++ b/pages/pages.go @@ -264,10 +264,14 @@ // startup error the daemon should report with a sentence naming what it was // 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) }