183 lines
3.9 KiB
Go
183 lines
3.9 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
|
|
}
|
|
|
|
// 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
|
|
}
|