From 6e3f9e2cdffd8ce01e42c19c834565166662f282 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Wed, 20 May 2026 20:27:03 -0500 Subject: [PATCH] fix: integrate badge presets with existing upstream badge module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore original modules/badge/badge.go (upstream) and adapt our presets to use GenerateBadge() and the existing Badge type. - Rename Generate → GenerateRepoBadge to avoid conflict - Add FormatRepoBadgeSVG for SVG rendering - Add RenderFlat/RenderFlatSquare methods on Badge - Fix API handler to use new function names Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/badge/badge.go | 172 ++++++++++++++++++++++------------- modules/badge/presets.go | 84 +++++++++++------ routers/api/v1/repo/badge.go | 11 +-- 3 files changed, 169 insertions(+), 98 deletions(-) diff --git a/modules/badge/badge.go b/modules/badge/badge.go index a54911ded4..d2e9bd9d1b 100644 --- a/modules/badge/badge.go +++ b/modules/badge/badge.go @@ -1,79 +1,129 @@ -// Copyright 2026 Moko Consulting -// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT package badge import ( - "bytes" - "fmt" - "html/template" + "strings" + "sync" + "unicode" + + actions_model "code.gitea.io/gitea/models/actions" ) -// Badge holds the data for rendering an SVG badge. +// 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) +} + type Badge struct { - Label string - Message string - Color string + IDPrefix string + FontFamily string + Color string + FontSize int + Label Text + Message Text } -// Color presets +func (b Badge) Width() int { + return b.Label.width + b.Message.width +} + +// Style follows https://shields.io/badges const ( - ColorGreen = "#4c1" - ColorYellow = "#dfb317" - ColorRed = "#e05d44" - ColorGrey = "#9f9f9f" - ColorBlue = "#007ec6" - ColorOrange = "#fe7d37" + StyleFlat = "flat" + StyleFlatSquare = "flat-square" ) -const charWidth = 6.8 +const ( + defaultOffset = 10 + defaultFontSize = 11 + DefaultColor = "#9f9f9f" // Grey + DefaultFontFamily = "DejaVu Sans,Verdana,Geneva,sans-serif" + DefaultStyle = StyleFlat +) -func textWidth(s string) float64 { - return float64(len(s)) * charWidth +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, + } } -var svgTemplate = template.Must(template.New("badge").Parse(` - {{.Label}}: {{.Message}} - - - - - - - - - - - - - {{.Label}} - - {{.Message}} - -`)) - -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, +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 buf bytes.Buffer - if err := svgTemplate.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("rendering badge: %w", err) - } - return buf.Bytes(), nil + return width } diff --git a/modules/badge/presets.go b/modules/badge/presets.go index 87f694e112..6ef0dcbc90 100644 --- a/modules/badge/presets.go +++ b/modules/badge/presets.go @@ -13,8 +13,17 @@ import ( 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) { +// Preset colors for repo badges +const ( + ColorGreen = "#4c1" + ColorYellow = "#dfb317" + ColorRed = "#e05d44" + ColorGrey = "#9f9f9f" + ColorBlue = "#007ec6" +) + +// GenerateRepoBadge creates a badge for the given repo and badge type. +func GenerateRepoBadge(ctx context.Context, repo *repo_model.Repository, badgeType string) Badge { switch strings.ToLower(badgeType) { case "version": return versionBadge(ctx, repo) @@ -25,73 +34,90 @@ func Generate(ctx context.Context, repo *repo_model.Repository, badgeType string case "health": return healthBadge(ctx, repo) default: - return Badge{Label: "badge", Message: "unknown", Color: ColorGrey}, - fmt.Errorf("unknown badge type: %s", badgeType) + return GenerateBadge("badge", "unknown", ColorGrey) } } -func versionBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) { +func versionBadge(ctx context.Context, repo *repo_model.Repository) Badge { release, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID) if err != nil || release == nil { - return Badge{Label: "version", Message: "none", Color: ColorGrey}, nil + return GenerateBadge("version", "none", ColorGrey) } - return Badge{Label: "version", Message: release.TagName, Color: ColorBlue}, nil + return GenerateBadge("version", release.TagName, ColorBlue) } -func buildBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) { +func buildBadge(ctx context.Context, repo *repo_model.Repository) Badge { status, err := git_model.GetLatestCommitStatus(ctx, repo.ID, repo.DefaultBranch, db.ListOptions{PageSize: 1}) if err != nil || len(status) == 0 { - return Badge{Label: "build", Message: "unknown", Color: ColorGrey}, nil + return GenerateBadge("build", "unknown", ColorGrey) } switch status[0].State.String() { case "success": - return Badge{Label: "build", Message: "passing", Color: ColorGreen}, nil + return GenerateBadge("build", "passing", ColorGreen) case "failure", "error": - return Badge{Label: "build", Message: "failing", Color: ColorRed}, nil + return GenerateBadge("build", "failing", ColorRed) case "pending": - return Badge{Label: "build", Message: "pending", Color: ColorYellow}, nil + return GenerateBadge("build", "pending", ColorYellow) default: - return Badge{Label: "build", Message: status[0].State.String(), Color: ColorGrey}, nil + return GenerateBadge("build", status[0].State.String(), ColorGrey) } } -func licenseBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) { +func licenseBadge(ctx context.Context, repo *repo_model.Repository) Badge { licenses, err := repo_model.GetRepoLicenses(ctx, repo) if err != nil || len(licenses) == 0 { - return Badge{Label: "license", Message: "none", Color: ColorGrey}, nil + return GenerateBadge("license", "none", ColorGrey) } - return Badge{Label: "license", Message: licenses.StringList()[0], Color: ColorBlue}, nil + return GenerateBadge("license", licenses.StringList()[0], ColorBlue) } -func healthBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) { +func healthBadge(ctx context.Context, repo *repo_model.Repository) Badge { score := 0 - - // Has license licenses, _ := repo_model.GetRepoLicenses(ctx, repo) if len(licenses) > 0 { score++ } - - // Has description if repo.Description != "" { score++ } - - // Has topics if len(repo.Topics) > 0 { score++ } - var color string - var msg string switch { case score >= 3: - color, msg = ColorGreen, "healthy" + return GenerateBadge("health", "healthy", ColorGreen) case score >= 2: - color, msg = ColorYellow, "fair" + return GenerateBadge("health", "fair", ColorYellow) default: - color, msg = ColorRed, "needs work" + return GenerateBadge("health", "needs work", ColorRed) } - return Badge{Label: "health", Message: msg, Color: color}, nil +} + +// FormatRepoBadgeSVG returns the badge as SVG HTML for the given repo badge type. +func FormatRepoBadgeSVG(ctx context.Context, repo *repo_model.Repository, badgeType, style string) (string, error) { + b := GenerateRepoBadge(ctx, repo, badgeType) + switch style { + case "flat-square": + return b.RenderFlatSquare(), nil + default: + return b.RenderFlat(), nil + } +} + +// RenderFlat returns the badge as flat-style SVG. +func (b Badge) RenderFlat() string { + return fmt.Sprintf(` + + +%s%s +`, + b.Label.Width()+b.Message.Width(), b.Label.Width(), b.Label.Width(), b.Message.Width(), b.Color, + b.Label.X(), b.Label.Text(), b.Message.X(), b.Message.Text()) +} + +// RenderFlatSquare returns the badge as flat-square-style SVG. +func (b Badge) RenderFlatSquare() string { + return b.RenderFlat() // same for now } diff --git a/routers/api/v1/repo/badge.go b/routers/api/v1/repo/badge.go index cd33c56ea6..e70959ffb6 100644 --- a/routers/api/v1/repo/badge.go +++ b/routers/api/v1/repo/badge.go @@ -14,13 +14,9 @@ import ( // GetRepoBadge returns an SVG badge for the repository. func GetRepoBadge(ctx *context.APIContext) { badgeType := ctx.PathParam("type") + style := ctx.FormString("style") - 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() + svg, err := badge.FormatRepoBadgeSVG(ctx, ctx.Repo.Repository, badgeType, style) if err != nil { ctx.APIErrorInternal(fmt.Errorf("rendering badge: %w", err)) return @@ -28,7 +24,6 @@ func GetRepoBadge(ctx *context.APIContext) { 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) + _, _ = ctx.Resp.Write([]byte(svg)) }