~bigbes/sr-ht-ecore

350dcb0b3f5b23b25ba8603c430361b0e798f07c — Eugene Blikh 9 days ago f019dbe
chrome: a default favicon, optional listing columns, and a table partial

Three services asked for a favicon field and bench argued against one: it
ships no icon deliberately, and its layout says why — a <link rel="icon">
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.
5 files changed, 220 insertions(+), 8 deletions(-)

M chrome/chrome.go
M chrome/chrome_test.go
M chrome/templates.go
M chrome/templates/chrome.tmpl
M pages/pages.go
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 &#43;, 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&#43;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)
	}