180 lines
3.4 KiB
Go
180 lines
3.4 KiB
Go
package post
|
|
|
|
import (
|
|
"html/template"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.jolheiser.com/blog/markdown"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/rs/zerolog/log"
|
|
"go.jolheiser.com/emdbed"
|
|
)
|
|
|
|
func NewBlog(basePath string) (*Blog, error) {
|
|
posts, err := scan(basePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := watcher.Add(basePath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
b := &Blog{
|
|
Path: basePath,
|
|
posts: posts,
|
|
}
|
|
|
|
go func() {
|
|
var debounce time.Time
|
|
for {
|
|
select {
|
|
case event := <-watcher.Events:
|
|
log.Debug().Str("op", event.Op.String()).Msgf("fsnotify %q", event.Name)
|
|
debounce = time.Now()
|
|
case err := <-watcher.Errors:
|
|
log.Err(err).Msg("")
|
|
default:
|
|
if !debounce.IsZero() && time.Since(debounce) > (time.Second*5) {
|
|
log.Info().Msg("reloading blog")
|
|
if err := b.Scan(); err != nil {
|
|
log.Err(err).Msg("")
|
|
}
|
|
debounce = time.Time{}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return b, nil
|
|
}
|
|
|
|
func scan(basePath string) (map[string]*Post, error) {
|
|
posts := make(map[string]*Post)
|
|
ents, err := os.ReadDir(basePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, ent := range ents {
|
|
var apn string
|
|
if ent.IsDir() {
|
|
apn = filepath.Join(basePath, ent.Name(), "index.md")
|
|
if _, err := os.Stat(apn); err != nil {
|
|
log.Error().Err(err).Msgf("could not parse %s", ent.Name())
|
|
continue
|
|
}
|
|
} else {
|
|
if !strings.HasSuffix(ent.Name(), ".md") {
|
|
continue
|
|
}
|
|
apn = filepath.Join(basePath, ent.Name())
|
|
}
|
|
|
|
fi, err := os.Open(apn)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("could not open file")
|
|
continue
|
|
}
|
|
post := &Post{Path: apn, Slug: strings.TrimSuffix(ent.Name(), ".md")}
|
|
if err := markdown.Meta(fi, &post); err != nil {
|
|
log.Error().Err(err).Msg("could not extract meta")
|
|
continue
|
|
}
|
|
posts[post.Slug] = post
|
|
|
|
if err := fi.Close(); err != nil {
|
|
log.Error().Err(err).Msg("could not close file")
|
|
continue
|
|
}
|
|
|
|
if err := post.Load(); err != nil {
|
|
log.Err(err).Msg("could not load post")
|
|
continue
|
|
}
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
type Blog struct {
|
|
Path string
|
|
posts map[string]*Post
|
|
mx sync.RWMutex
|
|
}
|
|
|
|
func (b *Blog) Scan() error {
|
|
posts, err := scan(b.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.mx.Lock()
|
|
defer b.mx.Unlock()
|
|
b.posts = posts
|
|
return nil
|
|
}
|
|
|
|
func (b *Blog) SortedPosts() []*Post {
|
|
posts := make([]*Post, 0, len(b.posts))
|
|
for _, post := range b.posts {
|
|
posts = append(posts, post)
|
|
}
|
|
sort.Slice(posts, func(i, j int) bool {
|
|
return posts[i].Date.After(posts[j].Date)
|
|
})
|
|
return posts
|
|
}
|
|
|
|
func (b *Blog) Post(name string) (*Post, bool) {
|
|
b.mx.RLock()
|
|
defer b.mx.RUnlock()
|
|
if post, ok := b.posts[name]; ok {
|
|
return post, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
type Post struct {
|
|
Path string `toml:"-"`
|
|
Slug string `toml:"-"`
|
|
Content template.HTML `toml:"-"`
|
|
Title string `toml:"title"`
|
|
Author string `toml:"author"`
|
|
Date time.Time `toml:"date"`
|
|
Tags []string `toml:"tags"`
|
|
}
|
|
|
|
func (p *Post) Load() error {
|
|
fi, err := os.Open(p.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer fi.Close()
|
|
|
|
mdContent, err := markdown.Content(fi)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
emdbedContent, err := emdbed.Convert(filepath.Dir(p.Path), strings.NewReader(mdContent))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
md, err := markdown.Convert(strings.NewReader(emdbedContent))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p.Content = template.HTML(md)
|
|
return nil
|
|
}
|