Initial commit: chaosmith-site with blog
Static landing page + markdown blog served by Go. Goldmark renders posts in-memory, no database. Dockerfile + compose for podman deployment. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
This commit is contained in:
161
blog/store.go
Normal file
161
blog/store.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package blog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
Slug string
|
||||
Title string
|
||||
Date time.Time
|
||||
HTML string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
posts []Post
|
||||
index map[string]int
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{index: make(map[string]int)}
|
||||
}
|
||||
|
||||
func (s *Store) Load(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.posts = s.posts[:0]
|
||||
s.index = make(map[string]int)
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, e.Name())
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
slug := strings.TrimSuffix(e.Name(), ".md")
|
||||
title, body, date := parseFrontMatter(raw, slug, path)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := goldmark.Convert(body, &buf); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
s.index[slug] = len(s.posts)
|
||||
s.posts = append(s.posts, Post{
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
Date: date,
|
||||
HTML: buf.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// sort newest first
|
||||
sort.Slice(s.posts, func(i, j int) bool {
|
||||
return s.posts[i].Date.After(s.posts[j].Date)
|
||||
})
|
||||
for i, p := range s.posts {
|
||||
s.index[p.Slug] = i
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) All() []Post {
|
||||
return s.posts
|
||||
}
|
||||
|
||||
func (s *Store) Get(slug string) (Post, bool) {
|
||||
i, ok := s.index[slug]
|
||||
if !ok {
|
||||
return Post{}, false
|
||||
}
|
||||
return s.posts[i], true
|
||||
}
|
||||
|
||||
func (s *Store) Count() int {
|
||||
return len(s.posts)
|
||||
}
|
||||
|
||||
var h1Re = regexp.MustCompile(`(?m)^#\s+(.+)$`)
|
||||
|
||||
// Date formats we recognize on the line after the title
|
||||
var dateFormats = []string{
|
||||
"2006-01-02",
|
||||
"January 2, 2006",
|
||||
"Jan 2, 2006",
|
||||
"02/01/2006",
|
||||
}
|
||||
|
||||
// parseFrontMatter extracts the title from the first `# heading`,
|
||||
// strips that line from the body, and tries to parse a date from
|
||||
// the line immediately after. Falls back to file mod time.
|
||||
func parseFrontMatter(raw []byte, slug, path string) (title string, body []byte, date time.Time) {
|
||||
body = raw
|
||||
|
||||
// Extract title
|
||||
m := h1Re.FindSubmatch(raw)
|
||||
if len(m) >= 2 {
|
||||
title = strings.TrimSpace(string(m[1]))
|
||||
} else {
|
||||
title = slug
|
||||
}
|
||||
|
||||
// Strip the first `# title` line from body so it's not rendered twice
|
||||
if loc := h1Re.FindIndex(raw); loc != nil {
|
||||
// Find end of that line
|
||||
lineEnd := loc[1]
|
||||
// advance past newline
|
||||
for lineEnd < len(raw) && (raw[lineEnd] == '\n' || raw[lineEnd] == '\r') {
|
||||
lineEnd++
|
||||
}
|
||||
body = raw[lineEnd:]
|
||||
}
|
||||
|
||||
// Try to parse date from first remaining line
|
||||
date = fileModTime(path)
|
||||
rest := strings.TrimSpace(string(body))
|
||||
if rest != "" {
|
||||
firstLine := rest
|
||||
if idx := strings.IndexByte(rest, '\n'); idx >= 0 {
|
||||
firstLine = strings.TrimSpace(rest[:idx])
|
||||
}
|
||||
for _, fmt := range dateFormats {
|
||||
if t, err := time.Parse(fmt, firstLine); err == nil {
|
||||
date = t
|
||||
// strip the date line from body too
|
||||
if idx := strings.IndexByte(string(body), '\n'); idx >= 0 {
|
||||
body = body[idx+1:]
|
||||
} else {
|
||||
body = nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return title, body, date
|
||||
}
|
||||
|
||||
func fileModTime(path string) time.Time {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
return info.ModTime()
|
||||
}
|
||||
Reference in New Issue
Block a user