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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
*.log
|
||||
.letta/
|
||||
.directory
|
||||
/content/og/
|
||||
|
||||
BIN
blog/assets/OpenSans-Bold.ttf
Normal file
BIN
blog/assets/OpenSans-Bold.ttf
Normal file
Binary file not shown.
BIN
blog/assets/OpenSans-Regular.ttf
Normal file
BIN
blog/assets/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
blog/assets/og-base.png
Normal file
BIN
blog/assets/og-base.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -13,6 +14,25 @@ const pageTpl = `<!DOCTYPE html>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{.Title}}</title>
|
||||
<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.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">
|
||||
@@ -176,10 +196,14 @@ func render(w http.ResponseWriter, data pageData) {
|
||||
}
|
||||
|
||||
func RegisterRoutes(mux *http.ServeMux, contentDir string) error {
|
||||
ogDir := filepath.Join(contentDir, "..", "og")
|
||||
store := NewStore()
|
||||
if err := store.Load(contentDir); err != nil {
|
||||
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) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -190,6 +214,7 @@ func RegisterRoutes(mux *http.ServeMux, contentDir string) error {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
store.GenerateOGImages(ogDir)
|
||||
w.Write([]byte("reloaded\n"))
|
||||
})
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
7
go.mod
7
go.mod
@@ -3,3 +3,10 @@ module chaosmith-site
|
||||
go 1.26.4
|
||||
|
||||
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
8
go.sum
@@ -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/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=
|
||||
|
||||
Reference in New Issue
Block a user