| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125 |
- 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)
- }
|