packages.wenpai.net/internal/og/og.go
Ben Word 7cb8fef01b
WP Packages rename (#42)
* Update all import paths

* Rename directory cmd/wpcomposer/ → cmd/wppackages/

* Rename import alias wpcomposergo → wppackagesgo in main.go and migrate_test.go

* Makefile — binary name wpcomposer → wppackages

* Update Air path

* Global replace repo.wp-composer.com → repo.wp-packages.org

* Global replace cdn.wp-composer.com → cdn.wp-packages.org

* Global replace wp-composer.com → wp-packages.org (remaining)

* Composer repo key in templates/docs: repositories.wp-composer → repositories.wp-packages

* Rename columns on the existing schema

* Update all Go code referencing these column names

* Routes & SEO

* Templates & front-end

* Admin UI

* Documentation

* CI/CD

* Config defaults

* Rename role directory

* Rename all systemd template files inside the role

* Update contents of all .j2 templates — service names, binary paths, descriptions

* Update tasks/main.yml and handlers/main.yml in the role

* Update deploy/ansible/roles/app/tasks/main.yml and deploy.yml

* Update deploy/ansible/group_vars/production/main.yml

* Additional renames/fixes

* Additional renames/fixes

* Additional renames/fixes

* not needed
2026-03-19 11:50:12 -05:00

449 lines
12 KiB
Go

package og
import (
"bytes"
"embed"
"fmt"
"image"
"image/color"
"image/png"
"strings"
"github.com/fogleman/gg"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
)
//go:embed fonts/*.ttf
var fontsFS embed.FS
//go:embed roots-icon.png
var rootsIconData []byte
const (
Width = 1200
Height = 630
)
// Brand colors matching Tailwind config in layout.html
var (
colorBrandPrimary = color.RGBA{R: 0x52, G: 0x5d, B: 0xdc, A: 0xff}
colorGray900 = color.RGBA{R: 0x11, G: 0x18, B: 0x27, A: 0xff}
colorGray500 = color.RGBA{R: 0x6b, G: 0x72, B: 0x80, A: 0xff}
colorGray400 = color.RGBA{R: 0x9c, G: 0xa3, B: 0xaf, A: 0xff}
colorGray200 = color.RGBA{R: 0xe5, G: 0xe7, B: 0xeb, A: 0xff}
colorGray100 = color.RGBA{R: 0xf3, G: 0xf4, B: 0xf6, A: 0xff}
colorWhite = color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
)
// PackageData holds the data needed to render an OG image.
type PackageData struct {
DisplayName string
Name string
Type string // "plugin" or "theme"
CurrentVersion string
Description string
ActiveInstalls string // pre-formatted, e.g. "1.2M"
WpPackagesInstalls string // pre-formatted, e.g. "350"
}
var (
fontSansBold *truetype.Font
fontSansMedium *truetype.Font
fontSansRegular *truetype.Font
fontMonoRegular *truetype.Font
fontMonoMedium *truetype.Font
rootsIcon image.Image
)
func init() {
fontSansBold = mustLoadFont("fonts/PublicSans-Bold.ttf")
fontSansMedium = mustLoadFont("fonts/PublicSans-Medium.ttf")
fontSansRegular = mustLoadFont("fonts/PublicSans-Regular.ttf")
fontMonoRegular = mustLoadFont("fonts/JetBrainsMono-Regular.ttf")
fontMonoMedium = mustLoadFont("fonts/JetBrainsMono-Medium.ttf")
img, err := png.Decode(bytes.NewReader(rootsIconData))
if err != nil {
panic(fmt.Sprintf("decoding roots icon: %v", err))
}
rootsIcon = img
}
func mustLoadFont(path string) *truetype.Font {
data, err := fontsFS.ReadFile(path)
if err != nil {
panic(fmt.Sprintf("loading font %s: %v", path, err))
}
f, err := truetype.Parse(data)
if err != nil {
panic(fmt.Sprintf("parsing font %s: %v", path, err))
}
return f
}
func fontFace(f *truetype.Font, size float64) font.Face {
return truetype.NewFace(f, &truetype.Options{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
}
// drawTextVCenter draws text vertically centered at centerY using font ascent metrics.
// This gives true visual centering unlike DrawStringAnchored which includes descenders.
func drawTextVCenter(dc *gg.Context, text string, x, centerY float64) {
m := dc.FontHeight()
// Ascent is roughly 70% of total height for most fonts
ascent := m * 0.72
dc.DrawString(text, x, centerY+ascent/2)
}
// GeneratePackageImage renders an OG image for a package and returns PNG bytes.
func GeneratePackageImage(pkg PackageData) ([]byte, error) {
dc := gg.NewContext(Width, Height)
// Background — light gray
dc.SetColor(colorGray100)
dc.Clear()
// Card — white rounded rectangle centered with shadow-like border
cardX, cardY := 60.0, 40.0
cardW, cardH := float64(Width)-120.0, float64(Height)-80.0
drawRoundedRect(dc, cardX, cardY, cardW, cardH, 20)
dc.SetColor(colorGray200)
dc.SetLineWidth(1)
dc.StrokePreserve()
dc.SetColor(colorWhite)
dc.Fill()
// Content padding within card
cx := cardX + 48
contentMaxW := cardW - 96
y := cardY + 52
// --- "WP Packages" header ---
dc.SetFontFace(fontFace(fontSansBold, 18))
dc.SetColor(colorBrandPrimary)
dc.DrawString("WP Packages", cx, y)
y += 44
// --- Package name (large, bold) ---
title := pkg.DisplayName
if title == "" {
title = pkg.Name
}
dc.SetFontFace(fontFace(fontSansBold, 34))
dc.SetColor(colorGray900)
title = truncateText(dc, title, contentMaxW)
dc.DrawString(title, cx, y)
y += 32
// --- Version + type badge ---
lineCenterY := y + 10
if pkg.CurrentVersion != "" {
dc.SetFontFace(fontFace(fontMonoRegular, 15))
dc.SetColor(colorGray500)
vStr := "v" + pkg.CurrentVersion
drawTextVCenter(dc, vStr, cx, lineCenterY)
vw, _ := dc.MeasureString(vStr)
drawTypeBadge(dc, cx+vw+10, lineCenterY, pkg.Type)
} else {
drawTypeBadge(dc, cx, lineCenterY, pkg.Type)
}
y += 36
// --- Composer require line ---
codeH := 40.0
drawRoundedRect(dc, cx, y, contentMaxW, codeH, 8)
dc.SetColor(colorGray100)
dc.Fill()
codeCenterY := y + codeH/2
dc.SetFontFace(fontFace(fontMonoMedium, 15))
dc.SetColor(colorGray400)
drawTextVCenter(dc, "$", cx+14, codeCenterY)
dc.SetFontFace(fontFace(fontMonoRegular, 15))
dc.SetColor(colorGray500)
requireStr := fmt.Sprintf("composer require wp-%s/%s", pkg.Type, pkg.Name)
requireStr = truncateText(dc, requireStr, contentMaxW-60)
drawTextVCenter(dc, requireStr, cx+32, codeCenterY)
y += codeH + 38
// --- Description ---
if pkg.Description != "" {
dc.SetFontFace(fontFace(fontSansRegular, 19))
dc.SetColor(colorGray500)
desc := wrapAndTruncate(dc, pkg.Description, contentMaxW, 4)
for i, line := range desc {
dc.DrawString(line, cx, y+float64(i)*30)
}
}
// --- Bottom stats bar ---
// Divider line
dividerY := cardY + cardH - 60
dc.SetColor(colorGray200)
dc.SetLineWidth(1)
dc.DrawLine(cx, dividerY, cx+contentMaxW, dividerY)
dc.Stroke()
// Footer center line — midpoint between divider and card bottom
footerCenterY := dividerY + (cardY+cardH-dividerY)/2
// Active installs — icon vertically centered with text
dc.SetFontFace(fontFace(fontSansRegular, 15))
dc.SetColor(colorGray400)
drawPeopleIcon(dc, cx, footerCenterY-5, 14, colorGray400)
dc.SetFontFace(fontFace(fontSansRegular, 15))
dc.SetColor(colorGray400)
drawTextVCenter(dc, pkg.ActiveInstalls+" active installs", cx+22, footerCenterY)
// Composer installs
composerX := cx + 260.0
drawTerminalIcon(dc, composerX, footerCenterY-7, 14, colorGray400)
dc.SetFontFace(fontFace(fontSansRegular, 15))
dc.SetColor(colorGray400)
drawTextVCenter(dc, pkg.WpPackagesInstalls+" composer installs", composerX+22, footerCenterY)
// "by [icon] roots.io" (bottom right)
drawByRootsFooter(dc, cx+contentMaxW, footerCenterY, 16, 15)
// Encode to PNG
var buf bytes.Buffer
if err := png.Encode(&buf, dc.Image()); err != nil {
return nil, fmt.Errorf("encoding PNG: %w", err)
}
return buf.Bytes(), nil
}
// GenerateFallbackImage creates a generic branded OG image.
func GenerateFallbackImage() ([]byte, error) {
dc := gg.NewContext(Width, Height)
// Background
dc.SetColor(colorGray100)
dc.Clear()
// Card
cardX, cardY := 60.0, 40.0
cardW, cardH := float64(Width)-120.0, float64(Height)-80.0
drawRoundedRect(dc, cardX, cardY, cardW, cardH, 20)
dc.SetColor(colorGray200)
dc.SetLineWidth(1)
dc.StrokePreserve()
dc.SetColor(colorWhite)
dc.Fill()
// Roots icon centered
centerX, centerY := float64(Width)/2, float64(Height)/2-40
logoSize := 88.0
drawScaledImage(dc, rootsIcon, centerX-logoSize/2, centerY-logoSize/2, logoSize, logoSize)
// Title
dc.SetFontFace(fontFace(fontSansBold, 36))
dc.SetColor(colorGray900)
dc.DrawStringAnchored("WP Packages", centerX, centerY+logoSize/2+32, 0.5, 0.5)
// Subtitle
dc.SetFontFace(fontFace(fontSansRegular, 18))
dc.SetColor(colorGray500)
dc.DrawStringAnchored("WordPress plugins and themes via Composer", centerX, centerY+logoSize/2+68, 0.5, 0.5)
// "by [icon] roots.io" at bottom center
drawByRootsFooterCentered(dc, centerX, cardY+cardH-44, 20, 17)
var buf bytes.Buffer
if err := png.Encode(&buf, dc.Image()); err != nil {
return nil, fmt.Errorf("encoding PNG: %w", err)
}
return buf.Bytes(), nil
}
// Helper drawing functions
func drawScaledImage(dc *gg.Context, img image.Image, x, y, w, h float64) {
srcW := float64(img.Bounds().Dx())
srcH := float64(img.Bounds().Dy())
sx := w / srcW
sy := h / srcH
dc.Push()
dc.Translate(x, y)
dc.Scale(sx, sy)
dc.DrawImage(img, 0, 0)
dc.Pop()
}
func drawRoundedRect(dc *gg.Context, x, y, w, h, r float64) {
dc.NewSubPath()
dc.DrawArc(x+r, y+r, r, gg.Radians(180), gg.Radians(270))
dc.LineTo(x+w-r, y)
dc.DrawArc(x+w-r, y+r, r, gg.Radians(270), gg.Radians(360))
dc.LineTo(x+w, y+h-r)
dc.DrawArc(x+w-r, y+h-r, r, gg.Radians(0), gg.Radians(90))
dc.LineTo(x+r, y+h)
dc.DrawArc(x+r, y+h-r, r, gg.Radians(90), gg.Radians(180))
dc.ClosePath()
}
func drawTypeBadge(dc *gg.Context, x, centerY float64, pkgType string) {
dc.SetFontFace(fontFace(fontSansMedium, 13))
tw, _ := dc.MeasureString(pkgType)
padX := 8.0
badgeW := tw + padX*2
badgeH := 22.0
drawRoundedRect(dc, x, centerY-badgeH/2, badgeW, badgeH, 5)
dc.SetColor(colorGray100)
dc.Fill()
dc.SetColor(colorGray500)
drawTextVCenter(dc, pkgType, x+padX, centerY)
}
func drawPeopleIcon(dc *gg.Context, x, y, size float64, c color.Color) {
dc.SetColor(c)
dc.SetLineWidth(1.5)
// Simple two-person icon
// Person 1 (front, larger)
dc.DrawCircle(x+size*0.35, y-size*0.1, size*0.22)
dc.Stroke()
dc.DrawArc(x+size*0.35, y+size*0.75, size*0.38, gg.Radians(205), gg.Radians(335))
dc.Stroke()
// Person 2 (behind, smaller, offset right)
dc.DrawCircle(x+size*0.75, y-size*0.18, size*0.18)
dc.Stroke()
dc.DrawArc(x+size*0.75, y+size*0.6, size*0.3, gg.Radians(210), gg.Radians(330))
dc.Stroke()
}
func drawTerminalIcon(dc *gg.Context, x, y, size float64, c color.Color) {
dc.SetColor(c)
dc.SetLineWidth(1.5)
w := size * 1.2
h := size * 0.85
// Terminal box (simple rectangle with rounded corners)
r := 2.0
dc.MoveTo(x+r, y)
dc.LineTo(x+w-r, y)
dc.LineTo(x+w, y+r)
dc.LineTo(x+w, y+h-r)
dc.LineTo(x+w-r, y+h)
dc.LineTo(x+r, y+h)
dc.LineTo(x, y+h-r)
dc.LineTo(x, y+r)
dc.ClosePath()
dc.Stroke()
// Prompt chevron >_
cy := y + h*0.5
dc.MoveTo(x+w*0.15, cy-h*0.18)
dc.LineTo(x+w*0.35, cy)
dc.LineTo(x+w*0.15, cy+h*0.18)
dc.Stroke()
// Underscore cursor
dc.DrawLine(x+w*0.42, cy+h*0.18, x+w*0.65, cy+h*0.18)
dc.Stroke()
}
// drawByRootsFooter draws "by [icon] roots.io" right-aligned at (rightX, centerY).
func drawByRootsFooter(dc *gg.Context, rightX, centerY, iconSize, fontSize float64) {
dc.SetFontFace(fontFace(fontSansMedium, fontSize))
byW, _ := dc.MeasureString("by ")
rootsW, _ := dc.MeasureString("roots.io")
gap := 4.0
totalW := byW + iconSize + gap + rootsW
startX := rightX - totalW
dc.SetColor(colorGray400)
drawTextVCenter(dc, "by ", startX, centerY)
drawScaledImage(dc, rootsIcon, startX+byW, centerY-iconSize/2, iconSize, iconSize)
dc.SetColor(colorBrandPrimary)
dc.SetFontFace(fontFace(fontSansMedium, fontSize))
drawTextVCenter(dc, "roots.io", startX+byW+iconSize+gap, centerY)
}
// drawByRootsFooterCentered draws "by [icon] roots.io" centered at (centerX, centerY).
func drawByRootsFooterCentered(dc *gg.Context, centerX, centerY, iconSize, fontSize float64) {
dc.SetFontFace(fontFace(fontSansMedium, fontSize))
byW, _ := dc.MeasureString("by ")
rootsW, _ := dc.MeasureString("roots.io")
gap := 4.0
totalW := byW + iconSize + gap + rootsW
startX := centerX - totalW/2
dc.SetColor(colorGray400)
drawTextVCenter(dc, "by ", startX, centerY)
drawScaledImage(dc, rootsIcon, startX+byW, centerY-iconSize/2, iconSize, iconSize)
dc.SetColor(colorBrandPrimary)
dc.SetFontFace(fontFace(fontSansMedium, fontSize))
drawTextVCenter(dc, "roots.io", startX+byW+iconSize+gap, centerY)
}
func truncateText(dc *gg.Context, text string, maxW float64) string {
w, _ := dc.MeasureString(text)
if w <= maxW {
return text
}
for len(text) > 0 {
text = text[:len(text)-1]
w, _ = dc.MeasureString(text + "…")
if w <= maxW {
return text + "…"
}
}
return "…"
}
func wrapAndTruncate(dc *gg.Context, text string, maxW float64, maxLines int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return nil
}
var lines []string
current := words[0]
hasMore := false
for i, word := range words[1:] {
test := current + " " + word
w, _ := dc.MeasureString(test)
if w > maxW {
lines = append(lines, current)
if len(lines) >= maxLines {
hasMore = true
break
}
current = word
} else {
current = test
}
if i == len(words)-2 {
// Last word processed
lines = append(lines, current)
}
}
if !hasMore && len(lines) == 0 {
lines = append(lines, current)
}
// If we hit max lines and there's more text, add ellipsis to last line
if hasMore && len(lines) > 0 {
last := lines[len(lines)-1]
lines[len(lines)-1] = truncateText(dc, last+"…", maxW)
}
return lines
}