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
1 change: 1 addition & 0 deletions src/templates/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ 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';
export { default as githubSecurityAdvisory } from './security-advisory';
173 changes: 173 additions & 0 deletions src/templates/github/security-advisory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { TemplateDefinition } from '../types';
import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';

const SEVERITIES = {
critical: { label: 'Critical', color: '#8250df' },
high: { label: 'High', color: '#d1242f' },
medium: { label: 'Medium', color: '#bf8700' },
low: { label: 'Low', color: '#1a7f37' },
};

const DEFAULT_TITLE = 'Path traversal in @acme/static-files';

const template: TemplateDefinition = {
id: 'github-security-advisory',
name: 'Security Advisory',
category: 'github',
description: 'Security fix announcement with severity, advisory ID, affected package versions and site name',
tags: ['github', 'security', 'advisory', 'vulnerability', 'package'],
fields: [
{ ...commonFields.title, defaultValue: DEFAULT_TITLE, placeholder: 'Security advisory title' },
{
key: 'severity',
label: 'Severity',
type: 'select',
defaultValue: 'high',
options: [
{ label: 'Critical', value: 'critical' },
{ label: 'High', value: 'high' },
{ label: 'Medium', value: 'medium' },
{ label: 'Low', value: 'low' },
],
group: 'Content',
},
{ key: 'advisoryId', label: 'Advisory ID', type: 'text', defaultValue: 'GHSA-3x9q-7m2v-p4hc', placeholder: 'GHSA-xxxx-xxxx-xxxx', required: false, group: 'Content' },
{ key: 'packageName', label: 'Package Name', type: 'text', defaultValue: '@acme/static-files', placeholder: '@scope/package', required: false, group: 'Content' },
{ key: 'affected', label: 'Affected Versions', type: 'text', defaultValue: '< 2.4.1', placeholder: '< 2.4.1', required: false, group: 'Content' },
{ key: 'patched', label: 'Fixed Version', type: 'text', defaultValue: '2.4.1', placeholder: '2.4.1', required: false, group: 'Content' },
commonFields.siteName,
commonFields.bgColor,
commonFields.accentColor,
commonFields.textColor,
],
defaults: {
title: DEFAULT_TITLE,
severity: 'high',
advisoryId: 'GHSA-3x9q-7m2v-p4hc',
packageName: '@acme/static-files',
affected: '< 2.4.1',
patched: '2.4.1',
siteName: 'Acme Security',
bgColor: '#0d1117',
accentColor: '#58a6ff',
textColor: '#e6edf3',
},
render: (params) => {
const rawTitle = String(params.title ?? '').trim() || DEFAULT_TITLE;
const title = truncate(rawTitle, 90);
const rawSeverity = String(params.severity ?? '').trim().toLowerCase();
const severity = Object.hasOwn(SEVERITIES, rawSeverity)
? SEVERITIES[rawSeverity as keyof typeof SEVERITIES]
: SEVERITIES.high;
const advisoryId = truncate(String(params.advisoryId ?? '').trim(), 32);
const packageName = truncate(String(params.packageName ?? '').trim(), 48);
const affected = truncate(String(params.affected ?? '').trim(), 32);
const patched = truncate(String(params.patched ?? '').trim(), 32);
const siteName = truncate(String(params.siteName ?? '').trim(), 40);
const bgColor = String(params.bgColor || '#0d1117');
const accentColor = String(params.accentColor || '#58a6ff');
const textColor = String(params.textColor || '#e6edf3');
const accentTextColor = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
const details = [
packageName,
affected ? `affected ${affected}` : '',
patched ? `fixed in ${patched}` : '',
].filter(Boolean).join(' · ');
const titleSize = autoFontSize(rawTitle, [
{ maxLen: 28, size: 68 },
{ maxLen: 48, size: 58 },
{ maxLen: 68, size: 48 },
{ maxLen: 90, size: 40 },
]);

return {
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '1200px',
height: '630px',
padding: '64px 72px',
boxSizing: 'border-box',
backgroundColor: bgColor,
fontFamily: 'Inter, sans-serif',
},
children: [
{
type: 'div',
props: {
style: { display: 'flex', alignItems: 'center', gap: '18px' },
children: [
{
type: 'div',
props: {
style: {
display: 'flex',
alignItems: 'center',
padding: '10px 20px',
borderRadius: '999px',
backgroundColor: severity.color,
},
children: {
type: 'span',
props: {
style: { fontSize: '20px', fontWeight: 700, color: contrastText(severity.color) },
children: severity.label,
},
},
},
},
...(advisoryId
? [{
type: 'span',
props: {
style: { fontSize: '20px', fontWeight: 600, color: accentTextColor, fontFamily: 'JetBrains Mono, monospace' },
children: advisoryId,
},
}]
: []),
],
},
},
{
type: 'div',
props: {
style: { display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, gap: '24px' },
children: [
{
type: 'h1',
props: {
style: { fontSize: `${titleSize}px`, lineHeight: 1.1, fontWeight: 700, color: textColor, margin: 0 },
children: title,
},
},
...(details
? [{
type: 'span',
props: {
style: { fontSize: '26px', fontWeight: 500, color: textColor },
children: details,
},
}]
: []),
],
},
},
...(siteName
? [{
type: 'span',
props: {
style: { fontSize: '22px', fontWeight: 600, color: textColor },
children: siteName,
},
}]
: []),
],
},
};
},
};

