2
0

feat(ci): add dynamic social card generation for repositories
All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m22s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m16s
Build and Release / Lint (push) Successful in 5m28s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m9s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 4m16s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 4m54s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h5m28s
Build and Release / Build Binary (linux/arm64) (push) Successful in 7m8s

Implement Open Graph social card image generation with customizable themes. Cards are dynamically rendered with repository name, description, owner avatar, and GitCaddy branding.

Features:
- Three built-in themes: dark (default), light, and colorful
- 1200x630px cards optimized for social media platforms
- Avatar fetching with caching and fallback to default
- Text wrapping and truncation for long descriptions
- Repository settings UI for theme selection
- Migration to add social_card_theme column

Technical implementation:
- Uses golang.org/x/image for rendering with Inter font family
- Singleton renderer pattern for font reuse
- Endpoint: /:owner/:repo/socialcard.png
- Integrates with Open Graph meta tags in head template

Add Inter font files (Regular and Bold) and GitCaddy logo asset.
This commit is contained in:
2026-01-30 00:54:58 -05:00
parent c8fffc6cd6
commit 57f913b92b
15 changed files with 547 additions and 6 deletions

View File

@@ -416,6 +416,7 @@ func prepareMigrationTasks() []*migration {
newMigration(339, "Add is_limited to repository for limited visibility", v1_26.AddIsLimitedToRepository),
newMigration(340, "Create repo_hidden_folder table for hidden folders", v1_26.CreateRepoHiddenFolderTable),
newMigration(341, "Add hide_dotfiles to repository", v1_26.AddHideDotfilesToRepository),
newMigration(342, "Add social_card_theme to repository", v1_26.AddSocialCardThemeToRepository),
}
return preparedMigrations
}

View File

@@ -0,0 +1,17 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"xorm.io/xorm"
)
// AddSocialCardThemeToRepository adds a social_card_theme column to the repository table.
func AddSocialCardThemeToRepository(x *xorm.Engine) error {
type Repository struct {
SocialCardTheme string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'dark'"`
}
return x.Sync(new(Repository))
}

View File

