releases.go 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. package main
  2. import (
  3. "bytes"
  4. "embed"
  5. "fmt"
  6. "html/template"
  7. "io/fs"
  8. "log"
  9. "net/http"
  10. "regexp"
  11. "sort"
  12. "github.com/yuin/goldmark"
  13. )
  14. // The pull-release records are built into the binary, so the release notes
  15. // on the site always match the code that is running.
  16. //
  17. //go:embed pull-releases
  18. var releaseFS embed.FS
  19. // Release is one pull-release record: pull-releases/NNN-name/README.md plus
  20. // its screenshots.
  21. type Release struct {
  22. ID string // folder name, e.g. "003-css-cache-busting"
  23. Number string // "003"
  24. Title string
  25. Version string // "" if the record doesn't name one
  26. HTML template.HTML
  27. }
  28. var (
  29. releaseDir = regexp.MustCompile(`^(\d{3})-[a-z0-9][a-z0-9-]*$`)
  30. releaseVersion = regexp.MustCompile(`\bversion (\d+\.\d+\.\d+)`)
  31. releaseImage = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*\.png$`)
  32. )
  33. // loadReleases reads every NNN-name/README.md in fsys, newest first. The
  34. // first line must be a "# Title" heading; it is removed from the body.
  35. // Folders that don't follow the convention are logged and skipped.
  36. func loadReleases(fsys fs.FS, md goldmark.Markdown) ([]*Release, map[string]*Release, error) {
  37. entries, err := fs.ReadDir(fsys, ".")
  38. if err != nil {
  39. return nil, nil, err
  40. }
  41. var list []*Release
  42. byID := map[string]*Release{}
  43. for _, e := range entries {
  44. m := releaseDir.FindStringSubmatch(e.Name())
  45. if !e.IsDir() || m == nil {
  46. continue
  47. }
  48. r, err := parseRelease(fsys, e.Name(), m[1], md)
  49. if err != nil {
  50. log.Printf("skipping release %s: %v", e.Name(), err)
  51. continue
  52. }
  53. list = append(list, r)
  54. byID[r.ID] = r
  55. }
  56. sort.Slice(list, func(i, j int) bool { return list[i].ID > list[j].ID })
  57. return list, byID, nil
  58. }
  59. func parseRelease(fsys fs.FS, id, number string, md goldmark.Markdown) (*Release, error) {
  60. src, err := fs.ReadFile(fsys, id+"/README.md")
  61. if err != nil {
  62. return nil, err
  63. }
  64. title, body := takeHeading(bytes.ReplaceAll(src, []byte("\r\n"), []byte("\n")))
  65. if title == "" {
  66. return nil, fmt.Errorf("README.md must start with a # Title line")
  67. }
  68. r := &Release{ID: id, Number: number, Title: title}
  69. if m := releaseVersion.FindSubmatch(body); m != nil {
  70. r.Version = string(m[1])
  71. }
  72. var buf bytes.Buffer
  73. if err := md.Convert(body, &buf); err != nil {
  74. return nil, err
  75. }
  76. r.HTML = template.HTML(buf.String())
  77. return r, nil
  78. }
  79. func (a *app) releaseList(w http.ResponseWriter, r *http.Request) {
  80. a.render(w, http.StatusOK, "releases", pageData{Releases: a.releases})
  81. }
  82. // releaseRedirect adds the trailing slash, so the screenshots' relative
  83. // links (desktop.png) resolve inside the release's folder.
  84. func (a *app) releaseRedirect(w http.ResponseWriter, r *http.Request) {
  85. if _, ok := a.releaseByID[r.PathValue("id")]; !ok {
  86. a.notFound(w, r)
  87. return
  88. }
  89. http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
  90. }
  91. func (a *app) releasePage(w http.ResponseWriter, r *http.Request) {
  92. rel, ok := a.releaseByID[r.PathValue("id")]
  93. if !ok {
  94. a.notFound(w, r)
  95. return
  96. }
  97. a.render(w, http.StatusOK, "release", pageData{Release: rel})
  98. }
  99. // releaseFile serves a release's screenshots; nothing else in the folder.
  100. func (a *app) releaseFile(w http.ResponseWriter, r *http.Request) {
  101. id, file := r.PathValue("id"), r.PathValue("file")
  102. if _, ok := a.releaseByID[id]; !ok || !releaseImage.MatchString(file) {
  103. a.notFound(w, r)
  104. return
  105. }
  106. b, err := fs.ReadFile(a.releaseFS, id+"/"+file)
  107. if err != nil {
  108. a.notFound(w, r)
  109. return
  110. }
  111. w.Header().Set("Content-Type", "image/png")
  112. w.Header().Set("Cache-Control", "public, max-age=3600")
  113. w.Write(b)
  114. }