diff --git a/apps/website/src/app/blog/rss.xml/route.ts b/apps/website/src/app/blog/rss.xml/route.ts
index a6ad5b3f8..607c58b02 100644
--- a/apps/website/src/app/blog/rss.xml/route.ts
+++ b/apps/website/src/app/blog/rss.xml/route.ts
@@ -1,47 +1,10 @@
import { NextResponse } from 'next/server';
import { getAllPosts } from '../../../lib/blog';
+import { buildBlogRss } from '../../../lib/blog-rss';
import { SITE_ORIGIN } from '../../../lib/site-metadata';
-function escapeXml(s: string): string {
- return s
- .replace(/&/g, '&')
- .replace(//g, '>');
-}
-
export async function GET() {
- const posts = getAllPosts();
- const items = posts
- .map((p) => {
- const url = `${SITE_ORIGIN}/blog/${p.slug}`;
- const pubDate = (() => {
- const d = new Date(p.frontmatter.date);
- return Number.isNaN(d.getTime()) ? '' : d.toUTCString();
- })();
- return ` -
-
- ${url}
- ${url}
- ${pubDate}
-
-
`;
- })
- .join('\n');
-
- const xml = `
-
-
- ${escapeXml('Threadplane Blog')}
- ${SITE_ORIGIN}/blog
-
- ${escapeXml('Writing on agent UI for Angular.')}
- en
-${items}
-
-
-`;
-
- return new NextResponse(xml, {
+ return new NextResponse(buildBlogRss(getAllPosts(), SITE_ORIGIN), {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=300, s-maxage=300',
diff --git a/apps/website/src/lib/blog-rss.spec.ts b/apps/website/src/lib/blog-rss.spec.ts
new file mode 100644
index 000000000..63b50b24b
--- /dev/null
+++ b/apps/website/src/lib/blog-rss.spec.ts
@@ -0,0 +1,45 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+import type { Post } from './blog';
+import { buildBlogRss, postImage } from './blog-rss';
+
+const post = (content: string, extra: Partial = {}): Post => ({
+ slug: 'threadplane-0-3-0',
+ date: '2026-10-07',
+ filename: '2026-10-07-threadplane-0-3-0.mdx',
+ frontmatter: { title: 'Threadplane 0.3.0', description: 'AG-UI 1.0.', date: '2026-10-07', author: 'brian', ...extra },
+ content,
+});
+
+describe('postImage', () => {
+ it('prefers frontmatter image, then the first Markdown image or
, whichever comes first', () => {
+ expect(postImage(post('', { image: '/blog/x/cover.png' }))).toBe('/blog/x/cover.png');
+ expect(postImage(post('Intro.\n\n\n\n
'))).toBe('/blog/diagrams/flow.svg');
+ expect(postImage(post('\n
\n\n\n'))).toBe('/blog/x/hero.png');
+ expect(postImage(post('No pictures here.'))).toBeUndefined();
+ });
+});
+
+describe('buildBlogRss', () => {
+ const origin = 'https://threadplane.ai';
+
+ it('keeps the channel and item fields', () => {
+ const xml = buildBlogRss([post('Body.')], origin);
+ expect(xml).toContain('https://threadplane.ai/blog');
+ expect(xml).toContain('');
+ expect(xml).toContain('');
+ expect(xml).toContain('https://threadplane.ai/blog/threadplane-0-3-0');
+ expect(xml).toContain('Wed, 07 Oct 2026 00:00:00 GMT');
+ expect(xml).toContain('');
+ });
+
+ it("lists the post's picture as media:content with an absolute URL", () => {
+ const xml = buildBlogRss([post('')], origin);
+ expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"');
+ expect(xml).toContain('');
+ });
+
+ it('lists nothing for a post without a picture', () => {
+ expect(buildBlogRss([post('Body.')], origin)).not.toContain(']+)>?(?:\s+"[^"]*")?\s*\)/;
+const IMG_TAG_SRC = /
]*\ssrc=["']([^"']+)["']/i;
+
+const IMAGE_TYPES: Record = {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ webp: 'image/webp',
+ gif: 'image/gif',
+ svg: 'image/svg+xml',
+ avif: 'image/avif',
+};
+
+function escapeXml(s: string): string {
+ return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
+
+/** The post's picture: frontmatter `image`, else its first Markdown image or `
`, whichever comes first. */
+export function postImage(post: Post): string | undefined {
+ if (post.frontmatter.image) return post.frontmatter.image;
+ const md = MARKDOWN_IMAGE.exec(post.content);
+ const tag = IMG_TAG_SRC.exec(post.content);
+ if (md && (!tag || md.index < tag.index)) return md[1];
+ return tag?.[1];
+}
+
+/** The picture as Media RSS, so readers and syncs don't fall back to the title-card og:image. */
+function mediaContent(post: Post, origin: string): string {
+ const image = postImage(post);
+ if (!image) return '';
+ const url = new URL(image, origin);
+ const type = IMAGE_TYPES[url.pathname.split('.').pop()?.toLowerCase() ?? ''];
+ return `
+ `;
+}
+
+export function buildBlogRss(posts: Post[], origin: string): string {
+ const items = posts
+ .map((p) => {
+ const url = `${origin}/blog/${p.slug}`;
+ const d = new Date(p.frontmatter.date);
+ const pubDate = Number.isNaN(d.getTime()) ? '' : d.toUTCString();
+ return ` -
+
+ ${url}
+ ${url}
+ ${pubDate}
+ ${mediaContent(p, origin)}
+
`;
+ })
+ .join('\n');
+
+ return `
+
+
+ ${escapeXml('Threadplane Blog')}
+ ${origin}/blog
+
+ ${escapeXml('Writing on agent UI for Angular.')}
+ en
+${items}
+
+
+`;
+}
diff --git a/apps/website/src/lib/blog.ts b/apps/website/src/lib/blog.ts
index a948a9097..73edec706 100644
--- a/apps/website/src/lib/blog.ts
+++ b/apps/website/src/lib/blog.ts
@@ -11,6 +11,8 @@ export interface PostFrontmatter {
author: string;
featured?: boolean;
draft?: boolean;
+ /** A picture of the work for feeds; defaults to the post's first image. */
+ image?: string;
}
export interface Post {