@@ -213,6 +213,7 @@ type Repository struct {
IsFsckEnabled bool `xorm:"NOT NULL DEFAULT true"`
CloseIssuesViaCommitInAnyBranch bool `xorm:"NOT NULL DEFAULT false"`
HideDotfiles bool `xorm:"NOT NULL DEFAULT false"`
SocialCardTheme string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'dark'"`
Topics []string `xorm:"TEXT JSON"`
ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"`

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,20 @@
// Copyright 2026 The GitCaddy Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package socialcard
import "sync"
var (
globalRenderer *Renderer
globalRendererOnce sync.Once
globalRendererErr error
)
// GlobalRenderer returns the singleton renderer, initializing on first call.
func GlobalRenderer() (*Renderer, error) {
globalRendererOnce.Do(func() {
globalRenderer, globalRendererErr = NewRenderer()
})
return globalRenderer, globalRendererErr
}

View File

@@ -0,0 +1,384 @@
// Copyright 2026 The GitCaddy Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package socialcard
import (
"bytes"
_ "embed"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/jpeg" // register JPEG decoder for avatar images
"image/png"
"net/http"
"strings"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
xdraw "golang.org/x/image/draw"
)
//go:embed fonts/Inter-Regular.ttf
var interRegularTTF []byte
//go:embed fonts/Inter-Bold.ttf
var interBoldTTF []byte
//go:embed assets/gitcaddy-logo.png
var logoData []byte
const (
CardWidth = 1200
CardHeight = 630
)
// Theme defines the color scheme for a social card.
type Theme struct {
Name string
Background color.RGBA
TitleColor color.RGBA
DescColor color.RGBA
AccentColor color.RGBA
SubtextColor color.RGBA
CardBorderColor color.RGBA
}
var (
// ThemeDark is the default dark theme.
ThemeDark = Theme{
Name: "dark",
Background: color.RGBA{R: 22, G: 27, B: 34, A: 255},
TitleColor: color.RGBA{R: 240, G: 246, B: 252, A: 255},
DescColor: color.RGBA{R: 173, G: 186, B: 199, A: 255},
AccentColor: color.RGBA{R: 56, G: 132, B: 244, A: 255},
SubtextColor: color.RGBA{R: 125, G: 140, B: 155, A: 255},
CardBorderColor: color.RGBA{R: 56, G: 132, B: 244, A: 255},
}
// ThemeLight is a light theme.
ThemeLight = Theme{
Name: "light",
Background: color.RGBA{R: 255, G: 255, B: 255, A: 255},
TitleColor: color.RGBA{R: 31, G: 35, B: 40, A: 255},
DescColor: color.RGBA{R: 101, G: 109, B: 118, A: 255},
AccentColor: color.RGBA{R: 9, G: 105, B: 218, A: 255},
SubtextColor: color.RGBA{R: 101, G: 109, B: 118, A: 255},
CardBorderColor: color.RGBA{R: 9, G: 105, B: 218, A: 255},
}
// ThemeColorful is a vibrant branded theme.
ThemeColorful = Theme{
Name: "colorful",
Background: color.RGBA{R: 15, G: 23, B: 42, A: 255},
TitleColor: color.RGBA{R: 255, G: 255, B: 255, A: 255},
DescColor: color.RGBA{R: 203, G: 213, B: 225, A: 255},
AccentColor: color.RGBA{R: 99, G: 102, B: 241, A: 255},
SubtextColor: color.RGBA{R: 148, G: 163, B: 184, A: 255},
CardBorderColor: color.RGBA{R: 99, G: 102, B: 241, A: 255},
}
// ThemesByName maps theme name strings to Theme values.
ThemesByName = map[string]Theme{
"dark": ThemeDark,
"light": ThemeLight,
"colorful": ThemeColorful,
}
)
// ThemeNames returns all available theme names.
func ThemeNames() []string {
return []string{"dark", "light", "colorful"}
}
// GetTheme returns a theme by name, falling back to dark.
func GetTheme(name string) Theme {
if t, ok := ThemesByName[name]; ok {
return t
}
return ThemeDark
}
// CardData holds everything needed to render a social card.
type CardData struct {
Title string
Description string
OwnerName string
LanguageName string
LanguageColor string // hex color like "#3572A5"
OwnerAvatarURL string
RepoFullName string
}
// Renderer holds pre-parsed fonts and logo.
type Renderer struct {
boldFont *opentype.Font
regularFont *opentype.Font
logo image.Image
}
// NewRenderer parses embedded fonts and logo. Call once at startup.
func NewRenderer() (*Renderer, error) {
bold, err := opentype.Parse(interBoldTTF)
if err != nil {
return nil, fmt.Errorf("parse bold font: %w", err)
}
regular, err := opentype.Parse(interRegularTTF)
if err != nil {
return nil, fmt.Errorf("parse regular font: %w", err)
}
logo, err := png.Decode(bytes.NewReader(logoData))
if err != nil {
return nil, fmt.Errorf("decode logo: %w", err)
}
return &Renderer{boldFont: bold, regularFont: regular, logo: logo}, nil
}
// RenderCard generates a 1200x630 PNG social card.
func (r *Renderer) RenderCard(data CardData, theme Theme) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
// Fill background
fillRect(img, img.Bounds(), theme.Background)
// Top accent bar (4px)
fillRect(img, image.Rect(0, 0, CardWidth, 4), theme.CardBorderColor)
// Create font faces
titleFace, err := opentype.NewFace(r.boldFont, &opentype.FaceOptions{
Size: 48, DPI: 72, Hinting: font.HintingFull,
})
if err != nil {
return nil, fmt.Errorf("create title face: %w", err)
}
defer titleFace.Close()
descFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{
Size: 24, DPI: 72, Hinting: font.HintingFull,
})
if err != nil {
return nil, fmt.Errorf("create desc face: %w", err)
}
defer descFace.Close()
metaFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{
Size: 20, DPI: 72, Hinting: font.HintingFull,
})
if err != nil {
return nil, fmt.Errorf("create meta face: %w", err)
}
defer metaFace.Close()
padding := 60
contentWidth := CardWidth - 2*padding
// Draw title (max 2 lines)
titleY := 80 + 48
titleLines := wrapText(data.Title, titleFace, contentWidth)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = truncateWithEllipsis(titleLines[1], titleFace, contentWidth)
}
for i, line := range titleLines {
drawText(img, padding, titleY+i*60, line, titleFace, theme.TitleColor)
}
// Draw description (max 3 lines)
descY := titleY + len(titleLines)*60 + 20
if data.Description != "" {
descLines := wrapText(data.Description, descFace, contentWidth)
if len(descLines) > 3 {
descLines = descLines[:3]
descLines[2] = truncateWithEllipsis(descLines[2], descFace, contentWidth)
}
for i, line := range descLines {
drawText(img, padding, descY+i*34, line, descFace, theme.DescColor)
}
}
// Bottom section: language + owner info
bottomY := CardHeight - 80
xCursor := padding
// Language indicator (colored dot + name)
if data.LanguageName != "" {
langColor := parseHexColor(data.LanguageColor)
drawCircle(img, xCursor+8, bottomY-6, 8, langColor)
xCursor += 24
drawText(img, xCursor, bottomY, data.LanguageName, metaFace, theme.SubtextColor)
xCursor += measureText(data.LanguageName, metaFace) + 30
}
// Owner avatar (circular)
if data.OwnerAvatarURL != "" {
avatarImg := fetchAndDecodeAvatar(data.OwnerAvatarURL, 36)
if avatarImg != nil {
drawCircularAvatar(img, xCursor, bottomY-30, 36, avatarImg)
xCursor += 44
}
}
// Owner name
drawText(img, xCursor, bottomY, data.RepoFullName, metaFace, theme.SubtextColor)
// GitCaddy logo (bottom right)
logoBounds := r.logo.Bounds()
logoX := CardWidth - padding - logoBounds.Dx()
logoY := CardHeight - padding - logoBounds.Dy()
draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()),
r.logo, logoBounds.Min, draw.Over)
// Encode to PNG
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
return buf.Bytes(), nil
}
// fillRect fills a rectangle with a solid color.
func fillRect(img *image.RGBA, rect image.Rectangle, c color.RGBA) {
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
img.SetRGBA(x, y, c)
}
}
}
// drawText draws a string at (x, y) baseline position.
func drawText(img *image.RGBA, x, y int, text string, face font.Face, c color.RGBA) {
d := &font.Drawer{
Dst: img,
Src: image.NewUniform(c),
Face: face,
Dot: fixed.P(x, y),
}
d.DrawString(text)
}
// wrapText breaks text into lines that fit within maxWidth pixels.
func wrapText(text string, face font.Face, maxWidth int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return nil
}
var lines []string
current := words[0]
for _, word := range words[1:] {
test := current + " " + word
if measureText(test, face) > maxWidth {
lines = append(lines, current)
current = word
} else {
current = test
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
// measureText returns the width in pixels of the rendered text.
func measureText(text string, face font.Face) int {
return font.MeasureString(face, text).Ceil()
}
// truncateWithEllipsis truncates text to fit width, adding "...".
func truncateWithEllipsis(text string, face font.Face, maxWidth int) string {
if measureText(text, face) <= maxWidth {
return text
}
ellipsis := "..."
runes := []rune(text)
for i := len(runes); i > 0; i-- {
candidate := string(runes[:i]) + ellipsis
if measureText(candidate, face) <= maxWidth {
return candidate
}
}
return ellipsis
}
// drawCircle draws a filled circle at (cx, cy) with the given radius.
func drawCircle(img *image.RGBA, cx, cy, radius int, c color.RGBA) {
for y := cy - radius; y <= cy+radius; y++ {
for x := cx - radius; x <= cx+radius; x++ {
dx := float64(x - cx)
dy := float64(y - cy)
if dx*dx+dy*dy <= float64(radius*radius) {
img.SetRGBA(x, y, c)
}
}
}
}
// drawCircularAvatar draws an avatar image clipped to a circle.
func drawCircularAvatar(dst *image.RGBA, x, y, size int, avatar image.Image) {
// Scale avatar to target size
scaled := scaleImage(avatar, size, size)
radius := size / 2
cx, cy := radius, radius
for py := range size {
for px := range size {
dx := float64(px - cx)
dy := float64(py - cy)
if dx*dx+dy*dy <= float64(radius*radius) {
r, g, b, a := scaled.At(px, py).RGBA()
dst.SetRGBA(x+px, y+py, color.RGBA{
R: uint8(r >> 8),
G: uint8(g >> 8),
B: uint8(b >> 8),
A: uint8(a >> 8),
})
}
}
}
}
// fetchAndDecodeAvatar fetches an avatar image from a URL with a short timeout.
func fetchAndDecodeAvatar(avatarURL string, size int) image.Image {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(avatarURL)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
img, _, err := image.Decode(resp.Body)
if err != nil {
return nil
}
return scaleImage(img, size, size)
}
// scaleImage resizes an image using BiLinear interpolation.
func scaleImage(src image.Image, width, height int) image.Image {
rect := image.Rect(0, 0, width, height)
dst := image.NewRGBA(rect)
xdraw.BiLinear.Scale(dst, rect, src, src.Bounds(), xdraw.Over, nil)
return dst
}
// parseHexColor converts "#RRGGBB" to color.RGBA.
func parseHexColor(hex string) color.RGBA {
hex = strings.TrimPrefix(hex, "#")
if len(hex) != 6 {
return color.RGBA{R: 128, G: 128, B: 128, A: 255}
}
var r, g, b uint8
_, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b)
if err != nil {
return color.RGBA{R: 128, G: 128, B: 128, A: 255}
}
return color.RGBA{R: r, G: g, B: b, A: 255}
}

View File

@@ -4034,6 +4034,12 @@
"repo.settings.group_header": "Group Header",
"repo.settings.group_header_placeholder": "e.g., Core Services, Libraries, Tools",
"repo.settings.group_header_help": "Optional header for grouping this repository on the organization page",
"repo.settings.social_card_theme": "Social Card Theme",
"repo.settings.social_card_theme_help": "Choose the visual theme for your repository's share card image used in link previews.",
"repo.settings.social_card_theme.dark": "Dark",
"repo.settings.social_card_theme.light": "Light",
"repo.settings.social_card_theme.colorful": "Colorful",
"repo.settings.social_card_preview": "Preview",
"repo.settings.license": "License",
"repo.settings.license_type": "License Type",
"repo.settings.license_none": "No license selected",

View File

@@ -23,6 +23,7 @@ import (
"code.gitcaddy.com/server/v3/modules/lfs"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/setting"
"code.gitcaddy.com/server/v3/modules/socialcard"
"code.gitcaddy.com/server/v3/modules/structs"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/modules/util"
@@ -88,6 +89,7 @@ func SettingsCtxData(ctx *context.Context) {
return
}
ctx.Data["PushMirrors"] = pushMirrors
ctx.Data["SocialCardThemes"] = socialcard.ThemeNames()
}
// Settings show a repository's settings page
@@ -206,6 +208,11 @@ func handleSettingsPostUpdate(ctx *context.Context) {
repo.Description = form.Description
repo.DisplayTitle = form.DisplayTitle
repo.GroupHeader = form.GroupHeader
if form.SocialCardTheme != "" {
if _, ok := socialcard.ThemesByName[form.SocialCardTheme]; ok {
repo.SocialCardTheme = form.SocialCardTheme
}
}
repo.Website = form.Website
repo.IsTemplate = form.Template

View File

@@ -0,0 +1,78 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"crypto/sha256"
"fmt"
"net/http"
"code.gitcaddy.com/server/v3/modules/socialcard"
"code.gitcaddy.com/server/v3/services/context"
)
// SocialPreview generates and serves a dynamic Open Graph social card image.
func SocialPreview(ctx *context.Context) {
repo := ctx.Repo.Repository
if err := repo.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return
}
title := repo.DisplayTitle
if title == "" {
title = repo.Name
}
data := socialcard.CardData{
Title: title,
Description: repo.Description,
OwnerName: repo.OwnerName,
RepoFullName: repo.FullName(),
}
if repo.PrimaryLanguage != nil {
data.LanguageName = repo.PrimaryLanguage.Language
data.LanguageColor = repo.PrimaryLanguage.Color
}
if repo.Owner != nil {
data.OwnerAvatarURL = repo.Owner.AvatarLink(ctx)
}
// Allow ?theme= query param for preview (falls back to saved theme)
themeName := ctx.FormString("theme")
if themeName == "" {
themeName = repo.SocialCardTheme
}
theme := socialcard.GetTheme(themeName)
renderer, err := socialcard.GlobalRenderer()
if err != nil {
ctx.ServerError("socialcard.GlobalRenderer", err)
return
}
pngData, err := renderer.RenderCard(data, theme)
if err != nil {
ctx.ServerError("RenderCard", err)
return
}
hash := sha256.Sum256(pngData)
etag := fmt.Sprintf(`"%x"`, hash[:16])
ctx.Resp.Header().Set("Content-Type", "image/png")
ctx.Resp.Header().Set("Cache-Control", "public, max-age=1800")
ctx.Resp.Header().Set("ETag", etag)
if ctx.Req.Header.Get("If-None-Match") == etag {
ctx.Resp.WriteHeader(http.StatusNotModified)
return
}
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(pngData)
}

