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
|
*.log
|
||||||
.letta/
|
.letta/
|
||||||
.directory
|
.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"
|
"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"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ var md = goldmark.New(
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Post struct {
|
type Post struct {
|
||||||
Slug string
|
Slug string
|
||||||
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
7
go.mod
@@ -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
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 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=
|
||||||
|
|||||||
Reference in New Issue
Block a user