prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit 4d5ac0f58f908dcda8320de4f335af448f49257e
parent 5cf9620370532ec0a210de7771fdbb108c68e011
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 18:39:13 +0000

fix(web): load the first project's readme on a cold cache

/projects rendered projects[0] straight from the list cache, which is bare after a deploy or TTL expiry, and the browser then cached "README not available" for an hour. Resolve it via GetProjectByID, keep the last good READMEs across cache expiry, and never cache a detail without a README.

Diffstat:
Mcmd/server/handlers/projects.go | 25++++++++++++++++++++-----
Mcmd/server/handlers/render.go | 2+-
Minternal/content/projects.go | 37++++++++++++++++++++++++++++++++++++-
3 files changed, 57 insertions(+), 7 deletions(-)

diff --git a/cmd/server/handlers/projects.go b/cmd/server/handlers/projects.go @@ -30,15 +30,21 @@ func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) { if id == "" { if len(projects) > 0 { - applyProjectDetailData(&data, &projects[0]) + // Resolve through GetProjectByID so a cold cache fetches the + // README synchronously instead of rendering "not available". + first := &projects[0] + if p, err := content.GetProjectByID(h.gitService, first.ID); err == nil && p != nil { + first = p + } + applyProjectDetailData(&data, first) } if utils.IsHTMXRequest(r) { - h.renderTemplate(w, r, "projects", data, fragmentCacheMaxAgeSeconds) + h.renderTemplate(w, r, "projects", data, detailMaxAge(data, fragmentCacheMaxAgeSeconds)) return } - h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds) + h.renderTemplate(w, r, "layout", data, detailMaxAge(data, fullPageCacheMaxAgeSeconds)) return } @@ -55,7 +61,16 @@ func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) { data.SelectedProjectID = id applyProjectDetailData(&data, project) - h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds) + h.renderTemplate(w, r, "layout", data, detailMaxAge(data, fullPageCacheMaxAgeSeconds)) +} + +// detailMaxAge never lets a browser cache a page whose README failed to +// load, so a transient fetch error isn't pinned for an hour. +func detailMaxAge(data web.Data, maxAge int) int { + if data.Project != nil && strings.TrimSpace(string(data.ProjectReadme)) == "" { + return 0 + } + return maxAge } func applyProjectDetailData(data *web.Data, project *config.Project) { @@ -84,7 +99,7 @@ func (h *Handler) ProjectDetailHandler(w http.ResponseWriter, r *http.Request) { ProjectReadme: template.HTML(project.HTML), } - h.renderTemplate(w, r, "project_detail", data, fragmentCacheMaxAgeSeconds) + h.renderTemplate(w, r, "project_detail", data, detailMaxAge(data, fragmentCacheMaxAgeSeconds)) } // ProjectRawHandler serves a file from HEAD of a public project's repo, so diff --git a/cmd/server/handlers/render.go b/cmd/server/handlers/render.go @@ -27,7 +27,7 @@ func (h *Handler) renderTemplate(w http.ResponseWriter, r *http.Request, name st cacheEnabled := shouldCacheResponses() w.Header().Set("Content-Type", "text/html; charset=utf-8") - if !cacheEnabled { + if !cacheEnabled || maxAgeSeconds == 0 { applyNoCacheHeaders(w) } else if maxAgeSeconds > 0 { w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAgeSeconds)) diff --git a/internal/content/projects.go b/internal/content/projects.go @@ -36,7 +36,7 @@ func LoadProjects(gitService *external.Git) ([]config.Project, error) { } } - projects := config.GetProjects() + projects := withLastGoodReadmes(config.GetProjects()) projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL) startProjectsRefresh(gitService) @@ -62,6 +62,7 @@ func GetProjectByID(gitService *external.Git, id string) (*config.Project, error if readme, err := fetchProjectReadme(gitService, projects[i]); err == nil { projects[i].Markdown = readme.Markdown projects[i].HTML = readme.HTML + rememberReadmes(projects[i : i+1]) projectsCache.Set(projectsCacheKey, cloneProjects(projects), projectsCacheTTL) } } @@ -160,6 +161,40 @@ func startProjectsRefresh(gitService *external.Git) { return } + projects = withLastGoodReadmes(projects) + rememberReadmes(projects) projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL) }() } + +// lastGoodReadmes outlives the projects cache, so an expired cache or a failed +// refresh serves the previous README instead of "README not available". +var ( + lastGoodMu sync.RWMutex + lastGoodReadmes = map[string]config.Project{} +) + +func rememberReadmes(projects []config.Project) { + lastGoodMu.Lock() + defer lastGoodMu.Unlock() + for _, p := range projects { + if strings.TrimSpace(p.HTML) != "" { + lastGoodReadmes[p.ID] = config.Project{Markdown: p.Markdown, HTML: p.HTML} + } + } +} + +func withLastGoodReadmes(projects []config.Project) []config.Project { + lastGoodMu.RLock() + defer lastGoodMu.RUnlock() + for i := range projects { + if strings.TrimSpace(projects[i].HTML) != "" { + continue + } + if good, ok := lastGoodReadmes[projects[i].ID]; ok { + projects[i].Markdown = good.Markdown + projects[i].HTML = good.HTML + } + } + return projects +}