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 }