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()