export default template;
2 changes: 2 additions & 0 deletions src/templates/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ import githubPullRequest from './github/pull-request';
import githubGoodFirstIssues from './github/good-first-issues';
import githubMilestone from './github/milestone';
import githubSponsorsGoal from './github/sponsors-goal';
import githubSecurityAdvisory from './github/security-advisory';

[
githubRepoCard, githubContributionGraph, githubReadmeHero, githubReleaseNotes,
Expand All @@ -156,6 +157,7 @@ import githubSponsorsGoal from './github/sponsors-goal';
githubGoodFirstIssues,
githubMilestone,
githubSponsorsGoal,
githubSecurityAdvisory,
].forEach(registerTemplate);

// Event
Expand Down
96 changes: 96 additions & 0 deletions tests/templates/security-advisory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { getTemplate } from '@/templates/registry';
import type { SatoriElement } from '@/templates/types';
import { contrastText, isLightColor, mixColors } from '@/templates/utils';
import securityAdvisory from '@/templates/github/security-advisory';

function elements(node: unknown): SatoriElement[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (node && typeof node === 'object' && 'props' in node) {
const element = node as SatoriElement;
return [element, ...elements(element.props.children)];
}
return [];
}

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 [];
}

const tree = (overrides: Record<string, unknown> = {}) =>
securityAdvisory.render({ ...securityAdvisory.defaults, ...overrides });
const render = (overrides: Record<string, unknown> = {}) => texts(tree(overrides));

describe('github-security-advisory', () => {
it('is registered in the github category', () => {
expect(getTemplate('github-security-advisory')?.category).toBe('github');
});

it('renders the severity, advisory, title, package details and site name', () => {
expect(render()).toEqual([
'High',
'GHSA-3x9q-7m2v-p4hc',
'Path traversal in @acme/static-files',
'@acme/static-files · affected < 2.4.1 · fixed in 2.4.1',
'Acme Security',
]);
});

it('is a 1200x630 card', () => {
const { style } = tree().props;
expect(style.width).toBe('1200px');
expect(style.height).toBe('630px');
});

it('uses a distinct readable pill color for each severity and falls back to high', () => {
const pill = (severity: string) => elements(tree({ severity })).find((element) => element.props.style?.borderRadius === '999px')!;
const colors = ['critical', 'high', 'medium', 'low'].map((severity) => pill(severity).props.style.backgroundColor);

expect(new Set(colors).size).toBe(4);
for (const severity of ['critical', 'high', 'medium', 'low']) {
const badge = pill(severity);
const color = String(badge.props.style.backgroundColor);
const label = elements(badge).find((element) => element.type === 'span');

expect(texts(badge)).toEqual([severity[0].toUpperCase() + severity.slice(1)]);
expect(label?.props.style.color).toBe(contrastText(color));
}
expect(pill('unknown').props.style.backgroundColor).toBe(pill('high').props.style.backgroundColor);
expect(texts(pill('unknown'))).toEqual(['High']);
});

it('truncates long titles after sizing them and shrinks their font', () => {
const title = 'A'.repeat(120);
const heading = elements(tree({ title })).find((element) => element.type === 'h1');

expect(heading?.props.children).toHaveLength(90);
expect(String(heading?.props.children).endsWith('…')).toBe(true);
expect(heading?.props.style.fontSize).toBe('40px');
});

it('hides blank optional fields and only separates available package details', () => {
expect(render({
advisoryId: ' ',
packageName: ' \t',
affected: '',
patched: '\n ',
siteName: ' ',
})).toEqual(['High', 'Path traversal in @acme/static-files']);
expect(render({ packageName: '', affected: '< 3.0', patched: '' })).toContain('affected < 3.0');
expect(render({ packageName: '', affected: '< 3.0', patched: '' }).join('')).not.toContain('·');
});

it('keeps accent text readable on a light background', () => {
const element = tree({ bgColor: '#ffffff', accentColor: '#fde047' });
const advisoryId = elements(element).find((item) => item.props.children === 'GHSA-3x9q-7m2v-p4hc');
const accentText = String(advisoryId?.props.style?.color);

expect(accentText).toBe(mixColors('#fde047', '#000000', 0.45));
expect(isLightColor(accentText)).toBe(false);
});
});
Loading