Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion lib/sponsors-image/card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,39 @@ describe('renderSvg', () => {
expect((svg.match(/<image/g) ?? []).length).toBe(3)
})

it('renders header-only svg (no <image>) when there are no sponsors', async () => {
it('renders an svg with no <image> when there are no sponsors', async () => {
const svg = await renderSvg(empty(), 'dark', fonts())
expect(svg.startsWith('<svg')).toBe(true)
expect(svg.includes('<image')).toBe(false)
})

it('wraps each avatar in an <a> link to its sponsor page', async () => {
const sponsors: WashedSponsors = {
...empty(),
gold: [
{ name: 'A', img: dot, url: 'https://github.com/a' },
{ name: 'B', img: dot, url: 'https://github.com/b' },
],
}
const svg = await renderSvg(sponsors, 'light', fonts())
const anchors = svg.match(/<a href="[^"]*"/g) ?? []
const images = svg.match(/<image/g) ?? []
// One clickable link per avatar, in sponsor order.
expect(images.length).toBe(2)
expect(anchors.length).toBe(images.length)
expect(svg).toContain(
'<a href="https://github.com/a" target="_blank" rel="noopener"><image',
)
expect(svg).toContain('href="https://github.com/b"')
})

it('escapes special characters in a link href', async () => {
const sponsors: WashedSponsors = {
...empty(),
gold: [{ name: 'A', img: dot, url: 'https://github.com/a&b' }],
}
const svg = await renderSvg(sponsors, 'light', fonts())
expect(svg).toContain('href="https://github.com/a&amp;b"')
expect(svg).not.toContain('href="https://github.com/a&b"')
})
})
133 changes: 85 additions & 48 deletions lib/sponsors-image/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function ensureYoga(wasm: WebAssembly.Module): Promise<void> {
// satori node helpers (avoids a JSX runtime; matches the spike-verified shape).
type Node = { type: string; props: Record<string, unknown> }

function avatar(sponsor: WashedSponsor, size: number): Node {
function avatar(sponsor: WashedSponsor, size: number, t: ThemeTokens): Node {
return {
type: 'img',
props: {
Expand All @@ -62,102 +62,134 @@ function avatar(sponsor: WashedSponsor, size: number): Node {
width: size,
height: size,
borderRadius: size / 2,
marginRight: 8,
marginBottom: 8,
// Faint ring so a logo matching the background still reads as present.
border: `1px solid ${t.ring}`,
// Symmetric horizontal margins so a centred, wrapping row stays centred.
marginLeft: 6,
marginRight: 6,
marginBottom: 12,
},
},
}
}

function tierSection(
spec: TierSpec,
list: WashedSponsor[],
t: ThemeTokens,
): Node {
function escapeXmlAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}

// satori strips <a> wrappers (it targets static images), so we wrap the rendered
// avatars ourselves: each self-closing <image> is one avatar, emitted in the same
// order as `urls`, so wrap the Nth in an SVG <a> to the Nth sponsor's page. Makes
// sponsors clickable when the .svg is opened directly; README `![]()` embeds still
// render as a flat image (anchors are ignored there).
function linkAvatars(svg: string, urls: string[]): string {
let i = 0
return svg.replace(/<image[^>]*\/>/g, (image) => {
const url = urls[i++]
return url
? `<a href="${escapeXmlAttr(url)}" target="_blank" rel="noopener">${image}</a>`
: image
})
}

// A centred tier label flanked by two short rules — "── SPECIAL THANKS ──".
function tierLabel(label: string, t: ThemeTokens): Node {
const rule = (side: 'left' | 'right'): Node => ({
type: 'div',
props: {
style: {
display: 'flex',
width: 24,
height: 1,
background: t.border,
[side === 'left' ? 'marginRight' : 'marginLeft']: 14,
},
},
})
return {
type: 'div',
props: {
style: { display: 'flex', flexDirection: 'column', marginTop: 20 },
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 18,
},
children: [
rule('left'),
{
type: 'div',
props: {
style: {
display: 'flex',
color: t.muted,
fontSize: 13,
fontWeight: 500,
letterSpacing: 1,
marginBottom: 10,
fontWeight: 600,
letterSpacing: 2.5,
},
children: spec.label,
},
},
{
type: 'div',
props: {
style: { display: 'flex', flexWrap: 'wrap' },
children: list.map((s) => avatar(s, spec.size)),
children: label,
},
},
rule('right'),
],
},
}
}

function header(t: ThemeTokens): Node {
function tierSection(
spec: TierSpec,
list: WashedSponsor[],
t: ThemeTokens,
first: boolean,
): Node {
return {
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
borderBottom: `1px solid ${t.border}`,
paddingBottom: 16,
alignItems: 'center',
width: '100%',
marginTop: first ? 0 : 40,
},
children: [
tierLabel(spec.label, t),
{
type: 'div',
props: {
style: {
display: 'flex',
color: t.fg,
fontSize: 30,
fontWeight: 700,
},
children: 'NAPI-RS Sponsors',
},
},
{
type: 'div',
props: {
style: {
display: 'flex',
color: t.accent,
fontSize: 15,
fontWeight: 500,
marginTop: 4,
flexWrap: 'wrap',
justifyContent: 'center',
Comment on lines 164 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Constrain avatar rows before centering

When a tier has enough sponsors to wrap, this row is now an auto-width flex item inside a column that centers its children, so it can size to the total avatar width instead of the card width; flexWrap then never gets the card constraint and large tiers such as backers overflow or get clipped horizontally. Add an explicit width: '100%' (or another constraint) to the avatar row so wrapping happens within the 800px card.

Useful? React with 👍 / 👎.

alignItems: 'center',
},
children: 'napi.rs',
children: list.map((s) => avatar(s, spec.size, t)),
},
},
],
},
}
}

