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:
2026-06-26 16:30:30 +03:00
parent cc80502a05
commit bd5eb15ade
9 changed files with 281 additions and 11 deletions

View File

@@ -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 ""
}