Files
rs_website/internal/blog/store.go

205 lines
4.5 KiB
Go

package blog
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
)
// Store manages blog posts stored as markdown files on disk.
type Store struct {
dir string
}
// NewStore creates a Store rooted at dir, creating the directory if needed.
func NewStore(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
// All returns posts sorted by date descending.
// If includeDrafts is false, draft posts are excluded.
func (s *Store) All(includeDrafts bool) ([]*Post, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
var posts []*Post
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
raw, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
if err != nil {
continue
}
post, err := ParsePost(raw, e.Name())
if err != nil {
continue
}
if post.Draft && !includeDrafts {
continue
}
posts = append(posts, post)
}
sort.Slice(posts, func(i, j int) bool {
return posts[i].ParsedDate.After(posts[j].ParsedDate)
})
return posts, nil
}
// ByTag returns published posts matching a given tag.
func (s *Store) ByTag(tag string) ([]*Post, error) {
all, err := s.All(false)
if err != nil {
return nil, err
}
var filtered []*Post
for _, p := range all {
for _, t := range p.Tags {
if strings.EqualFold(t, tag) {
filtered = append(filtered, p)
break
}
}
}
return filtered, nil
}
// Get returns a single post by slug.
func (s *Store) Get(slug string) (*Post, error) {
// Try filename = slug + ".md"
raw, err := os.ReadFile(filepath.Join(s.dir, slug+".md"))
if err == nil {
return ParsePost(raw, slug+".md")
}
// Fall back: scan all posts
posts, err := s.All(true)
if err != nil {
return nil, err
}
for _, p := range posts {
if p.Slug == slug {
return p, nil
}
}
return nil, errors.New("post not found: " + slug)
}
// RawContent returns the raw markdown source for a post.
func (s *Store) RawContent(slug string) (string, error) {
raw, err := os.ReadFile(filepath.Join(s.dir, slug+".md"))
if err != nil {
return "", err
}
return string(raw), nil
}
// Save writes raw markdown content to a file named slug+".md".
func (s *Store) Save(slug, content string) error {
return os.WriteFile(filepath.Join(s.dir, slug+".md"), []byte(content), 0644)
}
// Delete removes the file for a given slug.
func (s *Store) Delete(slug string) error {
return os.Remove(filepath.Join(s.dir, slug+".md"))
}
// Search returns published posts whose title, description, tags, or raw markdown
// content contain the query string (case-insensitive).
func (s *Store) Search(query string) ([]*Post, error) {
posts, err := s.All(false)
if err != nil {
return nil, err
}
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return posts, nil
}
seen := map[string]bool{}
var results []*Post
add := func(p *Post) {
if !seen[p.Slug] {
seen[p.Slug] = true
results = append(results, p)
}
}
for _, p := range posts {
if strings.Contains(strings.ToLower(p.Title), q) {
add(p)
continue
}
if strings.Contains(strings.ToLower(p.Description), q) {
add(p)
continue
}
matched := false
for _, t := range p.Tags {
if strings.Contains(strings.ToLower(t), q) {
matched = true
break
}
}
if matched {
add(p)
continue
}
// Fall back to raw markdown search (avoids rendering overhead)
raw, err := s.RawContent(p.Slug)
if err == nil && strings.Contains(strings.ToLower(raw), q) {
add(p)
}
}
return results, nil
}
// Neighbors returns the posts immediately older and newer than slug in
// date-descending order (newer = more recent = lower index).
// Either value may be nil if slug is at the boundary.
func (s *Store) Neighbors(slug string) (older, newer *Post, err error) {
posts, err := s.All(false)
if err != nil {
return nil, nil, err
}
for i, p := range posts {
if p.Slug == slug {
if i+1 < len(posts) {
older = posts[i+1]
}
if i > 0 {
newer = posts[i-1]
}
return older, newer, nil
}
}
return nil, nil, errors.New("post not found: " + slug)
}
// AllTags returns a deduplicated list of tags across all published posts.
func (s *Store) AllTags() ([]string, error) {
posts, err := s.All(false)
if err != nil {
return nil, err
}
seen := map[string]bool{}
var tags []string
for _, p := range posts {
for _, t := range p.Tags {
if !seen[t] {
seen[t] = true
tags = append(tags, t)
}
}
}
sort.Strings(tags)
return tags, nil
}