package main import ( "bytes" "embed" "fmt" "html/template" "io/fs" "log" "net/http" "regexp" "sort" "github.com/yuin/goldmark" ) // The pull-release records are built into the binary, so the release notes // on the site always match the code that is running. // //go:embed pull-releases var releaseFS embed.FS // Release is one pull-release record: pull-releases/NNN-name/README.md plus // its screenshots. type Release struct { ID string // folder name, e.g. "003-css-cache-busting" Number string // "003" Title string Version string // "" if the record doesn't name one HTML template.HTML } var ( releaseDir = regexp.MustCompile(`^(\d{3})-[a-z0-9][a-z0-9-]*$`) releaseVersion = regexp.MustCompile(`\bversion (\d+\.\d+\.\d+)`) releaseImage = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*\.png$`) ) // loadReleases reads every NNN-name/README.md in fsys, newest first. The // first line must be a "# Title" heading; it is removed from the body. // Folders that don't follow the convention are logged and skipped. func loadReleases(fsys fs.FS, md goldmark.Markdown) ([]*Release, map[string]*Release, error) { entries, err := fs.ReadDir(fsys, ".") if err != nil { return nil, nil, err } var list []*Release byID := map[string]*Release{} for _, e := range entries { m := releaseDir.FindStringSubmatch(e.Name()) if !e.IsDir() || m == nil { continue } r, err := parseRelease(fsys, e.Name(), m[1], md) if err != nil { log.Printf("skipping release %s: %v", e.Name(), err) continue } list = append(list, r) byID[r.ID] = r } sort.Slice(list, func(i, j int) bool { return list[i].ID > list[j].ID }) return list, byID, nil } func parseRelease(fsys fs.FS, id, number string, md goldmark.Markdown) (*Release, error) { src, err := fs.ReadFile(fsys, id+"/README.md") if err != nil { return nil, err } title, body := takeHeading(bytes.ReplaceAll(src, []byte("\r\n"), []byte("\n"))) if title == "" { return nil, fmt.Errorf("README.md must start with a # Title line") } r := &Release{ID: id, Number: number, Title: title} if m := releaseVersion.FindSubmatch(body); m != nil { r.Version = string(m[1]) } var buf bytes.Buffer if err := md.Convert(body, &buf); err != nil { return nil, err } r.HTML = template.HTML(buf.String()) return r, nil } func (a *app) releaseList(w http.ResponseWriter, r *http.Request) { a.render(w, http.StatusOK, "releases", pageData{Releases: a.releases}) } // releaseRedirect adds the trailing slash, so the screenshots' relative // links (desktop.png) resolve inside the release's folder. func (a *app) releaseRedirect(w http.ResponseWriter, r *http.Request) { if _, ok := a.releaseByID[r.PathValue("id")]; !ok { a.notFound(w, r) return } http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) } func (a *app) releasePage(w http.ResponseWriter, r *http.Request) { rel, ok := a.releaseByID[r.PathValue("id")] if !ok { a.notFound(w, r) return } a.render(w, http.StatusOK, "release", pageData{Release: rel}) } // releaseFile serves a release's screenshots; nothing else in the folder. func (a *app) releaseFile(w http.ResponseWriter, r *http.Request) { id, file := r.PathValue("id"), r.PathValue("file") if _, ok := a.releaseByID[id]; !ok || !releaseImage.MatchString(file) { a.notFound(w, r) return } b, err := fs.ReadFile(a.releaseFS, id+"/"+file) if err != nil { a.notFound(w, r) return } w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(b) }