Merge branch 'asset-caching' into main
Cache Home's assets by content version; the sky paints in the first frame Stylesheets and pictures linked as path?v=<content version> and cached a year immutably; bare paths revalidate by weakly compared ETag (304). The front page renders estilo's sky template, with estilo's new inline placeholder, so the sky is there in the first frame. Reviewed by Claude Fable 5.1 (discussion 76).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 files changed,
+263
−45
go.mod+1 −1go.sum+2 −0internal/home/app.go+2 −2internal/home/assetversion.go+86 −0internal/home/assetversion_test.go+74 −0internal/home/brand.go+1 −0internal/home/brand_test.go+1 −1internal/home/calma.go+4 −2internal/home/credits.go+1 −1internal/home/directory.go+2 −2internal/home/directory_catalog.go+3 −0internal/home/origins_test.go+5 −1internal/home/page.go+30 −3internal/home/page_test.go+22 −12internal/home/passkey.go+16 −1internal/home/placeholder.go+1 −1internal/home/static/site/index.html+1 −7internal/home/templates/credits.html+1 −1internal/home/templates/directory.html+1 −1internal/home/templates/layout.html+9 −9
CI passed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... cd handoff && CGO_ENABLED=0 go vet ./... cd roster && CGO_ENABLED=0 go vet ./... cd connect && CGO_ENABLED=0 go vet ./... === fmt === === build === CGO_ENABLED=0 go build ./... CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina ./internal/operatorlaunch cd handoff && CGO_ENABLED=0 go build ./... cd roster && CGO_ENABLED=0 go build ./... cd connect && CGO_ENABLED=0 go build ./... === go-test === CGO_ENABLED=0 go test -count=1 ./... ? amadan.net/oficina/home/cmd/home [no test files] ok amadan.net/oficina/home/cmd/home-placeholder 0.590s ? amadan.net/oficina/home/cmd/oficina [no test files] ok amadan.net/oficina/home/internal/guardstest 0.026s ok amadan.net/oficina/home/internal/home 8.645s ok amadan.net/oficina/home/internal/home/directorydata 0.958s ok amadan.net/oficina/home/internal/home/logtest 0.005s ok amadan.net/oficina/home/internal/home/seal 0.004s ok amadan.net/oficina/home/internal/home/sealtest 0.005s ok amadan.net/oficina/home/internal/operator 5.088s ok amadan.net/oficina/home/internal/operatorlaunch 4.135s ok amadan.net/oficina/home/internal/provisioner 48.440s cd handoff && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/handoff 0.548s ok amadan.net/oficina/home/handoff/seal 0.003s cd roster && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/roster 5.113s cd connect && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/connect 0.072s ok amadan.net/oficina/home/connect/linkstore 0.291s === race === go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/ ok amadan.net/oficina/home/internal/home 105.624s ok amadan.net/oficina/home/internal/operator 65.381s ok amadan.net/oficina/home/internal/operatorlaunch 22.600s ok amadan.net/oficina/home/internal/provisioner 303.516s ok amadan.net/oficina/home/internal/home/directorydata 20.174s cd handoff && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/handoff 9.195s ok amadan.net/oficina/home/handoff/seal 1.009s cd roster && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/roster 6.581s cd connect && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/connect 1.613s ok amadan.net/oficina/home/connect/linkstore 3.695s
diff --git a/go.mod b/go.mod| index 7823eeb..0f8db18 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,7 +5,7 @@ go 1.25.0 |
| toolchain go1.26.5 |
| require ( |
| - amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773 |
| + amadan.net/oficina/estilo.git v0.0.0-20260929082849-4d41ba2100ff |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
diff --git a/go.sum b/go.sum| index c3fdab5..b2b0844 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -10,6 +10,8 @@ amadan.net/oficina/estilo.git v0.0.0-20260929070257-7a1a376ad2ab h1:ypKDilZG0lxf |
| amadan.net/oficina/estilo.git v0.0.0-20260929070257-7a1a376ad2ab/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773 h1:9DIAhG3TaGG+LE2+8LvU73ZEhfK+oNn2y2p4kFjAAi0= |
| amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929082849-4d41ba2100ff h1:eGeR7E6hcL8b1z6FR1v0LkoY58lVyVLju7xvz39gJkQ= |
| +amadan.net/oficina/estilo.git v0.0.0-20260929082849-4d41ba2100ff/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo= |
diff --git a/internal/home/app.go b/internal/home/app.go| index ecb12f8..bd36888 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -242,8 +242,8 @@ func (a *App) Router() http.Handler { |
| // Vendored from rastrillo's ui package, not linked from it: an app |
| // owns its stylesheet from the moment it lands, and |
| // `rastrillo doctor --fix` is what reconciles it on an upgrade. |
| - r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8")) |
| - r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/tokens.css", staticAsset("tokens.css")) |
| + mountCSS(r, "/static/theme.css", theme.CSS()) |
| mountBrand(r) |
| mountCalma(r) |
| mountSite(r) |
diff --git a/internal/home/assetversion.go b/internal/home/assetversion.go| new file mode 100644 |
| index 0000000..bcbc7cd |
| --- /dev/null |
| +++ b/internal/home/assetversion.go |
| @@ -0,0 +1,86 @@ |
| +package home |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "net/http" |
| + "regexp" |
| + "strings" |
| + "sync" |
| +) |
| + |
| +// Asset versioning. Home's stylesheets and pictures are served at stable |
| +// paths (a closed list, mountBrand and friends), and every page links |
| +// them as path?v=<content version>. serveAsset caches a request carrying |
| +// the current version for a year, immutably, and revalidates anything |
| +// else, so a returning visitor fetches only the page, and a deploy still |
| +// takes effect at once: new bytes, new version, new URL. |
| +// |
| +// Scripts are left at their bare paths: the scriptless sweep's allowlist |
| +// names them there (passkey.go), and they are revalidated by ETag. |
| + |
| +// contentVersion is the first 16 hex digits of the body's SHA-256. |
| +func contentVersion(body []byte) string { |
| + sum := sha256.Sum256(body) |
| + return hex.EncodeToString(sum[:8]) |
| +} |
| + |
| +var ( |
| + assetVersionsMu sync.RWMutex |
| + assetVersions = map[string]string{} |
| +) |
| + |
| +// versionAsset records path's current version. Called where each asset is |
| +// mounted; every router mounts the same bytes, so the value never changes. |
| +func versionAsset(path string, body []byte) { |
| + assetVersionsMu.Lock() |
| + assetVersions[path] = contentVersion(body) |
| + assetVersionsMu.Unlock() |
| +} |
| + |
| +// versioned is path with its version, or path unchanged if it is not a |
| +// versioned asset (a script, an estilo URL already versioned in its path). |
| +func versioned(path string) string { |
| + assetVersionsMu.RLock() |
| + v, ok := assetVersions[path] |
| + assetVersionsMu.RUnlock() |
| + if !ok { |
| + return path |
| + } |
| + return path + "?v=" + v |
| +} |
| + |
| +var staticRef = regexp.MustCompile(`((?:href|src)=")(/static/[^"?#]+)"`) |
| + |
| +// versionStaticRefs rewrites every href/src naming a versioned asset in a |
| +// static page (the front page, which is bytes, not a template). |
| +func versionStaticRefs(page []byte) []byte { |
| + return staticRef.ReplaceAllFunc(page, func(m []byte) []byte { |
| + parts := staticRef.FindSubmatch(m) |
| + return []byte(string(parts[1]) + versioned(string(parts[2])) + `"`) |
| + }) |
| +} |
| + |
| +// etagMatches is If-None-Match's weak comparison (RFC 9110 §13.1.2): |
| +// any listed tag, W/ or not, naming the same opaque value matches, and so |
| +// does "*". Weak matters here: whatever gzips oficina.works on the way out |
| +// turns this binary's strong tag into W/"...", and an exact compare would |
| +// then never answer 304. |
| +func etagMatches(header, etag string) bool { |
| + want := strings.TrimPrefix(etag, "W/") |
| + for _, tag := range strings.Split(header, ",") { |
| + tag = strings.TrimSpace(tag) |
| + if tag == "*" || strings.TrimPrefix(tag, "W/") == want { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// mountCSS mounts a versioned stylesheet. |
| +func mountCSS(r interface { |
| + Get(string, http.HandlerFunc) |
| +}, path string, body []byte) { |
| + versionAsset(path, body) |
| + r.Get(path, serveAsset(body, "text/css; charset=utf-8")) |
| +} |
diff --git a/internal/home/assetversion_test.go b/internal/home/assetversion_test.go| new file mode 100644 |
| index 0000000..1de50ef |
| --- /dev/null |
| +++ b/internal/home/assetversion_test.go |
| @@ -0,0 +1,74 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Every stylesheet and picture a page links is linked by its content |
| +// version, and fetching that URL is cached immutably; the bare path and a |
| +// stale version revalidate, and a matching ETag is a 304. |
| +func TestAssetsAreLinkedByVersionAndCachedForAYear(t *testing.T) { |
| + app := newTestApp(t) |
| + get := func(host, path string, header ...string) *httptest.ResponseRecorder { |
| + req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil) |
| + for i := 0; i+1 < len(header); i += 2 { |
| + req.Header.Set(header[i], header[i+1]) |
| + } |
| + rec := httptest.NewRecorder() |
| + app.Router().ServeHTTP(rec, req) |
| + return rec |
| + } |
| + ref := regexp.MustCompile(`(?:href|src)="(/static/[^"]+)"`) |
| + for _, page := range []struct{ host, path string }{{directoryHost, "/"}, {credentialHost, "/signin"}} { |
| + body := get(page.host, page.path).Body.String() |
| + var checked int |
| + for _, m := range ref.FindAllStringSubmatch(body, -1) { |
| + url := m[1] |
| + bare := strings.SplitN(url, "?", 2)[0] |
| + if strings.HasSuffix(bare, ".js") || strings.HasSuffix(bare, ".mjs") || strings.HasPrefix(bare, "/static/estilo/sky/") || strings.HasPrefix(bare, "/static/estilo/fonts/") { |
| + continue // scripts stay bare on purpose; estilo's URLs carry their version in the path |
| + } |
| + checked++ |
| + if !strings.Contains(url, "?v=") { |
| + t.Errorf("%s%s links %s without its version", page.host, page.path, url) |
| + continue |
| + } |
| + rec := get(page.host, url) |
| + if rec.Code != 200 || rec.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { |
| + t.Errorf("GET %s = %d %q", url, rec.Code, rec.Header().Get("Cache-Control")) |
| + } |
| + etag := rec.Header().Get("ETag") |
| + if b := get(page.host, bare); b.Header().Get("Cache-Control") != "no-cache" || b.Header().Get("ETag") != etag { |
| + t.Errorf("GET %s (bare) = %q %q", bare, b.Header().Get("Cache-Control"), b.Header().Get("ETag")) |
| + } |
| + if n := get(page.host, bare, "If-None-Match", etag); n.Code != http.StatusNotModified || n.Body.Len() != 0 { |
| + t.Errorf("GET %s with its ETag = %d, want 304", bare, n.Code) |
| + } |
| + } |
| + if checked < 4 { |
| + t.Errorf("%s%s: only %d versioned assets checked", page.host, page.path, checked) |
| + } |
| + } |
| +} |
| + |
| +func TestIfNoneMatchIsWeakAndListed(t *testing.T) { |
| + tag := `"0123456789abcdef"` |
| + for header, want := range map[string]bool{ |
| + tag: true, |
| + "W/" + tag: true, |
| + `"other", W/` + tag: true, |
| + ` "other" , ` + tag + ` `: true, |
| + "*": true, |
| + `"other"`: false, |
| + "": false, |
| + `W/"0123456789abcdeX"`: false, |
| + } { |
| + if got := etagMatches(header, tag); got != want { |
| + t.Errorf("etagMatches(%q) = %v, want %v", header, got, want) |
| + } |
| + } |
| +} |
diff --git a/internal/home/brand.go b/internal/home/brand.go| index 7afaeb5..c4ba74c 100644 |
| --- a/internal/home/brand.go |
| +++ b/internal/home/brand.go |
| @@ -32,6 +32,7 @@ func mountBrand(r chi.Router) { |
| if err != nil { |
| panic(err) // A missing embedded release asset is a build error. |
| } |
| + versionAsset("/static/brand/"+name, body) |
| r.Get("/static/brand/"+name, serveAsset(body, contentType)) |
| } |
| } |
diff --git a/internal/home/brand_test.go b/internal/home/brand_test.go| index 56d605e..0570ca1 100644 |
| --- a/internal/home/brand_test.go |
| +++ b/internal/home/brand_test.go |
| @@ -21,7 +21,7 @@ func TestDirectoryBrandAssetsArePublicAndListingKeepsNames(t *testing.T) { |
| body := page.Body.String() |
| for _, name := range []string{"Docs", "Sheets", "Slides", "Calendar", "Calls", "Memoria"} { |
| path := "/static/brand/" + strings.ToLower(name) + ".svg" |
| - if !strings.Contains(body, `src="`+path+`" alt=""`) || !strings.Contains(body, ">"+name+"<") { |
| + if !strings.Contains(body, `src="`+versioned(path)+`" alt=""`) || !strings.Contains(body, ">"+name+"<") { |
| t.Fatal("listing lost icon or name", name) |
| } |
| // No session on the asset request: every sign-in surface needs its |
diff --git a/internal/home/calma.go b/internal/home/calma.go| index 863c937..916fac6 100644 |
| --- a/internal/home/calma.go |
| +++ b/internal/home/calma.go |
| @@ -19,6 +19,8 @@ var calmaCSS []byte |
| // sky for pages that belong to no one team: all of Home's, today, since |
| // the directory lists apps rather than one team's. |
| var homeFuncs = map[string]any{ |
| + // v links a stylesheet or picture by its content version (assetversion.go). |
| + "v": versioned, |
| "suiteSky": func() sky.View { return sky.For(sky.SuiteSeed) }, |
| // Onest's stylesheet, under estilo's versioned prefix. |
| "fontsCSS": func() string { return theme.FontPrefix + "fonts.css" }, |
| @@ -59,6 +61,6 @@ var hopPages = map[string]bool{ |
| func mountCalma(r chi.Router) { |
| r.Handle(theme.FontPrefix+"*", theme.FontHandler()) |
| r.Handle(sky.Prefix+"*", sky.Handler()) |
| - r.Get("/static/estilo/sky.css", serveAsset(sky.CSS(), "text/css; charset=utf-8")) |
| - r.Get("/static/calma.css", serveAsset(calmaCSS, "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/estilo/sky.css", sky.CSS()) |
| + mountCSS(r, "/static/calma.css", calmaCSS) |
| } |
diff --git a/internal/home/credits.go b/internal/home/credits.go| index 1c469ff..07dded1 100644 |
| --- a/internal/home/credits.go |
| +++ b/internal/home/credits.go |
| @@ -30,5 +30,5 @@ func (a *App) mountCredits(r chi.Router) { |
| a.render(w, http.StatusOK, "credits.html", credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}) |
| }) |
| - r.Get("/static/credits.css", serveAsset(credits.CSS(), "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/credits.css", credits.CSS()) |
| } |
diff --git a/internal/home/directory.go b/internal/home/directory.go| index 44d052b..c2c5a0d 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -117,8 +117,8 @@ func (a *App) directoryRouter() http.Handler { |
| a.render(w, 200, "directory_signed_out.html", map[string]any{}) |
| }) |
| r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| - r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8")) |
| - r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/tokens.css", staticAsset("tokens.css")) |
| + mountCSS(r, "/static/theme.css", theme.CSS()) |
| mountBrand(r) |
| mountCalma(r) |
| mountSite(r) |
diff --git a/internal/home/directory_catalog.go b/internal/home/directory_catalog.go| index d94bec8..b417778 100644 |
| --- a/internal/home/directory_catalog.go |
| +++ b/internal/home/directory_catalog.go |
| @@ -36,6 +36,9 @@ func directoryCatalog(cfg Runtime) []catalogApp { |
| apps[i].Status = "Open preview" |
| } |
| } |
| + for i := range apps { |
| + apps[i].Icon = versioned(apps[i].Icon) |
| + } |
| return apps |
| } |
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go| index abae455..a36335a 100644 |
| --- a/internal/home/origins_test.go |
| +++ b/internal/home/origins_test.go |
| @@ -31,7 +31,11 @@ func examplePath(route string) string { |
| case "/static/estilo/fonts/v1/*": |
| return theme.FontPrefix + "onest-latin.woff2" |
| case "/static/estilo/sky/v1/*": |
| - return sky.For(sky.SuiteSeed).Layers[0].Src |
| + for _, layer := range sky.For(sky.SuiteSeed).Layers { |
| + if layer.Src != "" { // the placeholder is inline, with no URL |
| + return layer.Src |
| + } |
| + } |
| } |
| return route |
| } |
diff --git a/internal/home/page.go b/internal/home/page.go| index 984d5bd..e22ef60 100644 |
| --- a/internal/home/page.go |
| +++ b/internal/home/page.go |
| @@ -1,9 +1,13 @@ |
| package home |
| import ( |
| + "html/template" |
| + |
| + "amadan.net/oficina/estilo.git/sky" |
| "bytes" |
| "embed" |
| "net/http" |
| + "sync" |
| "amadan.net/oficina/estilo.git/theme" |
| "github.com/go-chi/chi/v5" |
| @@ -57,12 +61,35 @@ var appsCSS = func() []byte { |
| // mountBrand keeps one: a request path must never be treated as a file |
| // name. |
| func mountSite(r chi.Router) { |
| - r.Get("/static/site/home.css", serveAsset(siteAsset("home.css"), "text/css; charset=utf-8")) |
| - r.Get("/static/site/apps.css", serveAsset(appsCSS, "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/site/home.css", siteAsset("home.css")) |
| + mountCSS(r, "/static/site/apps.css", appsCSS) |
| +} |
| + |
| +// skyMarker is where the front page's sky goes: rendered from estilo's |
| +// own template, so the page carries exactly the layers (and the inline |
| +// placeholder) the pinned estilo draws, never a hand-copied set of URLs. |
| +const skyMarker = "<!-- estilo-sky: the suite's sky, rendered from estilo's template on first serve (page.go) -->" |
| + |
| +func withSky(page []byte) []byte { |
| + t := template.Must(template.ParseFS(sky.Templates(), "templates/*.html")) |
| + var b bytes.Buffer |
| + if err := t.ExecuteTemplate(&b, "estilo-sky", sky.For(sky.SuiteSeed)); err != nil { |
| + panic(err) // estilo's own template over its own data; a broken pin |
| + } |
| + return bytes.Replace(page, []byte(skyMarker), b.Bytes(), 1) |
| } |
| +// servedIndex is the front page with every stylesheet and picture linked |
| +// by its content version. Built on first serve, once the routers have |
| +// mounted (and so versioned) everything the page links. |
| +var ( |
| + servedIndexOnce sync.Once |
| + servedIndex []byte |
| +) |
| + |
| // handleIndex serves the front page. |
| func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) { |
| + servedIndexOnce.Do(func() { servedIndex = versionStaticRefs(withSky(indexPage)) }) |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| - w.Write(indexPage) |
| + w.Write(servedIndex) |
| } |
diff --git a/internal/home/page_test.go b/internal/home/page_test.go| index b79c7f2..b43de74 100644 |
| --- a/internal/home/page_test.go |
| +++ b/internal/home/page_test.go |
| @@ -2,6 +2,7 @@ package home |
| import ( |
| "bytes" |
| + "html" |
| "regexp" |
| "testing" |
| @@ -20,20 +21,29 @@ func TestFrontPageLineBreaksKeepTheirSpace(t *testing.T) { |
| } |
| } |
| -// The front page is static bytes, so it names estilo's versioned URLs |
| -// directly. When estilo moves one (a new sky drawing, a new font file), |
| -// this fails rather than the page quietly linking files that 404. |
| -func TestFrontPageLinksEstilosCurrentURLs(t *testing.T) { |
| - want := []string{theme.FontPrefix + "fonts.css"} |
| - for _, layer := range sky.For(sky.SuiteSeed).Layers { |
| - want = append(want, layer.Src) |
| +// The front page renders its sky from estilo's template: every layer the |
| +// pinned estilo draws, the inline placeholders included, and the marker |
| +// gone. |
| +func TestFrontPageRendersEstilosSky(t *testing.T) { |
| + if !bytes.Contains(indexPage, []byte(skyMarker)) { |
| + t.Fatal("index.html has no sky marker") |
| + } |
| + // Attribute values are HTML-escaped (html/template writes "+" as |
| + // "+"); the browser reads them unescaped, so compare that. |
| + page := []byte(html.UnescapeString(string(withSky(indexPage)))) |
| + if bytes.Contains(page, []byte(skyMarker)) { |
| + t.Fatal("the sky marker was not replaced") |
| } |
| - for _, url := range want { |
| - if !bytes.Contains(indexPage, []byte(`"`+url+`"`)) { |
| - t.Errorf("front page does not link %s", url) |
| + for _, layer := range sky.For(sky.SuiteSeed).Layers { |
| + want := layer.Src |
| + if want == "" { |
| + want = string(layer.Inline) |
| + } |
| + if !bytes.Contains(page, []byte(`src="`+want+`"`)) { |
| + t.Errorf("front page lacks the %s layer (%s)", layer.Name, layer.Tone) |
| } |
| } |
| - if n := bytes.Count(indexPage, []byte(sky.Prefix)); n != len(sky.For(sky.SuiteSeed).Layers) { |
| - t.Errorf("front page links %d sky files, want exactly the suite sky's %d", n, len(sky.For(sky.SuiteSeed).Layers)) |
| + if !bytes.Contains(indexPage, []byte(`"`+theme.FontPrefix+`fonts.css"`)) { |
| + t.Errorf("front page does not link %sfonts.css", theme.FontPrefix) |
| } |
| } |
diff --git a/internal/home/passkey.go b/internal/home/passkey.go| index 4eb2e64..b351c1b 100644 |
| --- a/internal/home/passkey.go |
| +++ b/internal/home/passkey.go |
| @@ -199,9 +199,24 @@ func (a *App) handleRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Reque |
| // maps .mjs. A wrong MIME type makes a browser refuse a module silently, |
| // which is the worst way for this to fail. |
| func serveAsset(body []byte, contentType string) http.HandlerFunc { |
| + version := contentVersion(body) |
| + etag := `"` + version + `"` |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", contentType) |
| - w.Header().Set("Cache-Control", "no-cache") |
| + w.Header().Set("ETag", etag) |
| + // A URL carrying this body's version can never serve anything |
| + // else, so the browser keeps it for a year without asking. Any |
| + // other request — the bare path, a stale version — revalidates, |
| + // and the ETag makes that a 304 rather than the bytes again. |
| + if r.URL.Query().Get("v") == version { |
| + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| + } else { |
| + w.Header().Set("Cache-Control", "no-cache") |
| + } |
| + if etagMatches(r.Header.Get("If-None-Match"), etag) { |
| + w.WriteHeader(http.StatusNotModified) |
| + return |
| + } |
| w.WriteHeader(http.StatusOK) |
| w.Write(body) |
| } |
diff --git a/internal/home/placeholder.go b/internal/home/placeholder.go| index e388bd4..1eafcfb 100644 |
| --- a/internal/home/placeholder.go |
| +++ b/internal/home/placeholder.go |
| @@ -13,7 +13,7 @@ func PlaceholderHandler() http.Handler { |
| mountBrand(r) |
| r.Get("/", func(w http.ResponseWriter, _ *http.Request) { |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| - _, _ = w.Write([]byte(indexPage)) |
| + _, _ = w.Write(withSky(indexPage)) |
| }) |
| return r |
| } |
diff --git a/internal/home/static/site/index.html b/internal/home/static/site/index.html| index 23e596b..2096157 100644 |
| --- a/internal/home/static/site/index.html |
| +++ b/internal/home/static/site/index.html |
| @@ -35,13 +35,7 @@ |
| <!-- ───────── Hero: every app's colour, together, behind the headline ───────── --> |
| <section class="hero" estilo-sky-host> |
| - <div estilo-sky aria-hidden="true"> |
| - <img estilo-sky-layer="haze" estilo-sky-tone="light" src="/static/estilo/sky/v1/fffadbd9c1aa2b94/haze-light.svg" alt="" loading="lazy" decoding="async"> |
| - <img estilo-sky-layer="haze" estilo-sky-tone="dark" src="/static/estilo/sky/v1/fffadbd9c1aa2b94/haze-dark.svg" alt="" loading="lazy" decoding="async"> |
| - <img estilo-sky-layer="clouds" estilo-sky-tone="light" src="/static/estilo/sky/v1/fffadbd9c1aa2b94/clouds-light.svg" alt="" loading="lazy" decoding="async"> |
| - <img estilo-sky-layer="clouds" estilo-sky-tone="dark" src="/static/estilo/sky/v1/fffadbd9c1aa2b94/clouds-dark.svg" alt="" loading="lazy" decoding="async"> |
| - <img estilo-sky-layer="filaments" src="/static/estilo/sky/v1/fffadbd9c1aa2b94/filaments.svg" alt="" loading="lazy" decoding="async"> |
| - </div> |
| + <!-- estilo-sky: the suite's sky, rendered from estilo's template on first serve (page.go) --> |
| <div class="shell hero-in"> |
| <h1 class="hero-h">Open your new office suite.</h1> |
| <p class="hero-lead">Oficina is fast, modern and light enough to forget it’s running. |
diff --git a/internal/home/templates/credits.html b/internal/home/templates/credits.html| index b587fe5..596f7dd 100644 |
| --- a/internal/home/templates/credits.html |
| +++ b/internal/home/templates/credits.html |
| @@ -1,5 +1,5 @@ |
| {{define "title"}}Open source · Oficina{{end}} |
| -{{define "head"}}<link rel="stylesheet" href="/static/credits.css">{{end}} |
| +{{define "head"}}<link rel="stylesheet" href="{{v "/static/credits.css"}}">{{end}} |
| {{define "content"}}{{template "estilo/credits" .}}{{end}} |
| {{define "help"}}{{template "home/help" .}}{{end}} |
diff --git a/internal/home/templates/directory.html b/internal/home/templates/directory.html| index c5854b2..a085be3 100644 |
| --- a/internal/home/templates/directory.html |
| +++ b/internal/home/templates/directory.html |
| @@ -1,7 +1,7 @@ |
| {{define "title"}}Oficina{{end}} |
| {{define "content"}} |
| <header home-hub-head> |
| -<h1 estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="40" height="40">Oficina</h1> |
| +<h1 estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="40" height="40">Oficina</h1> |
| <form method="post" action="/signout"><button type="submit" rst-btn>Sign out</button></form> |
| </header> |
| <p home-hub-lede>You are signed in. Every app below opens with this one account.</p> |
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html| index 70a7b07..d536c5d 100644 |
| --- a/internal/home/templates/layout.html |
| +++ b/internal/home/templates/layout.html |
| @@ -1,18 +1,18 @@ |
| {{define "layout"}}<!doctype html> |
| <html lang="{{block "lang" .}}en{{end}}"> |
| <head> |
| -<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png"> |
| -<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg"> |
| -<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png"> |
| -<link rel="stylesheet" href="/static/brand/brand.css"> |
| +<link rel="icon" type="image/png" sizes="32x32" href="{{v "/static/brand/icon-32.png"}}"> |
| +<link rel="icon" type="image/svg+xml" href="{{v "/static/brand/icon.svg"}}"> |
| +<link rel="apple-touch-icon" sizes="180x180" href="{{v "/static/brand/apple-touch-icon.png"}}"> |
| +<link rel="stylesheet" href="{{v "/static/brand/brand.css"}}"> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>{{block "title" .}}Oficina{{end}}</title> |
| <link rel="stylesheet" href="{{fontsCSS}}"> |
| -<link rel="stylesheet" href="/static/tokens.css"> |
| -<link rel="stylesheet" href="/static/theme.css"> |
| -<link rel="stylesheet" href="/static/estilo/sky.css"> |
| -<link rel="stylesheet" href="/static/calma.css"> |
| +<link rel="stylesheet" href="{{v "/static/tokens.css"}}"> |
| +<link rel="stylesheet" href="{{v "/static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{v "/static/estilo/sky.css"}}"> |
| +<link rel="stylesheet" href="{{v "/static/calma.css"}}"> |
| {{block "head" .}}{{end}} |
| </head> |
| <body{{block "bodyattrs" .}}{{end}}> |
| @@ -20,7 +20,7 @@ |
| {{if not hop}}{{template "estilo-sky" suiteSky}}{{end}} |
| {{block "help" .}}{{end}} |
| <main rst-page{{if door}} home-door{{end}}> |
| -{{if door}}<p estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="36" height="36">Oficina</p>{{end}} |
| +{{if door}}<p estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="36" height="36">Oficina</p>{{end}} |
| {{template "content" .}} |
| </main> |
| </div> |