Signed-off-by: Naman Sood <mail@nsood.in>
This commit is contained in:
parent
18455362d3
commit
1981a291aa
17 changed files with 916 additions and 849 deletions
213
postmap/postmap.go
Normal file
213
postmap/postmap.go
Normal file
|
@ -0,0 +1,213 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue