~bigbes/sr-ht-compare

3e79c6dea6e380e00d7a7ad17ea8674dd62c8ca2 — bigbes 9 days ago 04b591c
web: draw the chrome from sr-ht-ecore

The nav/service-switcher, the brand, the login block and the environment
banner were a copy of code every custom service on this instance carries.
The copies drifted, so they now come from one place:
sourcecraft.dev/bigbes/sr-ht-ecore/chrome.

web/chrome.go is gone. New builds one chrome.Service from the shared
config.ini and hands it the discovered stylesheet; view() asks it for a
chrome.Page per request, which viewData embeds rather than copies, so a
field ecore adds later arrives here without an edit. The layout invokes
"srht-env-banner" and "srht-nav" instead of the markup it used to spell
out, the landing's repository listing goes through "srht-repo-list", and
the FuncMap starts from chrome.Funcs() with the local shortsha deleted.

Two ecore policies differ from what this service did and are adopted as
the instance's: the brand label is the config section's short name rather
than a literal "compare", and the origins are trimmed of a trailing
slash. What stays local is what is compare's alone: the bundle href, the
diff-status colours, and the container-fluid the two diff views ask for.

Tests move to testify; the nav ordering/exclusion test goes with the code
it tested, and a test that the layout invokes the chrome at all replaces
it.
11 files changed, 305 insertions(+), 428 deletions(-)

M .gitignore
M README.md
M go.mod
M go.sum
D web/chrome.go
M web/handlers.go
M web/server.go
M web/templates.go
M web/templates/index.html
M web/templates/layout.html
M web/web_test.go
M .gitignore => .gitignore +4 -0
@@ 9,3 9,7 @@ node_modules/

# Local instance config
/config.ini

# Git worktrees checked out inside the repo (both conventions seen here)
.worktrees/
.claude/worktrees/

M README.md => README.md +6 -3
@@ 24,8 24,9 @@ which git.sr.ht's own loader scopes to public/unlisted repositories, so private
repos are never leaked (an unauthorized or missing repo is always a 404, never a
403). Identity comes from decrypting the shared `sr.ht.unified-login.v1` cookie
with the instance network key; there is no login flow of our own. Pages are
server-rendered Go templates that reproduce the SourceHut chrome (nav,
service-switcher, login block, environment banner). The diff/tree UI is one
server-rendered Go templates wrapped in the SourceHut chrome (nav,
service-switcher, login block, environment banner), which comes from the shared
[sr-ht-ecore] `chrome` package rather than from a copy of its own. The diff/tree UI is one
vendored [pierre-libs] esbuild bundle driven by a JSON blob embedded in the
page, so the browser does all diff rendering from a single request with no
second authorization round-trip. A short-TTL in-memory cache in front of the


@@ 34,6 35,7 @@ authorizer spares git.sr.ht a GraphQL call on every page load.
[sourcehut-custom-service integration model]: https://man.sr.ht/
[go-git]: https://github.com/go-git/go-git
[pierre-libs]: https://www.npmjs.com/org/pierre
[sr-ht-ecore]: https://git.srht.bigb.es/~bigbes/sr-ht-ecore

## Layout



@@ 43,7 45,8 @@ authorizer spares git.sr.ht a GraphQL call on every page load.
  diffs, and commit logs, all bounded by context timeouts and output-size caps.
- `authz/` — cookie→identity and the git.sr.ht GraphQL authorizer with a short
  TTL cache.
- `web/` — chi router, handlers, chrome, Go templates, embedded static assets.
- `web/` — chi router, handlers, Go templates, embedded static assets. The page
  chrome is not here: it is `sr-ht-ecore/chrome`, wired up in `web/server.go`.
- `frontend/` + `scss/` — build-time TypeScript diff bundle and the SCSS entry.
- `cmd/comparesrht/` — the daemon entry point and startup validation.
- `contrib/` — nginx server block, systemd unit, and a dev GraphQL stub.

M go.mod => go.mod +5 -0
@@ 7,8 7,10 @@ require (
	github.com/go-chi/chi/v5 v5.3.1
	github.com/go-git/go-git/v5 v5.19.1
	github.com/sirupsen/logrus v1.9.4
	github.com/stretchr/testify v1.11.1
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
	sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895
)

