~bigbes/sr-ht-compare

5ca23c0553d1107eeb5f1199934af71749f40bb6 — bigbes 30 days ago 53d7b97
web: content-hash the bundle and colour the status column

Two diff-view fixes reported against the deployed instance.

Empty file tree after an upgrade: bundle.js had a stable filename served with a
1-hour cache, so browsers kept a pre-fix bundle and the @pierre/trees sidebar
rendered blank. Content-hash the bundle like the stylesheet — Makefile emits
bundle.<sha256[:8]>.js, server.go globs it into a BundleHref, the templates
reference {{.BundleHref}}, and the hashed name is served immutable. A deploy now
always busts the cache.

Unreadable status column: the changed-files table rendered A/M/D in the dim
default cell colour, near-invisible on the dark chrome. Render each status as a
semantic .diff-status badge (added=green, modified=amber, deleted=red,
renamed=blue) with light/dark variants, via statusClass/statusLabel helpers and
a small self-contained style block (the sourcehut-derived main.min.css can't be
rebuilt locally).

Tests resolve the hashed bundle via a bundleName helper and assert its immutable
cache header; docs updated for the hashed filename.
M Makefile => Makefile +9 -2
@@ 42,12 42,19 @@ css:
		web/static/main.min.$$(sha256sum web/static/main.min.css | cut -c1-8).css
	rm -f web/static/main.css

# Frontend diff/tree bundle: built once, output committed to web/static.
# Requires frontend/ (added in phase 2b).
# Frontend diff/tree bundle: built once, output committed to web/static. The
# filename carries a content hash (like the CSS) so a deploy busts the browser
# cache — a stale bundle.js is otherwise served for up to max-age and can leave
# the file tree rendering blank after an upgrade. Produces exactly ONE
# web/static/bundle.<sha256[:8]>.js (old ones removed).
bundle:
	mkdir -p web/static
	rm -f web/static/bundle.js web/static/bundle.*.js
	cd frontend && npm ci && \
		npx esbuild src/app.ts --bundle --minify --format=esm \
			--outfile=../web/static/bundle.js
	mv web/static/bundle.js \
		web/static/bundle.$$(sha256sum web/static/bundle.js | cut -c1-8).js

# Local development run. Requires a ./config.ini in the working directory (or
# ../config.ini, /etc/sr.ht/config.ini): copy config.example.ini and fill in the

M README.md => README.md +5 -4
@@ 60,8 60,9 @@ fork carries one production S3 patch on top of upstream.
  `core.sr.ht`'s `make install`). `dart-sass` also works for local builds; the
  production `css` target uses `sassc -I /usr/share/sourcehut/scss`.
- **Node.js** (build time only) — the frontend diff bundle (`make bundle`,
  esbuild). The vendored `web/static/bundle.js` is committed, so Node is not
  needed at runtime.
  esbuild). The vendored, content-hashed `web/static/bundle.<hash>.js` is
  committed (the hash busts the browser cache on deploy, like the stylesheet), so
  Node is not needed at runtime.

```sh
make build   # compile ./comparesrht


@@ 211,7 212,7 @@ compare.sr.ht deploys like any other SourceHut web service. On the instance:
   ```sh
   make install PREFIX=/usr/local
   # → /usr/local/bin/comparesrht
   # → /usr/share/sourcehut/compare.sr.ht/static/{bundle.js,main.min.<hash>.css,logo.svg}
   # → /usr/share/sourcehut/compare.sr.ht/static/{bundle.<hash>.js,main.min.<hash>.css,logo.svg}
   ```

2. **Config.** Append the `[compare.sr.ht]` section (`origin=` and


@@ 258,7 259,7 @@ compare.sr.ht deploys like any other SourceHut web service. On the instance:
  output was fidelity-verified against real `git` (see `gitx/fidelity_test.go`)
  so the patches `@pierre/diffs` parses carry standard `diff --git` headers and
  match git's rename/binary handling.
- **10 MB vendored bundle.** `web/static/bundle.js` is a ~10 MB committed
- **10 MB vendored bundle.** `web/static/bundle.<hash>.js` is a ~10 MB committed
  esbuild output. It is large because `@pierre/diffs` bundles Shiki with its
  grammars/themes for client-side syntax highlighting. We accept the size: it is
  built once, served with a long immutable cache lifetime, gzips/brotlis down

M config.example.ini => config.example.ini +2 -2
@@ 15,8 15,8 @@
# domain (*.srht.bigb.es).
origin=https://compare.srht.bigb.es
#
# Directory the built static assets (bundle.js, main.min.<hash>.css, logo.svg)
# are installed to; globbed at startup to resolve the hashed CSS filename.
# Directory the built static assets (bundle.<hash>.js, main.min.<hash>.css,
# logo.svg) are installed to; globbed at startup to resolve the hashed filenames.
static-dir=/usr/share/sourcehut/compare.sr.ht/static
#
# Address the HTTP server binds to. Optional; defaults to 127.0.0.1:5090.

M web/chrome.go => web/chrome.go +2 -0
@@ 84,6 84,7 @@ type viewData struct {
	RegisterURL string
	ProfileURL  string
	CSSHref     string
	BundleHref  string
	Environment string
	ShowBanner  bool



@@ 121,6 122,7 @@ func (s *Server) chrome(r *http.Request) viewData {
		RegisterURL:    s.metaOrigin,
		ProfileURL:     profileURL,
		CSSHref:        s.cssHref,
		BundleHref:     s.bundleHref,
		Environment:    strings.ToUpper(s.environment),
		ShowBanner:     s.environment != "" && s.environment != "production",
	}

M web/router.go => web/router.go +1 -1
@@ 37,7 37,7 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
	if strings.HasSuffix(name, ".js") {
		w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
	}
	if hashedCSSRe.MatchString(name) {
	if hashedCSSRe.MatchString(name) || hashedBundleRe.MatchString(name) {
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	} else {
		w.Header().Set("Cache-Control", "public, max-age=3600")

M web/server.go => web/server.go +27 -1
@@ 34,8 34,8 @@ import (
	"path"
	"regexp"

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

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


@@ 44,6 44,12 @@ import (
// with an immutable cache lifetime (the hash changes whenever the bytes do).
var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`)

