diff --git a/src/templates/quote/index.ts b/src/templates/quote/index.ts index c978c1c..3deb3e5 100644 --- a/src/templates/quote/index.ts +++ b/src/templates/quote/index.ts @@ -10,3 +10,4 @@ export { default as quoteStickyNote } from './sticky-note'; export { default as quoteCodeComment } from './code-comment'; export { default as quoteSubtitle } from './subtitle'; export { default as quoteArticleExcerpt } from './article-excerpt'; +export { default as quoteInterview } from './interview'; diff --git a/src/templates/quote/interview.ts b/src/templates/quote/interview.ts new file mode 100644 index 0000000..0747c3c --- /dev/null +++ b/src/templates/quote/interview.ts @@ -0,0 +1,145 @@ +import type { TemplateDefinition } from '../types'; +import { 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: 'quote-interview', + name: 'Interview Q&A', + category: 'quote', + description: 'Highlight a question and answer from an interview with the speaker and publication', + tags: ['quote', 'interview', 'question', 'answer', 'q&a'], + fields: [ + { key: 'question', label: 'Question', type: 'text', defaultValue: 'What would you tell yourself on day one?', placeholder: 'Ask a question...', group: 'Content' }, + { key: 'answer', label: 'Answer', type: 'textarea', defaultValue: 'Ship the small version first, then listen.', placeholder: 'Enter the answer...', required: true, group: 'Content' }, + { ...commonFields.author, defaultValue: 'Priya Raman', placeholder: 'Person name' }, + { key: 'role', label: 'Role', type: 'text', defaultValue: 'Staff Engineer at Acme', placeholder: 'Role or organization', group: 'Content' }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + question: 'What would you tell yourself on day one?', + answer: 'Ship the small version first, then listen.', + author: 'Priya Raman', + role: 'Staff Engineer at Acme', + siteName: 'THE BUILDER NOTES', + bgColor: '#0a0a0a', + accentColor: '#E07A5F', + textColor: '#FFFFFF', + }, + render: (params) => { + const rawQuestion = String(params.question ?? '').replace(/\s+/g, ' ').trim(); + const questionSize = autoFontSize(rawQuestion, [ + { maxLen: 38, size: 38 }, + { maxLen: 76, size: 32 }, + { maxLen: 120, size: 28 }, + { maxLen: 160, size: 24 }, + ]); + const question = truncate(rawQuestion, 160); + const rawAnswer = String(params.answer ?? 'Ship the small version first, then listen.') + .replace(/\s+/g, ' ').trim() || 'Ship the small version first, then listen.'; + const answerSize = autoFontSize(rawAnswer, [ + { maxLen: 60, size: 54 }, + { maxLen: 120, size: 46 }, + { maxLen: 200, size: 38 }, + { maxLen: 300, size: 32 }, + ]); + const answer = truncate(rawAnswer, 300); + const author = cleanText(params.author, 48); + const role = cleanText(params.role, 64); + const siteName = cleanText(params.siteName, 48); + const bgColor = String(params.bgColor || '#0a0a0a'); + const accentColor = String(params.accentColor || '#E07A5F'); + const textColor = String(params.textColor || '#FFFFFF'); + const lightBg = isLightColor(bgColor); + const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor; + const answerColor = lightBg && isLightColor(textColor) ? '#111111' : textColor; + const mutedColor = mixColors(bgColor, answerColor, 0.68); + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + width: '1200px', + height: '630px', + boxSizing: 'border-box', + padding: '66px 84px 54px', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + }, + children: [ + ...(question ? [{ + type: 'div', + props: { + style: { display: 'flex', alignItems: 'flex-start', gap: '20px', minHeight: '76px' }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, + width: '48px', height: '48px', borderRadius: '50%', + border: `2px solid ${accentInk}`, color: accentInk, + fontSize: '22px', fontWeight: 700, marginTop: '2px', + }, + children: 'Q', + }, + }, + { + type: 'span', + props: { + style: { display: 'flex', flex: 1, minWidth: 0, fontSize: `${questionSize}px`, lineHeight: 1.2, fontWeight: 600, color: accentInk }, + children: question, + }, + }, + ], + }, + }] : []), + { + type: 'div', + props: { + style: { + display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, + minHeight: 0, marginTop: question ? '20px' : 0, + borderTop: question ? `1px solid ${mutedColor}55` : 'none', + }, + children: [ + { type: 'span', props: { style: { display: 'flex', fontSize: '22px', lineHeight: 1, fontWeight: 700, color: accentInk, marginBottom: '18px' }, children: 'A' } }, + { type: 'span', props: { style: { display: 'flex', fontSize: `${answerSize}px`, lineHeight: 1.2, fontWeight: 600, color: answerColor }, children: answer } }, + ], + }, + }, + ...(author || role || siteName ? [{ + type: 'div', + props: { + style: { + display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: '24px', + minHeight: '56px', borderTop: `1px solid ${mutedColor}55`, paddingTop: '18px', + }, + children: [ + ...(author || role ? [{ + type: 'div', + props: { + style: { display: 'flex', flex: '1 1 auto', flexDirection: 'column', gap: '4px', minWidth: 0 }, + children: [ + ...(author ? [{ type: 'span', props: { style: { fontSize: '20px', fontWeight: 700, color: answerColor }, children: author } }] : []), + ...(role ? [{ type: 'span', props: { style: { fontSize: '16px', color: mutedColor }, children: role } }] : []), + ], + }, + }] : []), + ...(siteName ? [{ type: 'span', props: { style: { display: 'flex', flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '18px', fontWeight: 600, color: mutedColor, letterSpacing: '0.06em' }, children: siteName } }] : []), + ], + }, + }] : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 9474864..d377ecf 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -257,11 +257,12 @@ import quoteStickyNote from './quote/sticky-note'; import quoteCodeComment from './quote/code-comment'; import quoteSubtitle from './quote/subtitle'; import quoteArticleExcerpt from './quote/article-excerpt'; +import quoteInterview from './quote/interview'; [ quoteCenteredSerif, quoteGradientCard, quoteSpeechBubble, quoteTwitterStyle, quoteBookPage, quoteBoldHighlight, quoteMinimalDash, quotePhotoBackground, quoteStickyNote, - quoteCodeComment, quoteSubtitle, quoteArticleExcerpt, + quoteCodeComment, quoteSubtitle, quoteArticleExcerpt, quoteInterview, ].forEach(registerTemplate); // E-commerce diff --git a/tests/templates/quote-interview.test.ts b/tests/templates/quote-interview.test.ts new file mode 100644 index 0000000..005b65a --- /dev/null +++ b/tests/templates/quote-interview.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { quoteInterview } from '@/templates/quote'; +import interview from '@/templates/quote/interview'; +import { getTemplate, getTemplatesByCategory } from '@/templates/registry'; +import { mixColors } from '@/templates/utils'; +import type { SatoriElement } from '@/templates/types'; + +function elements(node: unknown): SatoriElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!node || typeof node !== 'object' || !('type' in node)) return []; + + const element = node as SatoriElement; + return [element, ...elements(element.props.children)]; +} + +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 = {}) => + interview.render({ ...interview.defaults, ...overrides }); + +describe('quote-interview', () => { + it('is re-exported and registered once in the Quote category', () => { + expect(quoteInterview).toBe(interview); + expect(getTemplate('quote-interview')).toBe(interview); + expect(getTemplatesByCategory('quote').filter(({ id }) => id === 'quote-interview')).toHaveLength(1); + }); + + it('renders the question, answer, person, role, and site name', () => { + expect(textContent(render())).toEqual([ + 'Q', + 'What would you tell yourself on day one?', + 'A', + 'Ship the small version first, then listen.', + 'Priya Raman', + 'Staff Engineer at Acme', + 'THE BUILDER NOTES', + ]); + }); + + it('hides whitespace-only question, author, role, and site name, including the Q mark', () => { + const tree = render({ question: ' ', author: ' ', role: '\t', siteName: ' ' }); + expect(textContent(tree)).toEqual(['A', 'Ship the small version first, then listen.']); + }); + + it('removes the answer divider when the question is blank', () => { + const answer = elements(render({ question: ' ' })).find((element) => + element.type === 'div' && element.props.style?.flex === 1 + ); + + expect(answer?.props.style.borderTop).toBe('none'); + }); + + it('keeps normal site names intact and lets the footer columns shrink when all are long', () => { + const longSiteName = 'THE WONDERFUL WEEKLY BUILDER NOTES MAGAZINE OK'; + const longFooter = render({ + author: 'A'.repeat(48), + role: 'R'.repeat(64), + siteName: longSiteName, + }); + const domain = 'newsletter.codercops.com'; + const regularSiteNames = [ + { value: 'THE BUILDER NOTES', tree: render() }, + { value: domain, tree: render({ siteName: domain }) }, + ]; + const siteName = elements(longFooter).find((element) => textContent(element)[0] === longSiteName); + const author = elements(longFooter).find((element) => + element.type === 'div' && element.props.style?.flexDirection === 'column' + && textContent(element)[0] === 'A'.repeat(48) && textContent(element).length === 2 + ); + + for (const { value, tree } of regularSiteNames) { + expect(textContent(tree)).toContain(value); + } + expect(author?.props.style).toMatchObject({ flex: '1 1 auto', minWidth: 0 }); + expect(siteName?.props.style).toMatchObject({ + flexShrink: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }); + expect(siteName?.props.style.flex).toBeUndefined(); + const footer = elements(longFooter).find((element) => + element.type === 'div' && element.props.style?.justifyContent === 'space-between' + ); + expect(footer).toBeDefined(); + }); + + it('shrinks long questions and answers before truncating them', () => { + const tree = render({ + question: 'What would you tell yourself on day one? '.repeat(5), + answer: 'Ship the small version first, then listen. '.repeat(10), + }); + const all = elements(tree); + const question = all.find((element) => element.type === 'span' && textContent(element)[0]?.startsWith('What would')); + const answer = all.find((element) => element.type === 'span' && textContent(element)[0]?.startsWith('Ship the small')); + + expect(textContent(question)[0].length).toBeLessThanOrEqual(160); + expect(textContent(question)[0]).toMatch(/…$/); + expect(question?.props.style.fontSize).toBe('24px'); + expect(textContent(answer)[0].length).toBeLessThanOrEqual(300); + expect(textContent(answer)[0]).toMatch(/…$/); + expect(answer?.props.style.fontSize).toBe('32px'); + }); + + it('darkens accent text on a light background and keeps default white text readable', () => { + const tree = render({ bgColor: '#ffffff', accentColor: '#fde047' }); + const accentInk = mixColors('#fde047', '#000000', 0.45); + const all = elements(tree); + const question = all.find((element) => + element.type === 'span' && textContent(element)[0]?.startsWith('What would') + ); + const questionMark = all.find((element) => element.type === 'div' && textContent(element).join('') === 'Q'); + const answer = all.find((element) => + element.type === 'span' && textContent(element)[0]?.startsWith('Ship the small') + ); + + expect(question?.props.style.color).toBe(accentInk); + expect(questionMark?.props.style.color).toBe(accentInk); + expect(answer?.props.style.color).toBe('#111111'); + }); + + it('uses a 1200 by 630 flex layout', () => { + const all = elements(render()); + expect(all[0].props.style.width).toBe('1200px'); + expect(all[0].props.style.height).toBe('630px'); + expect(all.every((element) => element.type !== 'div' || element.props.style?.display === 'flex')).toBe(true); + }); +});