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
2 changes: 2 additions & 0 deletions src/templates/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import saasUptimeStatus from './saas/uptime-status';
import saasApiEndpoint from './saas/api-endpoint';
import saasRoadmap from './saas/roadmap';
import saasMaintenance from './saas/maintenance';
import saasDeprecation from './saas/deprecation';

[
saasDashboardPreview, saasPricingHighlight, saasMetricShowcase, saasTestimonialBanner,
Expand All @@ -123,6 +124,7 @@ import saasMaintenance from './saas/maintenance';
saasApiEndpoint,
saasRoadmap,
saasMaintenance,
saasDeprecation,
].forEach(registerTemplate);

// GitHub
Expand Down
136 changes: 136 additions & 0 deletions src/templates/saas/deprecation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { SatoriElement, TemplateDefinition } from '../types';
import { arrowRightIcon, autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';

const cleanText = (value: unknown, maxLength: number) =>
truncate(String(value ?? '').replace(/\s+/g, ' ').trim(), maxLength);

const template: TemplateDefinition = {
id: 'saas-deprecation',
name: 'Deprecation Notice',
category: 'saas',
description: 'Announce a deprecated API, feature, or version with its replacement and sunset date',
tags: ['saas', 'deprecated', 'deprecation', 'sunset', 'migration'],
fields: [
commonFields.title,
{ key: 'replacement', label: 'Replacement', type: 'text', defaultValue: 'API v2', placeholder: 'What replaces it', group: 'Content' },
{ key: 'sunsetDate', label: 'Sunset Date', type: 'text', defaultValue: 'Shuts down on March 31, 2027', placeholder: 'When it will be unavailable', group: 'Content' },
commonFields.description,
commonFields.siteName,
commonFields.bgColor,
commonFields.accentColor,
commonFields.textColor,
],
defaults: {
title: 'API v1',
replacement: 'API v2',
sunsetDate: 'Shuts down on March 31, 2027',
description: 'The migration guide is in the docs',
siteName: 'Acme API',
bgColor: '#0b0f1a',
accentColor: '#f97316',
textColor: '#f8fafc',
},
render: (params) => {
const rawTitle = String(params.title || 'API v1').replace(/\s+/g, ' ').trim() || 'API v1';
const titleSize = autoFontSize(rawTitle, [
{ maxLen: 16, size: 76 },
{ maxLen: 28, size: 64 },
{ maxLen: 44, size: 52 },
{ maxLen: 72, size: 42 },
]);
const title = truncate(rawTitle, 44);
const rawReplacement = String(params.replacement ?? '').replace(/\s+/g, ' ').trim();
const replacementSize = autoFontSize(rawReplacement, [
{ maxLen: 16, size: 76 },
{ maxLen: 28, size: 64 },
{ maxLen: 44, size: 52 },
{ maxLen: 72, size: 42 },
]);
const replacement = truncate(rawReplacement, 44);
const sunsetDate = cleanText(params.sunsetDate, 72);
const description = cleanText(params.description, 100);
const siteName = cleanText(params.siteName, 40);
const bgColor = String(params.bgColor || '#0b0f1a');
const accentColor = String(params.accentColor || '#f97316');
const textColor = String(params.textColor || '#f8fafc');
const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
const mutedColor = mixColors(bgColor, textColor, 0.68);

const nameRow: SatoriElement[] = [
{
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
...(replacement ? { flex: 1 } : {}),
minWidth: 0,
},
children: [
{ type: 'span', props: { style: { fontSize: '16px', fontWeight: 700, letterSpacing: '0.12em', color: mutedColor, textTransform: 'uppercase' }, children: 'NO LONGER SUPPORTED' } },
{ type: 'h1', props: { style: { display: 'flex', overflow: 'hidden', fontSize: `${titleSize}px`, lineHeight: 1.05, fontWeight: 700, color: textColor, textDecoration: 'line-through', margin: '18px 0 0' }, children: title } },
],
},
},
...(replacement
? [
{ type: 'div', props: { style: { display: 'flex', flexShrink: 0, margin: '0 28px' }, children: arrowRightIcon(50, accentInk) } },
{
type: 'div',
props: {
style: { display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 },
children: [
{ type: 'span', props: { style: { fontSize: '16px', fontWeight: 700, letterSpacing: '0.12em', color: accentInk, textTransform: 'uppercase' }, children: 'USE INSTEAD' } },
{ type: 'span', props: { style: { display: 'flex', overflow: 'hidden', fontSize: `${replacementSize}px`, lineHeight: 1.05, fontWeight: 700, color: textColor, marginTop: '18px' }, children: replacement } },
],
},
},
]
: []),
];

return {
type: 'div',
props: {
style: {
display: 'flex',
flexDirection: 'column',
width: '1200px',
height: '630px',
padding: '58px 72px 48px',
boxSizing: 'border-box',
backgroundColor: bgColor,
fontFamily: 'Inter, sans-serif',
},
children: [
{
type: 'div',
props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '54px' }, children: [
{
type: 'div',
props: { style: { display: 'flex', alignItems: 'center', padding: '10px 20px', borderRadius: '999px', backgroundColor: `${accentInk}14`, border: `1px solid ${accentInk}66` }, children: {
type: 'span', props: { style: { fontSize: '18px', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: accentInk }, children: 'Deprecated' },
} },
},
] },
},
{
type: 'div',
props: { style: { display: 'flex', alignItems: 'center', flex: 1, minHeight: 0, marginTop: '28px' }, children: nameRow },
},
...(sunsetDate
? [{ type: 'span', props: { style: { fontSize: '30px', fontWeight: 700, lineHeight: 1.2, color: accentInk, marginTop: '16px' }, children: sunsetDate } }]
: []),
...(description
? [{ type: 'span', props: { style: { fontSize: '22px', lineHeight: 1.3, color: mutedColor, marginTop: '12px' }, children: description } }]
: []),
...(siteName
? [{ type: 'span', props: { style: { alignSelf: 'flex-end', fontSize: '20px', fontWeight: 600, color: mutedColor, marginTop: 'auto' }, children: siteName } }]
: []),
],
},
};
},
};

