diff --git a/.gitignore b/.gitignore
index 6ffbe9c..530ad40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
*.log
.letta/
.directory
+/content/og/
diff --git a/blog/assets/OpenSans-Bold.ttf b/blog/assets/OpenSans-Bold.ttf
new file mode 100644
index 0000000..b7fadfa
Binary files /dev/null and b/blog/assets/OpenSans-Bold.ttf differ
diff --git a/blog/assets/OpenSans-Regular.ttf b/blog/assets/OpenSans-Regular.ttf
new file mode 100644
index 0000000..8529c43
Binary files /dev/null and b/blog/assets/OpenSans-Regular.ttf differ
diff --git a/blog/assets/og-base.png b/blog/assets/og-base.png
new file mode 100644
index 0000000..820dce3
Binary files /dev/null and b/blog/assets/og-base.png differ
diff --git a/blog/ogimage.go b/blog/ogimage.go
new file mode 100644
index 0000000..113a22e
--- /dev/null
+++ b/blog/ogimage.go
@@ -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
+}
diff --git a/blog/routes.go b/blog/routes.go
index 5bec168..2fbb168 100644
--- a/blog/routes.go
+++ b/blog/routes.go
@@ -4,6 +4,7 @@ import (
"html/template"
"log"
"net/http"
+ "path/filepath"
"strings"
)
@@ -13,6 +14,25 @@ const pageTpl = `
{{.Title}}
+ {{if .Post.HTML}}
+
+
+
+
+
+
+
+
+
+
+
+ {{else}}
+
+
+
+
+
+ {{end}}
@@ -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"))
})
diff --git a/blog/store.go b/blog/store.go
index 65a6cf7..db1a8f5 100644
--- a/blog/store.go
+++ b/blog/store.go
@@ -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 ""
+}
diff --git a/go.mod b/go.mod
index 718d1ee..dd96117 100644
--- a/go.mod
+++ b/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
+)
diff --git a/go.sum b/go.sum
index 6a37955..f9f9a61 100644
--- a/go.sum
+++ b/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=