export function renderSvg(
export async function renderSvg(
sponsors: WashedSponsors,
theme: Theme,
fonts: SatoriFont[],
): Promise<string> {
const t = THEMES[theme]
const sections = TIER_SPECS.map((spec) => ({
const rendered = TIER_SPECS.map((spec) => ({
spec,
list: sponsors[spec.key],
}))
.filter(({ list }) => list.length > 0)
.map(({ spec, list }) => tierSection(spec, list, t))
})).filter(({ list }) => list.length > 0)
const sections = rendered.map(({ spec, list }, i) =>
tierSection(spec, list, t, i === 0),
)
// Same order the avatars are emitted in, so linkAvatars can pair them 1:1.
const urls = rendered.flatMap(({ list }) => list.map((s) => s.url))

const root: Node = {
type: 'div',
Expand All @@ -166,17 +198,22 @@ export function renderSvg(
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
background: t.bg,
padding: 40,
paddingTop: 48,
paddingBottom: 52,
paddingLeft: 48,
paddingRight: 48,
fontFamily: 'Manrope',
},
children: [header(t), ...sections],
children: sections,
},
}

// satori accepts plain {type,props} nodes as ReactNode; cast keeps TS happy.
return satori(root as unknown as Parameters<typeof satori>[0], {
const svg = await satori(root as unknown as Parameters<typeof satori>[0], {
width: CARD_WIDTH,
fonts,
})
return linkAvatars(svg, urls)
}
6 changes: 6 additions & 0 deletions lib/sponsors-image/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ describe('THEMES', () => {
}
expect(THEMES.light.bg).not.toBe(THEMES.dark.bg)
})
it('has a non-empty ring token per theme (avatar outline)', () => {
for (const theme of ['light', 'dark'] as const) {
expect(typeof THEMES[theme].ring).toBe('string')
expect(THEMES[theme].ring.length).toBeGreaterThan(0)
}
})
})
6 changes: 6 additions & 0 deletions lib/sponsors-image/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export interface ThemeTokens {
muted: string
accent: string
border: string
// Faint outline drawn around every avatar so a logo that matches the
// background (a black circle on dark, a white mark on light) still reads as
// present instead of vanishing. Light outline on dark, dark outline on light.
ring: string
}

export const THEMES: Record<Theme, ThemeTokens> = {
Expand All @@ -19,13 +23,15 @@ export const THEMES: Record<Theme, ThemeTokens> = {
muted: '#64748b',
accent: '#e66000',
border: '#e2e8f0',
ring: 'rgba(15, 23, 42, 0.12)',
},
dark: {
bg: '#0b0d10',
fg: '#f8fafc',
muted: '#94a3b8',
accent: '#f97316',
border: '#1f2937',
ring: 'rgba(248, 250, 252, 0.24)',
},
}

Expand Down
Loading