| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- package main
- import (
- "bytes"
- "errors"
- "fmt"
- "html/template"
- "log"
- "os"
- "path/filepath"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/yuin/goldmark"
- )
- // Post is one rendered blog post.
- type Post struct {
- Slug string
- Title string
- Date time.Time
- Summary string
- Author string
- Draft bool
- Pinned bool
- HTML template.HTML
- }
- // index is an immutable snapshot of the published posts.
- type index struct {
- posts []*Post // newest first; feed and newer/older links use this
- listing []*Post // index page order: pinned first, each group newest first
- bySlug map[string]int // slug -> position in posts
- }
- // Store reads posts from a directory and caches them until the directory's
- // contents (file names, sizes or modification times) change.
- type Store struct {
- dir string
- md goldmark.Markdown
- mu sync.Mutex
- sig string
- idx *index
- }
- func NewStore(dir string, md goldmark.Markdown) *Store {
- return &Store{dir: dir, md: md}
- }
- // Index returns the current published posts, reloading them if any file in
- // the content directory has changed. Files that fail to parse are logged and
- // skipped so one bad post cannot take the site down.
- func (s *Store) Index() (*index, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- entries, err := os.ReadDir(s.dir)
- if err != nil {
- return nil, err
- }
- var sig strings.Builder
- var files []os.DirEntry
- for _, e := range entries {
- if !e.Type().IsRegular() || filepath.Ext(e.Name()) != ".md" {
- continue
- }
- info, err := e.Info()
- if err != nil {
- return nil, err
- }
- fmt.Fprintf(&sig, "%s\x00%d\x00%d\n", e.Name(), info.Size(), info.ModTime().UnixNano())
- files = append(files, e)
- }
- if s.idx != nil && sig.String() == s.sig {
- return s.idx, nil
- }
- idx := &index{bySlug: map[string]int{}}
- for _, e := range files {
- path := filepath.Join(s.dir, e.Name())
- src, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- info, err := e.Info()
- if err != nil {
- return nil, err
- }
- p, err := parsePost(s.md, e.Name(), src, info.ModTime())
- if err != nil {
- log.Printf("skipping %s: %v", path, err)
- continue
- }
- if p.Draft {
- continue
- }
- if _, dup := idx.bySlug[p.Slug]; dup {
- log.Printf("skipping %s: duplicate slug %q", path, p.Slug)
- continue
- }
- idx.bySlug[p.Slug] = -1
- idx.posts = append(idx.posts, p)
- }
- sort.SliceStable(idx.posts, func(i, j int) bool {
- a, b := idx.posts[i], idx.posts[j]
- if !a.Date.Equal(b.Date) {
- return a.Date.After(b.Date)
- }
- return a.Slug < b.Slug
- })
- for i, p := range idx.posts {
- idx.bySlug[p.Slug] = i
- }
- idx.listing = make([]*Post, 0, len(idx.posts))
- for _, pinned := range []bool{true, false} {
- for _, p := range idx.posts {
- if p.Pinned == pinned {
- idx.listing = append(idx.listing, p)
- }
- }
- }
- s.sig, s.idx = sig.String(), idx
- return idx, nil
- }
- var (
- datedName = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})-([a-z0-9][a-z0-9-]*)\.md$`)
- plainName = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*)\.md$`)
- )
- // slugFromName extracts the slug and, for YYYY-MM-DD-slug.md names, the date.
- func slugFromName(name string) (slug string, date time.Time, err error) {
- if m := datedName.FindStringSubmatch(name); m != nil {
- date, err = time.Parse(time.DateOnly, m[1])
- if err != nil {
- return "", time.Time{}, fmt.Errorf("bad date in filename: %w", err)
- }
- return m[2], date, nil
- }
- if m := plainName.FindStringSubmatch(name); m != nil {
- return m[1], time.Time{}, nil
- }
- return "", time.Time{}, errors.New("filename must be YYYY-MM-DD-slug.md or slug.md using lowercase letters, digits and hyphens")
- }
- // parsePost builds a Post from a file's name and contents. modTime is the
- // last-resort date when neither front matter nor the filename gives one.
- func parsePost(md goldmark.Markdown, name string, src []byte, modTime time.Time) (*Post, error) {
- slug, fileDate, err := slugFromName(name)
- if err != nil {
- return nil, err
- }
- meta, body, err := splitFrontMatter(src)
- if err != nil {
- return nil, err
- }
- p := &Post{Slug: slug, Title: meta["title"], Summary: meta["summary"], Author: meta["author"]}
- switch {
- case meta["date"] != "":
- if p.Date, err = parseDate(meta["date"]); err != nil {
- return nil, err
- }
- case !fileDate.IsZero():
- p.Date = fileDate
- default:
- p.Date = modTime.UTC().Truncate(24 * time.Hour)
- }
- if v := meta["draft"]; v != "" {
- if p.Draft, err = strconv.ParseBool(v); err != nil {
- return nil, fmt.Errorf("draft: %q is not true or false", v)
- }
- }
- if v := meta["pinned"]; v != "" {
- if p.Pinned, err = strconv.ParseBool(v); err != nil {
- return nil, fmt.Errorf("pinned: %q is not true or false", v)
- }
- }
- if p.Title == "" {
- p.Title, body = takeHeading(body)
- }
- if p.Title == "" {
- p.Title = slug
- }
- var buf bytes.Buffer
- if err := md.Convert(body, &buf); err != nil {
- return nil, fmt.Errorf("rendering markdown: %w", err)
- }
- p.HTML = template.HTML(buf.String())
- return p, nil
- }
- func parseDate(s string) (time.Time, error) {
- for _, layout := range []string{time.DateOnly, time.RFC3339} {
- if t, err := time.Parse(layout, s); err == nil {
- return t, nil
- }
- }
- return time.Time{}, fmt.Errorf("date: %q is not YYYY-MM-DD", s)
- }
- // splitFrontMatter separates an optional leading block of "key: value" lines
- // fenced by "---" lines from the Markdown body.
- func splitFrontMatter(src []byte) (map[string]string, []byte, error) {
- src = bytes.ReplaceAll(src, []byte("\r\n"), []byte("\n"))
- meta := map[string]string{}
- lines := strings.SplitAfter(string(src), "\n")
- if strings.TrimSpace(lines[0]) != "---" {
- return meta, src, nil
- }
- for i := 1; i < len(lines); i++ {
- line := strings.TrimSpace(lines[i])
- if line == "---" {
- return meta, []byte(strings.Join(lines[i+1:], "")), nil
- }
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- key, value, ok := strings.Cut(line, ":")
- if !ok {
- return nil, nil, fmt.Errorf("front matter line %d: expected key: value", i+1)
- }
- meta[strings.ToLower(strings.TrimSpace(key))] = unquote(strings.TrimSpace(value))
- }
- return nil, nil, errors.New("front matter is missing its closing ---")
- }
- func unquote(s string) string {
- if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') && s[len(s)-1] == s[0] {
- return s[1 : len(s)-1]
- }
- return s
- }
- // takeHeading removes a leading "# Title" line from body and returns the title.
- func takeHeading(body []byte) (string, []byte) {
- rest := bytes.TrimLeft(body, " \t\n")
- line, after, _ := bytes.Cut(rest, []byte("\n"))
- if title, ok := bytes.CutPrefix(line, []byte("# ")); ok {
- return strings.TrimSpace(string(title)), after
- }
- return "", body
- }
|