feat(api): native SVG badge engine (#103) #127
+61
-111
@@ -1,129 +1,79 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package badge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
// The Badge layout: |offset|label|message|
|
||||
// We use 10x scale to calculate more precisely
|
||||
// Then scale down to normal size in tmpl file
|
||||
|
||||
type Text struct {
|
||||
text string
|
||||
width int
|
||||
x int
|
||||
}
|
||||
|
||||
func (t Text) Text() string {
|
||||
return t.text
|
||||
}
|
||||
|
||||
func (t Text) Width() int {
|
||||
return t.width
|
||||
}
|
||||
|
||||
func (t Text) X() int {
|
||||
return t.x
|
||||
}
|
||||
|
||||
func (t Text) TextLength() int {
|
||||
return int(float64(t.width-defaultOffset) * 10)
|
||||
}
|
||||
|
||||
// Badge holds the data for rendering an SVG badge.
|
||||
type Badge struct {
|
||||
IDPrefix string
|
||||
FontFamily string
|
||||
Color string
|
||||
FontSize int
|
||||
Label Text
|
||||
Message Text
|
||||
Label string
|
||||
Message string
|
||||
Color string
|
||||
}
|
||||
|
||||
func (b Badge) Width() int {
|
||||
return b.Label.width + b.Message.width
|
||||
}
|
||||
|
||||
// Style follows https://shields.io/badges
|
||||
// Color presets
|
||||
const (
|
||||
StyleFlat = "flat"
|
||||
StyleFlatSquare = "flat-square"
|
||||
ColorGreen = "#4c1"
|
||||
ColorYellow = "#dfb317"
|
||||
ColorRed = "#e05d44"
|
||||
ColorGrey = "#9f9f9f"
|
||||
ColorBlue = "#007ec6"
|
||||
ColorOrange = "#fe7d37"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOffset = 10
|
||||
defaultFontSize = 11
|
||||
DefaultColor = "#9f9f9f" // Grey
|
||||
DefaultFontFamily = "DejaVu Sans,Verdana,Geneva,sans-serif"
|
||||
DefaultStyle = StyleFlat
|
||||
)
|
||||
const charWidth = 6.8
|
||||
|
||||
var GlobalVars = sync.OnceValue(func() (ret struct {
|
||||
StatusColorMap map[actions_model.Status]string
|
||||
DejaVuGlyphWidthData map[rune]uint8
|
||||
AllStyles []string
|
||||
},
|
||||
) {
|
||||
ret.StatusColorMap = map[actions_model.Status]string{
|
||||
actions_model.StatusSuccess: "#4c1", // Green
|
||||
actions_model.StatusSkipped: "#dfb317", // Yellow
|
||||
actions_model.StatusUnknown: "#97ca00", // Light Green
|
||||
actions_model.StatusFailure: "#e05d44", // Red
|
||||
actions_model.StatusCancelled: "#fe7d37", // Orange
|
||||
actions_model.StatusWaiting: "#dfb317", // Yellow
|
||||
actions_model.StatusRunning: "#dfb317", // Yellow
|
||||
actions_model.StatusBlocked: "#dfb317", // Yellow
|
||||
}
|
||||
ret.DejaVuGlyphWidthData = dejaVuGlyphWidthDataFunc()
|
||||
ret.AllStyles = []string{StyleFlat, StyleFlatSquare}
|
||||
return ret
|
||||
})
|
||||
|
||||
// GenerateBadge generates badge with given template
|
||||
func GenerateBadge(label, message, color string) Badge {
|
||||
lw := calculateTextWidth(label) + defaultOffset
|
||||
mw := calculateTextWidth(message) + defaultOffset
|
||||
|
||||
lx := lw * 5
|
||||
mx := lw*10 + mw*5 - 10
|
||||
return Badge{
|
||||
FontFamily: DefaultFontFamily,
|
||||
Label: Text{
|
||||
text: label,
|
||||
width: lw,
|
||||
x: lx,
|
||||
},
|
||||
Message: Text{
|
||||
text: message,
|
||||
width: mw,
|
||||
x: mx,
|
||||
},
|
||||
FontSize: defaultFontSize * 10,
|
||||
Color: color,
|
||||
}
|
||||
func textWidth(s string) float64 {
|
||||
return float64(len(s)) * charWidth
|
||||
}
|
||||
|
||||
func calculateTextWidth(text string) int {
|
||||
width := 0
|
||||
widthData := GlobalVars().DejaVuGlyphWidthData
|
||||
for _, char := range strings.TrimSpace(text) {
|
||||
charWidth, ok := widthData[char]
|
||||
if !ok {
|
||||
// use the width of 'm' in case of missing glyph width data for a printable character
|
||||
if unicode.IsPrint(char) {
|
||||
charWidth = widthData['m']
|
||||
} else {
|
||||
charWidth = 0
|
||||
}
|
||||
}
|
||||
width += int(charWidth)
|
||||
var svgTemplate = template.Must(template.New("badge").Parse(`<svg xmlns="http://www.w3.org/2000/svg" width="{{.TotalWidth}}" height="20" role="img" aria-label="{{.Label}}: {{.Message}}">
|
||||
<title>{{.Label}}: {{.Message}}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="{{.TotalWidth}}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="{{.LabelWidth}}" height="20" fill="#555"/>
|
||||
<rect x="{{.LabelWidth}}" width="{{.MessageWidth}}" height="20" fill="{{.Color}}"/>
|
||||
<rect width="{{.TotalWidth}}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="{{.LabelX}}" y="15" fill="#010101" fill-opacity=".3">{{.Label}}</text>
|
||||
<text x="{{.LabelX}}" y="14">{{.Label}}</text>
|
||||
<text aria-hidden="true" x="{{.MessageX}}" y="15" fill="#010101" fill-opacity=".3">{{.Message}}</text>
|
||||
<text x="{{.MessageX}}" y="14">{{.Message}}</text>
|
||||
</g>
|
||||
</svg>`))
|
||||
|
||||
type templateData struct {
|
||||
Label, Message, Color string
|
||||
LabelWidth, MessageWidth int
|
||||
TotalWidth int
|
||||
LabelX, MessageX float64
|
||||
}
|
||||
|
||||
// Render generates an SVG badge as a byte slice.
|
||||
func (b Badge) Render() ([]byte, error) {
|
||||
padding := 12.0
|
||||
lw := int(textWidth(b.Label) + padding)
|
||||
mw := int(textWidth(b.Message) + padding)
|
||||
|
||||
data := templateData{
|
||||
Label: b.Label, Message: b.Message, Color: b.Color,
|
||||
LabelWidth: lw, MessageWidth: mw, TotalWidth: lw + mw,
|
||||
LabelX: float64(lw) / 2, MessageX: float64(lw) + float64(mw)/2,
|
||||
}
|
||||
|
||||
return width
|
||||
var buf bytes.Buffer
|
||||
if err := svgTemplate.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("rendering badge: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package badge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
)
|
||||
|
||||
// Generate creates a badge for the given repo and badge type.
|
||||
func Generate(ctx context.Context, repo *repo_model.Repository, badgeType string) (Badge, error) {
|
||||
switch strings.ToLower(badgeType) {
|
||||
case "version":
|
||||
return versionBadge(ctx, repo)
|
||||
case "build":
|
||||
return buildBadge(ctx, repo)
|
||||
case "license":
|
||||
return licenseBadge(repo)
|
||||
case "health":
|
||||
return healthBadge(ctx, repo)
|
||||
default:
|
||||
return Badge{Label: "badge", Message: "unknown", Color: ColorGrey},
|
||||
fmt.Errorf("unknown badge type: %s", badgeType)
|
||||
}
|
||||
}
|
||||
|
||||
func versionBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) {
|
||||
release, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID)
|
||||
if err != nil || release == nil {
|
||||
return Badge{Label: "version", Message: "none", Color: ColorGrey}, nil
|
||||
}
|
||||
return Badge{Label: "version", Message: release.TagName, Color: ColorBlue}, nil
|
||||
}
|
||||
|
||||
func buildBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) {
|
||||
status, err := git_model.GetLatestCommitStatus(ctx, repo.ID, repo.DefaultBranch, 1)
|
||||
if err != nil || len(status) == 0 {
|
||||
return Badge{Label: "build", Message: "unknown", Color: ColorGrey}, nil
|
||||
}
|
||||
|
||||
switch status[0].State.String() {
|
||||
case "success":
|
||||
return Badge{Label: "build", Message: "passing", Color: ColorGreen}, nil
|
||||
case "failure", "error":
|
||||
return Badge{Label: "build", Message: "failing", Color: ColorRed}, nil
|
||||
case "pending":
|
||||
return Badge{Label: "build", Message: "pending", Color: ColorYellow}, nil
|
||||
default:
|
||||
return Badge{Label: "build", Message: status[0].State.String(), Color: ColorGrey}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func licenseBadge(repo *repo_model.Repository) (Badge, error) {
|
||||
if len(repo.License) > 0 {
|
||||
return Badge{Label: "license", Message: repo.License, Color: ColorBlue}, nil
|
||||
}
|
||||
return Badge{Label: "license", Message: "none", Color: ColorGrey}, nil
|
||||
}
|
||||
|
||||
func healthBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) {
|
||||
score := 0
|
||||
if repo.HasWiki() {
|
||||
score++
|
||||
}
|
||||
if len(repo.License) > 0 {
|
||||
score++
|
||||
}
|
||||
if repo.Description != "" {
|
||||
score++
|
||||
}
|
||||
|
||||
var color string
|
||||
var msg string
|
||||
switch {
|
||||
case score >= 3:
|
||||
color, msg = ColorGreen, "healthy"
|
||||
case score >= 2:
|
||||
color, msg = ColorYellow, "fair"
|
||||
default:
|
||||
color, msg = ColorRed, "needs work"
|
||||
}
|
||||
return Badge{Label: "health", Message: msg, Color: color}, nil
|
||||
}
|
||||
@@ -1430,7 +1430,9 @@ func Routes() *web.Router {
|
||||
Delete(reqToken(), repo.DeleteTopic)
|
||||
}, reqAdmin())
|
||||
}, reqAnyRepoReader())
|
||||
m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
||||
// MokoGitea badge engine
|
||||
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
||||
m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
||||
m.Get("/issue_config", context.ReferencesGitRepo(), repo.GetIssueConfig)
|
||||
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
|
||||
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/badge"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// GetRepoBadge returns an SVG badge for the repository.
|
||||
func GetRepoBadge(ctx *context.APIContext) {
|
||||
badgeType := ctx.PathParam("type")
|
||||
|
||||
b, err := badge.Generate(ctx, ctx.Repo.Repository, badgeType)
|
||||
if err != nil {
|
||||
b = badge.Badge{Label: "badge", Message: "error", Color: badge.ColorGrey}
|
||||
}
|
||||
|
||||
svg, err := b.Render()
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(fmt.Errorf("rendering badge: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Type", "image/svg+xml")
|
||||
ctx.Resp.Header().Set("Cache-Control", "public, max-age=300")
|
||||
ctx.Resp.Header().Set("ETag", fmt.Sprintf(`"%s-%s"`, b.Label, b.Message))
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write(svg)
|
||||
}
|
||||
Reference in New Issue
Block a user