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
1 change: 1 addition & 0 deletions src/templates/quote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
145 changes: 145 additions & 0 deletions src/templates/quote/interview.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion src/templates/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions tests/templates/quote-interview.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) =>
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);
});
});
Loading