1
1

posts.go 6.0 KB

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