diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 8afded0..a91e587 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -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, @@ -123,6 +124,7 @@ import saasMaintenance from './saas/maintenance'; saasApiEndpoint, saasRoadmap, saasMaintenance, + saasDeprecation, ].forEach(registerTemplate); // GitHub diff --git a/src/templates/saas/deprecation.ts b/src/templates/saas/deprecation.ts new file mode 100644 index 0000000..851e3bd --- /dev/null +++ b/src/templates/saas/deprecation.ts @@ -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; diff --git a/src/templates/saas/index.ts b/src/templates/saas/index.ts index a4242fd..ea61858 100644 --- a/src/templates/saas/index.ts +++ b/src/templates/saas/index.ts @@ -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'; diff --git a/tests/templates/deprecation.test.ts b/tests/templates/deprecation.test.ts new file mode 100644 index 0000000..6eb734b --- /dev/null +++ b/tests/templates/deprecation.test.ts @@ -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 = {}) => + 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); + }); +});