first commit
This commit is contained in:
115
internal/blog/post.go
Normal file
115
internal/blog/post.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package blog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// FrontMatter is the YAML block at the top of a post file.
|
||||
type FrontMatter struct {
|
||||
Title string `yaml:"title"`
|
||||
Date string `yaml:"date"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Slug string `yaml:"slug"`
|
||||
Draft bool `yaml:"draft"`
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
// Post is a parsed blog post with rendered HTML content.
|
||||
type Post struct {
|
||||
FrontMatter
|
||||
ParsedDate time.Time
|
||||
Content template.HTML
|
||||
Slug string
|
||||
}
|
||||
|
||||
// RenderMarkdown converts a markdown string to HTML.
|
||||
func RenderMarkdown(src string) (template.HTML, error) {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
extension.Footnote,
|
||||
extension.DefinitionList,
|
||||
extension.Strikethrough,
|
||||
extension.Table,
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithFormatOptions(
|
||||
chromahtml.WithClasses(true),
|
||||
),
|
||||
),
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithXHTML(),
|
||||
),
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(src), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return template.HTML(buf.String()), nil
|
||||
}
|
||||
|
||||
// ParsePost parses raw markdown bytes (with optional YAML frontmatter) into a Post.
|
||||
// filename is the bare filename (e.g. "my-post.md") used to derive the slug if not set.
|
||||
func ParsePost(raw []byte, filename string) (*Post, error) {
|
||||
content := string(raw)
|
||||
var fm FrontMatter
|
||||
var markdownContent string
|
||||
|
||||
if strings.HasPrefix(content, "---") {
|
||||
// Find closing ---
|
||||
rest := content[3:]
|
||||
idx := strings.Index(rest, "\n---")
|
||||
if idx >= 0 {
|
||||
fmRaw := rest[:idx]
|
||||
if err := yaml.Unmarshal([]byte(fmRaw), &fm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
markdownContent = strings.TrimSpace(rest[idx+4:])
|
||||
} else {
|
||||
markdownContent = content
|
||||
}
|
||||
} else {
|
||||
markdownContent = content
|
||||
}
|
||||
|
||||
htmlContent, err := RenderMarkdown(markdownContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slug := fm.Slug
|
||||
if slug == "" {
|
||||
slug = strings.TrimSuffix(filename, ".md")
|
||||
}
|
||||
|
||||
var parsedDate time.Time
|
||||
if fm.Date != "" {
|
||||
for _, layout := range []string{"2006-01-02", "2006-01-02T15:04:05Z07:00"} {
|
||||
if t, err := time.Parse(layout, fm.Date); err == nil {
|
||||
parsedDate = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Post{
|
||||
FrontMatter: fm,
|
||||
ParsedDate: parsedDate,
|
||||
Content: htmlContent,
|
||||
Slug: slug,
|
||||
}, nil
|
||||
}
|
||||
182
internal/blog/store.go
Normal file
182
internal/blog/store.go
Normal file
@@ -0,0 +1,182 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user