feat: OpenGraph metadata + auto-generated OG card images
- 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>
This commit is contained in:
@@ -20,10 +20,13 @@ var md = goldmark.New(
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
Slug string
|
||||
Title string
|
||||
Date time.Time
|
||||
HTML string
|
||||
Slug string
|
||||
Title string
|
||||
Date time.Time
|
||||
HTML string
|
||||
Description string
|
||||
OGImage string
|
||||
OGImageCustom bool
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -56,19 +59,27 @@ func (s *Store) Load(dir string) error {
|
||||
}
|
||||
|
||||
slug := strings.TrimSuffix(e.Name(), ".md")
|
||||
title, body, date := parseFrontMatter(raw, slug, path)
|
||||
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(),
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
Date: date,
|
||||
HTML: buf.String(),
|
||||
Description: description,
|
||||
OGImage: ogImagePath,
|
||||
OGImageCustom: ogImageCustom,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,6 +94,15 @@ func (s *Store) Load(dir string) error {
|
||||
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
|
||||
}
|
||||
@@ -112,7 +132,8 @@ var dateFormats = []string{
|
||||
// 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) {
|
||||
// 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
|
||||
@@ -156,7 +177,27 @@ func parseFrontMatter(raw []byte, slug, path string) (title string, body []byte,
|
||||
}
|
||||
}
|
||||
|
||||
return title, body, date
|
||||
// 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 {
|
||||
@@ -166,3 +207,24 @@ func fileModTime(path string) time.Time {
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user