From 8e705f682486488e1265cdba77b7d50b4c75ad1a Mon Sep 17 00:00:00 2001 From: Ben Balter Date: Tue, 14 Jul 2026 13:38:45 -0400 Subject: [PATCH] Frame RSS/email feed items with a lead-in and book CTA Kit renders the raw post body straight into subscriber email, so it opened mid-thought with no indication it was a new post on ben.balter.com, and the on-site (Astro/Tailwind-only) never reached the feed. Wrap each feed item's content with a plain, inline-styled framing line up top and a relation-aware book callout at the bottom, mirroring BookCta's copy. Applies to RSS readers and email alike. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/pages/feed.xml.test.ts | 11 ++++- src/pages/feed.xml.ts | 63 ++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/__tests__/pages/feed.xml.test.ts b/src/__tests__/pages/feed.xml.test.ts index 559ece999..0276ff79d 100644 --- a/src/__tests__/pages/feed.xml.test.ts +++ b/src/__tests__/pages/feed.xml.test.ts @@ -127,7 +127,16 @@ describe('feed.xml', () => { const item = options.items[0]; expect(item.title).toBe('My Post'); expect(item.description).toBe('Post description'); - expect(item.content).toBe('

Rendered HTML

'); + // Content is framed: a "new post" lead-in, the rendered body, and a book CTA. + expect(item.content).toContain('

Rendered HTML

'); + expect(item.content).toContain('A new post from'); + expect(item.content).toContain(siteConfig.bookUrl); + expect(item.content.indexOf('A new post from')).toBeLessThan( + item.content.indexOf('

Rendered HTML

'), + ); + expect(item.content.indexOf('

Rendered HTML

')).toBeLessThan( + item.content.indexOf(siteConfig.bookUrl), + ); expect(item.link).toBe('https://ben.balter.com/2024/06/15/my-post/'); expect(item.author).toBe(siteConfig.email); expect(item.pubDate).toBeInstanceOf(Date); diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts index 11e8fcddc..492d1f112 100644 --- a/src/pages/feed.xml.ts +++ b/src/pages/feed.xml.ts @@ -18,6 +18,57 @@ import { sharedShikiConfig, } from '../lib/markdown-pipeline'; +// Escape a raw ampersand for use inside HTML (feed content is delivered as HTML, +// e.g. rendered by Kit into email). Only the book title needs it today. +const bookTitle = siteConfig.bookTitle.replace(/&/g, '&'); + +/** + * A short framing line prepended to each feed item so email subscribers (and + * RSS readers) immediately know this is a new post on ben.balter.com and can + * jump to the canonical web version. The raw post body starts mid-thought + * otherwise — fine in context, disorienting as the first line of an email. + */ +function leadInHtml(link: string): string { + return ( + `

` + + `A new post from ben.balter.com` + + ` · Read it on the web →` + + `

` + ); +} + +/** + * A plain, inline-styled book callout appended to each feed item. The site's + * relies on Tailwind, SVG, and scoped styles that don't survive email, + * so this is a self-contained equivalent. The headline mirrors BookCta's + * relation-aware copy (see src/components/BookCta.astro). + * + * LAUNCH (July 21, 2026): the "Coming …" label and "Get notified when it + * launches" CTA below are hardcoded and go stale at launch. When you flip + * to buy-now, update these two strings to match. + */ +function bookCtaHtml(relation?: 'adapted' | 'cut' | 'inspired'): string { + const headline = + relation === 'adapted' + ? `This post is adapted from my book, ${bookTitle}.` + : relation === 'cut' + ? `This one didn't make my book's final cut.` + : relation === 'inspired' + ? `This post inspired a chapter in my book, ${bookTitle}.` + : `Like this post? It's becoming a book.`; + + return ( + `
` + + `` + + `
` + + `

Coming ${siteConfig.bookLaunch}

` + + `

${headline}

` + + `

${siteConfig.bookDescription}.

` + + `Get notified when it launches →` + + `
` + ); +} + // Create a markdown processor once at module level to avoid recreating it on each request // This improves response times by reusing the processor configuration // Uses the same plugin configuration as astro.config.mjs via shared imports @@ -57,17 +108,21 @@ export async function GET(context: APIContext) { const pubDate = getDateFromSlug(post.id); const postUrl = getPostUrl(post.id); - // Render the post markdown to HTML + const link = `${baseUrl}${postUrl}`; + + // Render the post markdown to HTML, then frame it for email/RSS: a + // "new post" lead-in on top and a book CTA on the bottom (the on-site + // is Astro-only and never reaches the feed). const result = await processor.render(post.body, { frontmatter: post.data, }); - const content = result.code; - + const content = leadInHtml(link) + result.code + bookCtaHtml(post.data.bookRelation); + return { title: post.data.title, description: post.data.description, content, - link: `${baseUrl}${postUrl}`, + link, pubDate, author: siteConfig.email, };