CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
badge.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3648956 | 1 | /** |
| 2 | * Block J10 — SVG status badges. | |
| 3 | * | |
| 4 | * Render a two-segment "label | value" shields.io-style badge as an inline | |
| 5 | * SVG string. Pure, deterministic, zero IO. Keep this file dependency-free | |
| 6 | * so it can be reused server- or client-side in the future. | |
| 7 | */ | |
| 8 | ||
| 9 | export type BadgeColor = | |
| 10 | | "green" | |
| 11 | | "red" | |
| 12 | | "yellow" | |
| 13 | | "blue" | |
| 14 | | "grey" | |
| 15 | | "orange" | |
| 16 | | string; | |
| 17 | ||
| 18 | export interface BadgeInput { | |
| 19 | label: string; | |
| 20 | value: string; | |
| 21 | color?: BadgeColor; | |
| 22 | labelColor?: string; | |
| 23 | } | |
| 24 | ||
| 25 | const NAMED_COLORS: Record<string, string> = { | |
| 26 | green: "#2ea043", | |
| 27 | red: "#da3633", | |
| 28 | yellow: "#d29922", | |
| 29 | blue: "#1f6feb", | |
| 30 | grey: "#586069", | |
| 31 | gray: "#586069", | |
| 32 | orange: "#db6d28", | |
| 33 | }; | |
| 34 | ||
| 35 | function resolveColor(c: BadgeColor | undefined, fallback: string): string { | |
| 36 | if (!c) return fallback; | |
| 37 | if (NAMED_COLORS[c]) return NAMED_COLORS[c]; | |
| 38 | if (/^#[0-9a-fA-F]{3,8}$/.test(c)) return c; | |
| 39 | return fallback; | |
| 40 | } | |
| 41 | ||
| 42 | /** XML/HTML-safe escape (&, <, >, ", '). */ | |
| 43 | export function escapeXml(s: string): string { | |
| 44 | return s | |
| 45 | .replace(/&/g, "&") | |
| 46 | .replace(/</g, "<") | |
| 47 | .replace(/>/g, ">") | |
| 48 | .replace(/"/g, """) | |
| 49 | .replace(/'/g, "'"); | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Rough Verdana-11 width estimator. Good enough for badges without shipping a | |
| 54 | * full font-metrics table. Based on average char widths observed in shields | |
| 55 | * SVGs (~6.5px per char for small DejaVu / Verdana, tighter for narrow chars). | |
| 56 | */ | |
| 57 | export function estimateTextWidth(s: string): number { | |
| 58 | let w = 0; | |
| 59 | for (const ch of s) { | |
| 60 | if ("iIl.,:;|!'".includes(ch)) w += 3.5; | |
| 61 | else if ("fjrt".includes(ch)) w += 4.5; | |
| 62 | else if (ch >= "A" && ch <= "Z") w += 7.5; | |
| 63 | else if (ch >= "0" && ch <= "9") w += 6.5; | |
| 64 | else if (ch === " ") w += 3.5; | |
| 65 | else w += 6.2; | |
| 66 | } | |
| 67 | return Math.round(w); | |
| 68 | } | |
| 69 | ||
| 70 | /** Build the SVG string for a two-segment badge. */ | |
| 71 | export function renderBadge(input: BadgeInput): string { | |
| 72 | const label = String(input.label || "").slice(0, 64); | |
| 73 | const value = String(input.value || "").slice(0, 64); | |
| 74 | const valueColor = resolveColor(input.color, NAMED_COLORS.grey); | |
| 75 | const labelColor = resolveColor(input.labelColor, "#555"); | |
| 76 | ||
| 77 | const padding = 10; | |
| 78 | const labelWidth = estimateTextWidth(label) + padding * 2; | |
| 79 | const valueWidth = estimateTextWidth(value) + padding * 2; | |
| 80 | const totalWidth = labelWidth + valueWidth; | |
| 81 | const height = 20; | |
| 82 | ||
| 83 | const labelEsc = escapeXml(label); | |
| 84 | const valueEsc = escapeXml(value); | |
| 85 | const title = escapeXml(`${label}: ${value}`); | |
| 86 | ||
| 87 | // Matches shields.io's flat style closely enough to blend into READMEs. | |
| 88 | return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${title}"> | |
| 89 | <title>${title}</title> | |
| 90 | <linearGradient id="s" x2="0" y2="100%"> | |
| 91 | <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> | |
| 92 | <stop offset="1" stop-opacity=".1"/> | |
| 93 | </linearGradient> | |
| 94 | <clipPath id="r"><rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/></clipPath> | |
| 95 | <g clip-path="url(#r)"> | |
| 96 | <rect width="${labelWidth}" height="${height}" fill="${labelColor}"/> | |
| 97 | <rect x="${labelWidth}" width="${valueWidth}" height="${height}" fill="${valueColor}"/> | |
| 98 | <rect width="${totalWidth}" height="${height}" fill="url(#s)"/> | |
| 99 | </g> | |
| 100 | <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> | |
| 101 | <text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${labelEsc}</text> | |
| 102 | <text x="${labelWidth / 2}" y="14">${labelEsc}</text> | |
| 103 | <text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${valueEsc}</text> | |
| 104 | <text x="${labelWidth + valueWidth / 2}" y="14">${valueEsc}</text> | |
| 105 | </g> | |
| 106 | </svg>`; | |
| 107 | } | |
| 108 | ||
| 109 | /** Pick a colour from a (state, source) pair. Used by route handlers. */ | |
| 110 | export function colorForState( | |
| 111 | state: "success" | "passed" | "pending" | "failure" | "failed" | "error" | "unknown" | |
| 112 | ): BadgeColor { | |
| 113 | switch (state) { | |
| 114 | case "success": | |
| 115 | case "passed": | |
| 116 | return "green"; | |
| 117 | case "pending": | |
| 118 | return "yellow"; | |
| 119 | case "failure": | |
| 120 | case "failed": | |
| 121 | case "error": | |
| 122 | return "red"; | |
| 123 | default: | |
| 124 | return "grey"; | |
| 125 | } | |
| 126 | } |