diff --git a/lib/sponsors-image/card.test.ts b/lib/sponsors-image/card.test.ts index d39f8948..5c1f92ca 100644 --- a/lib/sponsors-image/card.test.ts +++ b/lib/sponsors-image/card.test.ts @@ -46,9 +46,39 @@ describe('renderSvg', () => { expect((svg.match(/) when there are no sponsors', async () => { + it('renders an svg with no when there are no sponsors', async () => { const svg = await renderSvg(empty(), 'dark', fonts()) expect(svg.startsWith(' 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(/ { + 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&b"') + expect(svg).not.toContain('href="https://github.com/a&b"') + }) }) diff --git a/lib/sponsors-image/card.ts b/lib/sponsors-image/card.ts index d4ea6b00..f34995ea 100644 --- a/lib/sponsors-image/card.ts +++ b/lib/sponsors-image/card.ts @@ -51,7 +51,7 @@ export function ensureYoga(wasm: WebAssembly.Module): Promise { // satori node helpers (avoids a JSX runtime; matches the spike-verified shape). type Node = { type: string; props: Record } -function avatar(sponsor: WashedSponsor, size: number): Node { +function avatar(sponsor: WashedSponsor, size: number, t: ThemeTokens): Node { return { type: 'img', props: { @@ -62,23 +62,65 @@ 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +// satori strips wrappers (it targets static images), so we wrap the rendered +// avatars ourselves: each self-closing is one avatar, emitted in the same +// order as `urls`, so wrap the Nth in an SVG 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(/]*\/>/g, (image) => { + const url = urls[i++] + return url + ? `${image}` + : 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: { @@ -86,59 +128,46 @@ function tierSection( 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', + alignItems: 'center', }, - children: 'napi.rs', + children: list.map((s) => avatar(s, spec.size, t)), }, }, ], @@ -146,18 +175,21 @@ function header(t: ThemeTokens): Node { } } -export function renderSvg( +export async function renderSvg( sponsors: WashedSponsors, theme: Theme, fonts: SatoriFont[], ): Promise { 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', @@ -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[0], { + const svg = await satori(root as unknown as Parameters[0], { width: CARD_WIDTH, fonts, }) + return linkAvatars(svg, urls) } diff --git a/lib/sponsors-image/theme.test.ts b/lib/sponsors-image/theme.test.ts index 5203e5f3..e91a1305 100644 --- a/lib/sponsors-image/theme.test.ts +++ b/lib/sponsors-image/theme.test.ts @@ -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) + } + }) }) diff --git a/lib/sponsors-image/theme.ts b/lib/sponsors-image/theme.ts index 0e2eb569..db4a41eb 100644 --- a/lib/sponsors-image/theme.ts +++ b/lib/sponsors-image/theme.ts @@ -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 = { @@ -19,6 +23,7 @@ export const THEMES: Record = { muted: '#64748b', accent: '#e66000', border: '#e2e8f0', + ring: 'rgba(15, 23, 42, 0.12)', }, dark: { bg: '#0b0d10', @@ -26,6 +31,7 @@ export const THEMES: Record = { muted: '#94a3b8', accent: '#f97316', border: '#1f2937', + ring: 'rgba(248, 250, 252, 0.24)', }, }