View File

@@ -1312,6 +1312,7 @@ func registerWebRoutes(m *web.Router) {
m.Get("/{username}/{reponame}", optSignIn, context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch), repo.SetEditorconfigIfExists, repo.Home)
m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Get("/{username}/{reponame}/social-preview", optSignIn, context.RepoAssignment, repo.SocialPreview)
m.Group("/{username}/{reponame}", func() {
m.Group("/tree-list", func() {

View File

@@ -93,6 +93,7 @@ type RepoSettingForm struct {
Description string `binding:"MaxSize(2048)"`
DisplayTitle string `binding:"MaxSize(255)"`
GroupHeader string `binding:"MaxSize(255)"`
SocialCardTheme string
Website string `binding:"ValidUrl;MaxSize(1024)"`
Interval string
MirrorAddress string

View File

@@ -25,18 +25,19 @@
{{- end -}}
{{end}}
{{else}}
<meta property="og:title" content="{{.Repository.Name}}">
<meta property="og:title" content="{{if .Repository.DisplayTitle}}{{.Repository.DisplayTitle}}{{else}}{{.Repository.Name}}{{end}}">
<meta property="og:url" content="{{.Repository.HTMLURL ctx}}">
{{if .Repository.Description}}
<meta property="og:description" content="{{StringUtils.EllipsisString .Repository.Description 300}}">
{{end}}
{{end}}
<meta property="og:type" content="object">
{{if (.Repository.AvatarLink ctx)}}
<meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
{{else}}
<meta property="og:image" content="{{.Repository.Owner.AvatarLink ctx}}">
{{end}}
<meta property="og:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:type" content="image/png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{AppUrl}}{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview">
{{else}}
<meta property="og:title" content="{{AppName}}">
<meta property="og:type" content="website">

View File

@@ -40,6 +40,30 @@
<label for="website">{{ctx.Locale.Tr "repo.settings.site"}}</label>
<input id="website" name="website" type="url" maxlength="1024" value="{{.Repository.Website}}">
</div>
<div class="field">
<label for="social_card_theme">{{ctx.Locale.Tr "repo.settings.social_card_theme"}}</label>
<select id="social_card_theme" name="social_card_theme" class="ui dropdown">
{{range .SocialCardThemes}}
<option value="{{.}}" {{if eq . $.Repository.SocialCardTheme}}selected{{end}}>
{{ctx.Locale.Tr (printf "repo.settings.social_card_theme.%s" .)}}
</option>
{{end}}
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.social_card_theme_help"}}</p>
<div class="tw-mt-2">
<label>{{ctx.Locale.Tr "repo.settings.social_card_preview"}}</label>
<img id="social-card-preview"
src="{{AppSubUrl}}/{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview?theme={{.Repository.SocialCardTheme}}"
alt="Social card preview"
style="max-width: 600px; width: 100%; border-radius: 8px; border: 1px solid var(--color-secondary); margin-top: 4px;">
</div>
<script>
document.getElementById('social_card_theme').addEventListener('change', function() {
const img = document.getElementById('social-card-preview');
img.src = '{{AppSubUrl}}/{{.Repository.OwnerName}}/{{.Repository.Name}}/social-preview?theme=' + this.value + '&t=' + Date.now();
});
</script>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
</div>