diff --git a/src/templates/github/index.ts b/src/templates/github/index.ts index 845c4ae..eb21553 100644 --- a/src/templates/github/index.ts +++ b/src/templates/github/index.ts @@ -13,3 +13,4 @@ export { default as githubIssueCard } from './issue-card'; export { default as githubPullRequest } from './pull-request'; export { default as githubGoodFirstIssues } from './good-first-issues'; export { default as githubMilestone } from './milestone'; +export { default as githubSponsorsGoal } from './sponsors-goal'; diff --git a/src/templates/github/sponsors-goal.ts b/src/templates/github/sponsors-goal.ts new file mode 100644 index 0000000..2251869 --- /dev/null +++ b/src/templates/github/sponsors-goal.ts @@ -0,0 +1,309 @@ +import type { TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, isLightColor, mixColors, normalizeHexColor, truncate } from '../utils'; + +function parseRaised(value: unknown, fallback: number): number { + if (value === undefined || value === null) return fallback; + const raw = String(value).trim(); + if (raw === '') return 0; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.round(n); +} + +function parseGoal(value: unknown, fallback: number): number { + if (value === undefined || value === null) return fallback; + const raw = String(value).trim(); + if (raw === '') return 1; + const n = Number(raw); + if (!Number.isFinite(n) || n < 1) return 1; + return Math.round(n); +} + +const template: TemplateDefinition = { + id: 'github-sponsors-goal', + name: 'Sponsors Goal', + category: 'github', + description: 'Maintainer or project sponsorship progress bar toward a monthly or recurring goal, with raised amounts, percentage, and sponsor count', + tags: ['github', 'sponsors', 'funding', 'goal', 'open-source'], + fields: [ + commonFields.title, + commonFields.description, + { + key: 'raised', + label: 'Raised', + type: 'number' as const, + defaultValue: 420, + group: 'Content' as const, + }, + { + key: 'goal', + label: 'Goal', + type: 'number' as const, + defaultValue: 1000, + group: 'Content' as const, + }, + { + key: 'currency', + label: 'Currency', + type: 'text' as const, + defaultValue: '$', + placeholder: '$', + group: 'Content' as const, + }, + { + key: 'period', + label: 'Period', + type: 'text' as const, + defaultValue: 'a month', + placeholder: 'a month', + group: 'Content' as const, + }, + { + key: 'sponsors', + label: 'Sponsors', + type: 'text' as const, + defaultValue: '37 sponsors', + placeholder: '37 sponsors', + group: 'Content' as const, + }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: 'codercops/toolbelt', + description: 'Free developer tools that run in your browser', + raised: 420, + goal: 1000, + currency: '$', + period: 'a month', + sponsors: '37 sponsors', + siteName: 'github.com/sponsors/codercops', + bgColor: '#0d1117', + accentColor: '#ea4aaa', + textColor: '#e6edf3', + }, + render: (params) => { + const bgColor = String(params.bgColor || '#0d1117'); + const accentColor = String(params.accentColor || '#ea4aaa'); + const textColor = String(params.textColor || '#e6edf3'); + const normTextColor = normalizeHexColor(textColor, '#e6edf3'); + + const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor; + + const rawTitle = String(params.title ?? '').trim() || 'codercops/toolbelt'; + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 16, size: 76 }, + { maxLen: 28, size: 64 }, + { maxLen: 42, size: 52 }, + { maxLen: 60, size: 40 }, + ]); + const title = truncate(rawTitle, 60); + + const rawDescription = String(params.description ?? '').trim(); + const descriptionSize = autoFontSize(rawDescription, [ + { maxLen: 40, size: 28 }, + { maxLen: 70, size: 24 }, + { maxLen: 100, size: 20 }, + ]); + const description = truncate(rawDescription, 100); + + const raised = parseRaised(params.raised, 420); + const goal = parseGoal(params.goal, 1000); + const percent = Math.round((raised / goal) * 100); + const barWidthPercent = Math.min(100, Math.max(0, percent)); + + const currency = String(params.currency ?? '$').trim(); + const formattedRaised = `${currency}${raised.toLocaleString('en-US')}`; + const formattedGoal = `${currency}${goal.toLocaleString('en-US')}`; + + const rawPeriod = String(params.period ?? '').trim(); + const period = truncate(rawPeriod, 24); + const progressText = period ? `${formattedRaised} of ${formattedGoal} ${period}` : `${formattedRaised} of ${formattedGoal}`; + + const sponsors = truncate(String(params.sponsors ?? '').trim(), 32); + const siteName = truncate(String(params.siteName ?? '').trim(), 40); + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + width: '1200px', + height: '630px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '64px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '16px', + fontWeight: 700, + color: accentInk, + letterSpacing: '0.14em', + textTransform: 'uppercase' as const, + marginBottom: '16px', + }, + children: 'Sponsor', + }, + }, + { + type: 'h1', + props: { + style: { + fontSize: `${titleSize}px`, + fontWeight: 800, + color: textColor, + lineHeight: 1.1, + letterSpacing: '-0.02em', + textAlign: 'center' as const, + margin: 0, + marginBottom: description ? '14px' : '48px', + }, + children: title, + }, + }, + ...(description + ? [ + { + type: 'p', + props: { + style: { + fontSize: `${descriptionSize}px`, + fontWeight: 400, + color: `${normTextColor}AA`, + lineHeight: 1.4, + textAlign: 'center' as const, + margin: 0, + marginBottom: '48px', + maxWidth: '880px', + }, + children: description, + }, + }, + ] + : []), + { + type: 'div', + props: { + style: { + display: 'flex', + width: '880px', + height: '24px', + borderRadius: '100px', + backgroundColor: `${normTextColor}1A`, + overflow: 'hidden', + }, + children: { + type: 'div', + props: { + style: { + display: 'flex', + width: `${barWidthPercent}%`, + height: '100%', + borderRadius: '100px', + backgroundColor: accentColor, + }, + }, + }, + }, + }, + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + width: '880px', + marginTop: '18px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '24px', + fontWeight: 600, + color: `${normTextColor}DD`, + }, + children: progressText, + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '32px', + fontWeight: 800, + color: accentInk, + }, + children: `${percent}%`, + }, + }, + ], + }, + }, + ...((sponsors || siteName) + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: sponsors ? 'space-between' : 'flex-end', + width: '880px', + marginTop: '36px', + }, + children: [ + ...(sponsors + ? [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 500, + color: `${normTextColor}88`, + }, + children: sponsors, + }, + }, + ] + : []), + ...(siteName + ? [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 500, + color: `${normTextColor}66`, + }, + children: siteName, + }, + }, + ] + : []), + ], + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 8afded0..2ead0c8 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -141,6 +141,7 @@ import githubIssueCard from './github/issue-card'; import githubPullRequest from './github/pull-request'; import githubGoodFirstIssues from './github/good-first-issues'; import githubMilestone from './github/milestone'; +import githubSponsorsGoal from './github/sponsors-goal'; [ githubRepoCard, githubContributionGraph, githubReadmeHero, githubReleaseNotes, @@ -150,6 +151,7 @@ import githubMilestone from './github/milestone'; githubPullRequest, githubGoodFirstIssues, githubMilestone, + githubSponsorsGoal, ].forEach(registerTemplate); // Event diff --git a/tests/templates/sponsors-goal.test.ts b/tests/templates/sponsors-goal.test.ts new file mode 100644 index 0000000..b0c6b12 --- /dev/null +++ b/tests/templates/sponsors-goal.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; +import sponsorsGoal from '@/templates/github/sponsors-goal'; +import { getTemplate } from '@/templates/registry'; +import { hexToRgb } from '@/templates/utils'; +import type { SatoriElement } from '@/templates/types'; + +function texts(node: unknown): string[] { + if (typeof node === 'string') return [node]; + if (Array.isArray(node)) return node.flatMap(texts); + if (node && typeof node === 'object' && 'props' in node) { + return texts((node as SatoriElement).props.children); + } + return []; +} + +function elements(node: unknown): SatoriElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!node || typeof node !== 'object' || !('type' in node)) return []; + const element = node as SatoriElement; + return [element, ...elements(element.props.children)]; +} + +function styleOf(node: unknown, text: string): Record | undefined { + if (Array.isArray(node)) { + for (const child of node) { + const found = styleOf(child, text); + if (found) return found; + } + return undefined; + } + if (node && typeof node === 'object' && 'props' in node) { + const { style, children } = (node as SatoriElement).props; + if (children === text) return style; + return styleOf(children, text); + } + return undefined; +} + +function findStyle(node: unknown, match: (style: Record) => boolean): Record | undefined { + if (Array.isArray(node)) { + for (const child of node) { + const found = findStyle(child, match); + if (found) return found; + } + return undefined; + } + if (node && typeof node === 'object' && 'props' in node) { + const { style, children } = (node as SatoriElement).props; + if (style && match(style)) return style; + return findStyle(children, match); + } + return undefined; +} + +const tree = (overrides: Record = {}) => + sponsorsGoal.render({ ...sponsorsGoal.defaults, ...overrides }); +const render = (overrides: Record = {}) => texts(tree(overrides)); + +function luminance(hex: string): number { + const rgb = hexToRgb(hex); + if (!rgb) throw new Error(`not a hex color: ${hex}`); + const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +describe('github-sponsors-goal', () => { + it('is registered in the github category and re-exported', async () => { + expect(getTemplate('github-sponsors-goal')?.category).toBe('github'); + const exports = await import('@/templates/github'); + expect(exports.githubSponsorsGoal.id).toBe('github-sponsors-goal'); + }); + + it('renders label, title, pitch, progress amounts, percentage, sponsors and site name with defaults', () => { + expect(render()).toEqual([ + 'Sponsor', + 'codercops/toolbelt', + 'Free developer tools that run in your browser', + '$420 of $1,000 a month', + '42%', + '37 sponsors', + 'github.com/sponsors/codercops', + ]); + }); + + it('fills the bar to the same percentage it prints when below goal', () => { + const bar = findStyle(tree({ raised: 500, goal: 1000 }), (s) => typeof s.width === 'string' && s.width.endsWith('%')); + expect(bar?.width).toBe('50%'); + expect(render({ raised: 500, goal: 1000 })).toContain('50%'); + }); + + it('stops the bar at 100% when goal is passed while showing real amounts and percentage', () => { + const rendered = render({ raised: 1260, goal: 1000 }); + expect(rendered).toContain('$1,260 of $1,000 a month'); + expect(rendered).toContain('126%'); + + const bar = findStyle(tree({ raised: 1260, goal: 1000 }), (s) => typeof s.width === 'string' && s.width.endsWith('%')); + expect(bar?.width).toBe('100%'); + }); + + it('counts a goal below 1 as 1', () => { + expect(render({ goal: 0, raised: 5 })).toEqual( + expect.arrayContaining(['$5 of $1 a month', '500%']) + ); + expect(render({ goal: -10, raised: 0 })).toEqual( + expect.arrayContaining(['$0 of $1 a month', '0%']) + ); + expect(render({ goal: 'abc', raised: 2 })).toEqual( + expect.arrayContaining(['$2 of $1 a month', '200%']) + ); + }); + + it('counts negative or non-numeric raised as 0', () => { + expect(render({ raised: -5, goal: 1000 })).toEqual( + expect.arrayContaining(['$0 of $1,000 a month', '0%']) + ); + expect(render({ raised: '-10', goal: 1000 })).toEqual( + expect.arrayContaining(['$0 of $1,000 a month', '0%']) + ); + expect(render({ raised: 'abc', goal: 1000 })).toEqual( + expect.arrayContaining(['$0 of $1,000 a month', '0%']) + ); + expect(render({ raised: '', goal: 1000 })).toEqual( + expect.arrayContaining(['$0 of $1,000 a month', '0%']) + ); + expect(render({ raised: ' ', goal: 1000 })).toEqual( + expect.arrayContaining(['$0 of $1,000 a month', '0%']) + ); + }); + + it('formats amounts with thousands separators', () => { + expect(render({ raised: 15000, goal: 20000 })).toEqual( + expect.arrayContaining(['$15,000 of $20,000 a month']) + ); + expect(render({ raised: 1000000, goal: 2500000 })).toEqual( + expect.arrayContaining(['$1,000,000 of $2,500,000 a month']) + ); + }); + + it('supports custom currency', () => { + expect(render({ currency: '€', raised: 500, goal: 1000 })).toEqual( + expect.arrayContaining(['€500 of €1,000 a month']) + ); + expect(render({ currency: '', raised: 500, goal: 1000 })).toEqual( + expect.arrayContaining(['500 of 1,000 a month']) + ); + }); + + it('hides period cleanly when empty or whitespace', () => { + expect(render({ period: '' })).toEqual( + expect.arrayContaining(['$420 of $1,000']) + ); + expect(render({ period: ' ' })).toEqual( + expect.arrayContaining(['$420 of $1,000']) + ); + expect(render({ period: '' })).not.toContain('$420 of $1,000 a month'); + }); + + it('hides description cleanly when empty or whitespace', () => { + for (const emptyDesc of ['', ' ']) { + expect(render({ description: emptyDesc })).not.toContain('Free developer tools that run in your browser'); + const nodes = elements(tree({ description: emptyDesc })); + expect(nodes.some((n) => n.type === 'p')).toBe(false); + } + }); + + it('hides sponsors and site name cleanly when empty or whitespace', () => { + expect(render({ sponsors: '', siteName: '' })).toEqual([ + 'Sponsor', + 'codercops/toolbelt', + 'Free developer tools that run in your browser', + '$420 of $1,000 a month', + '42%', + ]); + + expect(render({ sponsors: ' ' })).not.toContain('37 sponsors'); + expect(render({ siteName: ' ' })).not.toContain('github.com/sponsors/codercops'); + }); + + it('shrinks long maintainer names and pitches with autoFontSize and truncates them', () => { + const longTitle = 'codercops-open-source-foundation-and-extended-toolbelt-project'; + const longPitch = 'Free, privacy-friendly, high-performance developer tools running completely client-side in modern web browsers without telemetry'; + const nodes = elements(tree({ title: longTitle, description: longPitch })); + + const heading = nodes.find((n) => n.type === 'h1'); + expect(heading?.props.style?.fontSize).toBe('40px'); + const [renderedTitle] = texts(heading); + expect(renderedTitle.length).toBeLessThanOrEqual(60); + expect(renderedTitle.endsWith('…')).toBe(true); + + const pitch = nodes.find((n) => n.type === 'p'); + expect(pitch?.props.style?.fontSize).toBe('20px'); + const [renderedPitch] = texts(pitch); + expect(renderedPitch.length).toBeLessThanOrEqual(100); + expect(renderedPitch.endsWith('…')).toBe(true); + }); + + it('darkens the accent text on a light background for contrast', () => { + const light = tree({ bgColor: '#ffffff', textColor: '#111111' }); + const percentStyle = styleOf(light, '42%')!; + expect(contrast(percentStyle.color, '#ffffff')).toBeGreaterThanOrEqual(4.5); + + const labelStyle = styleOf(light, 'Sponsor')!; + expect(contrast(labelStyle.color, '#ffffff')).toBeGreaterThanOrEqual(4.5); + }); + + it('leaves the accent color untouched on a dark background', () => { + const dark = tree(); + expect(styleOf(dark, '42%')!.color).toBe('#ea4aaa'); + expect(styleOf(dark, 'Sponsor')!.color).toBe('#ea4aaa'); + }); +});