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

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
}