require (


@@ 25,6 27,7 @@ require (
	github.com/cespare/xxhash/v2 v2.2.0 // indirect
	github.com/cloudflare/circl v1.6.3 // indirect
	github.com/cyphar/filepath-securejoin v0.6.1 // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
	github.com/emersion/go-message v0.18.2 // indirect
	github.com/emersion/go-pgpmail v0.2.2 // indirect


@@ 49,6 52,7 @@ require (
	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
	github.com/mitchellh/mapstructure v1.5.0 // indirect
	github.com/pjbgf/sha1cd v0.6.0 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/prometheus/client_golang v1.16.0 // indirect
	github.com/prometheus/client_model v0.4.0 // indirect
	github.com/prometheus/common v0.44.0 // indirect


@@ 63,4 67,5 @@ require (
	golang.org/x/text v0.36.0 // indirect
	google.golang.org/protobuf v1.33.0 // indirect
	gopkg.in/warnings.v0 v0.1.2 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

M go.sum => go.sum +2 -0
@@ 322,3 322,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895 h1:OGZrtBtMoXhyZGXrPqMzmrNQnStoCLBVuegGo7yF1Us=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895/go.mod h1:KeoZjm+/nnsdtc1WxB7X/0EeC+Rggt2OkwJDEc6XWnw=

D web/chrome.go => web/chrome.go +0 -129
@@ 1,129 0,0 @@
package web

import (
	"net/http"
	"net/url"
	"sort"
	"strings"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/config"

	"sourcecraft.dev/bigbes/sr-ht-compare/authz"
)

// navCanonical is the SourceHut service-switcher order. Services not listed here
// (including our own compare) sort alphabetically after these.
var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"}

// navExcluded are service sections that never appear in the switcher: paste and
// pages have no top-level UI worth linking, and hub is rendered as the brand.
var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true}

// navItem is one entry in the service switcher.
type navItem struct {
	Name   string // short service name, e.g. "git"
	Origin string // external origin URL
	Active bool   // true for compare.sr.ht (this service)
}

// buildNav derives the service switcher from the shared config: every section
// whose name ends in ".sr.ht" (with a configured origin) except paste/pages/hub,
// ordered by navCanonical then alphabetically, with compare.sr.ht marked active.
func buildNav(conf ini.File) []navItem {
	var items []navItem
	for section := range conf {
		if !strings.HasSuffix(section, ".sr.ht") {
			continue
		}
		short := strings.TrimSuffix(section, ".sr.ht")
		if navExcluded[short] {
			continue
		}
		origin := config.GetOrigin(conf, section, true)
		if origin == "" {
			continue
		}
		items = append(items, navItem{
			Name:   short,
			Origin: origin,
			Active: section == "compare.sr.ht",
		})
	}
	sort.SliceStable(items, func(i, j int) bool {
		ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name)
		if ci != cj {
			return ci < cj
		}
		return items[i].Name < items[j].Name
	})
	return items
}

// canonIndex returns a service's position in navCanonical, or a sentinel past
// the end for services that are not canonically ordered.
func canonIndex(name string) int {
	for i, n := range navCanonical {
		if n == name {
			return i
		}
	}
	return len(navCanonical)
}

// viewData is the root value every template is executed against: the chrome
// fields are common to all pages; Data carries the page-specific payload.
type viewData struct {
	Title       string
	SiteName    string
	HubOrigin   string // non-empty ⇒ brand links to hub instead of "/"
	Nav         []navItem
	Username    string // "" for an anonymous viewer
	LoginURL    string
	LogoutURL   string
	RegisterURL string
	ProfileURL  string
	CSSHref     string
	BundleHref  string
	Environment string
	ShowBanner  bool

	// ContainerClass selects the width of the page's content wrapper: the
	// centered Bootstrap "container" by default, or "container-fluid" for the
	// full-bleed diff views (commit / compare).
	ContainerClass string

	Data any
}

// chrome builds the common chrome fields for a request. Login return_to is the
// current full URL (so the viewer lands back where they were); logout return_to
// is this service's origin.
func (s *Server) chrome(r *http.Request) viewData {
	username := authz.ForContext(r.Context())

	current := s.compareOrigin + r.URL.RequestURI()
	loginURL := s.metaOrigin + "/login?return_to=" + url.QueryEscape(current)
	logoutURL := s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.compareOrigin)

	profileURL := s.metaOrigin + "/profile"
	if s.hubOrigin != "" && username != "" {
		profileURL = s.hubOrigin + "/~" + username
	}

	return viewData{
		ContainerClass: "container",
		SiteName:       s.siteName,
		HubOrigin:      s.hubOrigin,
		Nav:            s.nav,
		Username:       username,
		LoginURL:       loginURL,
		LogoutURL:      logoutURL,
		RegisterURL:    s.metaOrigin,
		ProfileURL:     profileURL,
		CSSHref:        s.cssHref,
		BundleHref:     s.bundleHref,
		Environment:    strings.ToUpper(s.environment),
		ShowBanner:     s.environment != "" && s.environment != "production",
	}
}

M web/handlers.go => web/handlers.go +32 -10
@@ 11,6 11,7 @@ import (

	"github.com/go-chi/chi/v5"
	"github.com/sirupsen/logrus"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"

	"sourcecraft.dev/bigbes/sr-ht-compare/authz"
	"sourcecraft.dev/bigbes/sr-ht-compare/core"


@@ 129,15 130,22 @@ func (s *Server) resolve(ctx context.Context, owner, repo string) (*gitx.Repo, *

type indexData struct {
	LoggedIn bool
	Repos    []authz.RepoInfo

	// Repos is the viewer's own repositories in the shape ecore's
	// "srht-repo-list" partial renders, so the landing's listing is the same
	// event-list card the sibling services draw for their own projects.
	Repos chrome.RepoList
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	username := authz.ForContext(ctx)

	vd := s.chrome(r)
	vd.Title = s.siteName + " compare"
	// The title is built from the chrome's own brand fields rather than from a
	// second read of site-name, so the tab and the nav cannot name the instance
	// differently.
	vd := s.view(r, "")
	vd.Title = vd.SiteName + " " + vd.SiteLabel

	if username == "" {
		vd.Data = indexData{LoggedIn: false}


@@ 150,10 158,27 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
		s.fail(w, r, err)
		return
	}
	vd.Data = indexData{LoggedIn: true, Repos: repos}
	vd.Data = indexData{LoggedIn: true, Repos: repoList(username, repos)}
	s.render(w, http.StatusOK, "index", vd)
}

// repoList turns the authorizer's repositories into the listing ecore renders.
// The owner is always the viewer — MyRepos answers for one account — so the
// "~owner/name" title and the link are built from the same name and cannot point
// at somebody else's repository.
func repoList(owner string, repos []authz.RepoInfo) chrome.RepoList {
	items := make([]chrome.ListItem, 0, len(repos))
	for _, info := range repos {
		items = append(items, chrome.ListItem{
			Href:        "/~" + owner + "/" + info.Name,
			Title:       "~" + owner + "/" + info.Name,
			Visibility:  info.Visibility,
			Description: info.Description,
		})
	}
	return chrome.RepoList{Items: items, Empty: "You have no repositories yet."}
}

// ---- repo page ------------------------------------------------------------

type repoData struct {


@@ 184,8 209,7 @@ func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) {
	def, _ := g.DefaultBranch(ctx)
	commits, _ := recentCommits(ctx, g, def, recentCommitLimit)

	vd := s.chrome(r)
	vd.Title = "~" + owner + "/" + repo
	vd := s.view(r, "~"+owner+"/"+repo)
	vd.Data = repoData{
		Owner:         owner,
		Info:          info,


@@ 311,9 335,8 @@ func (s *Server) handleCompare(w http.ResponseWriter, r *http.Request) {
		return
	}

	vd := s.chrome(r)
	vd := s.view(r, fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head))
	vd.ContainerClass = "container-fluid"
	vd.Title = fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head)
	vd.Data = compareView{
		Owner:      owner,
		RepoName:   repo,


@@ 404,9 427,8 @@ func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
		return
	}

	vd := s.chrome(r)
	vd := s.view(r, fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA))
	vd.ContainerClass = "container-fluid"
	vd.Title = fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA)
	vd.Data = commitView{
		Owner:     owner,
		RepoName:  repo,

M web/server.go => web/server.go +81 -31
@@ 1,8 1,7 @@
// Package web is the HTTP layer of compare.sr.ht. It ports the SourceHut chrome
// (nav/service-switcher, login block, environment banner) to Go html/templates,
// renders the repository landing, compare (base...head) and single-commit pages
// server-side, and embeds a compact JSON payload plus the vendored esbuild
// bundle so the browser renders the diff with @pierre/diffs and @pierre/trees.
// Package web is the HTTP layer of compare.sr.ht. It renders the repository
// landing, compare (base...head) and single-commit pages server-side, and
// embeds a compact JSON payload plus the vendored esbuild bundle so the browser
// renders the diff with @pierre/diffs and @pierre/trees.
//
// The package owns no state of its own: identity comes from the authz cookie
// middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and


@@ 10,6 9,22 @@
// a repository authorizes first (a not-found or forbidden repo is a 404, never
// a 403, so private-repo existence never leaks) and only then reads the disk.
//
// # The chrome is not ours
//
// The nav/service-switcher, the brand, the login block and the environment
// banner come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom
// service on the instance shares. This package builds one chrome.Service at
// startup, asks it for a chrome.Page per request, and embeds that Page in
// viewData so the fields promote into the templates. Nothing here rebuilds the
// switcher or re-derives a login URL: the copy that used to live in web/chrome.go
// is exactly what ecore exists to have deleted.
//
// Two things about the chrome remain this service's own, because they are about
// what compare renders and not about the instance: the vendored bundle's href
// (BundleHref below), and the full-bleed ContainerClass the two diff views set —
// a side-by-side diff in a centered "container" is a column of code half the
// window wide.
//
// # What the cmd layer must wire
//
// Register only installs routes; it assumes the following middleware is already


@@ 35,11 50,18 @@ import (
	"regexp"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"

	"sourcecraft.dev/bigbes/sr-ht-compare/authz"
)

// configSection is this service's literal section in the shared config.ini. It
// is what the switcher's "which entry is me" test compares against, so it must
// be spelled the same here, in the config file and in the middleware the cmd
// layer installs — a service that spelled it two ways would appear in the
// instance's navigation and fail to recognise itself in it.
const configSection = "compare.sr.ht"

// hashedCSSRe matches the content-addressed stylesheet name so it can be served
// with an immutable cache lifetime (the hash changes whenever the bytes do).
var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`)


@@ 55,43 77,47 @@ var hashedBundleRe = regexp.MustCompile(`^bundle\.[0-9a-f]{6,}\.js$`)
type Server struct {
	authorizer authz.Authorizer
	reposRoot  string
	conf       ini.File

	siteName      string
	environment   string
	metaOrigin    string
	compareOrigin string
	hubOrigin     string
	cssHref       string
	bundleHref    string
	// chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service
	// switcher, the login block and the environment banner, built once from
	// config.ini and asked for a per-request chrome.Page in view (chrome.go's
	// job until this service stopped carrying its own copy).
	chromeSvc *chrome.Service

	bundleHref string

	nav              []navItem
	staticFileServer http.Handler
}

// New assembles a Server from the shared SourceHut config. It reads
// [git.sr.ht] repos, [meta.sr.ht] origin and [compare.sr.ht] origin (all
// required), the [sr.ht] site-name/environment display values, and resolves the
// hashed stylesheet name by globbing the embedded static FS. A missing required
// key is a clear error, not a panic, so the cmd layer can fail startup loudly.
// required), hands the whole file to chrome.NewService — the switcher is a
// question about every [*.sr.ht] section the instance defines, not about our own
// keys — and resolves the hashed stylesheet and bundle names by globbing the
// embedded static FS. A missing required key is a clear error, not a panic, so
// the cmd layer can fail startup loudly.
func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
	reposRoot, ok := conf.Get("git.sr.ht", "repos")
	if !ok || reposRoot == "" {
		return nil, fmt.Errorf("web: [git.sr.ht] repos is required")
	}
	metaOrigin := config.GetOrigin(conf, "meta.sr.ht", true)
	if metaOrigin == "" {

	// The two origins are checked through the chrome that will render them
	// rather than read a second time here, so the startup refusal and the links
	// on the page cannot disagree about which origins this service has.
	chromeSvc := chrome.NewService(conf, configSection)
	if chromeSvc.MetaOrigin() == "" {
		return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
	}
	compareOrigin := config.GetOrigin(conf, "compare.sr.ht", true)
	if compareOrigin == "" {
		return nil, fmt.Errorf("web: [compare.sr.ht] origin is required")
	if chromeSvc.SelfOrigin() == "" {
		return nil, fmt.Errorf("web: [%s] origin is required", configSection)
	}

	cssHref, err := resolveCSSHref()
	if err != nil {
		return nil, err
	}
	chromeSvc.StyleHref = cssHref

	bundleHref, err := resolveBundleHref()
	if err != nil {


@@ 106,19 132,43 @@ func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
	return &Server{
		authorizer:       authorizer,
		reposRoot:        reposRoot,
		conf:             conf,
		siteName:         config.GetString(conf, "sr.ht", "site-name", "sourcehut"),
		environment:      config.GetString(conf, "sr.ht", "environment", "production"),
		metaOrigin:       metaOrigin,
		compareOrigin:    compareOrigin,
		hubOrigin:        config.GetOrigin(conf, "hub.sr.ht", true),
		cssHref:          cssHref,
		chromeSvc:        chromeSvc,
		bundleHref:       bundleHref,
		nav:              buildNav(conf),
		staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
	}, nil
}

// viewData is the root value every template is executed against.
//
// chrome.Page is embedded rather than copied field by field, so the shared
// partials — "srht-nav", "srht-env-banner", "srht-repo-list" — find the fields
// they need on the dot they are handed, and a field ecore adds later arrives here
// without an edit. The page's own payload lives under Data and is reached as
// {{.Data.Something}}, which is what keeps a page from shadowing a chrome field.
type viewData struct {
	chrome.Page

	// BundleHref is the hashed front-end bundle's URL. It is chrome — every page
	// may load it — but it is this service's alone: no other service on the
	// instance ships a diff renderer, so it stays here and not in ecore's Page.
	BundleHref string

	// Data is the page's own payload.
	Data any
}

// view builds the frame for one request: the shared chrome plus a title.
//
// The username is whatever the authz cookie middleware resolved, which is "" for
// a viewer whose cookie is missing, expired or unreadable — so the nav offers
// login to exactly the viewers the handlers treat as anonymous.
func (s *Server) view(r *http.Request, title string) viewData {
	return viewData{
		Page:       s.chromeSvc.Page(r, title, authz.ForContext(r.Context())),
		BundleHref: s.bundleHref,
	}
}

// resolveCSSHref globs the embedded static FS for the content-addressed
// stylesheet and returns its site-absolute URL.
func resolveCSSHref() (string, error) {

M web/templates.go => web/templates.go +27 -19
@@ 8,6 8,7 @@ import (
	"time"

	"github.com/sirupsen/logrus"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
)

// tmplFS holds the page templates. Each page is parsed together with the shared


@@ 24,22 25,24 @@ var tmplFS embed.FS
var staticFS embed.FS

// funcMap holds the template helpers shared by every page.
var funcMap = template.FuncMap{
	// shortsha abbreviates an object id to 8 hex chars.
	"shortsha": func(s string) string {
		if len(s) > 8 {
			return s[:8]
		}
		return s
	},
//
// It starts from chrome.Funcs — so the shared partials find the helpers they
// were written against, and so "shortsha" is the instance's one abbreviation
// rule rather than this service's copy of it — and adds compare's own on top.
// Adding after is deliberate: a name may then be shadowed on purpose rather than
// by accident of map ordering.
var funcMap = func() template.FuncMap {
	m := chrome.Funcs()

	// date formats a commit timestamp for display.
	"date": func(t time.Time) string {
	m["date"] = func(t time.Time) string {
		return t.UTC().Format("2006-01-02 15:04 MST")
	},
	}

	// statusClass maps a git file-change status letter to a CSS modifier used by
	// the .diff-status badge (see the diff-status rules in layout.html). Anything
	// unrecognized falls back to the neutral "o".
	"statusClass": func(s string) string {
	m["statusClass"] = func(s string) string {
		if s == "" {
			return "o"
		}


@@ 55,9 58,10 @@ var funcMap = template.FuncMap{
		default:
			return "o"
		}
	},
	}

	// statusLabel spells out a status letter for the badge's tooltip.
	"statusLabel": func(s string) string {
	m["statusLabel"] = func(s string) string {
		if s == "" {
			return "changed"
		}


@@ 73,17 77,22 @@ var funcMap = template.FuncMap{
		default:
			return "changed"
		}
	},
}
	}

	return m
}()

// pageNames are the content templates; each is parsed with layout.html.
var pageNames = []string{"index", "repo", "compare", "commit", "error"}

// pages maps a page name to its parsed template set (layout + that page).
// pages maps a page name to its parsed template set: the shared chrome partials
// of sr-ht-ecore, the layout, and that one page. The partials are attached to
// every set rather than to a shared one, for the same reason the layout is —
// each page defines its own "content", and one set would let the last parsed win.
var pages = func() map[string]*template.Template {
	m := make(map[string]*template.Template, len(pageNames))
	for _, name := range pageNames {
		t := template.New("layout.html").Funcs(funcMap)
		t := chrome.MustAttach(template.New("layout.html").Funcs(funcMap))
		t = template.Must(t.ParseFS(tmplFS, "templates/layout.html", "templates/"+name+".html"))
		m[name] = t
	}


@@ 121,8 130,7 @@ type errorData struct {
// renderError renders the chrome-wrapped error page. It never recurses into
// render on failure (render falls back to http.Error itself).
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
	vd := s.chrome(r)
	vd.Title = http.StatusText(status)
	vd := s.view(r, http.StatusText(status))
	vd.Data = errorData{
		Status:     status,
		StatusText: http.StatusText(status),

M web/templates/index.html => web/templates/index.html +2 -20
@@ 22,31 22,13 @@
  </div>
  <div class="col-md-8">
    <hr class="d-md-none" />
    {{if .Data.Repos}}
    <div class="event-list">
      {{range .Data.Repos}}
      <div class="event">
        <h4>
          <a href="/~{{$.Username}}/{{.Name}}">~{{$.Username}}/{{.Name}}</a>
          {{if ne .Visibility "PUBLIC"}}
          <small class="pull-right">{{if eq .Visibility "UNLISTED"}}unlisted{{else}}private{{end}}</small>
          {{end}}
        </h4>
        {{if .Description}}
        <p>{{.Description}}</p>
        {{end}}
      </div>
      {{end}}
    </div>
    {{else}}
    <p class="text-muted">You have no repositories yet.</p>
    {{end}}
    {{template "srht-repo-list" .Data.Repos}}
  </div>
</div>
{{else}}
<div class="row">
  <div class="col-md-12">
    <h2>{{.SiteName}} <span class="text-danger">compare</span></h2>
    <h2>{{.SiteName}} <span class="text-danger">{{.SiteLabel}}</span></h2>
    <p>
      Compare two references — branches, tags or commits — of any git repository
      on this instance, and review the diff file by file.

M web/templates/layout.html => web/templates/layout.html +19 -38
@@ 1,3 1,16 @@
{{/*
  The SourceHut chrome. The brand, the service switcher, the login block and the
  environment banner are NOT rendered here: they come from sr-ht-ecore's shared
  partials ("srht-env-banner", "srht-nav"), which every custom service on the
  instance draws from one copy. The dot is this package's viewData, which embeds
  chrome.Page, so the fields those partials read promote into it.

  What is left here is the document, the diff-status colours below (compare's
  own, and nobody else's), and three seams: "head" and "scripts" are blocks, so
  a page that needs neither still renders; "content" is a {{template}} and must
  stay one — a block would give every page an empty default, which is the chrome
  around a hole served 200 for the page that forgot to define it.
*/}}
<!doctype html>
<html lang="en">
  <head>


@@ 5,7 18,10 @@
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{.Title}}</title>
    <link rel="icon" type="image/svg+xml" href="/static/logo.svg">
    <link rel="stylesheet" href="{{.CSSHref}}">
    {{/* Guarded rather than emitted empty: <link href=""> re-requests the page
         it is on, which is a page load per page load. New refuses to start
         without a stylesheet, so in a deployed binary this is always taken. */}}
    {{if .StyleHref}}<link rel="stylesheet" href="{{.StyleHref}}">{{end}}
    <style>
      /* Semantic status badge for the changed-files table. The default table
         cell colour is too dim on the dark chrome, so each git status gets an


@@ 37,44 53,9 @@
    {{block "head" .}}{{end}}
  </head>
  <body>
    {{if .ShowBanner}}
    <div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
      {{.Environment}} ENVIRONMENT
    </div>
    {{end}}
    {{template "srht-env-banner" .}}
    <nav class="container navbar navbar-light navbar-expand-sm">
      <span class="navbar-brand">
        <span class="icon icon-circle" aria-hidden="true"><svg width="22" height="22" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"/></svg></span>
        <a class="navbar-brand" href="/">
          {{.SiteName}}
          <span class="text-danger">compare</span>
        </a>
      </span>
      <ul class="navbar-nav">
        {{if .Username}}
        {{range .Nav}}
        <li class="nav-item {{if .Active}}active{{end}}">
          <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
        </li>
        {{end}}
        {{end}}
      </ul>
      <div class="login">
        {{if .Username}}
        <span class="navbar-text">
          Logged in as
          <a href="{{.ProfileURL}}">{{.Username}}</a>
          &mdash;
          <a href="{{.LogoutURL}}">Log out</a>
        </span>
        {{else}}
        <span class="navbar-text">
          <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
          &mdash;
          <a href="{{.RegisterURL}}">Register</a>
        </span>
        {{end}}
      </div>
      {{template "srht-nav" .}}
    </nav>
    <div class="{{.ContainerClass}}">
      {{template "content" .}}

M web/web_test.go => web/web_test.go +127 -178
@@ 16,6 16,8 @@ import (

	"github.com/fernet/fernet-go"
	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"


@@ 99,15 101,11 @@ func gitFixture(t *testing.T) (root, mainSHA string) {
			"GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date,
		)
		out, err := cmd.CombinedOutput()
		if err != nil {
			t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
		}
		require.NoErrorf(t, err, "git %s:\n%s", strings.Join(args, " "), out)
		return string(out)
	}
	write := func(name, data string) {
		if err := os.WriteFile(filepath.Join(work, name), []byte(data), 0o644); err != nil {
			t.Fatal(err)
		}
		require.NoError(t, os.WriteFile(filepath.Join(work, name), []byte(data), 0o644))
	}

	d1, d2, d3 := "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z"


@@ 126,9 124,7 @@ func gitFixture(t *testing.T) (root, mainSHA string) {
	git(d3, "commit", "-m", "add feature.txt")
	git(d3, "checkout", "main")

	if err := os.MkdirAll(filepath.Join(root, "~alice"), 0o755); err != nil {
		t.Fatal(err)
	}
	require.NoError(t, os.MkdirAll(filepath.Join(root, "~alice"), 0o755))
	bare := filepath.Join(root, "~alice", "demo")
	git(d3, "clone", "--bare", work, bare)



@@ 140,9 136,7 @@ func runGit(t *testing.T, dir string, args ...string) string {
	t.Helper()
	cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
	require.NoErrorf(t, err, "git %s:\n%s", strings.Join(args, " "), out)
	return string(out)
}



@@ 160,18 154,12 @@ func testServer(t *testing.T, root string, az authz.Authorizer) http.Handler {
		"compare.sr.ht": ini.Section{"origin": "https://compare.example"},
		"meta.sr.ht":    ini.Section{"origin": "https://meta.example"},
		"git.sr.ht":     ini.Section{"origin": "https://git.example", "repos": root},
		// Extra service sections to exercise nav ordering/exclusions.
		"todo.sr.ht":   ini.Section{"origin": "https://todo.example"},
		"builds.sr.ht": ini.Section{"origin": "https://builds.example"},
		"lists.sr.ht":  ini.Section{"origin": "https://lists.example"},
		"paste.sr.ht":  ini.Section{"origin": "https://paste.example"},
		"pages.sr.ht":  ini.Section{"origin": "https://pages.example"},
		"hub.sr.ht":    ini.Section{"origin": "https://hub.example"},
		"todo.sr.ht":    ini.Section{"origin": "https://todo.example"},
		"hub.sr.ht":     ini.Section{"origin": "https://hub.example"},
	}
	srv, err := New(conf, az)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	require.NoError(t, err, "New")

	r := chi.NewRouter()
	r.Use(config.Middleware(conf, "compare.sr.ht"))
	r.Use(authz.Middleware())


@@ 215,40 203,27 @@ func TestComparePage(t *testing.T) {
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/main...feature", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	require.Equalf(t, http.StatusOK, rec.Code, "body:\n%s", rec.Body.String())

	body := rec.Body.String()
	if !strings.Contains(body, `id="compare-data"`) {
		t.Fatal("missing compare-data script")
	}
	if !strings.Contains(body, `data-diff-wrap`) {
		t.Fatal("missing long-line wrapping control")
	}
	if !strings.Contains(body, `src="/static/`+bundleName(t)+`"`) {
		t.Fatal("missing hashed bundle script tag")
	}
	assert.Contains(t, body, `id="compare-data"`, "missing compare-data script")
	assert.Contains(t, body, `data-diff-wrap`, "missing long-line wrapping control")
	assert.Contains(t, body, `src="/static/`+bundleName(t)+`"`, "missing hashed bundle script tag")

	cd := extractCompareData(t, body)
	if cd.Mode != "compare" {
		t.Fatalf("mode = %q, want compare", cd.Mode)
	}
	if cd.Spec.Base != "main" || cd.Spec.Head != "feature" || !cd.Spec.ThreeDot {
		t.Fatalf("spec = %+v, want base=main head=feature threeDot=true", cd.Spec)
	}
	// feature adds feature.txt relative to the merge base (c1).
	assert.Equal(t, "compare", cd.Mode)
	assert.Equal(t, jsonSpec{Base: "main", Head: "feature", ThreeDot: true}, cd.Spec)

	// feature adds feature.txt relative to the merge base (c1), and paths carry
	// no a/ or b/ diff prefix.
	found := false
	for _, f := range cd.Files {
		if f.Path == "feature.txt" {
			found = true
			if strings.HasPrefix(f.Path, "a/") || strings.HasPrefix(f.Path, "b/") {
				t.Fatalf("file path has diff prefix: %q", f.Path)
			}
		}
		assert.NotRegexp(t, `^[ab]/`, f.Path, "file path has a diff prefix")
	}
	if !found {
		t.Fatalf("feature.txt not in files: %+v", cd.Files)
	}
	assert.Truef(t, found, "feature.txt not in files: %+v", cd.Files)
}

func TestTwoDotVsThreeDot(t *testing.T) {


@@ 256,13 231,10 @@ func TestTwoDotVsThreeDot(t *testing.T) {
	h := testServer(t, root, demoAuthorizer())

	two := extractCompareData(t, get(t, h, "/~alice/demo/compare/main..feature", "").Body.String())
	if two.Spec.ThreeDot {
		t.Fatal("main..feature parsed as three-dot")
	}
	assert.False(t, two.Spec.ThreeDot, "main..feature parsed as three-dot")

	three := extractCompareData(t, get(t, h, "/~alice/demo/compare/main...feature", "").Body.String())
	if !three.Spec.ThreeDot {
		t.Fatal("main...feature parsed as two-dot")
	}
	assert.True(t, three.Spec.ThreeDot, "main...feature parsed as two-dot")
}

func TestComparePatchRoute(t *testing.T) {


@@ 270,15 242,10 @@ func TestComparePatchRoute(t *testing.T) {
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/main...feature.patch", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
		t.Fatalf("content-type = %q, want text/plain", ct)
	}
	if !strings.Contains(rec.Body.String(), "diff --git") {
		t.Fatalf("patch body missing diff header:\n%s", rec.Body.String())
	}
	require.Equal(t, http.StatusOK, rec.Code)
	assert.True(t, strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain"),
		"content-type = %q, want text/plain", rec.Header().Get("Content-Type"))
	assert.Contains(t, rec.Body.String(), "diff --git", "patch body missing diff header")
}

func TestCommitPage(t *testing.T) {


@@ 286,161 253,155 @@ func TestCommitPage(t *testing.T) {
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/commit/"+mainSHA, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	if !strings.Contains(rec.Body.String(), `data-diff-wrap`) {
		t.Fatal("missing long-line wrapping control")
	}
	require.Equalf(t, http.StatusOK, rec.Code, "body:\n%s", rec.Body.String())
	assert.Contains(t, rec.Body.String(), `data-diff-wrap`, "missing long-line wrapping control")

	cd := extractCompareData(t, rec.Body.String())
	if cd.Mode != "commit" {
		t.Fatalf("mode = %q, want commit", cd.Mode)
	}
	assert.Equal(t, "commit", cd.Mode)
	// c2 modifies a.txt and adds b.txt.
	if len(cd.Files) == 0 {
		t.Fatal("commit page has no files")
	}
	assert.NotEmpty(t, cd.Files, "commit page has no files")
}

func TestCommitPatchRoute(t *testing.T) {
	root, mainSHA := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/commit/"+mainSHA+".patch", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "diff --git") {
		t.Fatal("commit patch missing diff header")
	}
	require.Equal(t, http.StatusOK, rec.Code)
	assert.Contains(t, rec.Body.String(), "diff --git", "commit patch missing diff header")
}

func TestUnknownRepoIs404(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/nope/compare/main...feature", "")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rec.Code)
	}
	assert.Equal(t, http.StatusNotFound, rec.Code)
}

func TestPrivateRepoInvisibleIs404(t *testing.T) {
	// Authorizer reports the repo as not-found (visibility hidden) even though
	// the bare repo exists on disk.
	root, _ := gitFixture(t)
	az := &stubAuthorizer{repos: map[string]authz.RepoInfo{}}
	h := testServer(t, root, az)
	h := testServer(t, root, &stubAuthorizer{repos: map[string]authz.RepoInfo{}})

	rec := get(t, h, "/~alice/demo", "")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rec.Code)
	}
	assert.Equal(t, http.StatusNotFound, rec.Code)
}

func TestAuthorizerTransportErrorIs500(t *testing.T) {
	root, _ := gitFixture(t)
	az := &stubAuthorizer{err: errors.New("graphql unreachable")}
	h := testServer(t, root, az)
	h := testServer(t, root, &stubAuthorizer{err: errors.New("graphql unreachable")})

	rec := get(t, h, "/~alice/demo", "")
	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500 (transport error must not be 404)", rec.Code)
	}
	assert.Equal(t, http.StatusInternalServerError, rec.Code,
		"a transport error must not be reported as 404")
}

func TestBadRefIs400(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/..bad", "")
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", rec.Code)
	}
	assert.Equal(t, http.StatusBadRequest, rec.Code)
}

func TestIndexAnonymous(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/", "")
	require.Equal(t, http.StatusOK, rec.Code)

	body := rec.Body.String()
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(body, `action="/jump"`) {
		t.Fatal("anonymous index missing jump form")
	}
	if !strings.Contains(body, "return_to=") {
		t.Fatal("login URL missing return_to")
	}
	assert.Contains(t, body, `action="/jump"`, "anonymous index missing jump form")
	assert.Contains(t, body, "return_to=", "login URL missing return_to")
}

func TestIndexLoggedIn(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/", "bigbes")
	require.Equal(t, http.StatusOK, rec.Code)

	body := rec.Body.String()
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(body, "/~bigbes/demo") {
		t.Fatal("logged-in index missing repo link from MyRepos")
	}
	if !strings.Contains(body, `<small class="pull-right">private</small>`) {
		t.Fatal("logged-in index missing visibility label on the event card")
	}
	assert.Contains(t, body, "/~bigbes/demo", "logged-in index missing repo link from MyRepos")
	assert.Contains(t, body, `<small class="pull-right">private</small>`,
		"logged-in index missing the visibility label on the event card")
}

func TestNavExclusionsAndActive(t *testing.T) {
// TestChromeIsRendered checks that the layout really draws the shared partials
// of sr-ht-ecore — the brand's red service label and the login block. What the
// switcher contains and how it is ordered is ecore's business and ecore's test;
// this one only asserts that this service's layout invokes the chrome at all,
// which is the wiring a bad merge here would break.
func TestChromeIsRendered(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	// Nav switcher only renders for a logged-in viewer.
	body := get(t, h, "/", "bigbes").Body.String()

	if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") {
		t.Fatal("nav missing expected services")
	}
	if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") {
		t.Fatal("nav must exclude paste/pages")
	}
	// hub is the brand, never a switcher item.
	nav := body[strings.Index(body, `<ul class="navbar-nav">`):strings.Index(body, "</ul>")]
	if strings.Contains(nav, "hub.example") {
		t.Fatal("hub must not appear in the switcher list")
	}
	if !strings.Contains(nav, `nav-item active`) {
		t.Fatal("compare should be the active nav item")
	anon := get(t, h, "/", "").Body.String()
	assert.Contains(t, anon, `<span class="text-danger">compare</span>`,
		"the brand's service label is missing")
	assert.Contains(t, anon, "Log in", "an anonymous viewer must be offered the login")
	// The environment is "development" in the test config, so the banner shows.
	assert.Contains(t, anon, "DEVELOPMENT ENVIRONMENT", "missing the non-production banner")

	viewer := get(t, h, "/", "bigbes").Body.String()
	assert.Contains(t, viewer, "Logged in as", "the login block does not name the viewer")
	assert.Contains(t, viewer, "https://hub.example/~bigbes",
		"the profile link should prefer hub's ~username page")
	assert.Contains(t, viewer, "https://todo.example", "the switcher is missing a sibling service")
}

// TestDiffPagesAreFullBleed pins the one chrome decision compare makes for
// itself: the diff views ask for the full window, because a side-by-side diff in
// the centered container is a column of code half the page wide.
func TestDiffPagesAreFullBleed(t *testing.T) {
	root, mainSHA := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	for _, tc := range []struct {
		name, target, want string
	}{
		{"index", "/", `<div class="container">`},
		{"repo", "/~alice/demo", `<div class="container">`},
		{"compare", "/~alice/demo/compare/main...feature", `<div class="container-fluid">`},
		{"commit", "/~alice/demo/commit/" + mainSHA, `<div class="container-fluid">`},
	} {
		t.Run(tc.name, func(t *testing.T) {
			rec := get(t, h, tc.target, "")
			require.Equal(t, http.StatusOK, rec.Code)
			assert.Contains(t, rec.Body.String(), tc.want)
		})
	}
}

func TestHealthz(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/healthz", "")
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
		t.Fatalf("healthz = %d %q", rec.Code, rec.Body.String())
	}
	require.Equal(t, http.StatusOK, rec.Code)
	assert.Contains(t, rec.Body.String(), "ok")
}

func TestStaticBundleAndCSS(t *testing.T) {
	root, _ := gitFixture(t)
	srvHandler := testServer(t, root, demoAuthorizer())
	h := testServer(t, root, demoAuthorizer())

	bundle := bundleName(t)
	rec := get(t, srvHandler, "/static/"+bundle, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("%s status = %d", bundle, rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
		t.Fatalf("%s content-type = %q", bundle, ct)
	}
	if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
		t.Fatalf("hashed bundle cache-control = %q, want immutable", cc)
	}
	rec := get(t, h, "/static/"+bundle, "")
	require.Equalf(t, http.StatusOK, rec.Code, "%s", bundle)
	assert.Contains(t, rec.Header().Get("Content-Type"), "javascript")
	assert.Contains(t, rec.Header().Get("Cache-Control"), "immutable",
		"a hashed bundle must be cacheable forever")

	css := cssName(t)
	rec = get(t, srvHandler, "/static/"+css, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("css status = %d", rec.Code)
	}
	if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
		t.Fatalf("hashed css cache-control = %q, want immutable", cc)
	}
	rec = get(t, h, "/static/"+css, "")
	require.Equalf(t, http.StatusOK, rec.Code, "%s", css)
	assert.Contains(t, rec.Header().Get("Cache-Control"), "immutable",
		"a hashed stylesheet must be cacheable forever")
}

// TestCompareJSONNoScriptBreakout verifies a file path containing "</script>"


@@ 448,29 409,23 @@ func TestStaticBundleAndCSS(t *testing.T) {
func TestCompareJSONNoScriptBreakout(t *testing.T) {
	patch := &gitx.Patch{Text: "diff --git a/x b/x\n"}
	files := []gitx.FileChange{{Path: "evil</script><script>alert(1)</script>.txt", Status: "A", Additions: 1}}

	html, err := buildCompareJSON("compare", patch, files, jsonSpec{Base: "a", Head: "b"})
	if err != nil {
		t.Fatal(err)
	}
	require.NoError(t, err)

	s := string(html)
	// The '<' of "</script>" must be escaped, so no literal "</script" tag can
	// appear to close the embedding element.
	if strings.Contains(s, "</script") {
		t.Fatalf("raw </script present in JSON (breakout possible): %s", s)
	}
	// ...and it must appear in its escaped </script form instead, proving
	// the marshaler HTML-escaped the '<'.
	if !strings.Contains(s, "\\u003c/script") {
		t.Fatalf("expected escaped \\u003c/script, got: %s", s)
	}
	// appear to close the embedding element...
	assert.NotContainsf(t, s, "</script", "raw </script in the JSON (breakout possible): %s", s)
	// ...and it must appear in its escaped form instead, proving the marshaler
	// HTML-escaped the '<'.
	assert.Containsf(t, s, "\\u003c/script", "expected an escaped \\u003c/script, got: %s", s)
}

func cssName(t *testing.T) string {
	t.Helper()
	href, err := resolveCSSHref()
	if err != nil {
		t.Fatal(err)
	}
	require.NoError(t, err)
	return strings.TrimPrefix(href, "/static/")
}



@@ 478,9 433,7 @@ func cssName(t *testing.T) string {
func bundleName(t *testing.T) string {
	t.Helper()
	href, err := resolveBundleHref()
	if err != nil {
		t.Fatal(err)
	}
	require.NoError(t, err)
	return strings.TrimPrefix(href, "/static/")
}



@@ 489,17 442,13 @@ func extractCompareData(t *testing.T, body string) compareData {
	t.Helper()
	const open = `id="compare-data" type="application/json">`
	i := strings.Index(body, open)
	if i < 0 {
		t.Fatalf("no compare-data script in body:\n%s", body)
	}
	require.GreaterOrEqualf(t, i, 0, "no compare-data script in body:\n%s", body)

	rest := body[i+len(open):]
	j := strings.Index(rest, "</script>")
	if j < 0 {
		t.Fatal("compare-data script not closed")
	}
	require.GreaterOrEqual(t, j, 0, "compare-data script not closed")

	var cd compareData
	if err := json.Unmarshal([]byte(rest[:j]), &cd); err != nil {
		t.Fatalf("decode compare-data: %v\nraw: %s", err, rest[:j])
	}
	require.NoErrorf(t, json.Unmarshal([]byte(rest[:j]), &cd), "raw: %s", rest[:j])
	return cd
}