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=