package postmap import ( "bytes" "fmt" "io" "iter" "log" "os" "prose/common" "prose/watcher" "slices" "strings" "sync" "github.com/aymerick/raymond" "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) Render(tpl *raymond.Template) (string, error) { return tpl.Exec(p) } 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 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 } } } }