@@ -14,12 +14,17 @@ const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size'
1414const NPMS_API = 'https://api.npms.io/v2/package'
1515
1616const SafeStringSchema = v . pipe ( v . string ( ) , v . regex ( / ^ [ ^ < > " & ] * $ / , 'Invalid characters' ) )
17+ const SafeColorSchema = v . pipe (
18+ v . string ( ) ,
19+ v . transform ( value => ( value . startsWith ( '#' ) ? value : `#${ value } ` ) ) ,
20+ v . hexColor ( ) ,
21+ )
1722
1823const QUERY_SCHEMA = v . object ( {
19- color : v . optional ( SafeStringSchema ) ,
2024 name : v . optional ( v . string ( ) ) ,
21- labelColor : v . optional ( SafeStringSchema ) ,
2225 label : v . optional ( SafeStringSchema ) ,
26+ color : v . optional ( SafeColorSchema ) ,
27+ labelColor : v . optional ( SafeColorSchema ) ,
2328} )
2429
2530const COLORS = {
@@ -95,6 +100,23 @@ function escapeXML(str: string): string {
95100 . replace ( / " / g, '"' )
96101}
97102
103+ function toLinear ( c : number ) : number {
104+ return c <= 0.04045 ? c / 12.92 : Math . pow ( ( c + 0.055 ) / 1.055 , 2.4 )
105+ }
106+
107+ function getContrastTextColor ( bgHex : string ) : string {
108+ let clean = bgHex . replace ( '#' , '' )
109+ if ( clean . length === 3 )
110+ clean = clean [ 0 ] ! + clean [ 0 ] ! + clean [ 1 ] ! + clean [ 1 ] ! + clean [ 2 ] ! + clean [ 2 ] !
111+ if ( ! / ^ [ 0 - 9 a - f ] { 6 } $ / i. test ( clean ) ) return '#ffffff'
112+ const r = parseInt ( clean . slice ( 0 , 2 ) , 16 ) / 255
113+ const g = parseInt ( clean . slice ( 2 , 4 ) , 16 ) / 255
114+ const b = parseInt ( clean . slice ( 4 , 6 ) , 16 ) / 255
115+ const luminance = 0.2126 * toLinear ( r ) + 0.7152 * toLinear ( g ) + 0.0722 * toLinear ( b )
116+ // threshold where contrast ratio with white equals contrast ratio with black
117+ return luminance > 0.179 ? '#000000' : '#ffffff'
118+ }
119+
98120function measureShieldsTextLength ( text : string ) : number {
99121 const measuredWidth = measureTextWidth ( text , SHIELDS_FONT_SHORTHAND )
100122
@@ -110,8 +132,11 @@ function renderDefaultBadgeSvg(params: {
110132 finalLabel : string
111133 finalLabelColor : string
112134 finalValue : string
135+ labelTextColor : string
136+ valueTextColor : string
113137} ) : string {
114- const { finalColor, finalLabel, finalLabelColor, finalValue } = params
138+ const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
139+ params
115140 const leftWidth = finalLabel . trim ( ) . length === 0 ? 0 : measureDefaultTextWidth ( finalLabel )
116141 const rightWidth = measureDefaultTextWidth ( finalValue )
117142 const totalWidth = leftWidth + rightWidth
@@ -120,19 +145,19 @@ function renderDefaultBadgeSvg(params: {
120145 const escapedValue = escapeXML ( finalValue )
121146
122147 return `
123- <svg xmlns="http://www.w3.org/2000/svg" width="${ totalWidth } " height="${ height } " role="img" aria-label="${ escapedLabel } : ${ escapedValue } ">
124- <clipPath id="r">
125- <rect width="${ totalWidth } " height="${ height } " rx="3" fill="#fff"/>
126- </clipPath>
127- <g clip-path="url(#r)">
128- <rect width="${ leftWidth } " height="${ height } " fill="${ finalLabelColor } "/>
129- <rect x="${ leftWidth } " width="${ rightWidth } " height="${ height } " fill="${ finalColor } "/>
130- </g>
131- <g text-anchor="middle" font-family="Geist, system-ui, -apple-system, sans-serif" font-size="11">
132- <text x="${ leftWidth / 2 } " y="14" fill="#ffffff ">${ escapedLabel } </text>
133- <text x="${ leftWidth + rightWidth / 2 } " y="14" fill="#ffffff ">${ escapedValue } </text>
134- </g>
135- </svg>
148+ <svg xmlns="http://www.w3.org/2000/svg" width="${ totalWidth } " height="${ height } " role="img" aria-label="${ escapedLabel } : ${ escapedValue } ">
149+ <clipPath id="r">
150+ <rect width="${ totalWidth } " height="${ height } " rx="3" fill="#fff"/>
151+ </clipPath>
152+ <g clip-path="url(#r)">
153+ <rect width="${ leftWidth } " height="${ height } " fill="${ finalLabelColor } "/>
154+ <rect x="${ leftWidth } " width="${ rightWidth } " height="${ height } " fill="${ finalColor } "/>
155+ </g>
156+ <g text-anchor="middle" font-family="Geist, system-ui, -apple-system, sans-serif" font-size="11">
157+ <text x="${ leftWidth / 2 } " y="14" fill="${ labelTextColor } ">${ escapedLabel } </text>
158+ <text x="${ leftWidth + rightWidth / 2 } " y="14" fill="${ valueTextColor } ">${ escapedValue } </text>
159+ </g>
160+ </svg>
136161 ` . trim ( )
137162}
138163
@@ -141,8 +166,11 @@ function renderShieldsBadgeSvg(params: {
141166 finalLabel : string
142167 finalLabelColor : string
143168 finalValue : string
169+ labelTextColor : string
170+ valueTextColor : string
144171} ) : string {
145- const { finalColor, finalLabel, finalLabelColor, finalValue } = params
172+ const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
173+ params
146174 const hasLabel = finalLabel . trim ( ) . length > 0
147175
148176 const leftTextLength = hasLabel ? measureShieldsTextLength ( finalLabel ) : 0
@@ -161,26 +189,26 @@ function renderShieldsBadgeSvg(params: {
161189 const rightTextLengthAttr = rightTextLength * 10
162190
163191 return `
164- <svg xmlns="http://www.w3.org/2000/svg" width="${ totalWidth } " height="${ height } " role="img" aria-label="${ title } ">
165- <linearGradient id="s" x2="0" y2="100%">
166- <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
167- <stop offset="1" stop-opacity=".1"/>
168- </linearGradient>
169- <clipPath id="r">
170- <rect width="${ totalWidth } " height="${ height } " rx="3" fill="#fff"/>
171- </clipPath>
172- <g clip-path="url(#r)">
173- <rect width="${ leftWidth } " height="${ height } " fill="${ finalLabelColor } "/>
174- <rect x="${ leftWidth } " width="${ rightWidth } " height="${ height } " fill="${ finalColor } "/>
175- <rect width="${ totalWidth } " height="${ height } " fill="url(#s)"/>
176- </g>
177- <g fill="#fff" text-anchor="middle" font-family="Verdana, Geneva, DejaVu Sans, sans-serif" text-rendering="geometricPrecision" font-size="110">
178- <text aria-hidden="true" x="${ leftCenter } " y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${ leftTextLengthAttr } ">${ escapedLabel } </text>
179- <text x="${ leftCenter } " y="140" transform="scale(.1)" fill="#fff " textLength="${ leftTextLengthAttr } ">${ escapedLabel } </text>
180- <text aria-hidden="true" x="${ rightCenter } " y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${ rightTextLengthAttr } ">${ escapedValue } </text>
181- <text x="${ rightCenter } " y="140" transform="scale(.1)" fill="#fff " textLength="${ rightTextLengthAttr } ">${ escapedValue } </text>
182- </g>
183- </svg>
192+ <svg xmlns="http://www.w3.org/2000/svg" width="${ totalWidth } " height="${ height } " role="img" aria-label="${ title } ">
193+ <linearGradient id="s" x2="0" y2="100%">
194+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
195+ <stop offset="1" stop-opacity=".1"/>
196+ </linearGradient>
197+ <clipPath id="r">
198+ <rect width="${ totalWidth } " height="${ height } " rx="3" fill="#fff"/>
199+ </clipPath>
200+ <g clip-path="url(#r)">
201+ <rect width="${ leftWidth } " height="${ height } " fill="${ finalLabelColor } "/>
202+ <rect x="${ leftWidth } " width="${ rightWidth } " height="${ height } " fill="${ finalColor } "/>
203+ <rect width="${ totalWidth } " height="${ height } " fill="url(#s)"/>
204+ </g>
205+ <g text-anchor="middle" font-family="Verdana, Geneva, DejaVu Sans, sans-serif" text-rendering="geometricPrecision" font-size="110">
206+ <text aria-hidden="true" x="${ leftCenter } " y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${ leftTextLengthAttr } ">${ escapedLabel } </text>
207+ <text x="${ leftCenter } " y="140" transform="scale(.1)" fill="${ labelTextColor } " textLength="${ leftTextLengthAttr } ">${ escapedLabel } </text>
208+ <text aria-hidden="true" x="${ rightCenter } " y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${ rightTextLengthAttr } ">${ escapedValue } </text>
209+ <text x="${ rightCenter } " y="140" transform="scale(.1)" fill="${ valueTextColor } " textLength="${ rightTextLengthAttr } ">${ escapedValue } </text>
210+ </g>
211+ </svg>
184212 ` . trim ( )
185213}
186214
@@ -442,8 +470,18 @@ export default defineCachedEventHandler(
442470 const rawLabelColor = labelColor ?? defaultLabelColor
443471 const finalLabelColor = rawLabelColor . startsWith ( '#' ) ? rawLabelColor : `#${ rawLabelColor } `
444472
473+ const labelTextColor = getContrastTextColor ( finalLabelColor )
474+ const valueTextColor = getContrastTextColor ( finalColor )
475+
445476 const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
446- const svg = renderFn ( { finalColor, finalLabel, finalLabelColor, finalValue } )
477+ const svg = renderFn ( {
478+ finalColor,
479+ finalLabel,
480+ finalLabelColor,
481+ finalValue,
482+ labelTextColor,
483+ valueTextColor,
484+ } )
447485
448486 setHeader ( event , 'Content-Type' , 'image/svg+xml' )
449487 setHeader (
0 commit comments