export default template;
1 change: 1 addition & 0 deletions src/templates/saas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export { default as saasUptimeStatus } from './uptime-status';
export { default as saasApiEndpoint } from './api-endpoint';
export { default as saasRoadmap } from './roadmap';
export { default as saasMaintenance } from './maintenance';
export { default as saasDeprecation } from './deprecation';
111 changes: 111 additions & 0 deletions tests/templates/deprecation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import deprecation from '@/templates/saas/deprecation';
import { saasDeprecation } from '@/templates/saas';
import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
import type { SatoriElement } from '@/templates/types';

function walk(node: unknown, visit: (element: SatoriElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => walk(child, visit));
return;
}
if (!node || typeof node !== 'object' || !('type' in node)) return;

const element = node as SatoriElement;
visit(element);
walk(element.props.children, visit);
}

function allElements(node: unknown): SatoriElement[] {
const found: SatoriElement[] = [];
walk(node, (element) => found.push(element));
return found;
}

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

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

describe('saas-deprecation', () => {
it('is re-exported and registered exactly once in the SaaS category', () => {
expect(saasDeprecation).toBe(deprecation);
expect(getTemplate('saas-deprecation')).toBe(deprecation);
expect(getTemplatesByCategory('saas').filter((template) => template.id === 'saas-deprecation'))
.toHaveLength(1);
});

it('renders the deprecated name, replacement, sunset date, note, and site name', () => {
expect(textContent(render())).toEqual([
'Deprecated',
'NO LONGER SUPPORTED',
'API v1',
'USE INSTEAD',
'API v2',
'Shuts down on March 31, 2027',
'The migration guide is in the docs',
'Acme API',
]);
expect(allElements(render()).find((element) =>
element.type === 'span' && textContent(element).includes('Acme API')
)?.props.style.alignSelf).toBe('flex-end');
expect(allElements(render()).some((element) => element.type === 'svg')).toBe(true);
});

it('hides whitespace-only optional fields and the arrow when the replacement is empty', () => {
const tree = render({ replacement: ' ', sunsetDate: ' ', description: ' ', siteName: ' ' });
expect(textContent(tree)).toEqual(['Deprecated', 'NO LONGER SUPPORTED', 'API v1']);
expect(allElements(tree).some((element) => element.type === 'svg')).toBe(false);
});

it('shrinks then truncates long old and replacement names', () => {
const tree = render({ title: 'Old API name '.repeat(9), replacement: 'Replacement API name '.repeat(8) });
const elements = allElements(tree);
const oldName = elements.find((element) => element.type === 'h1');
const newName = elements.find((element) =>
element.type === 'span' && textContent(element).some((text) => text.startsWith('Replacement API'))
);

expect(textContent(oldName)[0].length).toBeLessThanOrEqual(44);
expect(textContent(oldName)[0]).toMatch(/…$/);
expect(oldName?.props.style.fontSize).toBe('42px');
expect(oldName?.props.style.textDecoration).toBe('line-through');
expect(textContent(newName)[0].length).toBeLessThanOrEqual(44);
expect(textContent(newName)[0]).toMatch(/…$/);
expect(newName?.props.style.fontSize).toBe('42px');
});

it('darkens label and accent text on a light background with a pale accent', () => {
const tree = render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' });
const elements = allElements(tree);
const deprecatedLabel = elements.find((element) =>
textContent(element).includes('Deprecated') && element.type === 'span'
);
const replacementLabel = elements.find((element) =>
textContent(element).includes('USE INSTEAD') && element.type === 'span'
);
const sunset = elements.find((element) =>
element.type === 'span' && textContent(element).includes('Shuts down on March 31, 2027')
);

expect(deprecatedLabel?.props.style.color).toBe('#8b7b27');
expect(replacementLabel?.props.style.color).toBe('#8b7b27');
expect(sunset?.props.style.color).toBe('#8b7b27');
});

it('uses the required canvas size and flex layout for all divs', () => {
const elements = allElements(render());
expect(elements[0].props.style.width).toBe('1200px');
expect(elements[0].props.style.height).toBe('630px');
expect(elements.every((element) =>
element.type !== 'div' || element.props.style?.display === 'flex'
)).toBe(true);
});
});
Loading