From 350dcb0b3f5b23b25ba8603c430361b0e798f07c Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 23:59:37 +0300 Subject: [PATCH] chrome: a default favicon, optional listing columns, and a table partial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three services asked for a favicon field and bench argued against one: it ships no icon deliberately, and its layout says why — a pointing at an asset the binary does not have is a 404 on every page load, for a file nobody asked for. A default that is a path hands that to every service without a logo, so the default carries its own bytes instead: the brand's ring as a data: URI, which cannot 404 and costs no request. It is a template.URL because html/template rewrites any href whose scheme is not http, https or mailto to "#ZgotmplZ" — the type is how a caller says it meant a data: URI. Updated and Meta are optional for the reason the four listing services could not agree: bench and spec have a modification time, dolt's schema has no timestamp at all, and cover's index is a table of sparklines no shared partial will render. A required column would have pushed dolt back onto a local copy. Updated is a time.Time so the partial renders "3 hours ago" with the exact stamp in the title once, rather than five services spelling it five ways. srht-repo-table is a second partial over the same dot rather than a variadic first one: growing columns on the cards would have made them worse cards for the services that wanted cards. Attach now installs Funcs itself, because the partials call reltime and abstime and an unknown function is a parse error — a caller who had not merged Funcs would have got a startup panic naming a template it never wrote. pages consequently attaches before layering the service's own map on top, which is what keeps a deliberate shadow working. --- chrome/chrome.go | 68 ++++++++++++++++++++++++++-- chrome/chrome_test.go | 87 ++++++++++++++++++++++++++++++++++++ chrome/templates.go | 15 +++++-- chrome/templates/chrome.tmpl | 52 +++++++++++++++++++++ pages/pages.go | 6 ++- 5 files changed, 220 insertions(+), 8 deletions(-) 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 @@ 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 ; "" 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 @@ 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 . 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 @@ 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 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", 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 @@ 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, "")) + 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") @@ -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, ``) + // 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, ` 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,6 +47,12 @@ {{if .Description}}

{{.Description}}

{{end}} + {{if or (not .Updated.IsZero) .Meta}} + + {{if not .Updated.IsZero}}{{reltime .Updated}}{{end}} + {{range .Meta}}{{.}}{{end}} + + {{end}}
{{end}} @@ -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}} + + + {{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}} +{{- end}} + {{define "srht-nav" -}} {{/* The brand carries a fixed min-width so the service switcher starts at the 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 @@ 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) }