All checks were successful
/ Deploy blog to deuterium (push) Successful in 1m50s
Signed-off-by: Naman Sood <mail@nsood.in>
210 lines
4.4 KiB
Go
210 lines
4.4 KiB
Go
package postmap
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"iter"
|
|
"log"
|
|
"os"
|
|
"prose/common"
|
|
"prose/watcher"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/mitchellh/mapstructure"
|
|
"github.com/yuin/goldmark"
|
|
emoji "github.com/yuin/goldmark-emoji"
|
|
highlighting "github.com/yuin/goldmark-highlighting"
|
|
meta "github.com/yuin/goldmark-meta"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
)
|
|
|
|
// Metadata stores the data about a post that needs to be visible
|
|
// at the home page.
|
|
type Metadata struct {
|
|
Title string
|
|
Summary string
|
|
Time int64 // unix timestamp
|
|
}
|
|
|
|
// Post stores the contents of a blog post.
|
|
type Post struct {
|
|
Slug string
|
|
Metadata Metadata
|
|
Contents string
|
|
Image []byte
|
|
}
|
|
|
|
func (p *Post) String() string {
|
|
return p.Slug
|
|
}
|
|
|
|
func postLess(a, b *Post) int {
|
|
return int(b.Metadata.Time - a.Metadata.Time)
|
|
}
|
|
|
|
// postList stores Posts in reverse order of Post.Metadata.Time.
|
|
type postList struct {
|
|
mu sync.RWMutex
|
|
posts []*Post
|
|
md goldmark.Markdown
|
|
}
|
|
|
|
// New returns a new postList.
|
|
func New() (watcher.OrderedAutoMap[string, *Post], error) {
|
|
files, err := os.ReadDir("posts/")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pl := &postList{
|
|
posts: make([]*Post, 0, len(files)),
|
|
md: goldmark.New(
|
|
goldmark.WithExtensions(
|
|
extension.Linkify,
|
|
extension.Strikethrough,
|
|
extension.Typographer,
|
|
extension.Footnote,
|
|
meta.Meta,
|
|
highlighting.Highlighting,
|
|
emoji.New(emoji.WithRenderingMethod(emoji.Unicode)),
|
|
),
|
|
goldmark.WithRendererOptions(
|
|
html.WithUnsafe(),
|
|
),
|
|
),
|
|
}
|
|
for _, f := range files {
|
|
filename := f.Name()
|
|
|
|
if filename == ".gitignore" {
|
|
continue
|
|
}
|
|
|
|
err := pl.fetchLocked(filename)
|
|
if err != nil {
|
|
log.Printf("could not render %q, skipping: %v", filename, err)
|
|
} else {
|
|
log.Printf("loaded post: %q", filename)
|
|
}
|
|
}
|
|
|
|
go watcher.Watch("posts/", pl)
|
|
|
|
return pl, nil
|
|
}
|
|
|
|
func (pl *postList) newPost(filename string) (*Post, error) {
|
|
slug, ok := strings.CutSuffix(filename, ".md")
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown file extension in posts directory: %q", filename)
|
|
}
|
|
data, err := os.ReadFile("posts/" + filename)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not read file: %w", err)
|
|
}
|
|
|
|
var converted bytes.Buffer
|
|
ctx := parser.NewContext()
|
|
err = pl.md.Convert(data, &converted, parser.WithContext(ctx))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not parse markdown: %w", err)
|
|
}
|
|
mdMap, err := meta.TryGet(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not parse metadata: %w", err)
|
|
}
|
|
var metadata Metadata
|
|
err = mapstructure.Decode(mdMap, &metadata)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not destructure metadata: %w", err)
|
|
}
|
|
|
|
post := &Post{
|
|
Slug: slug,
|
|
Metadata: metadata,
|
|
Contents: converted.String(),
|
|
}
|
|
|
|
url := common.BlogURL + "/" + slug
|
|
var buf bytes.Buffer
|
|
err = common.CreateImage(post.Metadata.Title, post.Metadata.Summary, url, &buf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not create post image: %w", err)
|
|
}
|
|
post.Image, err = io.ReadAll(&buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return post, nil
|
|
}
|
|
|
|
func (pl *postList) Get(slug string) (*Post, bool) {
|
|
pl.mu.RLock()
|
|
defer pl.mu.RUnlock()
|
|
for _, p := range pl.posts {
|
|
if p.Slug == slug {
|
|
return p, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (pl *postList) Fetch(filename string) error {
|
|
pl.mu.Lock()
|
|
defer pl.mu.Unlock()
|
|
return pl.fetchLocked(filename)
|
|
}
|
|
|
|
func (pl *postList) fetchLocked(filename string) error {
|
|
p, err := pl.newPost(filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
slices.SortFunc(pl.posts, postLess)
|
|
}()
|
|
for i, post := range pl.posts {
|
|
if post.Slug == p.Slug {
|
|
pl.posts[i] = p
|
|
return nil
|
|
}
|
|
}
|
|
pl.posts = append(pl.posts, p)
|
|
return nil
|
|
}
|
|
|
|
func (pl *postList) Delete(filename string) error {
|
|
pl.mu.Lock()
|
|
defer pl.mu.Unlock()
|
|
slug, ok := strings.CutSuffix(filename, ".md")
|
|
if !ok {
|
|
return fmt.Errorf("unknown file extension in posts directory: %q", filename)
|
|
}
|
|
for i, post := range pl.posts {
|
|
if post.Slug == slug {
|
|
pl.posts = append(pl.posts[:i], pl.posts[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// All implements watcher.OrderedCompMap[string, *Post], providing Posts in
|
|
// order of most recent first.
|
|
func (pl *postList) All() iter.Seq2[string, *Post] {
|
|
return func(yield func(string, *Post) bool) {
|
|
pl.mu.RLock()
|
|
defer pl.mu.RUnlock()
|
|
for _, p := range pl.posts {
|
|
if !yield(p.Slug, p) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|