Files
codegirl.games-hugo/cms/internal/posts/posts.go
T
Cursor Agentandcodegirl007 ce15f54a52 Add lightweight self-hosted Hugo CMS with GitHub API backend
Implements a Go-based CMS for editing Hugo posts and uploading images
via the GitHub REST API, with no database and Git as the source of truth.

Features:
- Single admin auth with bcrypt password and signed session cookies
- Dashboard, post listing with search, create/edit with EasyMDE editor
- Media library for static/uploads/ with drag-drop and clipboard paste
- Client-side autosave, unsaved changes warning, dark mode, responsive UI
- Modular packages for auth, github, posts, media, session, and handlers

Co-authored-by: codegirl007 <s.raide@gmail.com>
2026-07-06 18:56:17 +00:00

369 lines
8.0 KiB
Go

package posts
import (
"context"
"fmt"
"path"
"sort"
"strings"
"time"
"github.com/codegirl-007/hugo-cms/internal/github"
"gopkg.in/yaml.v3"
)
const postsDir = "content/posts"
// Post represents a Hugo blog post with front matter and body.
type Post struct {
Slug string
Title string
Date time.Time
Draft bool
Tags []string
Body string
SHA string
Extra map[string]any
LastModified time.Time
}
// ListItem is a summary of a post for dashboard display.
type ListItem struct {
Slug string
Title string
Date time.Time
Draft bool
LastModified time.Time
}
// SaveInput contains fields submitted when saving a post.
type SaveInput struct {
Slug string
Title string
Date time.Time
Draft bool
Tags []string
Body string
Original string
}
// Service manages Hugo posts via GitHub.
type Service struct {
github github.Client
}
// NewService creates a post service.
func NewService(client github.Client) *Service {
return &Service{github: client}
}
// List returns all posts sorted by date descending.
func (s *Service) List(ctx context.Context) ([]ListItem, error) {
items, err := s.github.ListDirectory(ctx, postsDir)
if err != nil {
return nil, fmt.Errorf("list posts: %w", err)
}
var posts []ListItem
for _, item := range items {
if item.Type != "file" || !strings.HasSuffix(item.Name, ".md") {
continue
}
if item.Name == "_index.md" {
continue
}
file, err := s.github.GetFile(ctx, item.Path)
if err != nil {
continue
}
content, err := github.DecodeContent(file)
if err != nil {
continue
}
post, err := parsePost(item.Name, content)
if err != nil {
continue
}
posts = append(posts, ListItem{
Slug: post.Slug,
Title: post.Title,
Date: post.Date,
Draft: post.Draft,
LastModified: post.LastModified,
})
}
sort.Slice(posts, func(i, j int) bool {
return posts[i].Date.After(posts[j].Date)
})
return posts, nil
}
// Get loads a single post by slug.
func (s *Service) Get(ctx context.Context, slug string) (*Post, error) {
slug = sanitizeSlug(slug)
if slug == "" {
return nil, fmt.Errorf("invalid slug")
}
filePath := postPath(slug)
file, err := s.github.GetFile(ctx, filePath)
if err != nil {
return nil, fmt.Errorf("get post: %w", err)
}
content, err := github.DecodeContent(file)
if err != nil {
return nil, fmt.Errorf("decode post: %w", err)
}
post, err := parsePost(path.Base(file.Path), content)
if err != nil {
return nil, err
}
post.SHA = file.SHA
return post, nil
}
// Save creates or updates a post on the main branch.
func (s *Service) Save(ctx context.Context, input SaveInput) error {
slug := sanitizeSlug(input.Slug)
if slug == "" {
return fmt.Errorf("slug is required")
}
if strings.TrimSpace(input.Title) == "" {
return fmt.Errorf("title is required")
}
filePath := postPath(slug)
existingSHA := ""
// Check if we're renaming from an original slug.
if input.Original != "" && input.Original != slug {
originalPath := postPath(sanitizeSlug(input.Original))
if originalFile, err := s.github.GetFile(ctx, originalPath); err == nil {
// Delete old file by committing empty content is not ideal;
// instead update old path only if slug changed - create new, delete old.
if err := s.github.DeleteFile(ctx, originalPath,
fmt.Sprintf("Remove post after rename: %s", input.Title), originalFile.SHA); err != nil {
return fmt.Errorf("remove renamed post: %w", err)
}
}
}
if existing, err := s.github.GetFile(ctx, filePath); err == nil {
existingSHA = existing.SHA
}
var extra map[string]any
if input.Original != "" && input.Original == slug {
if existing, err := s.Get(ctx, slug); err == nil {
extra = existing.Extra
}
}
content := []byte(renderPost(input, extra))
action := "Create"
if existingSHA != "" {
action = "Update"
}
message := fmt.Sprintf("%s post: %s", action, input.Title)
return s.github.CreateOrUpdateFile(ctx, filePath, message, content, existingSHA)
}
func postPath(slug string) string {
return path.Join(postsDir, slug+".md")
}
// SanitizeSlug normalizes a post slug.
func SanitizeSlug(slug string) string {
return sanitizeSlug(slug)
}
func sanitizeSlug(slug string) string {
slug = strings.TrimSpace(strings.ToLower(slug))
slug = strings.ReplaceAll(slug, " ", "-")
var b strings.Builder
for _, r := range slug {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
} else if r == ' ' || r == '_' {
b.WriteRune('-')
}
}
result := strings.Trim(b.String(), "-")
for strings.Contains(result, "--") {
result = strings.ReplaceAll(result, "--", "-")
}
return result
}
func parsePost(filename string, content []byte) (*Post, error) {
slug := strings.TrimSuffix(filename, ".md")
fm, body, err := splitFrontMatter(string(content))
if err != nil {
return &Post{Slug: slug, Title: slug, Body: string(content)}, nil
}
meta := map[string]any{}
if err := yaml.Unmarshal([]byte(fm), &meta); err != nil {
return nil, fmt.Errorf("parse front matter: %w", err)
}
post := &Post{
Slug: slug,
Body: strings.TrimLeft(body, "\n"),
Extra: make(map[string]any),
}
if v, ok := meta["title"].(string); ok {
post.Title = v
}
if v, ok := meta["draft"].(bool); ok {
post.Draft = v
}
if tags, ok := meta["tags"].([]any); ok {
for _, t := range tags {
if s, ok := t.(string); ok {
post.Tags = append(post.Tags, s)
}
}
}
post.Date = parseDate(meta["date"])
post.LastModified = post.Date
for k, v := range meta {
switch k {
case "title", "date", "draft", "tags":
continue
default:
post.Extra[k] = v
}
}
if post.Title == "" {
post.Title = slug
}
return post, nil
}
func parseDate(v any) time.Time {
switch d := v.(type) {
case string:
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02",
}
for _, f := range formats {
if t, err := time.Parse(f, d); err == nil {
return t
}
}
case time.Time:
return d
}
return time.Now().UTC()
}
func splitFrontMatter(content string) (string, string, error) {
content = strings.TrimPrefix(content, "\ufeff")
if !strings.HasPrefix(content, "---") {
return "", content, fmt.Errorf("no front matter")
}
rest := content[3:]
if strings.HasPrefix(rest, "\n") {
rest = rest[1:]
} else if strings.HasPrefix(rest, "\r\n") {
rest = rest[2:]
}
idx := strings.Index(rest, "\n---")
if idx < 0 {
return "", content, fmt.Errorf("unclosed front matter")
}
fm := rest[:idx]
body := rest[idx+4:]
if strings.HasPrefix(body, "\n") {
body = body[1:]
} else if strings.HasPrefix(body, "\r\n") {
body = body[2:]
}
return fm, body, nil
}
func renderPost(input SaveInput, extra map[string]any) string {
meta := map[string]any{
"title": input.Title,
"date": input.Date.UTC().Format(time.RFC3339),
"draft": input.Draft,
}
if len(input.Tags) > 0 {
meta["tags"] = input.Tags
}
for k, v := range extra {
if _, exists := meta[k]; !exists {
meta[k] = v
}
}
fmBytes, _ := yaml.Marshal(meta)
var b strings.Builder
b.WriteString("---\n")
b.Write(fmBytes)
if !strings.HasSuffix(b.String(), "\n") {
b.WriteString("\n")
}
b.WriteString("---\n\n")
b.WriteString(strings.TrimRight(input.Body, "\n"))
b.WriteString("\n")
return b.String()
}
// Stats summarizes post counts for the dashboard.
type Stats struct {
Total int
Drafts int
Published int
}
// ComputeStats returns counts from a post list.
func ComputeStats(posts []ListItem) Stats {
var s Stats
s.Total = len(posts)
for _, p := range posts {
if p.Draft {
s.Drafts++
} else {
s.Published++
}
}
return s
}
// FilterByTitle returns posts whose title contains the query (case-insensitive).
func FilterByTitle(posts []ListItem, query string) []ListItem {
query = strings.TrimSpace(strings.ToLower(query))
if query == "" {
return posts
}
var filtered []ListItem
for _, p := range posts {
if strings.Contains(strings.ToLower(p.Title), query) {
filtered = append(filtered, p)
}
}
return filtered
}