posts.go 6.5 KB

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