// hashedBundleRe matches the content-addressed frontend bundle. Like the
// stylesheet it carries a content hash in its name so a deploy busts the browser
// cache (a stale bundle.js is why the file tree can render blank after an
// upgrade); a matching name is served immutable.
var hashedBundleRe = regexp.MustCompile(`^bundle\.[0-9a-f]{6,}\.js$`)

// Server holds the immutable configuration a request handler needs. It is built
// once at startup and is safe for concurrent use.
type Server struct {


@@ 57,6 63,7 @@ type Server struct {
	compareOrigin string
	hubOrigin     string
	cssHref       string
	bundleHref    string

	nav              []navItem
	staticFileServer http.Handler


@@ 86,6 93,11 @@ func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
		return nil, err
	}

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

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return nil, fmt.Errorf("web: sub static FS: %w", err)


@@ 101,6 113,7 @@ func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
		compareOrigin:    compareOrigin,
		hubOrigin:        config.GetOrigin(conf, "hub.sr.ht", true),
		cssHref:          cssHref,
		bundleHref:       bundleHref,
		nav:              buildNav(conf),
		staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
	}, nil


@@ 118,3 131,16 @@ func resolveCSSHref() (string, error) {
	}
	return "/static/" + path.Base(matches[0]), nil
}

// resolveBundleHref globs the embedded static FS for the content-addressed
// frontend bundle and returns its site-absolute URL.
func resolveBundleHref() (string, error) {
	matches, err := fs.Glob(staticFS, "static/bundle.*.js")
	if err != nil {
		return "", fmt.Errorf("web: glob bundle: %w", err)
	}
	if len(matches) == 0 {
		return "", fmt.Errorf("web: no bundle.*.js in embedded static assets")
	}
	return "/static/" + path.Base(matches[0]), nil
}

R web/static/bundle.js => web/static/bundle.3e3b8462.js +0 -0
M web/templates.go => web/templates.go +40 -2
@@ 17,8 17,8 @@ import (
//go:embed templates/*.html
var tmplFS embed.FS

// staticFS holds the built front-end assets (bundle.js, the hashed stylesheet,
// logo.svg). It is served read-only under /static/.
// staticFS holds the built front-end assets (the hashed bundle.<hash>.js, the
// hashed stylesheet, logo.svg). It is served read-only under /static/.
//
//go:embed static
var staticFS embed.FS


@@ 36,6 36,44 @@ var funcMap = template.FuncMap{
	"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 {
		if s == "" {
			return "o"
		}
		switch s[0] {
		case 'A', 'a':
			return "a"
		case 'M', 'm':
			return "m"
		case 'D', 'd':
			return "d"
		case 'R', 'r':
			return "r"
		default:
			return "o"
		}
	},
	// statusLabel spells out a status letter for the badge's tooltip.
	"statusLabel": func(s string) string {
		if s == "" {
			return "changed"
		}
		switch s[0] {
		case 'A', 'a':
			return "added"
		case 'M', 'm':
			return "modified"
		case 'D', 'd':
			return "deleted"
		case 'R', 'r':
			return "renamed"
		default:
			return "changed"
		}
	},
}

// pageNames are the content templates; each is parsed with layout.html.

