diff --git a/src/templates/podcast/index.ts b/src/templates/podcast/index.ts index e818913..d8e027d 100644 --- a/src/templates/podcast/index.ts +++ b/src/templates/podcast/index.ts @@ -7,3 +7,4 @@ export { default as podcastMinimalPlayer } from './minimal-player'; export { default as podcastVideoThumbnail } from './video-thumbnail'; export { default as podcastAudiogram } from './audiogram'; export { default as podcastShowNotes } from './show-notes'; +export { default as podcastListenOn } from './listen-on'; diff --git a/src/templates/podcast/listen-on.ts b/src/templates/podcast/listen-on.ts new file mode 100644 index 0000000..c448549 --- /dev/null +++ b/src/templates/podcast/listen-on.ts @@ -0,0 +1,300 @@ +import type { TemplateDefinition } from '../types'; +import { truncate, autoFontSize, commonFields, contrastText, isLightColor, mixColors } from '../utils'; + +const template: TemplateDefinition = { + id: 'podcast-listen-on', + name: 'Listen On', + category: 'podcast', + description: 'Podcast release card with show name, episode number, title, tagline, and platform links', + tags: ['podcast', 'listen', 'platforms', 'episode', 'audio'], + fields: [ + commonFields.title, + { key: 'showName', label: 'Show Name', type: 'text' as const, defaultValue: 'The Dev Show', placeholder: 'The Dev Show', group: 'Content' as const }, + { key: 'episodeNumber', label: 'Episode #', type: 'text' as const, defaultValue: '42', placeholder: '42', group: 'Content' as const }, + { key: 'tagline', label: 'Tagline', type: 'text' as const, defaultValue: 'Out now', placeholder: 'Out now', group: 'Content' as const }, + { key: 'platform1', label: 'Platform 1', type: 'text' as const, defaultValue: 'Spotify', placeholder: 'Spotify', group: 'Content' as const }, + { key: 'platform2', label: 'Platform 2', type: 'text' as const, defaultValue: 'Apple Podcasts', placeholder: 'Apple Podcasts', group: 'Content' as const }, + { key: 'platform3', label: 'Platform 3', type: 'text' as const, defaultValue: 'YouTube', placeholder: 'YouTube', group: 'Content' as const }, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: 'Building Developer Tools That Scale', + showName: 'The Dev Show', + episodeNumber: '42', + tagline: 'Out now', + platform1: 'Spotify', + platform2: 'Apple Podcasts', + platform3: 'YouTube', + bgColor: '#0d1117', + accentColor: '#E07A5F', + textColor: '#FFFFFF', + }, + render: (params) => { + const title = truncate(String(params.title ?? '').trim() || 'Episode Title', 80); + const showName = truncate(String(params.showName ?? '').trim(), 40); + const episodeNumber = truncate(String(params.episodeNumber ?? '').trim(), 12); + const taglineRaw = params.tagline !== undefined ? String(params.tagline).trim() : 'Out now'; + const tagline = truncate(taglineRaw, 40); + + const p1 = params.platform1 !== undefined ? String(params.platform1).trim() : 'Spotify'; + const p2 = params.platform2 !== undefined ? String(params.platform2).trim() : 'Apple Podcasts'; + const p3 = params.platform3 !== undefined ? String(params.platform3).trim() : 'YouTube'; + + const platforms = [p1, p2, p3] + .filter((p) => p.length > 0) + .slice(0, 3) + .map((p) => truncate(p, 28)); + + const bgColorRaw = String(params.bgColor || '#0d1117'); + const accentColorRaw = String(params.accentColor || '#E07A5F'); + const textColorRaw = String(params.textColor || '#FFFFFF'); + + const bgColor = bgColorRaw.startsWith('#') ? bgColorRaw : `#${bgColorRaw}`; + const accentColor = accentColorRaw.startsWith('#') ? accentColorRaw : `#${accentColorRaw}`; + const textColor = textColorRaw.startsWith('#') ? textColorRaw : `#${textColorRaw}`; + + const lightBg = isLightColor(bgColor); + const lightAccent = isLightColor(accentColor); + + // Ensure pill text remains readable for any accent color against the background + const pillText = lightAccent === lightBg + ? mixColors(accentColor, textColor, 0.6) + : accentColor; + + const pillBg = lightAccent === lightBg + ? `${pillText}18` + : `${accentColor}18`; + + const pillBorder = lightAccent === lightBg + ? `${pillText}44` + : `${accentColor}55`; + + const bgEnd = lightBg + ? mixColors(bgColor, textColor, 0.05) + : mixColors(bgColor, '#000000', 0.35); + + const titleSize = autoFontSize(title, [ + { maxLen: 30, size: 52 }, + { maxLen: 50, size: 44 }, + { maxLen: 70, size: 36 }, + { maxLen: 100, size: 30 }, + ]); + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + width: '1200px', + height: '630px', + background: `linear-gradient(160deg, ${bgColor} 0%, ${bgEnd} 100%)`, + fontFamily: 'Inter, sans-serif', + padding: '60px 70px', + }, + children: [ + // Top: Show name + episode badge + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '16px', + marginBottom: '32px', + }, + children: [ + // Podcast icon badge + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '48px', + height: '48px', + borderRadius: '12px', + background: accentColor, + flexShrink: 0, + }, + children: { + type: 'span', + props: { + style: { fontSize: '22px', color: contrastText(accentColor) }, + children: '\u266A', + }, + }, + }, + }, + ...(showName + ? [ + { + type: 'span', + props: { + style: { + fontSize: '20px', + fontWeight: 700, + color: `${textColor}cc`, + maxWidth: '700px', + whiteSpace: 'nowrap' as const, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + children: showName, + }, + }, + ] + : []), + ...(episodeNumber + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + padding: '4px 14px', + background: pillBg, + borderRadius: '100px', + border: `1px solid ${pillBorder}`, + flexShrink: 0, + }, + children: { + type: 'span', + props: { + style: { + fontSize: '14px', + fontWeight: 600, + color: pillText, + }, + children: `EP ${episodeNumber}`, + }, + }, + }, + }, + ] + : []), + ], + }, + }, + // Middle: Title + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + flex: 1, + }, + children: { + type: 'h1', + props: { + style: { + display: 'flex', + fontSize: `${titleSize}px`, + fontWeight: 700, + color: textColor, + lineHeight: 1.25, + margin: 0, + maxWidth: '1060px', + }, + children: title, + }, + }, + }, + }, + // Bottom: Tagline + platform pills + ...(tagline || platforms.length > 0 + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + marginTop: 'auto', + paddingTop: '24px', + borderTop: `1px solid ${textColor}15`, + }, + children: [ + ...(tagline + ? [ + { + type: 'span', + props: { + style: { + fontSize: '15px', + fontWeight: 600, + color: lightBg + ? mixColors(textColor, bgColor, 0.4) + : `${textColor}88`, + letterSpacing: '0.08em', + textTransform: 'uppercase' as const, + marginBottom: platforms.length > 0 ? '16px' : '0px', + }, + children: tagline, + }, + }, + ] + : []), + ...(platforms.length > 0 + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '14px', + flexWrap: 'nowrap' as const, + }, + children: platforms.map((name) => ({ + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + padding: '10px 22px', + borderRadius: '100px', + background: pillBg, + border: `1.5px solid ${pillBorder}`, + maxWidth: '320px', + }, + children: { + type: 'span', + props: { + style: { + fontSize: '16px', + fontWeight: 600, + color: pillText, + whiteSpace: 'nowrap' as const, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + children: name, + }, + }, + }, + })), + }, + }, + ] + : []), + ], + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 2477e8b..8fa118f 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -164,10 +164,12 @@ import podcastMinimalPlayer from './podcast/minimal-player'; import podcastVideoThumbnail from './podcast/video-thumbnail'; import podcastAudiogram from './podcast/audiogram'; import podcastShowNotes from './podcast/show-notes'; +import podcastListenOn from './podcast/listen-on'; [ podcastEpisodeCard, podcastWaveform, podcastGuestSpotlight, podcastConversation, podcastSeriesBanner, podcastMinimalPlayer, podcastVideoThumbnail, podcastAudiogram, podcastShowNotes, + podcastListenOn, ].forEach(registerTemplate); // Developer diff --git a/tests/templates/listen-on.test.ts b/tests/templates/listen-on.test.ts new file mode 100644 index 0000000..500c53a --- /dev/null +++ b/tests/templates/listen-on.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest'; +import listenOn from '@/templates/podcast/listen-on'; +import { getTemplate } from '@/templates/registry'; +import { hexToRgb } from '@/templates/utils'; +import type { SatoriElement } from '@/templates/types'; + +/** Every string rendered anywhere in the element tree, in order. */ +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 []; +} + +/** Recursively find all elements matching a predicate. */ +function findElements(node: unknown, predicate: (el: SatoriElement) => boolean): SatoriElement[] { + const result: SatoriElement[] = []; + if (Array.isArray(node)) { + for (const child of node) { + result.push(...findElements(child, predicate)); + } + } else if (node && typeof node === 'object' && 'props' in node) { + const el = node as SatoriElement; + if (predicate(el)) result.push(el); + result.push(...findElements(el.props.children, predicate)); + } + return result; +} + +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); +} + +const renderTree = (overrides: Record = {}) => + listenOn.render({ ...listenOn.defaults, ...overrides }); + +const renderTexts = (overrides: Record = {}) => + texts(renderTree(overrides)); + +describe('podcast-listen-on', () => { + it('is registered in the podcast category with id podcast-listen-on', () => { + const registered = getTemplate('podcast-listen-on'); + expect(registered).toBeDefined(); + expect(registered?.category).toBe('podcast'); + expect(registered?.name).toBe('Listen On'); + expect(registered?.tags.length).toBeGreaterThanOrEqual(4); + expect(registered?.tags.length).toBeLessThanOrEqual(6); + }); + + it('renders default values including showName, episode badge, title, tagline, and platform pills', () => { + const t = renderTexts(); + expect(t).toContain('\u266A'); + expect(t).toContain('The Dev Show'); + expect(t).toContain('EP 42'); + expect(t).toContain('Building Developer Tools That Scale'); + expect(t).toContain('Out now'); + expect(t).toContain('Spotify'); + expect(t).toContain('Apple Podcasts'); + expect(t).toContain('YouTube'); + }); + + it('hides blank episodeNumber', () => { + const emptyEp = renderTexts({ episodeNumber: '' }); + expect(emptyEp.some((text) => text.startsWith('EP '))).toBe(false); + + const whitespaceEp = renderTexts({ episodeNumber: ' ' }); + expect(whitespaceEp.some((text) => text.startsWith('EP '))).toBe(false); + }); + + it('hides blank tagline', () => { + const emptyTagline = renderTexts({ tagline: '' }); + expect(emptyTagline).not.toContain('Out now'); + + const whitespaceTagline = renderTexts({ tagline: ' ' }); + expect(whitespaceTagline).not.toContain('Out now'); + }); + + it('skips blank platforms', () => { + const skipped = renderTexts({ + platform1: 'Spotify', + platform2: '', + platform3: ' ', + }); + expect(skipped).toContain('Spotify'); + expect(skipped).not.toContain('Apple Podcasts'); + expect(skipped).not.toContain('YouTube'); + }); + + it('keeps pill shapes stable with flexShrink: 0 and constrains width', () => { + const tree = renderTree({ + platform1: 'Very Long Platform Name That Exceeds Normal Limits', + }); + + const pills = findElements(tree, (el) => el.props?.style?.borderRadius === '100px' && el.props?.style?.padding === '10px 22px'); + expect(pills.length).toBe(3); + + for (const pill of pills) { + expect(pill.props.style.flexShrink).toBe(0); + expect(pill.props.style.maxWidth).toBe('320px'); + } + + // Long platform name should be truncated + const t = texts(tree); + expect(t.some((s) => s.endsWith('…'))).toBe(true); + }); + + it('ensures pill text remains readable for any accent color and background', () => { + // Test dark background with dark accent (e.g. #000000 on #0d1117) + const darkOnDarkTree = renderTree({ + bgColor: '#0d1117', + textColor: '#FFFFFF', + accentColor: '#000000', + }); + const darkPills = findElements(darkOnDarkTree, (el) => el.props?.style?.borderRadius === '100px' && el.props?.style?.padding === '10px 22px'); + const darkPillTextColor = (darkPills[0].props.children as SatoriElement).props.style.color; + expect(contrast(darkPillTextColor, '#0d1117')).toBeGreaterThanOrEqual(4.5); + + // Test light background with light accent (e.g. #FFFFFF on #FFFFFF) + const lightOnLightTree = renderTree({ + bgColor: '#FFFFFF', + textColor: '#000000', + accentColor: '#FFFFFF', + }); + const lightPills = findElements(lightOnLightTree, (el) => el.props?.style?.borderRadius === '100px' && el.props?.style?.padding === '10px 22px'); + const lightPillTextColor = (lightPills[0].props.children as SatoriElement).props.style.color; + expect(contrast(lightPillTextColor, '#FFFFFF')).toBeGreaterThanOrEqual(4.5); + + // Test default accent (#E07A5F) on light background + const terracottaOnLight = renderTree({ + bgColor: '#FFFFFF', + textColor: '#111827', + accentColor: '#E07A5F', + }); + const terraPills = findElements(terracottaOnLight, (el) => el.props?.style?.borderRadius === '100px' && el.props?.style?.padding === '10px 22px'); + const terraTextColor = (terraPills[0].props.children as SatoriElement).props.style.color; + expect(contrast(terraTextColor, '#FFFFFF')).toBeGreaterThanOrEqual(4.5); + }); + + it('renders cleanly when optional fields are empty', () => { + const emptyTree = renderTree({ + showName: '', + episodeNumber: '', + tagline: '', + platform1: '', + platform2: '', + platform3: '', + }); + const t = texts(emptyTree); + expect(t).toContain('\u266A'); + expect(t).toContain('Building Developer Tools That Scale'); + expect(t).toHaveLength(2); // Only icon and title + }); +});