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
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:
@@ -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
|
||||
}
|
||||
|
||||
17
models/migrations/v1_26/v342.go
Normal file
17
models/migrations/v1_26/v342.go
Normal 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))
|
||||
}
|
||||
@@ -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'"`
|
||||
|
||||
|
||||
BIN
modules/socialcard/assets/gitcaddy-logo.png
Normal file
BIN
modules/socialcard/assets/gitcaddy-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
modules/socialcard/fonts/Inter-Bold.ttf
Normal file
BIN
modules/socialcard/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
modules/socialcard/fonts/Inter-Regular.ttf
Normal file
BIN
modules/socialcard/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
20
modules/socialcard/init.go
Normal file
20
modules/socialcard/init.go
Normal 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
|
||||
}
|
||||
384
modules/socialcard/socialcard.go
Normal file
384
modules/socialcard/socialcard.go
Normal 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}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
78
routers/web/repo/socialcard.go
Normal file
78
routers/web/repo/socialcard.go
Normal 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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user