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:
167
blog/ogimage.go
Normal file
167
blog/ogimage.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user