- Per-post OG tags (title, description, image, date) in template head - Twitter Card support (summary_large_image) - Auto-generate 1200x630 card images from embedded base PNG - Title, description, domain, date drawn on image via fogleman/gg - Fonts embedded via go:embed (OpenSans Bold + Regular) - Optional og-image: and og-description: front matter overrides - Layout constants in ogimage.go for easy repositioning - Generated images cached in content/og/ (gitignored) 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
231 lines
5.2 KiB
Go
231 lines
5.2 KiB
Go
package blog
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
)
|
|
|
|
var md = goldmark.New(
|
|
goldmark.WithExtensions(extension.GFM),
|
|
goldmark.WithRendererOptions(html.WithHardWraps(), html.WithUnsafe()),
|
|
)
|
|
|
|
type Post struct {
|
|
Slug string
|
|
Title string
|
|
Date time.Time
|
|
HTML string
|
|
Description string
|
|
OGImage string
|
|
OGImageCustom bool
|
|
}
|
|
|
|
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, ogImage, ogImageCustom, description := parseFrontMatter(raw, slug, path)
|
|
|
|
var buf bytes.Buffer
|
|
if err := md.Convert(body, &buf); err != nil {
|
|
continue
|
|
}
|
|
|
|
ogImagePath := ogImage
|
|
if !ogImageCustom {
|
|
ogImagePath = "/blog/og/" + slug + ".png"
|
|
}
|
|
|
|
s.index[slug] = len(s.posts)
|
|
s.posts = append(s.posts, Post{
|
|
Slug: slug,
|
|
Title: title,
|
|
Date: date,
|
|
HTML: buf.String(),
|
|
Description: description,
|
|
OGImage: ogImagePath,
|
|
OGImageCustom: ogImageCustom,
|
|
})
|
|
}
|
|
|
|
// 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) GenerateOGImages(dir string) {
|
|
for _, p := range s.posts {
|
|
if p.OGImageCustom {
|
|
continue
|
|
}
|
|
ensureGeneratedOGImage(dir, p.Slug, p.Title, p.Date.Format("January 2, 2006"), p.Description)
|
|
}
|
|
}
|
|
|
|
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.
|
|
// Also extracts optional og-image: and og-description: lines.
|
|
func parseFrontMatter(raw []byte, slug, path string) (title string, body []byte, date time.Time, ogImage string, ogImageCustom bool, description string) {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strip og-image: and og-description: lines from body (can appear anywhere)
|
|
ogImageRe := regexp.MustCompile(`(?m)^og-image:\s*(.+)$`)
|
|
ogDescRe := regexp.MustCompile(`(?m)^og-description:\s*(.+)$`)
|
|
|
|
if m := ogImageRe.FindSubmatch(body); len(m) >= 2 {
|
|
ogImage = strings.TrimSpace(string(m[1]))
|
|
ogImageCustom = true
|
|
body = ogImageRe.ReplaceAll(body, nil)
|
|
}
|
|
|
|
if m := ogDescRe.FindSubmatch(body); len(m) >= 2 {
|
|
description = strings.TrimSpace(string(m[1]))
|
|
body = ogDescRe.ReplaceAll(body, nil)
|
|
}
|
|
|
|
// Auto-extract description from first paragraph if not set
|
|
if description == "" {
|
|
description = extractDescription(body)
|
|
}
|
|
|
|
return title, body, date, ogImage, ogImageCustom, description
|
|
}
|
|
|
|
func fileModTime(path string) time.Time {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return time.Now()
|
|
}
|
|
return info.ModTime()
|
|
}
|
|
|
|
func extractDescription(body []byte) string {
|
|
// Find first non-empty line that isn't a markdown heading or front matter
|
|
lines := strings.Split(string(body), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "---") {
|
|
continue
|
|
}
|
|
// Strip basic markdown formatting
|
|
desc := strings.ReplaceAll(line, "**", "")
|
|
desc = strings.ReplaceAll(desc, "*", "")
|
|
desc = strings.ReplaceAll(desc, "_", "")
|
|
// Truncate
|
|
if len(desc) > 160 {
|
|
desc = desc[:157] + "..."
|
|
}
|
|
return desc
|
|
}
|
|
return ""
|
|
}
|