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 }