@@ -25,16 +25,32 @@ const THEME = {
2525
2626const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
2727const repoRoot = path . resolve ( __dirname , ".." ) ;
28- const repoIconPath = path . join ( repoRoot , "apps/gittensory-ui/public/brand/gittensory-icon-citron.svg" ) ;
28+ const repoIconPath = path . join (
29+ repoRoot ,
30+ "apps/gittensory-ui/public/brand/gittensory-icon-citron.svg" ,
31+ ) ;
2932
3033function compact ( n ) {
3134 if ( n >= 1_000_000 ) return ( n / 1_000_000 ) . toFixed ( 1 ) + "M" ;
3235 if ( n >= 1000 ) return ( n / 1000 ) . toFixed ( 0 ) + "k" ;
3336 return String ( n ) ;
3437}
3538
39+ // api.gittensor.io values (and the repo name) end up as SVG <text> content,
40+ // so escape XML special chars rather than trust they're clean numbers/strings.
41+ function escapeXml ( value ) {
42+ return String ( value )
43+ . replace ( / & / g, "&" )
44+ . replace ( / < / g, "<" )
45+ . replace ( / > / g, ">" )
46+ . replace ( / " / g, """ )
47+ . replace ( / ' / g, "'" ) ;
48+ }
49+
3650async function fetchJson ( url ) {
37- const res = await fetch ( url , { headers : { "User-Agent" : "gittensor-impact-card/1.0" } } ) ;
51+ const res = await fetch ( url , {
52+ headers : { "User-Agent" : "gittensor-impact-card/1.0" } ,
53+ } ) ;
3854 if ( ! res . ok ) throw new Error ( `fetch failed: ${ url } (${ res . status } )` ) ;
3955 return res . json ( ) ;
4056}
@@ -70,8 +86,16 @@ function sparkline(x, y, w, h, values, mutedColor, accentColor, cardBg) {
7086 const range = max - min || 1 ;
7187 const n = values . length ;
7288 const stepX = w / ( n - 1 ) ;
73- const pts = values . map ( ( v , i ) => [ x + i * stepX , y + h - ( ( v - min ) / range ) * h ] ) ;
74- const svgPath = pts . map ( ( [ px , py ] , i ) => `${ i === 0 ? "M" : "L" } ${ px . toFixed ( 1 ) } ,${ py . toFixed ( 1 ) } ` ) . join ( " " ) ;
89+ const pts = values . map ( ( v , i ) => [
90+ x + i * stepX ,
91+ y + h - ( ( v - min ) / range ) * h ,
92+ ] ) ;
93+ const svgPath = pts
94+ . map (
95+ ( [ px , py ] , i ) =>
96+ `${ i === 0 ? "M" : "L" } ${ px . toFixed ( 1 ) } ,${ py . toFixed ( 1 ) } ` ,
97+ )
98+ . join ( " " ) ;
7599 const [ lastX , lastY ] = pts [ pts . length - 1 ] ;
76100 return `
77101<path d="${ svgPath } " fill="none" stroke="${ mutedColor } " stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
@@ -88,37 +112,79 @@ function meter(x, y, w, h, value, max, accentColor, trackColor) {
88112
89113function render ( { repo, impact, buckets, gtLogoB64, repoIconB64 } ) {
90114 const { cardBg, fg, muted, accent, accentTrack, border, radius } = THEME ;
91- const W = 1200 , H = 420 ;
115+ const W = 1200 ,
116+ H = 420 ;
92117 const pad = 56 ;
93118 const cols = 4 ;
94119 const colW = ( W - 2 * pad ) / cols ;
95120 const font = "'DM Sans', ui-sans-serif, system-ui, -apple-system, sans-serif" ;
96- const displayFont = "'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif" ;
121+ const displayFont =
122+ "'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif" ;
97123 const sparkW = colW - 40 ;
98124 const sparkH = 56 ;
99125 const sparkY = 150 ;
100126
101127 const stats = [
102- { type : "sparkline" , series : buckets . prBuckets , value : impact . totalPRs . toLocaleString ( ) , label : "merged PRs" } ,
103- { type : "sparkline" , series : buckets . contributorBuckets , value : String ( impact . totalContributors ) , label : "contributors" } ,
104- { type : "sparkline" , series : buckets . locBuckets , value : compact ( impact . totalLinesChanged ) , label : "lines changed" } ,
105- { type : "meter" , raw : impact . emissionShare * 100 , max : 100 , value : `${ ( impact . emissionShare * 100 ) . toFixed ( 1 ) } %` , label : "emission share" } ,
128+ {
129+ type : "sparkline" ,
130+ series : buckets . prBuckets ,
131+ value : impact . totalPRs . toLocaleString ( ) ,
132+ label : "merged PRs" ,
133+ } ,
134+ {
135+ type : "sparkline" ,
136+ series : buckets . contributorBuckets ,
137+ value : String ( impact . totalContributors ) ,
138+ label : "contributors" ,
139+ } ,
140+ {
141+ type : "sparkline" ,
142+ series : buckets . locBuckets ,
143+ value : compact ( impact . totalLinesChanged ) ,
144+ label : "lines changed" ,
145+ } ,
146+ {
147+ type : "meter" ,
148+ raw : impact . emissionShare * 100 ,
149+ max : 100 ,
150+ value : `${ ( impact . emissionShare * 100 ) . toFixed ( 1 ) } %` ,
151+ label : "emission share" ,
152+ } ,
106153 ] ;
107154
108155 let statsSvg = "" ;
109156 stats . forEach ( ( s , i ) => {
110157 const x = pad + i * colW ;
111158 if ( s . type === "meter" ) {
112- statsSvg += meter ( x , sparkY + sparkH / 2 - 7 , sparkW , 14 , s . raw , s . max , accent , accentTrack ) ;
159+ statsSvg += meter (
160+ x ,
161+ sparkY + sparkH / 2 - 7 ,
162+ sparkW ,
163+ 14 ,
164+ s . raw ,
165+ s . max ,
166+ accent ,
167+ accentTrack ,
168+ ) ;
113169 } else {
114- statsSvg += sparkline ( x , sparkY , sparkW , sparkH , s . series , muted , accent , cardBg ) ;
170+ statsSvg += sparkline (
171+ x ,
172+ sparkY ,
173+ sparkW ,
174+ sparkH ,
175+ s . series ,
176+ muted ,
177+ accent ,
178+ cardBg ,
179+ ) ;
115180 }
116181 statsSvg += `
117- <text x="${ x } " y="${ sparkY + sparkH + 78 } " font-family="${ font } " font-size="60" font-weight="700" fill="${ fg } ">${ s . value } </text>
118- <text x="${ x } " y="${ sparkY + sparkH + 112 } " font-family="${ font } " font-size="21" font-weight="500" fill="${ muted } ">${ s . label } </text>` ;
182+ <text x="${ x } " y="${ sparkY + sparkH + 78 } " font-family="${ font } " font-size="60" font-weight="700" fill="${ fg } ">${ escapeXml ( s . value ) } </text>
183+ <text x="${ x } " y="${ sparkY + sparkH + 112 } " font-family="${ font } " font-size="21" font-weight="500" fill="${ muted } ">${ escapeXml ( s . label ) } </text>` ;
119184 } ) ;
120185
121- const logoW = 48 , logoH = 48 / ( 708 / 567 ) ; // gittensor.io/gt-logo.svg aspect ratio
186+ const logoW = 48 ,
187+ logoH = 48 / ( 708 / 567 ) ; // gittensor.io/gt-logo.svg aspect ratio
122188 const repoIconSize = 26 ;
123189 const repoIconX = W - pad - repoIconSize ;
124190 const repoIconY = 396 - 14 - ( repoIconSize - 16 ) ;
@@ -132,25 +198,29 @@ function render({ repo, impact, buckets, gtLogoB64, repoIconB64 }) {
132198${ statsSvg }
133199<text x="${ pad } " y="396" font-family="${ font } " font-size="19" font-weight="400" fill="${ muted } ">Updated weekly · gittensor.io</text>
134200<image href="data:image/svg+xml;base64,${ repoIconB64 } " x="${ repoIconX } " y="${ repoIconY } " width="${ repoIconSize } " height="${ repoIconSize } "/>
135- <text x="${ repoTextX } " y="396" font-family="${ displayFont } " font-size="20" font-weight="500" letter-spacing="-0.01em" fill="${ fg } " text-anchor="end">${ repo } </text>
201+ <text x="${ repoTextX } " y="396" font-family="${ displayFont } " font-size="20" font-weight="500" letter-spacing="-0.01em" fill="${ fg } " text-anchor="end">${ escapeXml ( repo ) } </text>
136202</svg>` ;
137203}
138204
139205async function main ( ) {
140206 const [ repo , outFile ] = process . argv . slice ( 2 ) ;
141207 if ( ! repo || ! outFile ) {
142- console . error ( "Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>" ) ;
208+ console . error (
209+ "Usage: node scripts/gittensor-impact-card.mjs <owner/repo> <out-file.svg>" ,
210+ ) ;
143211 process . exit ( 1 ) ;
144212 }
145- const encoded = repo . replace ( "/" , "%2F" ) ;
213+ const encoded = encodeURIComponent ( repo ) ;
146214 const [ impact , prs , gtLogoSvg ] = await Promise . all ( [
147215 fetchJson ( `https://api.gittensor.io/repos/${ encoded } /impact` ) ,
148216 fetchJson ( `https://api.gittensor.io/repos/${ encoded } /prs` ) ,
149217 fetch ( "https://gittensor.io/gt-logo.svg" ) . then ( ( r ) => r . text ( ) ) ,
150218 ] ) ;
151219 const buckets = bucketWeekly ( prs , new Date ( ) ) ;
152220 const gtLogoB64 = Buffer . from ( gtLogoSvg ) . toString ( "base64" ) ;
153- const repoIconB64 = Buffer . from ( readFileSync ( repoIconPath ) ) . toString ( "base64" ) ;
221+ const repoIconB64 = Buffer . from ( readFileSync ( repoIconPath ) ) . toString (
222+ "base64" ,
223+ ) ;
154224
155225 const svg = render ( { repo, impact, buckets, gtLogoB64, repoIconB64 } ) ;
156226 writeFileSync ( outFile , svg ) ;
0 commit comments