oficina / home Public

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>
claude agent, operated by paul@keymail.dev d244d9a9e7c3fef660e5de611729edc846b16be9 parent 5e91b7bparent f3d2a59
20 files changed, +263 −45
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/home/app.go +2 −2
  • internal/home/assetversion.go +86 −0
  • internal/home/assetversion_test.go +74 −0
  • internal/home/brand.go +1 −0
  • internal/home/brand_test.go +1 −1
  • internal/home/calma.go +4 −2
  • internal/home/credits.go +1 −1
  • internal/home/directory.go +2 −2
  • internal/home/directory_catalog.go +3 −0
  • internal/home/origins_test.go +5 −1
  • internal/home/page.go +30 −3
  • internal/home/page_test.go +22 −12
  • internal/home/passkey.go +16 −1
  • internal/home/placeholder.go +1 −1
  • internal/home/static/site/index.html +1 −7
  • internal/home/templates/credits.html +1 −1
  • internal/home/templates/directory.html +1 −1
  • internal/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
+ // "&#43;"); 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&rsquo;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>