From 3f549d77f485ec7f7e770449d2610453bc7fa1be Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Wed, 28 Oct 2026 11:46:32 +0530 Subject: [PATCH] Added new template-member: Signed-off-by: Anurag Yadav --- src/templates/newsletter/index.ts | 1 + src/templates/newsletter/members-only.ts | 246 +++++++++++++++++++++++ src/templates/registry.ts | 3 +- tests/templates/members-only.test.ts | 148 ++++++++++++++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 src/templates/newsletter/members-only.ts create mode 100644 tests/templates/members-only.test.ts diff --git a/src/templates/newsletter/index.ts b/src/templates/newsletter/index.ts index cd4f45b..6dafabf 100644 --- a/src/templates/newsletter/index.ts +++ b/src/templates/newsletter/index.ts @@ -10,3 +10,4 @@ export { default as newsletterSubscribeCard } from './subscribe-card'; export { default as newsletterSponsor } from './sponsor'; export { default as newsletterPoll } from './poll'; export { default as newsletterWelcome } from './welcome'; +export { default as newsletterMembersOnly } from './members-only'; diff --git a/src/templates/newsletter/members-only.ts b/src/templates/newsletter/members-only.ts new file mode 100644 index 0000000..d72f088 --- /dev/null +++ b/src/templates/newsletter/members-only.ts @@ -0,0 +1,246 @@ +import type { TemplateDefinition } from '../types'; +import { truncate, autoFontSize, mixColors, isLightColor, contrastText, hexToRgb, commonFields } from '../utils'; + +const template: TemplateDefinition = { + id: 'newsletter-members-only', + name: 'Members Only', + category: 'newsletter', + description: 'A members-only newsletter preview with teaser text fading into the background and an upgrade CTA', + tags: ['newsletter', 'members', 'subscribers', 'paid', 'premium'], + fields: [ + commonFields.title, + commonFields.description, + { + key: 'label', + label: 'Label', + type: 'text', + defaultValue: 'For paid subscribers', + placeholder: 'For paid subscribers', + group: 'Content', + }, + { + key: 'cta', + label: 'Button Text', + type: 'text', + defaultValue: 'Upgrade to keep reading', + placeholder: 'Upgrade to keep reading', + group: 'Content', + }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: 'How we cut our cloud bill in half', + description: 'We spent three months moving off managed databases. Here is what worked, what broke, and the numbers before and after.', + label: 'For paid subscribers', + cta: 'Upgrade to keep reading', + siteName: 'The Weekly Byte', + bgColor: '#0a0a0a', + accentColor: '#E07A5F', + textColor: '#FFFFFF', + }, + render: (params) => { + const rawTitle = params.title !== undefined ? String(params.title).trim() : 'How we cut our cloud bill in half'; + const rawDescription = params.description !== undefined ? String(params.description).trim() : ''; + const rawLabel = params.label !== undefined ? String(params.label).trim() : 'For paid subscribers'; + const rawCta = params.cta !== undefined ? String(params.cta).trim() : 'Upgrade to keep reading'; + const rawSiteName = params.siteName !== undefined ? String(params.siteName).trim() : 'The Weekly Byte'; + + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 35, size: 58 }, + { maxLen: 55, size: 48 }, + { maxLen: 75, size: 40 }, + { maxLen: 100, size: 34 }, + ]); + const title = truncate(rawTitle || 'How we cut our cloud bill in half', 90); + + const teaserSize = autoFontSize(rawDescription, [ + { maxLen: 80, size: 24 }, + { maxLen: 140, size: 21 }, + { maxLen: 220, size: 19 }, + { maxLen: 320, size: 17 }, + ]); + const description = rawDescription ? truncate(rawDescription, 300) : ''; + + const label = rawLabel ? truncate(rawLabel, 40) : ''; + const cta = rawCta ? truncate(rawCta, 32) : ''; + const siteName = rawSiteName ? truncate(rawSiteName, 50) : ''; + + const bgColor = String(params.bgColor || '#0a0a0a'); + const accentColor = String(params.accentColor || '#E07A5F'); + const textColor = String(params.textColor || '#FFFFFF'); + + const lightBg = isLightColor(bgColor); + const accentInk = isLightColor(accentColor) === lightBg + ? mixColors(accentColor, lightBg ? '#000000' : '#FFFFFF', 0.55) + : accentColor; + const buttonText = contrastText(accentColor); + + const bgRgb = hexToRgb(bgColor); + const fadeStart = bgRgb ? `rgba(${bgRgb.r}, ${bgRgb.g}, ${bgRgb.b}, 0)` : 'transparent'; + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + width: '1200px', + height: '630px', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '60px 80px', + }, + children: [ + ...(label + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + marginBottom: '20px', + }, + children: { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 700, + color: accentInk, + letterSpacing: '0.08em', + textTransform: 'uppercase' as const, + }, + children: label, + }, + }, + }, + }, + ] + : []), + { + type: 'h1', + props: { + style: { + fontSize: `${titleSize}px`, + fontWeight: 700, + color: textColor, + lineHeight: 1.2, + margin: 0, + marginBottom: description ? '20px' : '32px', + }, + children: title, + }, + }, + ...(description + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + position: 'relative', + overflow: 'hidden', + maxHeight: '145px', + marginBottom: '32px', + }, + children: [ + { + type: 'p', + props: { + style: { + fontSize: `${teaserSize}px`, + fontWeight: 400, + color: `${textColor}99`, + lineHeight: 1.55, + margin: 0, + }, + children: description, + }, + }, + { + type: 'div', + props: { + style: { + display: 'flex', + position: 'absolute', + left: '0px', + width: '100%', + bottom: '0px', + height: '55px', + background: `linear-gradient(180deg, ${fadeStart} 0%, ${bgColor} 100%)`, + }, + children: [], + }, + }, + ], + }, + }, + ] + : []), + ...(cta + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'flex-start', + padding: '14px 28px', + backgroundColor: accentColor, + borderRadius: '999px', + }, + children: { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 700, + color: buttonText, + letterSpacing: '0.02em', + }, + children: cta, + }, + }, + }, + }, + ] + : []), + ...(siteName + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + marginTop: 'auto', + }, + children: { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 600, + color: `${textColor}80`, + letterSpacing: '0.04em', + }, + children: siteName, + }, + }, + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 094095b..7175f5d 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -240,12 +240,13 @@ import newsletterSubscribeCard from './newsletter/subscribe-card'; import newsletterSponsor from './newsletter/sponsor'; import newsletterPoll from './newsletter/poll'; import newsletterWelcome from './newsletter/welcome'; +import newsletterMembersOnly from './newsletter/members-only'; [ newsletterIssueCard, newsletterDigestBanner, newsletterPreviewText, newsletterEditorial, newsletterCuratedLinks, newsletterMinimalNumber, newsletterNewspaperFold, newsletterSubscriberCount, newsletterSubscribeCard, newsletterSponsor, newsletterPoll, - newsletterWelcome, + newsletterWelcome, newsletterMembersOnly, ].forEach(registerTemplate); // Quote diff --git a/tests/templates/members-only.test.ts b/tests/templates/members-only.test.ts new file mode 100644 index 0000000..89c0fed --- /dev/null +++ b/tests/templates/members-only.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import membersOnly from '@/templates/newsletter/members-only'; +import { getTemplate } from '@/templates/registry'; +import { contrastText, hexToRgb, isLightColor } from '@/templates/utils'; +import type { SatoriElement } from '@/templates/types'; + +/** Every element in the tree, in order. */ +function elements(node: unknown): SatoriElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (node && typeof node === 'object' && 'props' in node) { + const el = node as SatoriElement; + return [el, ...elements(el.props.children)]; + } + return []; +} + +/** 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 []; +} + +/** The style of the element that directly holds this text. */ +function styleOf(node: unknown, text: string): Record | undefined { + if (Array.isArray(node)) { + for (const child of node) { + const found = styleOf(child, text); + if (found) return found; + } + return undefined; + } + if (node && typeof node === 'object' && 'props' in node) { + const { style, children } = (node as SatoriElement).props; + if (children === text) return style; + return styleOf(children, text); + } + return undefined; +} + +const tree = (overrides: Record = {}) => + membersOnly.render({ ...membersOnly.defaults, ...overrides }); +const render = (overrides: Record = {}) => texts(tree(overrides)); + +describe('newsletter-members-only', () => { + it('is registered in the newsletter category', () => { + expect(getTemplate('newsletter-members-only')?.category).toBe('newsletter'); + }); + + it('renders a 1200x630 card', () => { + const { style } = tree().props; + expect(style.width).toBe('1200px'); + expect(style.height).toBe('630px'); + }); + + it('renders with its defaults', () => { + expect(render()).toEqual([ + 'For paid subscribers', + 'How we cut our cloud bill in half', + 'We spent three months moving off managed databases. Here is what worked, what broke, and the numbers before and after.', + 'Upgrade to keep reading', + 'The Weekly Byte', + ]); + }); + + it('hides teaser, label, cta, or site name when empty or whitespace-only', () => { + const emptyOverrides = { + label: ' ', + description: '', + cta: ' ', + siteName: '', + }; + expect(render(emptyOverrides)).toEqual(['How we cut our cloud bill in half']); + }); + + it('shrinks long title with autoFontSize and truncates', () => { + const normalTitle = 'How we cut our cloud bill in half'; + const longTitle = 'How we moved our entire multi-region cluster from managed cloud databases to bare metal servers in thirty days'; + + const normalStyle = styleOf(tree({ title: normalTitle }), normalTitle); + const longTree = tree({ title: longTitle }); + const renderedTexts = texts(longTree); + const truncatedTitle = renderedTexts.find((t) => t.startsWith('How we moved')); + expect(truncatedTitle).toBeDefined(); + expect(truncatedTitle?.length).toBeLessThanOrEqual(90); + + const longStyle = styleOf(longTree, truncatedTitle!); + const normalSize = parseInt(String(normalStyle?.fontSize), 10); + const longSize = parseInt(String(longStyle?.fontSize), 10); + expect(longSize).toBeLessThan(normalSize); + }); + + it('shrinks long teaser with autoFontSize and truncates', () => { + const normalTeaser = 'We spent three months moving off managed databases.'; + const longTeaser = 'We spent three months moving off managed databases. Here is what worked, what broke, and the numbers before and after. We documented everything across infrastructure, latency benchmarks, unexpected downtime, rollback plans, and team learnings.'; + + const normalStyle = styleOf(tree({ description: normalTeaser }), normalTeaser); + const longTree = tree({ description: longTeaser }); + const renderedTexts = texts(longTree); + const truncatedTeaser = renderedTexts.find((t) => t.startsWith('We spent three months')); + expect(truncatedTeaser).toBeDefined(); + expect(truncatedTeaser?.length).toBeLessThanOrEqual(300); + + const longStyle = styleOf(longTree, truncatedTeaser!); + const normalSize = parseInt(String(normalStyle?.fontSize), 10); + const longSize = parseInt(String(longStyle?.fontSize), 10); + expect(longSize).toBeLessThan(normalSize); + }); + + it('fades the teaser into the background color including on light backgrounds', () => { + // Dark background fade + const darkTree = tree({ bgColor: '#0a0a0a' }); + const darkFadeOverlay = elements(darkTree).find((el) => + String(el.props.style?.background).includes('linear-gradient'), + ); + expect(darkFadeOverlay).toBeDefined(); + expect(darkFadeOverlay?.props.style?.background).toContain('rgba(10, 10, 10, 0)'); + expect(darkFadeOverlay?.props.style?.background).toContain('#0a0a0a'); + + // Light background fade + const lightTree = tree({ bgColor: '#ffffff' }); + const lightFadeOverlay = elements(lightTree).find((el) => + String(el.props.style?.background).includes('linear-gradient'), + ); + expect(lightFadeOverlay).toBeDefined(); + expect(lightFadeOverlay?.props.style?.background).toContain('rgba(255, 255, 255, 0)'); + expect(lightFadeOverlay?.props.style?.background).toContain('#ffffff'); + }); + + it('keeps CTA pill readable with contrastText', () => { + for (const accentColor of ['#E07A5F', '#000000', '#FFFFFF', '#F5C518', '#3b82f6']) { + const ctaStyle = styleOf(tree({ accentColor }), 'Upgrade to keep reading'); + expect(ctaStyle?.color).toBe(contrastText(accentColor)); + } + }); + + it('keeps the label readable on a light background', () => { + const labelStyle = styleOf(tree({ bgColor: '#ffffff', accentColor: '#E07A5F' }), 'For paid subscribers'); + const labelColor = labelStyle?.color; + expect(labelColor).toBeDefined(); + // On a light background (#ffffff), a light accent (#E07A5F) should be darkened to contrast + expect(labelColor).not.toBe('#E07A5F'); + expect(isLightColor(labelColor!)).toBe(false); + }); +});