package blog import ( "bytes" "os" "path/filepath" "regexp" "sort" "strings" "time" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" ) var md = goldmark.New( goldmark.WithExtensions(extension.GFM), goldmark.WithRendererOptions(html.WithHardWraps()), ) type Post struct { Slug string Title string Date time.Time HTML string } type Store struct { posts []Post index map[string]int } func NewStore() *Store { return &Store{index: make(map[string]int)} } func (s *Store) Load(dir string) error { entries, err := os.ReadDir(dir) if err != nil { return err } s.posts = s.posts[:0] s.index = make(map[string]int) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { continue } path := filepath.Join(dir, e.Name()) raw, err := os.ReadFile(path) if err != nil { continue } slug := strings.TrimSuffix(e.Name(), ".md") title, body, date := parseFrontMatter(raw, slug, path) var buf bytes.Buffer if err := md.Convert(body, &buf); err != nil { continue } s.index[slug] = len(s.posts) s.posts = append(s.posts, Post{ Slug: slug, Title: title, Date: date, HTML: buf.String(), }) } // sort newest first sort.Slice(s.posts, func(i, j int) bool { return s.posts[i].Date.After(s.posts[j].Date) }) for i, p := range s.posts { s.index[p.Slug] = i } return nil } func (s *Store) All() []Post { return s.posts } func (s *Store) Get(slug string) (Post, bool) { i, ok := s.index[slug] if !ok { return Post{}, false } return s.posts[i], true } func (s *Store) Count() int { return len(s.posts) } var h1Re = regexp.MustCompile(`(?m)^#\s+(.+)$`) // Date formats we recognize on the line after the title var dateFormats = []string{ "2006-01-02", "January 2, 2006", "Jan 2, 2006", "02/01/2006", } // parseFrontMatter extracts the title from the first `# heading`, // strips that line from the body, and tries to parse a date from // the line immediately after. Falls back to file mod time. func parseFrontMatter(raw []byte, slug, path string) (title string, body []byte, date time.Time) { body = raw // Extract title m := h1Re.FindSubmatch(raw) if len(m) >= 2 { title = strings.TrimSpace(string(m[1])) } else { title = slug } // Strip the first `# title` line from body so it's not rendered twice if loc := h1Re.FindIndex(raw); loc != nil { // Find end of that line lineEnd := loc[1] // advance past newline for lineEnd < len(raw) && (raw[lineEnd] == '\n' || raw[lineEnd] == '\r') { lineEnd++ } body = raw[lineEnd:] } // Try to parse date from first remaining line date = fileModTime(path) rest := strings.TrimSpace(string(body)) if rest != "" { firstLine := rest if idx := strings.IndexByte(rest, '\n'); idx >= 0 { firstLine = strings.TrimSpace(rest[:idx]) } for _, fmt := range dateFormats { if t, err := time.Parse(fmt, firstLine); err == nil { date = t // strip the date line from body too if idx := strings.IndexByte(string(body), '\n'); idx >= 0 { body = body[idx+1:] } else { body = nil } break } } } return title, body, date } func fileModTime(path string) time.Time { info, err := os.Stat(path) if err != nil { return time.Now() } return info.ModTime() }