M web/templates/commit.html => web/templates/commit.html +2 -2
@@ 55,7 55,7 @@
            {{if and .OldPath (ne .OldPath .Path)}}<code>{{.OldPath}}</code> → {{end}}<code>{{.Path}}</code>
            {{if .Binary}}<span class="badge badge-secondary">BIN</span>{{end}}
          </td>
          <td>{{.Status}}</td>
          <td><span class="diff-status diff-status-{{statusClass .Status}}" title="{{statusLabel .Status}}">{{.Status}}</span></td>
          <td class="text-success">{{if .Additions}}+{{.Additions}}{{end}}</td>
          <td class="text-danger">{{if .Deletions}}-{{.Deletions}}{{end}}</td>
        </tr>


@@ 77,5 77,5 @@
{{end}}

{{define "scripts"}}
<script type="module" src="/static/bundle.js"></script>
<script type="module" src="{{.BundleHref}}"></script>
{{end}}

M web/templates/compare.html => web/templates/compare.html +2 -2
@@ 57,7 57,7 @@
            {{if and .OldPath (ne .OldPath .Path)}}<code>{{.OldPath}}</code> → {{end}}<code>{{.Path}}</code>
            {{if .Binary}}<span class="badge badge-secondary">BIN</span>{{end}}
          </td>
          <td>{{.Status}}</td>
          <td><span class="diff-status diff-status-{{statusClass .Status}}" title="{{statusLabel .Status}}">{{.Status}}</span></td>
          <td class="text-success">{{if .Additions}}+{{.Additions}}{{end}}</td>
          <td class="text-danger">{{if .Deletions}}-{{.Deletions}}{{end}}</td>
        </tr>


@@ 79,5 79,5 @@
{{end}}

{{define "scripts"}}
<script type="module" src="/static/bundle.js"></script>
<script type="module" src="{{.BundleHref}}"></script>
{{end}}

M web/templates/layout.html => web/templates/layout.html +28 -0
@@ 6,6 6,34 @@
    <title>{{.Title}}</title>
    <link rel="icon" type="image/svg+xml" href="/static/logo.svg">
    <link rel="stylesheet" href="{{.CSSHref}}">
    <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
         intentional, legible colour in both light and dark schemes. */
      .diff-status {
        display: inline-block;
        min-width: 1.6rem;
        padding: 0 0.4rem;
        border: 1px solid currentColor;
        border-radius: 3px;
        font-size: 0.75rem;
        font-weight: 700;
        line-height: 1.5;
        text-align: center;
      }
      .diff-status-a { color: #1a7f37; }
      .diff-status-m { color: #9a6700; }
      .diff-status-d { color: #cf222e; }
      .diff-status-r { color: #0969da; }
      .diff-status-o { color: #6e7781; }
      @media (prefers-color-scheme: dark) {
        .diff-status-a { color: #3fb950; }
        .diff-status-m { color: #d29922; }
        .diff-status-d { color: #f85149; }
        .diff-status-r { color: #58a6ff; }
        .diff-status-o { color: #8b949e; }
      }
    </style>
    {{block "head" .}}{{end}}
  </head>
  <body>

M web/web_test.go => web/web_test.go +21 -7
@@ 14,11 14,11 @@ import (
	"strings"
	"testing"

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

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


@@ 222,8 222,8 @@ func TestComparePage(t *testing.T) {
	if !strings.Contains(body, `id="compare-data"`) {
		t.Fatal("missing compare-data script")
	}
	if !strings.Contains(body, `src="/static/bundle.js"`) {
		t.Fatal("missing bundle.js script tag")
	if !strings.Contains(body, `src="/static/`+bundleName(t)+`"`) {
		t.Fatal("missing hashed bundle script tag")
	}

	cd := extractCompareData(t, body)


@@ 415,12 415,16 @@ func TestStaticBundleAndCSS(t *testing.T) {
	root, _ := gitFixture(t)
	srvHandler := testServer(t, root, demoAuthorizer())

	rec := get(t, srvHandler, "/static/bundle.js", "")
	bundle := bundleName(t)
	rec := get(t, srvHandler, "/static/"+bundle, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("bundle.js status = %d", rec.Code)
		t.Fatalf("%s status = %d", bundle, rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
		t.Fatalf("bundle.js content-type = %q", ct)
		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)
	}

	css := cssName(t)


@@ 464,6 468,16 @@ func cssName(t *testing.T) string {
	return strings.TrimPrefix(href, "/static/")
}

// bundleName resolves the content-hashed frontend bundle filename (bundle.<hash>.js).
func bundleName(t *testing.T) string {
	t.Helper()
	href, err := resolveBundleHref()
	if err != nil {
		t.Fatal(err)
	}
	return strings.TrimPrefix(href, "/static/")
}

// extractCompareData pulls and decodes the embedded JSON payload from a page.
func extractCompareData(t *testing.T, body string) compareData {
	t.Helper()