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 }