diff --git a/src/templates/podcast/index.ts b/src/templates/podcast/index.ts index 47ff0bb..9bf52fd 100644 --- a/src/templates/podcast/index.ts +++ b/src/templates/podcast/index.ts @@ -9,3 +9,4 @@ export { default as podcastAudiogram } from './audiogram'; export { default as podcastShowNotes } from './show-notes'; export { default as podcastListenOn } from './listen-on'; export { default as podcastTopEpisodes } from './top-episodes'; +export { default as podcastLiveRecording } from './live-recording'; diff --git a/src/templates/podcast/live-recording.ts b/src/templates/podcast/live-recording.ts new file mode 100644 index 0000000..cb582fb --- /dev/null +++ b/src/templates/podcast/live-recording.ts @@ -0,0 +1,311 @@ +import type { TemplateDefinition } from '../types'; +import { + autoFontSize, + commonFields, + contrastText, + isLightColor, + mixColors, + truncate, +} from '../utils'; + +const template: TemplateDefinition = { + id: 'podcast-live-recording', + name: 'Live Recording', + category: 'podcast', + description: 'Podcast episode announcement for a live recording with an audience', + tags: ['podcast', 'live', 'recording', 'episode', 'audience'], + 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: 'when', + label: 'When', + type: 'text' as const, + defaultValue: 'Thursday, 7:00 PM IST', + placeholder: 'Thursday, 7:00 PM IST', + group: 'Content' as const, + }, + { + key: 'where', + label: 'Where', + type: 'text' as const, + defaultValue: 'Live on our channel', + placeholder: 'Live on our channel', + group: 'Content' as const, + }, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: 'Shipping on Fridays: yes or no?', + showName: 'The Dev Show', + when: 'Thursday, 7:00 PM IST', + where: 'Live on our channel', + bgColor: '#F7F7F2', + accentColor: '#E07A5F', + textColor: '#18212B', + }, + render: (params) => { + const rawTitle = String(params.title || 'Episode Topic'); + + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 30, size: 58 }, + { maxLen: 50, size: 48 }, + { maxLen: 75, size: 40 }, + { maxLen: 110, size: 32 }, + { maxLen: 160, size: 26 }, + ]); + + const title = truncate(rawTitle, 160); + const showName = String(params.showName || '').trim(); + const when = String(params.when || '').trim(); + const where = String(params.where || '').trim(); + const bgColor = String(params.bgColor || '#F7F7F2'); + const accentColor = String(params.accentColor || '#E07A5F'); + const textColor = String(params.textColor || '#18212B'); + + // Resolve readable text color for the accent-colored live badge. + // Keep this aligned with the contrast helper used elsewhere in the project. + const badgeTextColor = contrastText(accentColor); + const accentLabelColor = isLightColor(bgColor) + ? mixColors(accentColor, '#000000', 0.45) + : accentColor; + const metaItems = [ + ...(when + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + gap: '10px', + flex: 1, + minWidth: 0, + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '15px', + fontWeight: 800, + color: accentLabelColor, + letterSpacing: '0.12em', + textTransform: 'uppercase', + }, + children: 'When', + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '21px', + fontWeight: 600, + color: textColor, + lineHeight: 1.4, + }, + children: when, + }, + }, + ], + }, + }, + ] + : []), + ...(where + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + gap: '10px', + flex: 1, + minWidth: 0, + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '15px', + fontWeight: 800, + color: accentLabelColor, + letterSpacing: '0.12em', + textTransform: 'uppercase', + }, + children: 'Where', + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '21px', + fontWeight: 600, + color: textColor, + lineHeight: 1.4, + }, + children: where, + }, + }, + ], + }, + }, + ] + : []), + ]; + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + width: '1200px', + height: '630px', + boxSizing: 'border-box', + overflow: 'hidden', + background: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '64px', + }, + children: [ + // Live status and show name. + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: '18px', + marginBottom: '52px', + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '11px 17px', + borderRadius: '100px', + background: accentColor, + color: badgeTextColor, + }, + children: [ + { + type: 'div', + props: { + style: { + width: '10px', + height: '10px', + minWidth: '10px', + borderRadius: '50%', + background: badgeTextColor, + }, + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '16px', + fontWeight: 800, + color: badgeTextColor, + }, + children: 'Recording live', + }, + }, + ], + }, + }, + ...(showName + ? [ + { + type: 'span', + props: { + style: { + fontSize: '22px', + fontWeight: 700, + color: textColor, + }, + children: showName, + }, + }, + ] + : []), + ], + }, + }, + + // Episode topic. + { + type: 'div', + props: { + style: { + display: 'flex', + flex: 1, + minHeight: 0, + alignItems: 'center', + }, + children: { + type: 'h1', + props: { + style: { + fontSize: `${titleSize}px`, + fontWeight: 800, + color: textColor, + lineHeight: 1.15, + margin: 0, + maxWidth: '1040px', + overflow: 'hidden', + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 3, + overflowWrap: 'break-word', + }, + children: title, + }, + }, + }, + }, + + // Event details. Hide the entire section when both values are empty. + ...(metaItems.length + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + gap: '64px', + paddingTop: '28px', + marginTop: '30px', + borderTop: `1px solid ${textColor}22`, + }, + children: metaItems, + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index e0dd5cb..19ef24f 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -195,11 +195,12 @@ import podcastAudiogram from './podcast/audiogram'; import podcastShowNotes from './podcast/show-notes'; import podcastListenOn from './podcast/listen-on'; import podcastTopEpisodes from './podcast/top-episodes'; +import podcastLiveRecording from './podcast/live-recording'; [ podcastEpisodeCard, podcastWaveform, podcastGuestSpotlight, podcastConversation, podcastSeriesBanner, podcastMinimalPlayer, podcastVideoThumbnail, podcastAudiogram, podcastShowNotes, podcastTopEpisodes, - podcastListenOn, + podcastListenOn, podcastLiveRecording, ].forEach(registerTemplate); // Developer diff --git a/tests/templates/live-recording.test.ts b/tests/templates/live-recording.test.ts new file mode 100644 index 0000000..e423143 --- /dev/null +++ b/tests/templates/live-recording.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import podcastLiveRecording from '@/templates/podcast/live-recording'; +import { podcastLiveRecording as podcastLiveRecordingExport } from '@/templates/podcast'; +import { getTemplate, getTemplatesByCategory } from '@/templates/registry'; +import { mixColors } from '@/templates/utils'; + + +function walk(node: any, visit: (node: any) => void): void { + if (!node || typeof node !== 'object') return; + + visit(node); + + const children = node.props?.children; + + if (Array.isArray(children)) { + children.forEach((child) => walk(child, visit)); + } else if (children && typeof children === 'object') { + walk(children, visit); + } +} + + +function textContent(element: any): string[] { + const values: string[] = []; + walk(element, (node) => { + if (typeof node.props?.children === 'string') { + values.push(node.props.children); + } + }); + return values; +} + +describe('podcast-live-recording', () => { + it('renders the live badge, show name, and episode topic', () => { + const rendered = podcastLiveRecording.render(podcastLiveRecording.defaults); + const text = textContent(rendered); + + expect(text).toContain('Recording live'); + expect(text).toContain('The Dev Show'); + expect(text).toContain('Shipping on Fridays: yes or no?'); + expect(rendered.props.style).toMatchObject({ + width: '1200px', + height: '630px', + }); + }); + + it('hides blank optional show name and event details', () => { + const rendered = podcastLiveRecording.render({ + ...podcastLiveRecording.defaults, + showName: ' ', + when: ' ', + where: '', + }); + const text = textContent(rendered); + + expect(text).toContain('Recording live'); + expect(text).not.toContain('The Dev Show'); + expect(text).not.toContain('When'); + expect(text).not.toContain('Where'); + }); + + it('truncates long episode topics and reduces their font size', () => { + const longTitle = 'A remarkably long episode topic '.repeat(8); + const rendered = podcastLiveRecording.render({ + ...podcastLiveRecording.defaults, + title: longTitle, + }); + const text = textContent(rendered); + + expect(text).toContain(`${longTitle.slice(0, 159).trimEnd()}…`); + + let heading: any; + walk(rendered, (node) => { + if (node.type === 'h1') heading = node; + }); + + expect(heading).toBeDefined(); + expect(heading.props.style.fontSize).toBe('26px'); + }); + + it('renders the live indicator as a plain circular dot', () => { + const rendered = podcastLiveRecording.render( + podcastLiveRecording.defaults, + ); + + let dot: any; + walk(rendered, (node) => { + if ( + node.props?.style?.width === '10px' && + node.props?.style?.height === '10px' && + node.props?.style?.borderRadius === '50%' + ) { + dot = node; + } + }); + + expect(dot).toBeDefined(); + expect(dot.props.children).toBeUndefined(); + }); + + it('darkens accent labels on a light background for readability', () => { + const rendered = podcastLiveRecording.render( + podcastLiveRecording.defaults, + ); + + const expectedColor = mixColors( + podcastLiveRecording.defaults.accentColor as string, + '#000000', + 0.45, + ); + + const labelColors: string[] = []; + walk(rendered, (node) => { + if ( + node.type === 'span' && + ['When', 'Where'].includes(node.props?.children) + ) { + labelColors.push(node.props.style.color); + } + }); + + expect(labelColors).toEqual([expectedColor, expectedColor]); + }); + + it('exposes required fields and is exported and registered once', () => { + for (const key of [ + 'title', + 'showName', + 'when', + 'where', + 'bgColor', + 'accentColor', + 'textColor', + ]) { + expect(podcastLiveRecording.fields.some((field) => field.key === key)).toBe(true); + } + + expect(podcastLiveRecordingExport).toBe(podcastLiveRecording); + expect(getTemplate('podcast-live-recording')).toBe(podcastLiveRecording); + expect( + getTemplatesByCategory('podcast').filter( + (template) => template.id === 'podcast-live-recording', + ), + ).toHaveLength(1); + }); +}); \ No newline at end of file