@@ -9,15 +9,22 @@ interface Sponsor {
99 amount : number
1010 createdAt : string
1111 tierTitle : string
12+ tierTitlePlural : string
1213 tierLevel : number
14+ /** Extra rel tokens for the sponsor's link (e.g. `sponsored`); may be empty. */
15+ rel : string
1316 link : string
14- org : boolean
15- sidebarSize ?: string
16- sidebarLogo ?: string
17+ /** Tagline, present on sponsors that bought an ad slot. */
18+ description ?: string
19+ /** Brand tint behind the ad card, both themes required together. */
20+ background ?: { light : string , dark : string }
21+ /** 1-based ad-grid position the sponsor bought. */
22+ slot ?: number
1723 [ key : string ] : unknown
1824}
1925
2026const SPONSORS_SOURCE_URL = 'https://raw.githubusercontent.com/middleapi/static/refs/heads/main/sponsors.json'
27+ const PAST_SPONSORS_URL = 'https://htmlpreview.github.io/?https://github.com/middleapi/static/blob/main/sponsors.svg'
2128const ROOT_DIR = process . cwd ( )
2229const README_FILE_NAME = 'README.md'
2330
@@ -47,17 +54,9 @@ async function findReadmes(dir: string): Promise<string[]> {
4754 return result . concat ( ...subResults )
4855}
4956
50- function withTracking ( url : string ) : string {
51- try {
52- const tracked = new URL ( url )
53-
54- tracked . searchParams . set ( 'ref' , 'orpc' )
55-
56- return tracked . toString ( )
57- }
58- catch {
59- return url
60- }
57+ /** `noopener` always, plus whatever extra tokens the data carries. */
58+ function relAttribute ( sponsor : Sponsor ) : string {
59+ return [ 'noopener' , sponsor . rel ] . filter ( Boolean ) . join ( ' ' )
6160}
6261
6362function escapeHtml ( value : string ) : string {
@@ -77,13 +76,48 @@ function getTierImageSizeAndColumns(tierLevel: number, tierLevels: number[]): [c
7776 return [ column , Math . floor ( 838 / column ) ]
7877}
7978
79+ /**
80+ * The slot sponsors' cards, mirroring the docs ad cards — logo on the left,
81+ * name over tagline on the right — but without the brand backgrounds (GitHub
82+ * strips inline styles anyway). One card per row. The oversized cell width is
83+ * a preferred width, not a minimum: GitHub caps tables at the container, so
84+ * the card spans the full row on any screen and the text wraps on phones
85+ * instead of scrolling. One cell per card, the logo floated with the
86+ * deprecated-but-sanitizer-safe `align`/`hspace` attributes, so no table
87+ * border cuts through the card.
88+ */
89+ function buildSlotCards ( slotSponsors : Sponsor [ ] ) : string [ ] {
90+ const lines = [ '<table>' ]
91+
92+ for ( const sponsor of slotSponsors ) {
93+ const name = escapeHtml ( sponsor . name ?? sponsor . login )
94+ const description = escapeHtml ( sponsor . description ?? '' )
95+
96+ lines . push ( ' <tr>' )
97+ lines . push ( ` <td width="2000"><a href="${ escapeHtml ( sponsor . link ) } " target="_blank" rel="${ relAttribute ( sponsor ) } " title="${ description } "><img src="${ escapeHtml ( sponsor . avatar ) } " width="64" align="left" hspace="12" alt="${ name } "/><b>${ name } </b></a><br /><sub>${ description } </sub></td>` )
98+ lines . push ( ' </tr>' )
99+ }
100+
101+ lines . push ( '</table>' )
102+ lines . push ( '' )
103+
104+ return lines
105+ }
106+
80107function buildSponsorsSection ( sponsors : Sponsor [ ] ) : string {
81108 const activeSponsors = sponsors . filter ( sponsor => sponsor . tierLevel > 0 && sponsor . amount > 0 )
82109 const pastSponsors = sponsors . filter ( sponsor => sponsor . tierLevel <= 0 || sponsor . amount <= 0 )
83110
111+ // Slot sponsors are featured as cards up top; the tier tables below carry
112+ // everyone else so nobody appears twice.
113+ const slotSponsors = activeSponsors
114+ . filter ( sponsor => sponsor . slot !== undefined )
115+ . sort ( ( a , b ) => a . slot ! - b . slot ! )
116+ const tieredSponsors = activeSponsors . filter ( sponsor => sponsor . slot === undefined )
117+
84118 const groupedSponsors = new Map < number , Sponsor [ ] > ( )
85119
86- for ( const sponsor of activeSponsors ) {
120+ for ( const sponsor of tieredSponsors ) {
87121 const group = groupedSponsors . get ( sponsor . tierLevel )
88122
89123 if ( group ) {
@@ -101,6 +135,13 @@ function buildSponsorsSection(sponsors: Sponsor[]): string {
101135 '' ,
102136 ]
103137
138+ if ( slotSponsors . length > 0 ) {
139+ lines . push ( ...buildSlotCards ( slotSponsors ) )
140+ }
141+
142+ // Sizes rank against every active tier, slot sponsors' tiers included, so
143+ // featuring the top tiers as cards does not inflate the tables below them.
144+ const sizeTierLevels = [ ...new Set ( activeSponsors . map ( sponsor => sponsor . tierLevel ) ) ] . sort ( ( a , b ) => b - a )
104145 const tierLevels = [ ...groupedSponsors . keys ( ) ] . sort ( ( a , b ) => b - a )
105146
106147 for ( const tierLevel of tierLevels ) {
@@ -110,8 +151,8 @@ function buildSponsorsSection(sponsors: Sponsor[]): string {
110151 continue
111152 }
112153
113- const tierTitle = tierSponsors [ 0 ] ?. tierTitle ?? `Tier ${ tierLevel } `
114- const [ columns , imageSize ] = getTierImageSizeAndColumns ( tierLevel , tierLevels )
154+ const tierTitle = tierSponsors [ 0 ] ?. tierTitlePlural ?? `Tier ${ tierLevel } `
155+ const [ columns , imageSize ] = getTierImageSizeAndColumns ( tierLevel , sizeTierLevels )
115156
116157 lines . push ( `### ${ tierTitle } ` )
117158 lines . push ( '' )
@@ -123,7 +164,7 @@ function buildSponsorsSection(sponsors: Sponsor[]): string {
123164 const displayName = sponsor . name ?? sponsor . login
124165 const escapedName = escapeHtml ( displayName )
125166
126- lines . push ( ` <td align="center"><a href="${ escapeHtml ( href ) } " target="_blank" rel="sponsored noopener " title="${ escapedName } "><img src="${ escapeHtml ( sponsor . avatar ) } " width="${ imageSize } " alt="${ escapedName } "/><br />${ escapedName } </a></td>` )
167+ lines . push ( ` <td align="center"><a href="${ escapeHtml ( href ) } " target="_blank" rel="${ relAttribute ( sponsor ) } " title="${ escapedName } "><img src="${ escapeHtml ( sponsor . avatar ) } " width="${ imageSize } " alt="${ escapedName } "/><br />${ escapedName } </a></td>` )
127168
128169 const isRowEnd = ( index + 1 ) % columns === 0
129170 const isLast = index === tierSponsors . length - 1
@@ -142,7 +183,7 @@ function buildSponsorsSection(sponsors: Sponsor[]): string {
142183 if ( pastSponsors . length > 0 ) {
143184 const noun = pastSponsors . length === 1 ? 'past sponsor' : 'past sponsors'
144185
145- lines . push ( `With thanks to ${ pastSponsors . length } ${ noun } who helped get us here.` )
186+ lines . push ( `With thanks to [ ${ pastSponsors . length } ${ noun } ]( ${ PAST_SPONSORS_URL } ) who helped get us here.` )
146187 lines . push ( '' )
147188 }
148189
@@ -160,7 +201,10 @@ function replaceSponsorsSection(content: string, replacement: string): string {
160201 const nextHeadingIndex = content . indexOf ( '\n## ' , startIndex + heading . length )
161202 const endIndex = nextHeadingIndex === - 1 ? content . length : nextHeadingIndex + 1
162203
163- return `${ content . slice ( 0 , startIndex ) } ${ replacement } ${ content . slice ( endIndex ) } `
204+ // Trimmed to a single trailing newline, or a section replaced at the end of
205+ // the file leaves a blank last line that eslint's markdown fixer removes —
206+ // and the next sync would put back, forever.
207+ return `${ `${ content . slice ( 0 , startIndex ) } ${ replacement } ${ content . slice ( endIndex ) } ` . trimEnd ( ) } \n`
164208}
165209
166210async function main ( ) : Promise < void > {
@@ -170,7 +214,8 @@ async function main(): Promise<void> {
170214 throw new Error ( `Failed to fetch sponsors data: ${ response . status } ${ response . statusText } ` )
171215 }
172216
173- const sponsors = ( await response . json ( ) as Sponsor [ ] ) . map ( sponsor => ( { ...sponsor , link : withTracking ( sponsor . link ) } ) )
217+ // Links arrive with their tracking params already baked in upstream.
218+ const sponsors = await response . json ( ) as Sponsor [ ]
174219 const readmeFiles = await findReadmes ( ROOT_DIR )
175220 const replacement = buildSponsorsSection ( sponsors )
176221
0 commit comments