package build import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "image" "image/color" "image/draw" _ "image/jpeg" // a logo can be a JPEG "image/png" "os" "path" "path/filepath" "strconv" "strings" xdraw "golang.org/x/image/draw" "golang.org/x/image/font" "golang.org/x/image/font/gofont/gobold" "golang.org/x/image/font/gofont/goregular" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" "git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site" ) // Social cards: the picture a link preview shows (Mastodon, LinkedIn, // Bluesky, chat apps), drawn at build time for every page that doesn't name // its own image. 1200×630, the page's title large, the site's name small, in // the site's colors. Drawn in Go with the Go fonts unless the site brings its // own, so the build needs nothing installed. The file name carries a hash of // what's on the card, so a changed title is a new address and previews // refresh. const cardW, cardH = 1200, 630 // cardPath is where a page's card is published, or "" if it gets none. func cardPath(s *site.Site, p *site.Page) string { cc := s.Config.Cards if !cc.On() || p.Image != "" || p.RedirectTo != "" || p.NoIndex || (p.Kind != site.KindPage && p.Kind != site.KindList) { return "" } name := strings.Trim(strings.ReplaceAll(p.Path, "/", "-"), "-") name = strings.TrimSuffix(name, ".html") if name == "" { name = "home" } sum := sha256.Sum256([]byte(p.Title + "\x00" + p.Summary + "\x00" + s.Config.Name + "\x00" + cc.Signature())) return "/cards/" + name + "." + hex.EncodeToString(sum[:4]) + ".png" } // assignCards points every page that will get a card at it, so templates // write og:image for it, and returns the cards to draw. The live preview does // this too, without drawing. func assignCards(s *site.Site) map[*site.Page]string { cards := map[*site.Page]string{} for _, p := range s.Pages { if c := cardPath(s, p); c != "" { cards[p] = c p.Image = c if p.ImageAlt == "" { p.ImageAlt = p.Title } } } return cards } type cardStyle struct { bg, fg, accent color.Color title, small *opentype.Font logo image.Image } func parseHex(h string, def color.Color) color.Color { h = strings.TrimPrefix(h, "#") if len(h) == 3 { h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) } v, err := strconv.ParseUint(h, 16, 32) if len(h) != 6 || err != nil { return def } return color.RGBA{uint8(v >> 16), uint8(v >> 8), uint8(v), 255} } func loadCardStyle(siteDir string, s *site.Site) (*cardStyle, error) { cc := s.Config.Cards // Colors: the card's own, else the site's look (band and its text, which // read well large), else a dark default. look := map[string]string{} if lf, err := site.LoadLook(siteDir); err == nil && lf != nil { vals, _ := lf.Values(s.Config.Look) for _, t := range lf.Tokens { look[t.Name] = lf.Get(vals, t.Name) } } pick := func(own string, names ...string) string { if own != "" { return own } for _, n := range names { if look[n] != "" { return look[n] } } return "" } st := &cardStyle{ bg: parseHex(pick(cc.Background, "band", "text"), color.RGBA{0x1d, 0x2b, 0x3a, 255}), fg: parseHex(pick(cc.Text, "band-text", "bg"), color.RGBA{0xf5, 0xf1, 0xea, 255}), accent: parseHex(pick(cc.Accent, "accent"), color.RGBA{0xf4, 0xbe, 0x2c, 255}), } var err error if st.title, err = loadFont(siteDir, cc.Font, gobold.TTF); err != nil { return nil, err } if st.small, err = loadFont(siteDir, cc.FontSmall, goregular.TTF); err != nil { return nil, err } logo := cc.Logo if logo == "" { logo = look["logo"] } if logo != "" && !strings.HasSuffix(strings.ToLower(logo), ".svg") { for _, base := range []string{"static", "assets"} { data, err := site.ReadFile(siteDir, path.Join(base, path.Clean("/"+logo)), 16<<20) if err != nil { continue } // Check the size from the header before decoding: a small file // can claim a huge picture. if cfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil && cfg.Width > 0 && cfg.Height > 0 && cfg.Width*cfg.Height <= maxLogoPixels { st.logo, _, _ = image.Decode(bytes.NewReader(data)) } break } } return st, nil } // maxLogoPixels bounds a card logo: far more than any logo needs. const maxLogoPixels = 16_000_000 func loadFont(siteDir, rel string, fallback []byte) (*opentype.Font, error) { data := fallback if rel != "" { b, err := site.ReadFile(siteDir, rel, 16<<20) if err != nil { return nil, fmt.Errorf("cards: font %s: %w", rel, err) } data = b } f, err := opentype.Parse(data) if err != nil { return nil, fmt.Errorf("cards: font %s: %w", rel, err) } return f, nil } func face(f *opentype.Font, size float64) font.Face { fc, _ := opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull}) return fc } // wrap breaks text into lines no wider than width. func wrap(fc font.Face, text string, width int) []string { var lines []string line := "" for _, w := range strings.Fields(text) { try := strings.TrimSpace(line + " " + w) if font.MeasureString(fc, try).Ceil() > width && line != "" { lines = append(lines, line) line = w } else { line = try } } if line != "" { lines = append(lines, line) } return lines } // drawCard renders one card. func drawCard(st *cardStyle, title, summary, siteName string) ([]byte, error) { img := image.NewRGBA(image.Rect(0, 0, cardW, cardH)) draw.Draw(img, img.Bounds(), &image.Uniform{st.bg}, image.Point{}, draw.Src) // The accent: a bar down the left edge. draw.Draw(img, image.Rect(0, 0, 18, cardH), &image.Uniform{st.accent}, image.Point{}, draw.Src) const margin = 90 width := cardW - 2*margin // The largest size at which the title fits in four lines. size := 76.0 var fc font.Face var lines []string for ; size >= 40; size -= 4 { fc = face(st.title, size) lines = wrap(fc, title, width) if len(lines) <= 4 { break } } if len(lines) > 4 { lines = append(lines[:3], strings.TrimSpace(lines[3])+"…") lines = lines[:4] } lineH := int(size * 1.18) top := margin + 40 if st.logo != nil { top += 30 } d := &font.Drawer{Dst: img, Src: &image.Uniform{st.fg}, Face: fc} for i, l := range lines { d.Dot = fixed.P(margin, top+int(size)+i*lineH) d.DrawString(l) } // The summary, softer, under a title short enough to leave room. if summary != "" && len(lines) <= 2 { sf := face(st.small, 34) r, g, b, _ := st.fg.RGBA() br, bg, bb, _ := st.bg.RGBA() soft := color.RGBA{uint8((r*3 + br) / 4 >> 8), uint8((g*3 + bg) / 4 >> 8), uint8((b*3 + bb) / 4 >> 8), 255} ds := &font.Drawer{Dst: img, Src: &image.Uniform{soft}, Face: sf} y := top + int(size) + (len(lines)-1)*lineH + 70 sl := wrap(sf, summary, width) if len(sl) > 3 { sl = append(sl[:2], strings.TrimSpace(sl[2])+"…") } for i, l := range sl { ds.Dot = fixed.P(margin, y+i*46) ds.DrawString(l) } } small := face(st.small, 34) ds := &font.Drawer{Dst: img, Src: &image.Uniform{st.fg}, Face: small} x := margin if st.logo != nil { // The logo, 56 px tall, before the site's name at the bottom. b := st.logo.Bounds() w := b.Dx() * 56 / max(1, b.Dy()) r := image.Rect(margin, cardH-margin-56+8, margin+w, cardH-margin+8) xdraw.CatmullRom.Scale(img, r, st.logo, b, xdraw.Over, nil) x += w + 18 } ds.Dot = fixed.P(x, cardH-margin) ds.DrawString(siteName) var buf bytes.Buffer if err := (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&buf, img); err != nil { return nil, err } return buf.Bytes(), nil } // writeCards draws the cards assignCards gave out. func writeCards(siteDir, out string, s *site.Site, cards map[*site.Page]string) error { if len(cards) == 0 { return nil } st, err := loadCardStyle(siteDir, s) if err != nil { return err } if err := os.MkdirAll(filepath.Join(out, "cards"), 0o755); err != nil { return err } for p, c := range cards { data, err := drawCard(st, p.Title, p.Summary, s.Config.Name) if err != nil { return fmt.Errorf("card for %s: %w", p.Path, err) } if err := os.WriteFile(filepath.Join(out, filepath.FromSlash(strings.TrimPrefix(c, "/"))), data, 0o644); err != nil { return err } } return nil }