From 2765ea8e786eed01b4f19fa0d70f34ed6817b60e Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Thu, 29 Oct 2026 10:38:21 +0530 Subject: [PATCH 1/2] Added template package-card Signed-off-by: Anurag Yadav --- src/templates/github/index.ts | 1 + src/templates/github/package-card.ts | 530 +++++++++++++++++++++++++++ src/templates/registry.ts | 2 + tests/templates/package-card.test.ts | 199 ++++++++++ 4 files changed, 732 insertions(+) create mode 100644 src/templates/github/package-card.ts create mode 100644 tests/templates/package-card.test.ts diff --git a/src/templates/github/index.ts b/src/templates/github/index.ts index 0456b7c..7dd81c6 100644 --- a/src/templates/github/index.ts +++ b/src/templates/github/index.ts @@ -16,3 +16,4 @@ export { default as githubMilestone } from './milestone'; export { default as githubSponsorsGoal } from './sponsors-goal'; export { default as githubSecurityAdvisory } from './security-advisory'; export { default as githubMaintainersWanted } from './maintainers-wanted'; +export { default as githubPackageCard } from './package-card'; diff --git a/src/templates/github/package-card.ts b/src/templates/github/package-card.ts new file mode 100644 index 0000000..21626da --- /dev/null +++ b/src/templates/github/package-card.ts @@ -0,0 +1,530 @@ +import type { TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils'; + +function downloadIcon(size: number, color: string) { + return { + type: 'svg', + props: { + width: size, + height: size, + viewBox: '0 0 16 16', + fill: 'none', + stroke: color, + strokeWidth: '1.75', + strokeLinecap: 'round', + strokeLinejoin: 'round', + children: [ + { + type: 'path', + props: { + d: 'M2.5 10.5v2a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-2M8 2.5v7m-3-3l3 3 3-3', + }, + }, + ], + }, + }; +} + +function licenseIcon(size: number, color: string) { + return { + type: 'svg', + props: { + width: size, + height: size, + viewBox: '0 0 16 16', + fill: 'none', + stroke: color, + strokeWidth: '1.75', + strokeLinecap: 'round', + strokeLinejoin: 'round', + children: [ + { + type: 'path', + props: { + d: 'M8 1.5l5.5 2v4.5c0 3.5-2.5 5.5-5.5 6.5-3-1-5.5-3-5.5-6.5V3.5l5.5-2z', + }, + }, + ], + }, + }; +} + +const template: TemplateDefinition = { + id: 'github-package-card', + name: 'Package Card', + category: 'github', + description: 'Published package announcement with registry badge, version pill, pitch, downloads, license, and install command', + tags: ['github', 'package', 'npm', 'pypi', 'crates', 'open-source', 'library'], + fields: [ + { + ...commonFields.title, + defaultValue: '@acme/csv-stream', + placeholder: '@scope/package-name', + }, + { + ...commonFields.description, + defaultValue: 'A streaming CSV parser for Node', + placeholder: 'A streaming CSV parser for Node', + required: false, + }, + { + key: 'registry', + label: 'Registry', + type: 'text' as const, + defaultValue: 'npm', + placeholder: 'npm', + required: false, + group: 'Content' as const, + }, + { + key: 'version', + label: 'Version', + type: 'text' as const, + defaultValue: 'v2.4.1', + placeholder: 'v2.4.1', + required: false, + group: 'Content' as const, + }, + { + key: 'downloads', + label: 'Downloads', + type: 'text' as const, + defaultValue: '182k weekly downloads', + placeholder: '182k weekly downloads', + required: false, + group: 'Content' as const, + }, + { + key: 'license', + label: 'License', + type: 'text' as const, + defaultValue: 'MIT', + placeholder: 'MIT', + required: false, + group: 'Content' as const, + }, + { + key: 'install', + label: 'Install Command', + type: 'text' as const, + defaultValue: 'npm i @acme/csv-stream', + placeholder: 'npm i @acme/csv-stream', + required: false, + group: 'Content' as const, + }, + { + ...commonFields.siteName, + defaultValue: 'github.com/acme/csv-stream', + placeholder: 'github.com/acme/csv-stream', + }, + { + ...commonFields.bgColor, + defaultValue: '#0d1117', + }, + { + ...commonFields.accentColor, + defaultValue: '#58a6ff', + }, + { + ...commonFields.textColor, + defaultValue: '#e6edf3', + }, + ], + defaults: { + title: '@acme/csv-stream', + description: 'A streaming CSV parser for Node', + registry: 'npm', + version: 'v2.4.1', + downloads: '182k weekly downloads', + license: 'MIT', + install: 'npm i @acme/csv-stream', + siteName: 'github.com/acme/csv-stream', + bgColor: '#0d1117', + accentColor: '#58a6ff', + textColor: '#e6edf3', + }, + render: (params) => { + const rawTitle = String(params.title ?? '').trim() || '@acme/csv-stream'; + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 18, size: 52 }, + { maxLen: 28, size: 44 }, + { maxLen: 38, size: 36 }, + { maxLen: 50, size: 30 }, + ]); + const title = truncate(rawTitle, 55); + + const rawDescription = params.description !== undefined && params.description !== null + ? String(params.description).trim() + : 'A streaming CSV parser for Node'; + const descriptionSize = autoFontSize(rawDescription, [ + { maxLen: 40, size: 24 }, + { maxLen: 70, size: 21 }, + { maxLen: 100, size: 19 }, + { maxLen: 140, size: 17 }, + ]); + const description = rawDescription ? truncate(rawDescription, 140) : ''; + + const rawRegistry = params.registry !== undefined && params.registry !== null + ? String(params.registry).trim() + : 'npm'; + const registry = rawRegistry ? truncate(rawRegistry, 30) : ''; + const labelText = registry + ? registry.toLowerCase().endsWith('package') + ? registry + : `${registry} package` + : ''; + + const rawVersion = params.version !== undefined && params.version !== null + ? String(params.version).trim() + : 'v2.4.1'; + const version = rawVersion ? truncate(rawVersion, 25) : ''; + + const rawDownloads = params.downloads !== undefined && params.downloads !== null + ? String(params.downloads).trim() + : '182k weekly downloads'; + const downloads = rawDownloads ? truncate(rawDownloads, 35) : ''; + + const rawLicense = params.license !== undefined && params.license !== null + ? String(params.license).trim() + : 'MIT'; + const license = rawLicense ? truncate(rawLicense, 30) : ''; + + const rawInstall = params.install !== undefined && params.install !== null + ? String(params.install).trim() + : 'npm i @acme/csv-stream'; + const installCmd = rawInstall ? truncate(rawInstall, 70) : ''; + + const rawSiteName = params.siteName !== undefined && params.siteName !== null + ? String(params.siteName).trim() + : 'github.com/acme/csv-stream'; + const siteName = rawSiteName ? truncate(rawSiteName, 50) : ''; + + const bgColor = String(params.bgColor || '#0d1117'); + const accentColor = String(params.accentColor || '#58a6ff'); + const textColor = String(params.textColor || '#e6edf3'); + + const lightBg = isLightColor(bgColor); + const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor; + const cardBg = mixColors(bgColor, textColor, lightBg ? 0.04 : 0.06); + const cardBorder = mixColors(bgColor, textColor, lightBg ? 0.12 : 0.16); + + const statsItems = [ + ...(downloads + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 18px', + borderRadius: '10px', + backgroundColor: cardBg, + border: `1px solid ${cardBorder}`, + }, + children: [ + downloadIcon(18, accentInk), + { + type: 'span', + props: { + style: { + fontSize: '17px', + fontWeight: 600, + color: textColor, + fontFamily: 'JetBrains Mono, monospace', + }, + children: downloads, + }, + }, + ], + }, + }, + ] + : []), + ...(license + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 18px', + borderRadius: '10px', + backgroundColor: cardBg, + border: `1px solid ${cardBorder}`, + }, + children: [ + licenseIcon(18, accentInk), + { + type: 'span', + props: { + style: { + fontSize: '17px', + fontWeight: 600, + color: textColor, + fontFamily: 'JetBrains Mono, monospace', + }, + children: license, + }, + }, + ], + }, + }, + ] + : []), + ]; + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + width: '1200px', + height: '630px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '48px 64px', + }, + children: [ + // Header block + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + }, + children: [ + ...(labelText + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + padding: '5px 14px', + borderRadius: '999px', + backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.16), + border: `1px solid ${accentInk}55`, + marginBottom: '16px', + alignSelf: 'flex-start', + }, + children: { + type: 'span', + props: { + style: { + fontSize: '13px', + fontWeight: 700, + color: accentInk, + letterSpacing: '0.04em', + }, + children: labelText, + }, + }, + }, + }, + ] + : []), + // Package name + version pill + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '16px', + }, + children: [ + { + type: 'h1', + props: { + style: { + fontSize: `${titleSize}px`, + fontWeight: 700, + color: textColor, + lineHeight: 1.15, + margin: 0, + fontFamily: 'JetBrains Mono, monospace', + letterSpacing: '-0.02em', + }, + children: title, + }, + }, + ...(version + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + padding: '4px 14px', + borderRadius: '999px', + backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.16), + border: `1px solid ${mixColors(bgColor, accentInk, lightBg ? 0.25 : 0.35)}`, + flexShrink: 0, + }, + children: { + type: 'span', + props: { + style: { + fontSize: '17px', + fontWeight: 600, + color: accentInk, + fontFamily: 'JetBrains Mono, monospace', + }, + children: version, + }, + }, + }, + }, + ] + : []), + ], + }, + }, + // Pitch + ...(description + ? [ + { + type: 'p', + props: { + style: { + fontSize: `${descriptionSize}px`, + color: lightBg ? mixColors(bgColor, textColor, 0.7) : `${textColor}aa`, + lineHeight: 1.4, + margin: 0, + marginTop: '12px', + maxWidth: '960px', + }, + children: description, + }, + }, + ] + : []), + ], + }, + }, + // Stats row: downloads & license side by side + ...(statsItems.length > 0 + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '14px', + marginTop: '22px', + }, + children: statsItems, + }, + }, + ] + : []), + // Install command box + ...(installCmd + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + padding: '16px 22px', + borderRadius: '12px', + backgroundColor: cardBg, + border: `1px solid ${cardBorder}`, + marginTop: '22px', + alignSelf: 'flex-start', + maxWidth: '960px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 700, + color: accentInk, + marginRight: '12px', + fontFamily: 'JetBrains Mono, monospace', + flexShrink: 0, + }, + children: '$', + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 500, + color: textColor, + fontFamily: 'JetBrains Mono, monospace', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + children: installCmd, + }, + }, + ], + }, + }, + ] + : []), + // Spacer + { + type: 'div', + props: { + style: { + display: 'flex', + flex: 1, + }, + children: [], + }, + }, + // Site name at bottom + ...(siteName + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + paddingTop: '16px', + borderTop: `1px solid ${lightBg ? mixColors(bgColor, textColor, 0.12) : `${textColor}15`}`, + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '17px', + fontWeight: 500, + color: lightBg ? mixColors(bgColor, textColor, 0.6) : `${textColor}88`, + fontFamily: 'Inter, sans-serif', + }, + children: siteName, + }, + }, + ], + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 7d71171..afe8605 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -148,6 +148,7 @@ import githubMilestone from './github/milestone'; import githubSponsorsGoal from './github/sponsors-goal'; import githubSecurityAdvisory from './github/security-advisory'; import githubMaintainersWanted from './github/maintainers-wanted'; +import githubPackageCard from './github/package-card'; [ githubRepoCard, githubContributionGraph, githubReadmeHero, githubReleaseNotes, @@ -160,6 +161,7 @@ import githubMaintainersWanted from './github/maintainers-wanted'; githubSponsorsGoal, githubSecurityAdvisory, githubMaintainersWanted, + githubPackageCard, ].forEach(registerTemplate); // Event diff --git a/tests/templates/package-card.test.ts b/tests/templates/package-card.test.ts new file mode 100644 index 0000000..2eda865 --- /dev/null +++ b/tests/templates/package-card.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import packageCard from '@/templates/github/package-card'; +import { getTemplate } from '@/templates/registry'; +import { isLightColor, mixColors } from '@/templates/utils'; +import type { SatoriElement } from '@/templates/types'; + +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 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 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; +} + +const tree = (overrides: Record = {}) => + packageCard.render({ ...packageCard.defaults, ...overrides }); + +const render = (overrides: Record = {}) => texts(tree(overrides)); + +describe('github-package-card', () => { + it('is registered in the github category and re-exported', async () => { + expect(getTemplate('github-package-card')?.category).toBe('github'); + const exports = await import('@/templates/github'); + expect(exports.githubPackageCard.id).toBe('github-package-card'); + }); + + it('has the fields the issue asks for, with the defaults it names', () => { + expect(packageCard.fields.map((field) => field.key)).toEqual([ + 'title', + 'description', + 'registry', + 'version', + 'downloads', + 'license', + 'install', + 'siteName', + 'bgColor', + 'accentColor', + 'textColor', + ]); + expect(packageCard.defaults).toMatchObject({ + title: '@acme/csv-stream', + description: 'A streaming CSV parser for Node', + registry: 'npm', + version: 'v2.4.1', + downloads: '182k weekly downloads', + license: 'MIT', + install: 'npm i @acme/csv-stream', + siteName: 'github.com/acme/csv-stream', + bgColor: '#0d1117', + accentColor: '#58a6ff', + textColor: '#e6edf3', + }); + }); + + it('renders a 1200x630 card and every div is flex', () => { + const root = tree(); + expect(root.props.style).toMatchObject({ width: '1200px', height: '630px' }); + for (const el of elements(root).filter((e) => e.type === 'div')) { + expect(el.props.style?.display).toBe('flex'); + } + }); + + it('renders with its defaults in order', () => { + expect(render()).toEqual([ + 'npm package', + '@acme/csv-stream', + 'v2.4.1', + 'A streaming CSV parser for Node', + '182k weekly downloads', + 'MIT', + '$', + 'npm i @acme/csv-stream', + 'github.com/acme/csv-stream', + ]); + }); + + it('formats custom registry names cleanly', () => { + expect(render({ registry: 'PyPI' })).toContain('PyPI package'); + expect(render({ registry: 'crates.io' })).toContain('crates.io package'); + expect(render({ registry: 'npm package' })).toContain('npm package'); + }); + + it('shrinks long package name with autoFontSize and truncates', () => { + const normalTitle = '@acme/csv-stream'; + const longTitle = '@acme-enterprise/ultra-resilient-high-throughput-csv-streaming-engine'; + + const normalStyle = styleOf(tree({ title: normalTitle }), normalTitle); + const longTree = tree({ title: longTitle }); + const renderedTexts = texts(longTree); + const truncatedTitle = renderedTexts.find((t) => t.startsWith('@acme-enterprise/')); + expect(truncatedTitle).toBeDefined(); + expect(truncatedTitle?.length).toBeLessThanOrEqual(55); + + const longStyle = styleOf(longTree, truncatedTitle!); + const normalSize = parseInt(String(normalStyle?.fontSize), 10); + const longSize = parseInt(String(longStyle?.fontSize), 10); + expect(longSize).toBeLessThan(normalSize); + }); + + it('shrinks long pitch with autoFontSize and truncates', () => { + const normalPitch = 'A streaming CSV parser for Node'; + const longPitch = + 'A high-performance streaming CSV parser and serializer designed specifically for enterprise Node.js environments with custom delimiters, asynchronous pipelines, and schema validation.'; + + const normalStyle = styleOf(tree({ description: normalPitch }), normalPitch); + const longTree = tree({ description: longPitch }); + const renderedTexts = texts(longTree); + const truncatedPitch = renderedTexts.find((t) => t.startsWith('A high-performance')); + expect(truncatedPitch).toBeDefined(); + expect(truncatedPitch?.length).toBeLessThanOrEqual(140); + + const longStyle = styleOf(longTree, truncatedPitch!); + const normalSize = parseInt(String(normalStyle?.fontSize), 10); + const longSize = parseInt(String(longStyle?.fontSize), 10); + expect(longSize).toBeLessThan(normalSize); + }); + + it('truncates a long install command inside its box', () => { + const longInstall = 'npm install @acme-enterprise/ultra-resilient-high-throughput-csv-streaming-engine-with-all-plugins-enabled --save-exact'; + const longTree = tree({ install: longInstall }); + const renderedTexts = texts(longTree); + const truncatedInstall = renderedTexts.find((t) => t.startsWith('npm install')); + expect(truncatedInstall).toBeDefined(); + expect(truncatedInstall?.length).toBeLessThanOrEqual(70); + }); + + it('hides an empty pitch, registry, version, downloads, license, install or site name (or only spaces) cleanly', () => { + expect(render({ registry: '' })).not.toContain('npm package'); + expect(render({ registry: ' ' })).not.toContain('npm package'); + + expect(render({ version: '' })).not.toContain('v2.4.1'); + expect(render({ version: ' ' })).not.toContain('v2.4.1'); + + expect(render({ description: '' })).not.toContain('A streaming CSV parser for Node'); + expect(render({ description: ' ' })).not.toContain('A streaming CSV parser for Node'); + + expect(render({ downloads: '' })).not.toContain('182k weekly downloads'); + expect(render({ downloads: ' ' })).not.toContain('182k weekly downloads'); + + expect(render({ license: '' })).not.toContain('MIT'); + expect(render({ license: ' ' })).not.toContain('MIT'); + + expect(render({ install: '' })).not.toContain('npm i @acme/csv-stream'); + expect(render({ install: ' ' })).not.toContain('npm i @acme/csv-stream'); + + expect(render({ siteName: '' })).not.toContain('github.com/acme/csv-stream'); + expect(render({ siteName: ' ' })).not.toContain('github.com/acme/csv-stream'); + + const allOptionalCleared = render({ + registry: '', + version: ' ', + description: '', + downloads: ' ', + license: '', + install: ' ', + siteName: '', + }); + expect(allOptionalCleared).toEqual(['@acme/csv-stream']); + }); + + it('keeps the label, version pill and accent text readable on a light background', () => { + const lightTree = tree({ bgColor: '#ffffff', accentColor: '#58a6ff' }); + const labelStyle = styleOf(lightTree, 'npm package'); + const labelColor = String(labelStyle?.color); + + const expectedColor = mixColors('#58a6ff', '#000000', 0.45); + expect(labelColor).toBe(expectedColor); + expect(isLightColor(labelColor)).toBe(false); + + const versionStyle = styleOf(lightTree, 'v2.4.1'); + expect(versionStyle?.color).toBe(expectedColor); + }); +}); From 479ad8eb0112579c28a10447f2045bbafc67635e Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Thu, 29 Oct 2026 21:23:42 +0530 Subject: [PATCH 2/2] remove 1 tag Signed-off-by: Anurag Yadav --- src/templates/github/package-card.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/github/package-card.ts b/src/templates/github/package-card.ts index 21626da..6683654 100644 --- a/src/templates/github/package-card.ts +++ b/src/templates/github/package-card.ts @@ -54,7 +54,7 @@ const template: TemplateDefinition = { name: 'Package Card', category: 'github', description: 'Published package announcement with registry badge, version pill, pitch, downloads, license, and install command', - tags: ['github', 'package', 'npm', 'pypi', 'crates', 'open-source', 'library'], + tags: ['github', 'package', 'npm', 'pypi', 'crates', 'open-source'], fields: [ { ...commonFields.title,