Skip to content

Commit 1decce6

Browse files
committed
Add badge service and CGI deployment
1 parent 9c4e909 commit 1decce6

43 files changed

Lines changed: 6127 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,24 @@ go.work.sum
3030
# Editor/IDE
3131
# .idea/
3232
# .vscode/
33+
34+
35+
# SvelteKit dependencies and intermediate output
36+
node_modules/
37+
.svelte-kit/
38+
39+
# Local server executable
40+
/badge-api.gosuda.org
41+
42+
# Local assistant skills and design metadata
43+
/.agents/
44+
/.hallmark/
45+
46+
# Generated frontend distribution output
47+
/build/
48+
/dist/*
49+
!/dist/.gitkeep
50+
51+
# Generated CGI executable
52+
/deployment/cgi-bin/*
53+
!/deployment/cgi-bin/.gitkeep

README.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,33 @@
1-
# badge-api.gosuda.org
2-
Badge API Server
1+
# Tiny Badge
2+
3+
A small sign for whatever deserves a little attention.
4+
5+
Maybe you have good news. Maybe a new issue just went out. Maybe you simply enjoy cheerful rectangles in sensible places. Fair enough.
6+
7+
## What happens here
8+
9+
You choose a few words, try on a look, fuss over the colors, and leave with a tiny badge of your own.
10+
11+
It’s quick enough for a passing idea and picky enough for the person who will spend twelve minutes deciding between two nearly identical greens.
12+
13+
## Where badges like to live
14+
15+
- Pages that could use a small bit of news.
16+
- Profiles that need one bright detail.
17+
- Notes, newsletters, and friendly announcements.
18+
- Any quiet corner of the internet with room for a link.
19+
20+
## A few house notes
21+
22+
Keep the words short. Let the colors see each other. If the first version feels too sensible, try the neon one.
23+
24+
That’s really it. Make something tiny. Give it good manners. Send it on its way.
25+
26+
## The practical drawer
27+
28+
If you came looking for switches, settings, and exact examples, they are tucked away here:
29+
30+
- [Set up Tiny Badge](docs/setup.md)
31+
- [Deploy every route through Apache CGI](docs/cgi.md)
32+
- [Choose environment variables](docs/environment-variables.md)
33+
- [Use the Badge API](docs/api.md)

badge.go

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"html"
6+
"math"
7+
"strconv"
8+
"strings"
9+
"unicode/utf8"
10+
)
11+
12+
type badgeOptions struct {
13+
Label string
14+
Message string
15+
Style string
16+
LabelColor string
17+
MessageColor string
18+
LabelTextColor string
19+
MessageTextColor string
20+
}
21+
22+
type styleSpec struct {
23+
Height float64
24+
Radius float64
25+
FontSize float64
26+
Padding float64
27+
Uppercase bool
28+
BoldLabel bool
29+
BoldMessage bool
30+
Kind string
31+
}
32+
33+
var badgeStyles = map[string]styleSpec{
34+
"flat": {Height: 20, Radius: 3, FontSize: 11, Padding: 7, BoldMessage: true, Kind: "flat"},
35+
"flat-square": {Height: 20, Radius: 0, FontSize: 11, Padding: 7, BoldMessage: true, Kind: "flat"},
36+
"plastic": {Height: 18, Radius: 4, FontSize: 10, Padding: 7, BoldMessage: true, Kind: "plastic"},
37+
"round": {Height: 24, Radius: 12, FontSize: 11, Padding: 10, BoldLabel: true, BoldMessage: true, Kind: "flat"},
38+
"outline": {Height: 22, Radius: 5, FontSize: 11, Padding: 9, BoldLabel: true, BoldMessage: true, Kind: "outline"},
39+
"neon": {Height: 24, Radius: 5, FontSize: 11, Padding: 9, BoldLabel: true, BoldMessage: true, Kind: "neon"},
40+
"glass": {Height: 24, Radius: 7, FontSize: 11, Padding: 9, BoldMessage: true, Kind: "glass"},
41+
"flatbar": {Height: 28, Radius: 0, FontSize: 10, Padding: 12, Uppercase: true, BoldMessage: true, Kind: "flatbar"},
42+
}
43+
44+
var namedColors = map[string]string{
45+
"brightgreen": "44cc11",
46+
"green": "97ca00",
47+
"yellowgreen": "a4a61d",
48+
"yellow": "dfb317",
49+
"orange": "fe7d37",
50+
"red": "e05d44",
51+
"blue": "007ec6",
52+
"grey": "555555",
53+
"gray": "555555",
54+
"lightgrey": "9f9f9f",
55+
"lightgray": "9f9f9f",
56+
"success": "2f855a",
57+
"important": "d97706",
58+
"critical": "c53030",
59+
"informational": "2563eb",
60+
"inactive": "718096",
61+
}
62+
63+
func availableStyles() []string {
64+
return []string{"flat", "flat-square", "plastic", "round", "outline", "neon", "glass", "flatbar"}
65+
}
66+
67+
func normalizeColor(value, fallback string) (string, error) {
68+
value = strings.ToLower(strings.TrimSpace(value))
69+
value = strings.TrimPrefix(value, "#")
70+
if named, ok := namedColors[value]; ok {
71+
return named, nil
72+
}
73+
if value == "" {
74+
return fallback, nil
75+
}
76+
if len(value) == 3 {
77+
value = string([]byte{value[0], value[0], value[1], value[1], value[2], value[2]})
78+
}
79+
if len(value) != 6 {
80+
return "", fmt.Errorf("color must be a 3- or 6-digit hex value")
81+
}
82+
for _, char := range value {
83+
if !strings.ContainsRune("0123456789abcdef", char) {
84+
return "", fmt.Errorf("color contains a non-hex character")
85+
}
86+
}
87+
return value, nil
88+
}
89+
90+
func validateBadgeOptions(options badgeOptions) (badgeOptions, error) {
91+
options.Label = strings.TrimSpace(options.Label)
92+
options.Message = strings.TrimSpace(options.Message)
93+
options.Style = strings.ToLower(strings.TrimSpace(options.Style))
94+
if options.Style == "" {
95+
options.Style = "flat"
96+
}
97+
if _, ok := badgeStyles[options.Style]; !ok {
98+
return badgeOptions{}, fmt.Errorf("unknown style %q; supported styles: %s", options.Style, strings.Join(availableStyles(), ", "))
99+
}
100+
if options.Message == "" {
101+
return badgeOptions{}, fmt.Errorf("message is required")
102+
}
103+
if utf8.RuneCountInString(options.Label) > 64 {
104+
return badgeOptions{}, fmt.Errorf("label must be 64 characters or fewer")
105+
}
106+
if utf8.RuneCountInString(options.Message) > 128 {
107+
return badgeOptions{}, fmt.Errorf("message must be 128 characters or fewer")
108+
}
109+
110+
var err error
111+
if options.LabelColor, err = normalizeColor(options.LabelColor, "555555"); err != nil {
112+
return badgeOptions{}, fmt.Errorf("labelColor: %w", err)
113+
}
114+
if options.MessageColor, err = normalizeColor(options.MessageColor, "44cc11"); err != nil {
115+
return badgeOptions{}, fmt.Errorf("color: %w", err)
116+
}
117+
if options.LabelTextColor, err = normalizeColor(options.LabelTextColor, "ffffff"); err != nil {
118+
return badgeOptions{}, fmt.Errorf("labelTextColor: %w", err)
119+
}
120+
if options.MessageTextColor, err = normalizeColor(options.MessageTextColor, "ffffff"); err != nil {
121+
return badgeOptions{}, fmt.Errorf("textColor: %w", err)
122+
}
123+
return options, nil
124+
}
125+
126+
func renderBadge(options badgeOptions) []byte {
127+
spec := badgeStyles[options.Style]
128+
label := options.Label
129+
message := options.Message
130+
if spec.Uppercase {
131+
label = strings.ToUpper(label)
132+
message = strings.ToUpper(message)
133+
}
134+
135+
labelTextWidth := measureText(label, spec.FontSize)
136+
messageTextWidth := measureText(message, spec.FontSize)
137+
labelWidth := 0.0
138+
if label != "" {
139+
labelWidth = math.Ceil(labelTextWidth + spec.Padding*2)
140+
}
141+
messageWidth := math.Ceil(messageTextWidth + spec.Padding*2)
142+
if messageWidth < spec.Height {
143+
messageWidth = spec.Height
144+
}
145+
totalWidth := labelWidth + messageWidth
146+
aria := message
147+
if label != "" {
148+
aria = label + ": " + message
149+
}
150+
151+
var builder strings.Builder
152+
builder.Grow(1400)
153+
builder.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="`)
154+
builder.WriteString(number(totalWidth))
155+
builder.WriteString(`" height="`)
156+
builder.WriteString(number(spec.Height))
157+
builder.WriteString(`" viewBox="0 0 `)
158+
builder.WriteString(number(totalWidth))
159+
builder.WriteByte(' ')
160+
builder.WriteString(number(spec.Height))
161+
builder.WriteString(`" role="img" aria-label="`)
162+
builder.WriteString(html.EscapeString(aria))
163+
builder.WriteString(`"><title>`)
164+
builder.WriteString(html.EscapeString(aria))
165+
builder.WriteString(`</title>`)
166+
writeDefinitions(&builder, spec, totalWidth, options)
167+
168+
if spec.Radius > 0 {
169+
builder.WriteString(`<g clip-path="url(#badge-clip)">`)
170+
} else {
171+
builder.WriteString(`<g shape-rendering="crispEdges">`)
172+
}
173+
writeBackgrounds(&builder, spec, labelWidth, messageWidth, options)
174+
builder.WriteString(`</g>`)
175+
writeTexts(&builder, spec, label, message, labelWidth, messageWidth, labelTextWidth, messageTextWidth, options)
176+
builder.WriteString(`</svg>`)
177+
return []byte(builder.String())
178+
}
179+
180+
func writeDefinitions(builder *strings.Builder, spec styleSpec, totalWidth float64, options badgeOptions) {
181+
builder.WriteString(`<defs>`)
182+
if spec.Radius > 0 {
183+
builder.WriteString(`<clipPath id="badge-clip"><rect width="`)
184+
builder.WriteString(number(totalWidth))
185+
builder.WriteString(`" height="`)
186+
builder.WriteString(number(spec.Height))
187+
builder.WriteString(`" rx="`)
188+
builder.WriteString(number(spec.Radius))
189+
builder.WriteString(`"/></clipPath>`)
190+
}
191+
if spec.Kind == "plastic" || spec.Kind == "glass" {
192+
builder.WriteString(`<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".38"/><stop offset=".48" stop-color="#fff" stop-opacity=".08"/><stop offset=".52" stop-color="#000" stop-opacity=".04"/><stop offset="1" stop-color="#000" stop-opacity=".18"/></linearGradient>`)
193+
}
194+
if spec.Kind == "neon" {
195+
builder.WriteString(`<filter id="label-glow" x="-30%" y="-50%" width="160%" height="200%"><feDropShadow dx="0" dy="0" stdDeviation="1.4" flood-color="#`)
196+
builder.WriteString(options.LabelColor)
197+
builder.WriteString(`" flood-opacity=".8"/></filter><filter id="message-glow" x="-30%" y="-50%" width="160%" height="200%"><feDropShadow dx="0" dy="0" stdDeviation="1.4" flood-color="#`)
198+
builder.WriteString(options.MessageColor)
199+
builder.WriteString(`" flood-opacity=".8"/></filter>`)
200+
}
201+
builder.WriteString(`</defs>`)
202+
}
203+
204+
func writeBackgrounds(builder *strings.Builder, spec styleSpec, labelWidth, messageWidth float64, options badgeOptions) {
205+
if labelWidth > 0 {
206+
writeRect(builder, 0, labelWidth, spec.Height, options.LabelColor, spec.Kind, "label-glow")
207+
}
208+
writeRect(builder, labelWidth, messageWidth, spec.Height, options.MessageColor, spec.Kind, "message-glow")
209+
210+
switch spec.Kind {
211+
case "plastic", "glass":
212+
builder.WriteString(`<rect width="100%" height="100%" fill="url(#shine)"/>`)
213+
if spec.Kind == "glass" {
214+
builder.WriteString(`<rect x=".5" y=".5" width="calc(100% - 1px)" height="calc(100% - 1px)" rx="`)
215+
builder.WriteString(number(math.Max(0, spec.Radius-0.5)))
216+
builder.WriteString(`" fill="none" stroke="#fff" stroke-opacity=".32"/>`)
217+
}
218+
case "outline":
219+
builder.WriteString(`<rect x=".75" y=".75" width="calc(100% - 1.5px)" height="calc(100% - 1.5px)" rx="`)
220+
builder.WriteString(number(math.Max(0, spec.Radius-0.75)))
221+
builder.WriteString(`" fill="none" stroke="#fff" stroke-opacity=".55" stroke-width="1.5"/>`)
222+
}
223+
}
224+
225+
func writeRect(builder *strings.Builder, x, width, height float64, color, kind, filterID string) {
226+
builder.WriteString(`<rect x="`)
227+
builder.WriteString(number(x))
228+
builder.WriteString(`" width="`)
229+
builder.WriteString(number(width))
230+
builder.WriteString(`" height="`)
231+
builder.WriteString(number(height))
232+
builder.WriteString(`" fill="#`)
233+
builder.WriteString(color)
234+
builder.WriteByte('"')
235+
if kind == "neon" {
236+
builder.WriteString(` filter="url(#`)
237+
builder.WriteString(filterID)
238+
builder.WriteString(`)"`)
239+
}
240+
builder.WriteString(`/>`)
241+
}
242+
243+
func writeTexts(builder *strings.Builder, spec styleSpec, label, message string, labelWidth, messageWidth, labelTextWidth, messageTextWidth float64, options badgeOptions) {
244+
y := spec.Height/2 + spec.FontSize*0.34
245+
builder.WriteString(`<g text-anchor="middle" font-family="Verdana, Geneva, DejaVu Sans, sans-serif" font-size="`)
246+
builder.WriteString(number(spec.FontSize))
247+
builder.WriteString(`" text-rendering="geometricPrecision">`)
248+
if label != "" {
249+
writeText(builder, label, labelWidth/2, y, labelTextWidth, options.LabelTextColor, spec.BoldLabel)
250+
}
251+
writeText(builder, message, labelWidth+messageWidth/2, y, messageTextWidth, options.MessageTextColor, spec.BoldMessage)
252+
builder.WriteString(`</g>`)
253+
}
254+
255+
func writeText(builder *strings.Builder, text string, x, y, width float64, color string, bold bool) {
256+
builder.WriteString(`<text x="`)
257+
builder.WriteString(number(x))
258+
builder.WriteString(`" y="`)
259+
builder.WriteString(number(y))
260+
builder.WriteString(`" textLength="`)
261+
builder.WriteString(number(width))
262+
builder.WriteString(`" lengthAdjust="spacingAndGlyphs" fill="#`)
263+
builder.WriteString(color)
264+
builder.WriteByte('"')
265+
if bold {
266+
builder.WriteString(` font-weight="700"`)
267+
}
268+
builder.WriteByte('>')
269+
builder.WriteString(html.EscapeString(text))
270+
builder.WriteString(`</text>`)
271+
}
272+
273+
func measureText(text string, fontSize float64) float64 {
274+
var width float64
275+
for _, char := range text {
276+
switch {
277+
case char == ' ':
278+
width += 0.34
279+
case strings.ContainsRune("ilI1.,'`|!:;", char):
280+
width += 0.34
281+
case strings.ContainsRune("mwMW@%&#", char):
282+
width += 0.92
283+
case char >= utf8.RuneSelf:
284+
width += 1.0
285+
default:
286+
width += 0.62
287+
}
288+
}
289+
return math.Max(width*fontSize, fontSize*0.5)
290+
}
291+
292+
func number(value float64) string {
293+
return strconv.FormatFloat(value, 'f', -1, 64)
294+
}

0 commit comments

Comments
 (0)