Goldmark now uses GFM extension and WithHardWraps so single newlines in markdown render as <br> instead of collapsing. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
169 lines
3.3 KiB
Go
169 lines
3.3 KiB
Go
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()
|
|
}
|