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

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
*.log *.log
.letta/ .letta/
.directory .directory
/content/og/

Binary file not shown.

Binary file not shown.

BIN
blog/assets/og-base.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

167
blog/ogimage.go Normal file
View File

@@ -0,0 +1,167 @@
package blog
import (
"bytes"
_ "embed"
"image"
"image/png"
"os"
"path/filepath"
"strings"
"github.com/fogleman/gg"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
)
//go:embed assets/OpenSans-Bold.ttf
var fontBoldBytes []byte
//go:embed assets/OpenSans-Regular.ttf
var fontRegularBytes []byte
//go:embed assets/og-base.png
var ogBaseImage []byte
var (
fontBoldParsed *truetype.Font
fontRegularParsed *truetype.Font
)
func init() {
fontBoldParsed, _ = truetype.Parse(fontBoldBytes)
fontRegularParsed, _ = truetype.Parse(fontRegularBytes)
}
func makeFace(f *truetype.Font, size float64) font.Face {
return truetype.NewFace(f, &truetype.Options{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
}
// Layout constants — adjust these to reposition text
const (
ogW = 1200
ogH = 630
// Text section (left side)
textX = 60 // left padding for title/description
textW = 680 // max text width for wrapping
textStartY = 195 // top of title block
lineGap = 12 // gap between title and description
// Art section (right side) — domain and date centered here
artStartX = 770 // left edge of art section
artCX = 985 // horizontal center of art section (770 + 430/2)
domainX = 1080
dateX = 1100
domainY = 590 // domain text Y
dateY = 30 // date text Y
titleFontSize = 56
descFontSize = 26
domainFontSize = 22
maxTitleLines = 3
maxDescLines = 5
)
func ensureGeneratedOGImage(dir, slug, title, dateStr, description string) string {
outPath := filepath.Join(dir, slug+".png")
if _, err := os.Stat(outPath); err == nil {
return outPath
}
if err := os.MkdirAll(dir, 0755); err != nil {
return ""
}
img := generateOGImage(title, dateStr, description)
f, err := os.Create(outPath)
if err != nil {
return ""
}
defer f.Close()
png.Encode(f, img)
return outPath
}
func generateOGImage(title, dateStr, description string) image.Image {
// Decode the embedded base image
baseImg, _, _ := image.Decode(bytes.NewReader(ogBaseImage))
dc := gg.NewContext(ogW, ogH)
dc.DrawImage(baseImg, 0, 0)
// Domain name — centered in right art section, top
dc.SetFontFace(makeFace(fontRegularParsed, domainFontSize))
dc.SetRGBA(0.663, 0.639, 0.769, 0.8)
dc.DrawStringAnchored("chaosmith.systems", float64(domainX), float64(domainY), 0.5, 0.9)
// Date — centered in right art section, bottom
if dateStr != "" {
dc.SetRGBA(0.663, 0.639, 0.769, 0.6)
dc.DrawStringAnchored(dateStr, float64(dateX), float64(dateY), 0.5, 0.9)
}
// Title — right section, top-aligned
dc.SetFontFace(makeFace(fontBoldParsed, titleFontSize))
dc.SetRGB(0.953, 0.941, 1.0)
titleLines := wrapText(dc, title, float64(textW), maxTitleLines)
currY := float64(textStartY)
lineH := titleFontSize * 1.2
for _, line := range titleLines {
dc.DrawStringAnchored(line, float64(textX), currY, 0, 0.5)
currY += lineH
}
// Description — below title
currY += float64(lineGap)
dc.SetFontFace(makeFace(fontRegularParsed, descFontSize))
dc.SetRGBA(0.663, 0.639, 0.769, 0.8)
descLines := wrapText(dc, description, float64(textW), maxDescLines)
descLineH := descFontSize * 1.3
for _, line := range descLines {
dc.DrawStringAnchored(line, float64(textX), currY, 0, 0.5)
currY += descLineH
}
return dc.Image()
}
func wrapText(dc *gg.Context, text string, maxW float64, maxLines int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return []string{""}
}
var lines []string
curr := words[0]
for _, w := range words[1:] {
test := curr + " " + w
width, _ := dc.MeasureString(test)
if width > maxW {
lines = append(lines, curr)
curr = w
} else {
curr = test
}
}
lines = append(lines, curr)
if len(lines) > maxLines {
lines = lines[:maxLines]
last := lines[maxLines-1]
if len(last) > 3 {
last = last[:len(last)-3] + "..."
}
lines[maxLines-1] = last
}
return lines
}

View File

@@ -4,6 +4,7 @@ import (
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
) )
@@ -13,6 +14,25 @@ const pageTpl = `<!DOCTYPE html>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{{.Title}}</title> <title>{{.Title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{{if .Post.HTML}}
<meta property="og:title" content="{{.Post.Title}}">
<meta property="og:description" content="{{.Post.Description}}">
<meta property="og:type" content="article">
<meta property="og:url" content="https://chaosmith.systems/blog/posts/{{.Post.Slug}}">
<meta property="og:image" content="https://chaosmith.systems{{.Post.OGImage}}">
<meta property="og:site_name" content="Chaosmith Systems">
<meta property="article:published_time" content="{{.Post.Date.Format "2006-01-02T15:04:05Z07:00"}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.Post.Title}}">
<meta name="twitter:description" content="{{.Post.Description}}">
<meta name="twitter:image" content="https://chaosmith.systems{{.Post.OGImage}}">
{{else}}
<meta property="og:title" content="{{.Title}}">
<meta property="og:type" content="website">
<meta property="og:url" content="https://chaosmith.systems/blog/">
<meta property="og:site_name" content="Chaosmith Systems">
<meta name="twitter:card" content="summary">
{{end}}
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@300;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@300;500;700&display=swap" rel="stylesheet">
@@ -176,10 +196,14 @@ func render(w http.ResponseWriter, data pageData) {
} }
func RegisterRoutes(mux *http.ServeMux, contentDir string) error { func RegisterRoutes(mux *http.ServeMux, contentDir string) error {
ogDir := filepath.Join(contentDir, "..", "og")
store := NewStore() store := NewStore()
if err := store.Load(contentDir); err != nil { if err := store.Load(contentDir); err != nil {
log.Printf("blog: warning: could not load posts from %s: %v", contentDir, err) log.Printf("blog: warning: could not load posts from %s: %v", contentDir, err)
} }
store.GenerateOGImages(ogDir)
mux.Handle("/blog/og/", http.StripPrefix("/blog/og/", http.FileServer(http.Dir(ogDir))))
mux.HandleFunc("/blog/admin/reload", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/blog/admin/reload", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@@ -190,6 +214,7 @@ func RegisterRoutes(mux *http.ServeMux, contentDir string) error {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
store.GenerateOGImages(ogDir)
w.Write([]byte("reloaded\n")) w.Write([]byte("reloaded\n"))
}) })

View File

@@ -24,6 +24,9 @@ type Post struct {
Title string Title string
Date time.Time Date time.Time
HTML string HTML string
Description string
OGImage string
OGImageCustom bool
} }
type Store struct { type Store struct {
@@ -56,19 +59,27 @@ func (s *Store) Load(dir string) error {
} }
slug := strings.TrimSuffix(e.Name(), ".md") 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 var buf bytes.Buffer
if err := md.Convert(body, &buf); err != nil { if err := md.Convert(body, &buf); err != nil {
continue continue
} }
ogImagePath := ogImage
if !ogImageCustom {
ogImagePath = "/blog/og/" + slug + ".png"
}
s.index[slug] = len(s.posts) s.index[slug] = len(s.posts)
s.posts = append(s.posts, Post{ s.posts = append(s.posts, Post{
Slug: slug, Slug: slug,
Title: title, Title: title,
Date: date, Date: date,
HTML: buf.String(), HTML: buf.String(),
Description: description,
OGImage: ogImagePath,
OGImageCustom: ogImageCustom,
}) })
} }
@@ -83,6 +94,15 @@ func (s *Store) Load(dir string) error {
return nil 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 { func (s *Store) All() []Post {
return s.posts return s.posts
} }
@@ -112,7 +132,8 @@ var dateFormats = []string{
// parseFrontMatter extracts the title from the first `# heading`, // parseFrontMatter extracts the title from the first `# heading`,
// strips that line from the body, and tries to parse a date from // strips that line from the body, and tries to parse a date from
// the line immediately after. Falls back to file mod time. // 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 body = raw
// Extract title // 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 { func fileModTime(path string) time.Time {
@@ -166,3 +207,24 @@ func fileModTime(path string) time.Time {
} }
return info.ModTime() 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 ""
}

7
go.mod
View File

@@ -3,3 +3,10 @@ module chaosmith-site
go 1.26.4 go 1.26.4
require github.com/yuin/goldmark v1.8.2 require github.com/yuin/goldmark v1.8.2
require (
github.com/fogleman/gg v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/text v0.38.0 // indirect
)

8
go.sum
View File

@@ -1,2 +1,10 @@
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=