posts.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. package main
  2. import (
  3. "bytes"
  4. "errors"
  5. "fmt"
  6. "html/template"
  7. "log"
  8. "os"
  9. "path/filepath"
  10. "regexp"
  11. "sort"
  12. "strconv"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/yuin/goldmark"
  17. )
  18. // Post is one rendered blog post.
  19. type Post struct {
  20. Slug string
  21. Title string
  22. Date time.Time
  23. Summary string
  24. Draft bool
  25. HTML template.HTML
  26. }
  27. // index is an immutable snapshot of the published posts.
  28. type index struct {
  29. posts []*Post // newest first
  30. bySlug map[string]int // slug -> position in posts
  31. }
  32. // Store reads posts from a directory and caches them until the directory's
  33. // contents (file names, sizes or modification times) change.
  34. type Store struct {
  35. dir string
  36. md goldmark.Markdown
  37. mu sync.Mutex
  38. sig string
  39. idx *index
  40. }
  41. func NewStore(dir string, md goldmark.Markdown) *Store {
  42. return &Store{dir: dir, md: md}
  43. }
  44. // Index returns the current published posts, reloading them if any file in
  45. // the content directory has changed. Files that fail to parse are logged and
  46. // skipped so one bad post cannot take the site down.
  47. func (s *Store) Index() (*index, error) {
  48. s.mu.Lock()
  49. defer s.mu.Unlock()
  50. entries, err := os.ReadDir(s.dir)
  51. if err != nil {
  52. return nil, err
  53. }
  54. var sig strings.Builder
  55. var files []os.DirEntry
  56. for _, e := range entries {
  57. if !e.Type().IsRegular() || filepath.Ext(e.Name()) != ".md" {
  58. continue
  59. }
  60. info, err := e.Info()
  61. if err != nil {
  62. return nil, err
  63. }
  64. fmt.Fprintf(&sig, "%s\x00%d\x00%d\n", e.Name(), info.Size(), info.ModTime().UnixNano())
  65. files = append(files, e)
  66. }
  67. if s.idx != nil && sig.String() == s.sig {
  68. return s.idx, nil
  69. }
  70. idx := &index{bySlug: map[string]int{}}
  71. for _, e := range files {
  72. path := filepath.Join(s.dir, e.Name())
  73. src, err := os.ReadFile(path)
  74. if err != nil {
  75. return nil, err
  76. }
  77. info, err := e.Info()
  78. if err != nil {
  79. return nil, err
  80. }
  81. p, err := parsePost(s.md, e.Name(), src, info.ModTime())
  82. if err != nil {
  83. log.Printf("skipping %s: %v", path, err)
  84. continue
  85. }
  86. if p.Draft {
  87. continue
  88. }
  89. if _, dup := idx.bySlug[p.Slug]; dup {
  90. log.Printf("skipping %s: duplicate slug %q", path, p.Slug)
  91. continue
  92. }
  93. idx.bySlug[p.Slug] = -1
  94. idx.posts = append(idx.posts, p)
  95. }
  96. sort.SliceStable(idx.posts, func(i, j int) bool {
  97. a, b := idx.posts[i], idx.posts[j]
  98. if !a.Date.Equal(b.Date) {
  99. return a.Date.After(b.Date)
  100. }
  101. return a.Slug < b.Slug
  102. })
  103. for i, p := range idx.posts {
  104. idx.bySlug[p.Slug] = i
  105. }
  106. s.sig, s.idx = sig.String(), idx
  107. return idx, nil
  108. }
  109. var (
  110. datedName = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})-([a-z0-9][a-z0-9-]*)\.md$`)
  111. plainName = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\.md$`)
  112. )
  113. // slugFromName extracts the slug and, for YYYY-MM-DD-slug.md names, the date.
  114. func slugFromName(name string) (slug string, date time.Time, err error) {
  115. if m := datedName.FindStringSubmatch(name); m != nil {
  116. date, err = time.Parse(time.DateOnly, m[1])
  117. if err != nil {
  118. return "", time.Time{}, fmt.Errorf("bad date in filename: %w", err)
  119. }
  120. return m[2], date, nil
  121. }
  122. if m := plainName.FindStringSubmatch(name); m != nil {
  123. return m[1], time.Time{}, nil
  124. }
  125. return "", time.Time{}, errors.New("filename must be YYYY-MM-DD-slug.md or slug.md using lowercase letters, digits and hyphens")
  126. }
  127. // parsePost builds a Post from a file's name and contents. modTime is the
  128. // last-resort date when neither front matter nor the filename gives one.
  129. func parsePost(md goldmark.Markdown, name string, src []byte, modTime time.Time) (*Post, error) {
  130. slug, fileDate, err := slugFromName(name)
  131. if err != nil {
  132. return nil, err
  133. }
  134. meta, body, err := splitFrontMatter(src)
  135. if err != nil {
  136. return nil, err
  137. }
  138. p := &Post{Slug: slug, Title: meta["title"], Summary: meta["summary"]}
  139. switch {
  140. case meta["date"] != "":
  141. if p.Date, err = parseDate(meta["date"]); err != nil {
  142. return nil, err
  143. }
  144. case !fileDate.IsZero():
  145. p.Date = fileDate
  146. default:
  147. p.Date = modTime.UTC().Truncate(24 * time.Hour)
  148. }
  149. if v := meta["draft"]; v != "" {
  150. if p.Draft, err = strconv.ParseBool(v); err != nil {
  151. return nil, fmt.Errorf("draft: %q is not true or false", v)
  152. }
  153. }
  154. if p.Title == "" {
  155. p.Title, body = takeHeading(body)
  156. }
  157. if p.Title == "" {
  158. p.Title = slug
  159. }
  160. var buf bytes.Buffer
  161. if err := md.Convert(body, &buf); err != nil {
  162. return nil, fmt.Errorf("rendering markdown: %w", err)
  163. }
  164. p.HTML = template.HTML(buf.String())
  165. return p, nil
  166. }
  167. func parseDate(s string) (time.Time, error) {
  168. for _, layout := range []string{time.DateOnly, time.RFC3339} {
  169. if t, err := time.Parse(layout, s); err == nil {
  170. return t, nil
  171. }
  172. }
  173. return time.Time{}, fmt.Errorf("date: %q is not YYYY-MM-DD", s)
  174. }
  175. // splitFrontMatter separates an optional leading block of "key: value" lines
  176. // fenced by "---" lines from the Markdown body.
  177. func splitFrontMatter(src []byte) (map[string]string, []byte, error) {
  178. src = bytes.ReplaceAll(src, []byte("\r\n"), []byte("\n"))
  179. meta := map[string]string{}
  180. lines := strings.SplitAfter(string(src), "\n")
  181. if strings.TrimSpace(lines[0]) != "---" {
  182. return meta, src, nil
  183. }
  184. for i := 1; i < len(lines); i++ {
  185. line := strings.TrimSpace(lines[i])
  186. if line == "---" {
  187. return meta, []byte(strings.Join(lines[i+1:], "")), nil
  188. }
  189. if line == "" || strings.HasPrefix(line, "#") {
  190. continue
  191. }
  192. key, value, ok := strings.Cut(line, ":")
  193. if !ok {
  194. return nil, nil, fmt.Errorf("front matter line %d: expected key: value", i+1)
  195. }
  196. meta[strings.ToLower(strings.TrimSpace(key))] = unquote(strings.TrimSpace(value))
  197. }
  198. return nil, nil, errors.New("front matter is missing its closing ---")
  199. }
  200. func unquote(s string) string {
  201. if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') && s[len(s)-1] == s[0] {
  202. return s[1 : len(s)-1]
  203. }
  204. return s
  205. }
  206. // takeHeading removes a leading "# Title" line from body and returns the title.
  207. func takeHeading(body []byte) (string, []byte) {
  208. rest := bytes.TrimLeft(body, " \t\n")
  209. line, after, _ := bytes.Cut(rest, []byte("\n"))
  210. if title, ok := bytes.CutPrefix(line, []byte("# ")); ok {
  211. return strings.TrimSpace(string(title)), after
  212. }
  213. return "", body
  214. }