@@ -51,7 +51,7 @@ export function ensureYoga(wasm: WebAssembly.Module): Promise<void> {
5151// satori node helpers (avoids a JSX runtime; matches the spike-verified shape).
5252type Node = { type : string ; props : Record < string , unknown > }
5353
54- function avatar ( sponsor : WashedSponsor , size : number ) : Node {
54+ function avatar ( sponsor : WashedSponsor , size : number , t : ThemeTokens ) : Node {
5555 return {
5656 type : 'img' ,
5757 props : {
@@ -62,102 +62,134 @@ function avatar(sponsor: WashedSponsor, size: number): Node {
6262 width : size ,
6363 height : size ,
6464 borderRadius : size / 2 ,
65- marginRight : 8 ,
66- marginBottom : 8 ,
65+ // Faint ring so a logo matching the background still reads as present.
66+ border : `1px solid ${ t . ring } ` ,
67+ // Symmetric horizontal margins so a centred, wrapping row stays centred.
68+ marginLeft : 6 ,
69+ marginRight : 6 ,
70+ marginBottom : 12 ,
6771 } ,
6872 } ,
6973 }
7074}
7175
72- function tierSection (
73- spec : TierSpec ,
74- list : WashedSponsor [ ] ,
75- t : ThemeTokens ,
76- ) : Node {
76+ function escapeXmlAttr ( value : string ) : string {
77+ return value
78+ . replace ( / & / g, '&' )
79+ . replace ( / < / g, '<' )
80+ . replace ( / > / g, '>' )
81+ . replace ( / " / g, '"' )
82+ }
83+
84+ // satori strips <a> wrappers (it targets static images), so we wrap the rendered
85+ // avatars ourselves: each self-closing <image> is one avatar, emitted in the same
86+ // order as `urls`, so wrap the Nth in an SVG <a> to the Nth sponsor's page. Makes
87+ // sponsors clickable when the .svg is opened directly; README `![]()` embeds still
88+ // render as a flat image (anchors are ignored there).
89+ function linkAvatars ( svg : string , urls : string [ ] ) : string {
90+ let i = 0
91+ return svg . replace ( / < i m a g e [ ^ > ] * \/ > / g, ( image ) => {
92+ const url = urls [ i ++ ]
93+ return url
94+ ? `<a href="${ escapeXmlAttr ( url ) } " target="_blank" rel="noopener">${ image } </a>`
95+ : image
96+ } )
97+ }
98+
99+ // A centred tier label flanked by two short rules — "── SPECIAL THANKS ──".
100+ function tierLabel ( label : string , t : ThemeTokens ) : Node {
101+ const rule = ( side : 'left' | 'right' ) : Node => ( {
102+ type : 'div' ,
103+ props : {
104+ style : {
105+ display : 'flex' ,
106+ width : 24 ,
107+ height : 1 ,
108+ background : t . border ,
109+ [ side === 'left' ? 'marginRight' : 'marginLeft' ] : 14 ,
110+ } ,
111+ } ,
112+ } )
77113 return {
78114 type : 'div' ,
79115 props : {
80- style : { display : 'flex' , flexDirection : 'column' , marginTop : 20 } ,
116+ style : {
117+ display : 'flex' ,
118+ alignItems : 'center' ,
119+ justifyContent : 'center' ,
120+ marginBottom : 18 ,
121+ } ,
81122 children : [
123+ rule ( 'left' ) ,
82124 {
83125 type : 'div' ,
84126 props : {
85127 style : {
86128 display : 'flex' ,
87129 color : t . muted ,
88130 fontSize : 13 ,
89- fontWeight : 500 ,
90- letterSpacing : 1 ,
91- marginBottom : 10 ,
131+ fontWeight : 600 ,
132+ letterSpacing : 2.5 ,
92133 } ,
93- children : spec . label ,
94- } ,
95- } ,
96- {
97- type : 'div' ,
98- props : {
99- style : { display : 'flex' , flexWrap : 'wrap' } ,
100- children : list . map ( ( s ) => avatar ( s , spec . size ) ) ,
134+ children : label ,
101135 } ,
102136 } ,
137+ rule ( 'right' ) ,
103138 ] ,
104139 } ,
105140 }
106141}
107142
108- function header ( t : ThemeTokens ) : Node {
143+ function tierSection (
144+ spec : TierSpec ,
145+ list : WashedSponsor [ ] ,
146+ t : ThemeTokens ,
147+ first : boolean ,
148+ ) : Node {
109149 return {
110150 type : 'div' ,
111151 props : {
112152 style : {
113153 display : 'flex' ,
114154 flexDirection : 'column' ,
115- borderBottom : `1px solid ${ t . border } ` ,
116- paddingBottom : 16 ,
155+ alignItems : 'center' ,
156+ width : '100%' ,
157+ marginTop : first ? 0 : 40 ,
117158 } ,
118159 children : [
160+ tierLabel ( spec . label , t ) ,
119161 {
120162 type : 'div' ,
121163 props : {
122164 style : {
123165 display : 'flex' ,
124- color : t . fg ,
125- fontSize : 30 ,
126- fontWeight : 700 ,
127- } ,
128- children : 'NAPI-RS Sponsors' ,
129- } ,
130- } ,
131- {
132- type : 'div' ,
133- props : {
134- style : {
135- display : 'flex' ,
136- color : t . accent ,
137- fontSize : 15 ,
138- fontWeight : 500 ,
139- marginTop : 4 ,
166+ flexWrap : 'wrap' ,
167+ justifyContent : 'center' ,
168+ alignItems : 'center' ,
140169 } ,
141- children : 'napi.rs' ,
170+ children : list . map ( ( s ) => avatar ( s , spec . size , t ) ) ,
142171 } ,
143172 } ,
144173 ] ,
145174 } ,
146175 }
147176}
148177
149- export function renderSvg (
178+ export async function renderSvg (
150179 sponsors : WashedSponsors ,
151180 theme : Theme ,
152181 fonts : SatoriFont [ ] ,
153182) : Promise < string > {
154183 const t = THEMES [ theme ]
155- const sections = TIER_SPECS . map ( ( spec ) => ( {
184+ const rendered = TIER_SPECS . map ( ( spec ) => ( {
156185 spec,
157186 list : sponsors [ spec . key ] ,
158- } ) )
159- . filter ( ( { list } ) => list . length > 0 )
160- . map ( ( { spec, list } ) => tierSection ( spec , list , t ) )
187+ } ) ) . filter ( ( { list } ) => list . length > 0 )
188+ const sections = rendered . map ( ( { spec, list } , i ) =>
189+ tierSection ( spec , list , t , i === 0 ) ,
190+ )
191+ // Same order the avatars are emitted in, so linkAvatars can pair them 1:1.
192+ const urls = rendered . flatMap ( ( { list } ) => list . map ( ( s ) => s . url ) )
161193
162194 const root : Node = {
163195 type : 'div' ,
@@ -166,17 +198,22 @@ export function renderSvg(
166198 width : '100%' ,
167199 display : 'flex' ,
168200 flexDirection : 'column' ,
201+ alignItems : 'center' ,
169202 background : t . bg ,
170- padding : 40 ,
203+ paddingTop : 48 ,
204+ paddingBottom : 52 ,
205+ paddingLeft : 48 ,
206+ paddingRight : 48 ,
171207 fontFamily : 'Manrope' ,
172208 } ,
173- children : [ header ( t ) , ... sections ] ,
209+ children : sections ,
174210 } ,
175211 }
176212
177213 // satori accepts plain {type,props} nodes as ReactNode; cast keeps TS happy.
178- return satori ( root as unknown as Parameters < typeof satori > [ 0 ] , {
214+ const svg = await satori ( root as unknown as Parameters < typeof satori > [ 0 ] , {
179215 width : CARD_WIDTH ,
180216 fonts,
181217 } )
218+ return linkAvatars ( svg , urls )
182219}
0 commit comments