|
1 | 1 | import { getTheme, getBadgeTheme } from '../utils/themes.js'; |
| 2 | +import type { BadgeType, BadgeOptions } from '../types.js'; |
| 3 | + |
| 4 | +interface BadgeConfig { |
| 5 | + label: string; |
| 6 | + iconPath: string; |
| 7 | + formatValue?: (n: number) => string; |
| 8 | +} |
| 9 | + |
| 10 | +const H = 26; |
| 11 | +const ICON_SIZE = 12; |
| 12 | +const ICON_PAD_L = 7; |
| 13 | +const ICON_GAP = 13; |
| 14 | +const LABEL_CHARW = 6.5; |
| 15 | +const LABEL_PAD_R = 9; |
| 16 | +const VALUE_CHARW = 8; |
| 17 | +const VALUE_PAD_H = 12; |
| 18 | +const ICON_SCALE = (ICON_SIZE / 14).toFixed(4); |
| 19 | +const ICON_TOP = ((H - ICON_SIZE) / 2).toFixed(1); |
| 20 | +const TEXT_Y = Math.round(H / 2 + 4); |
| 21 | + |
| 22 | +const BADGE_CONFIGS: Record<BadgeType, BadgeConfig> = { |
| 23 | + 'visitors': { |
| 24 | + label: 'Visitors', |
| 25 | + iconPath: 'M 1 7 Q 7 1.5 13 7 Q 7 12.5 1 7 Z M 7 5 A 2 2 0 1 1 6.99 5', |
| 26 | + }, |
| 27 | + 'repositories': { |
| 28 | + label: 'Repos', |
| 29 | + iconPath: 'M 5.25 1.75 a 0.583 0.583 0 0 1 0.354 0.12 l 0.058 0.051 l 1.579 1.579 h 3.841 a 1.75 1.75 0 0 1 1.747 1.647 l 0.003 0.103 v 4.667 a 1.75 1.75 0 0 1 -1.647 1.747 l -0.103 0.003 h -8.167 a 1.75 1.75 0 0 1 -1.747 -1.647 l -0.003 -0.103 v -6.417 a 1.75 1.75 0 0 1 1.647 -1.747 l 0.103 0.003 h 2.333 z', |
| 30 | + }, |
| 31 | + 'organization': { |
| 32 | + label: 'Orgs', |
| 33 | + iconPath: 'M 1.75 12.25 l 10.5 0 M 5.25 4.67 l 0.58 0 M 5.25 7 l 0.58 0 M 5.25 9.33 l 0.58 0 M 8.17 4.67 l 0.58 0 M 8.17 7 l 0.58 0 M 8.17 9.33 l 0.58 0 M 2.92 12.25 v -9.33 a 1.17 1.17 0 0 1 1.17 -1.17 h 5.83 a 1.17 1.17 0 0 1 1.17 1.17 v 9.33', |
| 34 | + }, |
| 35 | + 'languages': { |
| 36 | + label: 'Languages', |
| 37 | + iconPath: 'M 4.08 4.67 l -2.33 2.33 l 2.33 2.33 M 9.92 4.67 l 2.33 2.33 l -2.33 2.33 M 8.17 2.33 l -2.33 9.33', |
| 38 | + }, |
| 39 | + 'followers': { |
| 40 | + label: 'Followers', |
| 41 | + iconPath: 'M 5.83 7.58 a 1.167 1.167 0 1 0 2.333 0 a 1.167 1.167 0 0 0 -2.333 0 M 4.67 12.25 v -0.583 a 1.167 1.167 0 0 1 1.167 -1.167 h 2.333 a 1.167 1.167 0 0 1 1.167 1.167 v 0.583 M 8.75 2.92 a 1.167 1.167 0 1 0 2.333 0 a 1.167 1.167 0 0 0 -2.333 0 M 9.92 5.83 h 1.167 a 1.167 1.167 0 0 1 1.167 1.167 v 0.583 M 2.92 2.92 a 1.167 1.167 0 1 0 2.333 0 a 1.167 1.167 0 0 0 -2.333 0 M 1.75 7.58 v -0.583 a 1.167 1.167 0 0 1 1.167 -1.167 h 1.167', |
| 42 | + }, |
| 43 | + 'total-stars': { |
| 44 | + label: 'Stars', |
| 45 | + iconPath: 'M 4.808 4.281 l -3.722 0.540 l -0.066 0.013 a 0.583 0.583 0 0 0 -0.257 0.982 l 2.696 2.624 l -0.636 3.707 l -0.008 0.064 a 0.583 0.583 0 0 0 0.854 0.551 l 3.328 -1.750 l 3.321 1.750 l 0.058 0.027 a 0.583 0.583 0 0 0 0.789 -0.642 l -0.636 -3.707 l 2.697 -2.625 l 0.046 -0.050 a 0.583 0.583 0 0 0 -0.369 -0.945 l -3.722 -0.540 l -1.663 -3.372 a 0.583 0.583 0 0 0 -1.047 0 l -1.664 3.372 z', |
| 46 | + }, |
| 47 | + 'total-contributors': { |
| 48 | + label: 'Contributors', |
| 49 | + iconPath: 'M 2.917 4.083 a 2.333 2.333 0 1 0 4.667 0 a 2.333 2.333 0 1 0 -4.667 0 M 1.75 12.25 v -1.167 a 2.333 2.333 0 0 1 2.333 -2.333 h 2.333 a 2.333 2.333 0 0 1 2.333 2.333 v 1.167 M 9.333 1.826 a 2.333 2.333 0 0 1 0 4.521 M 12.25 12.25 v -1.167 a 2.333 2.333 0 0 0 -1.75 -2.246', |
| 50 | + }, |
| 51 | + 'total-commits': { |
| 52 | + label: 'Commits', |
| 53 | + iconPath: 'M 7 3.5 A 3.5 3.5 0 1 1 6.99 3.5 M 1 7 L 3.5 7 M 10.5 7 L 13 7', |
| 54 | + }, |
| 55 | + 'total-code-reviews': { |
| 56 | + label: 'Reviews', |
| 57 | + iconPath: 'M 2.91 7.50 a 4.14 4.14 0 0 0 7.10 2.29 a 1.14 1.14 0 0 1 -0.09 -0.37 l -0.003 -0.087 l 0.003 -0.088 a 1.167 1.167 0 1 1 1.031 1.246 a 5.308 5.308 0 0 1 -9.195 -2.829 a 0.583 0.583 0 0 1 1.155 -0.16 z M 7.0 4.667 a 2.333 2.333 0 1 1 -2.330 2.45 l -0.003 -0.117 l 0.003 -0.117 a 2.333 2.333 0 0 1 2.330 -2.217 z M 7.66 1.80 a 5.31 5.31 0 0 1 4.578 4.534 a 0.583 0.583 0 0 1 -1.155 0.161 a 4.14 4.14 0 0 0 -3.573 -3.537 a 4.14 4.14 0 0 0 -3.528 1.246 a 1.167 1.167 0 1 1 -2.235 0.548 l -0.003 -0.087 l 0.003 -0.088 a 1.167 1.167 0 0 1 1.293 -1.072 a 5.305 5.305 0 0 1 4.621 -1.705 z', |
| 58 | + }, |
| 59 | + 'total-issues': { |
| 60 | + label: 'Issues', |
| 61 | + iconPath: 'M 7 1 A 6 6 0 1 1 6.99 1 M 7 5 L 7 7.5 M 7 10 L 7 10.2', |
| 62 | + }, |
| 63 | + 'total-pull-requests': { |
| 64 | + label: 'Pull Reqs', |
| 65 | + iconPath: 'M 3 2 A 1.5 1.5 0 1 1 2.99 2 M 3 3.5 L 3 12.5 M 11 2 A 1.5 1.5 0 1 1 10.99 2 M 11 3.5 L 11 9.5 M 11 11 A 1.5 1.5 0 1 1 10.99 11 M 3 9 Q 3 13 9 13 L 9.5 13', |
| 66 | + }, |
| 67 | + 'total-joined-years': { |
| 68 | + label: 'Joined', |
| 69 | + iconPath: 'M 2 3 L 12 3 L 12 12 L 2 12 Z M 2 6 L 12 6 M 5 1.5 L 5 4 M 9 1.5 L 9 4 M 5 8.5 L 5 10 M 7 8.5 L 9 8.5', |
| 70 | + formatValue: (n) => `${n} yr${n !== 1 ? 's' : ''}`, |
| 71 | + }, |
| 72 | +}; |
2 | 73 |
|
3 | 74 | export class BadgeRenderer { |
4 | | - static generateBadge(username: string, count: number, themeName: string = 'default', customColors?: { |
5 | | - labelColor?: string; |
6 | | - labelBackground?: string; |
7 | | - valueColor?: string; |
8 | | - valueBackground?: string; |
9 | | - }): string { |
| 75 | + |
| 76 | + static visitors(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 77 | + return BadgeRenderer._render(BADGE_CONFIGS['visitors'], 'visitors', value, options); |
| 78 | + } |
| 79 | + |
| 80 | + static repositories(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 81 | + return BadgeRenderer._render(BADGE_CONFIGS['repositories'], 'repositories', value, options); |
| 82 | + } |
| 83 | + |
| 84 | + static organization(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 85 | + return BadgeRenderer._render(BADGE_CONFIGS['organization'], 'organization', value, options); |
| 86 | + } |
| 87 | + |
| 88 | + static languages(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 89 | + return BadgeRenderer._render(BADGE_CONFIGS['languages'], 'languages', value, options); |
| 90 | + } |
| 91 | + |
| 92 | + static followers(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 93 | + return BadgeRenderer._render(BADGE_CONFIGS['followers'], 'followers', value, options); |
| 94 | + } |
| 95 | + |
| 96 | + static totalStars(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 97 | + return BadgeRenderer._render(BADGE_CONFIGS['total-stars'], 'total-stars', value, options); |
| 98 | + } |
| 99 | + |
| 100 | + static totalContributors(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 101 | + return BadgeRenderer._render(BADGE_CONFIGS['total-contributors'], 'total-contributors', value, options); |
| 102 | + } |
| 103 | + |
| 104 | + static totalCommits(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 105 | + return BadgeRenderer._render(BADGE_CONFIGS['total-commits'], 'total-commits', value, options); |
| 106 | + } |
| 107 | + |
| 108 | + static totalCodeReviews(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 109 | + return BadgeRenderer._render(BADGE_CONFIGS['total-code-reviews'], 'total-code-reviews', value, options); |
| 110 | + } |
| 111 | + |
| 112 | + static totalIssues(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 113 | + return BadgeRenderer._render(BADGE_CONFIGS['total-issues'], 'total-issues', value, options); |
| 114 | + } |
| 115 | + |
| 116 | + static totalPullRequests(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 117 | + return BadgeRenderer._render(BADGE_CONFIGS['total-pull-requests'], 'total-pull-requests', value, options); |
| 118 | + } |
| 119 | + |
| 120 | + static totalJoinedYears(value: number, options: Omit<BadgeOptions, 'type'> = {}): string { |
| 121 | + return BadgeRenderer._render(BADGE_CONFIGS['total-joined-years'], 'total-joined-years', value, options); |
| 122 | + } |
| 123 | + |
| 124 | + /** |
| 125 | + * Render a badge SVG for any badge type. |
| 126 | + * @param value The numeric value to display (visitors count, stars, etc.) |
| 127 | + * @param options Badge display options including `type`, `theme`, and optional color overrides. |
| 128 | + */ |
| 129 | + /** |
| 130 | + * Render a badge SVG for any badge type. |
| 131 | + * @param value The numeric value to display (visitors count, stars, etc.) |
| 132 | + * @param options Badge display options including `type`, `theme`, and optional color overrides. |
| 133 | + */ |
| 134 | + static generateBadge(value: number, options: BadgeOptions): string { |
| 135 | + const type: BadgeType = options.type ?? 'visitors'; |
| 136 | + return BadgeRenderer._render(BADGE_CONFIGS[type], type, value, options); |
| 137 | + } |
| 138 | + |
| 139 | + private static _resolveColor(override: string | undefined, fallback: string): string { |
| 140 | + if (!override) return fallback; |
| 141 | + return override.startsWith('#') ? override : `#${override}`; |
| 142 | + } |
| 143 | + |
| 144 | + private static _render( |
| 145 | + config: BadgeConfig, |
| 146 | + type: BadgeType, |
| 147 | + value: number, |
| 148 | + options: Omit<BadgeOptions, 'type'> |
| 149 | + ): string { |
| 150 | + const themeName = options.theme ?? 'default'; |
10 | 151 | const theme = getTheme(themeName); |
11 | 152 | const badgeTheme = getBadgeTheme(themeName); |
| 153 | + const rc = BadgeRenderer._resolveColor; |
12 | 154 |
|
13 | | - const finalLabelColor = customColors?.labelColor || badgeTheme.labelColor; |
14 | | - const finalLabelBg = customColors?.labelBackground || badgeTheme.labelBackground; |
15 | | - const finalValueColor = customColors?.valueColor || badgeTheme.valueColor; |
16 | | - const finalValueBg = customColors?.valueBackground || badgeTheme.valueBackground; |
| 155 | + const labelColor = rc(options.labelColor, badgeTheme.labelColor); |
| 156 | + const labelBg = rc(options.labelBackground, badgeTheme.labelBackground); |
| 157 | + const valueColor = rc(options.valueColor, badgeTheme.valueColor); |
| 158 | + const valueBg = rc(options.valueBackground, badgeTheme.valueBackground); |
17 | 159 |
|
18 | 160 | const fontName = theme.fontName || 'Orbitron'; |
19 | | - const fontFamily = theme.fontFamily || `'${fontName}', 'Ubuntu', 'sans-serif'`; |
20 | | - |
21 | | - // Formatted count |
22 | | - const formattedCount = count.toLocaleString(); |
23 | | - |
24 | | - // Calculate widths based on text length (estimations) |
25 | | - const labelText = "VISITORS"; |
26 | | - const labelWidth = labelText.length * 8 + 20; |
27 | | - const countWidth = formattedCount.length * 10 + 20; |
28 | | - const totalWidth = labelWidth + countWidth; |
29 | | - const height = 28; |
30 | | - |
31 | | - return ` |
32 | | - <svg width="${totalWidth}" height="${height}" viewBox="0 0 ${totalWidth} ${height}" fill="none" xmlns="http://www.w3.org/2000/svg"> |
33 | | - <defs> |
34 | | - <filter id="glow" x="-20%" y="-20%" width="140%" height="140%"> |
35 | | - <feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur"/> |
36 | | - <feMerge> |
37 | | - <feMergeNode in="blur"/> |
38 | | - <feMergeNode in="SourceGraphic"/> |
39 | | - </feMerge> |
40 | | - </filter> |
41 | | - </defs> |
42 | | -
|
43 | | - <style> |
44 | | - @font-face { |
45 | | - font-family: '${fontName}'; |
46 | | - font-style: normal; |
47 | | - font-weight: 400 900; |
48 | | - font-display: swap; |
49 | | - src: url(/fonts/orbitron.woff2) format('woff2'); |
50 | | - } |
51 | | - text { |
52 | | - font-family: ${fontFamily}; |
53 | | - font-weight: 700; |
54 | | - text-transform: uppercase; |
55 | | - letter-spacing: 1px; |
56 | | - } |
57 | | - .label-text { |
58 | | - fill: ${finalLabelColor}; |
59 | | - font-size: 11px; |
60 | | - } |
61 | | - .count-text { |
62 | | - fill: ${finalValueColor}; |
63 | | - font-size: 14px; |
64 | | - } |
65 | | - </style> |
66 | | -
|
67 | | - <!-- Background --> |
68 | | - <rect width="${totalWidth}" height="${height}" rx="4" fill="${finalValueBg}" stroke="${theme.borderColor}" stroke-width="1" /> |
69 | | - |
70 | | - <!-- Label Section --> |
71 | | - <path d="M 4 1 H ${labelWidth} V ${height - 1} H 4 Q 1 ${height - 1} 1 ${height - 4} V 4 Q 1 1 4 1 Z" fill="${finalLabelBg}" /> |
72 | | - <text x="${labelWidth / 2}" y="${height / 2 + 4}" text-anchor="middle" class="label-text">${labelText}</text> |
73 | | - |
74 | | - <!-- Count Section --> |
75 | | - <text x="${labelWidth + (countWidth / 2)}" y="${height / 2 + 5}" text-anchor="middle" class="count-text" filter="url(#glow)">${formattedCount}</text> |
76 | | - |
77 | | - <!-- Accent Line --> |
78 | | - <line x1="${labelWidth}" y1="6" x2="${labelWidth}" y2="${height - 6}" stroke="${theme.borderColor}" stroke-width="1" opacity="0.3" /> |
79 | | - </svg> |
80 | | - `.trim(); |
| 161 | + const fontFamily = theme.fontFamily || `'${fontName}', 'Ubuntu', sans-serif`; |
| 162 | + |
| 163 | + const labelText = (options.customLabel ?? config.label).toUpperCase(); |
| 164 | + const displayValue = config.formatValue ? config.formatValue(value) : value.toLocaleString(); |
| 165 | + |
| 166 | + const labelTextW = Math.ceil(labelText.length * LABEL_CHARW); |
| 167 | + const labelSecW = ICON_PAD_L + ICON_SIZE + ICON_GAP + labelTextW + LABEL_PAD_R; |
| 168 | + const valueTextW = Math.ceil(displayValue.length * VALUE_CHARW); |
| 169 | + const valueSecW = VALUE_PAD_H + valueTextW + VALUE_PAD_H; |
| 170 | + const totalWidth = labelSecW + valueSecW; |
| 171 | + |
| 172 | + const labelTextX = (ICON_PAD_L + ICON_SIZE + ICON_GAP + labelTextW / 2).toFixed(1); |
| 173 | + const valueTextX = (labelSecW + valueSecW / 2).toFixed(1); |
| 174 | + |
| 175 | + return `<svg width="${totalWidth}" height="${H}" viewBox="0 0 ${totalWidth} ${H}" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><filter id="glow-${type}" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter><clipPath id="clip-${type}"><rect width="${totalWidth}" height="${H}" rx="4"/></clipPath></defs><style>@font-face{font-family:'${fontName}';font-style:normal;font-weight:400 900;font-display:swap;src:url(/fonts/orbitron.woff2) format('woff2')}.badge-text{font-family:${fontFamily};font-weight:700;letter-spacing:.8px;text-transform:uppercase}</style><rect width="${totalWidth}" height="${H}" rx="4" fill="${valueBg}" stroke="${theme.borderColor}" stroke-width="1"/><path clip-path="url(#clip-${type})" d="M 0 0 H ${labelSecW} V ${H} H 0 Z" fill="${labelBg}"/><g transform="translate(${ICON_PAD_L},${ICON_TOP}) scale(${ICON_SCALE})"><path d="${config.iconPath}" stroke="${labelColor}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity=".9"/></g><text x="${labelTextX}" y="${TEXT_Y}" text-anchor="middle" class="badge-text" fill="${labelColor}" font-size="9.5">${labelText}</text><line x1="${labelSecW}" y1="5" x2="${labelSecW}" y2="${H - 5}" stroke="${theme.borderColor}" stroke-width="1" opacity=".35"/><text x="${valueTextX}" y="${TEXT_Y}" text-anchor="middle" class="badge-text" fill="${valueColor}" font-size="12" filter="url(#glow-${type})">${displayValue}</text></svg>`; |
81 | 176 | } |
82 | 177 | } |
0 commit comments