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
11 changes: 10 additions & 1 deletion src/__tests__/pages/feed.xml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<p>Rendered HTML</p>');
// Content is framed: a "new post" lead-in, the rendered body, and a book CTA.
expect(item.content).toContain('<p>Rendered HTML</p>');
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('<p>Rendered HTML</p>'),
);
expect(item.content.indexOf('<p>Rendered HTML</p>')).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);
Expand Down
63 changes: 59 additions & 4 deletions src/pages/feed.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '&amp;');

/**
* 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 (
`<p style="margin:0 0 1.75em;font-size:15px;color:#57606a;">` +
`A new post from <a href="${siteConfig.url}" style="color:#0969da;text-decoration:none;">ben.balter.com</a>` +
` &middot; <a href="${link}" style="color:#0969da;text-decoration:none;">Read it on the web &rarr;</a>` +
`</p>`
);
}

/**
* A plain, inline-styled book callout appended to each feed item. The site's
* <BookCta> 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
* <BookCta> 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 (
`<hr style="margin:2.5em 0 1.5em;border:none;border-top:1px solid #d0d7de;" />` +
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 1em;border-collapse:collapse;">` +
`<tr><td style="border:1px solid #d0d7de;border-radius:8px;padding:16px 20px;background:#f6f8fa;">` +
`<p style="margin:0 0 6px;font-size:11px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:#57606a;">Coming ${siteConfig.bookLaunch}</p>` +
`<p style="margin:0 0 6px;font-size:16px;font-weight:600;color:#1f2328;">${headline}</p>` +
`<p style="margin:0 0 12px;font-size:14px;color:#424a53;">${siteConfig.bookDescription}.</p>` +
`<a href="${siteConfig.bookUrl}" style="font-size:14px;font-weight:600;color:#0969da;text-decoration:none;">Get notified when it launches &rarr;</a>` +
`</td></tr></table>`
);
}

// 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
Expand Down Expand Up @@ -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
// <BookCta> 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,
};
Expand Down
Loading