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
41 changes: 2 additions & 39 deletions apps/website/src/app/blog/rss.xml/route.ts
Original file line number Diff line number Diff line change
@@ -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, '&lt;')
.replace(/>/g, '&gt;');
}

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 ` <item>
<title><![CDATA[${p.frontmatter.title}]]></title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${p.frontmatter.description}]]></description>
</item>`;
})
.join('\n');

const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml('Threadplane Blog')}</title>
<link>${SITE_ORIGIN}/blog</link>
<atom:link href="${SITE_ORIGIN}/blog/rss.xml" rel="self" type="application/rss+xml" />
<description>${escapeXml('Writing on agent UI for Angular.')}</description>
<language>en</language>
${items}
</channel>
</rss>
`;

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',
Expand Down
45 changes: 45 additions & 0 deletions apps/website/src/lib/blog-rss.spec.ts
Original file line number Diff line number Diff line change
@@ -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['frontmatter']> = {}): 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 <img>, whichever comes first', () => {
expect(postImage(post('![a](/blog/diagrams/a.svg)', { image: '/blog/x/cover.png' }))).toBe('/blog/x/cover.png');
expect(postImage(post('Intro.\n\n![Flow](/blog/diagrams/flow.svg)\n\n<img src="/blog/x/1.png" />'))).toBe('/blog/diagrams/flow.svg');
expect(postImage(post('<figure>\n <img\n src="/blog/x/hero.png"\n alt="Hero"\n />\n</figure>\n\n![b](/b.svg)'))).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('<link>https://threadplane.ai/blog</link>');
expect(xml).toContain('<atom:link href="https://threadplane.ai/blog/rss.xml" rel="self" type="application/rss+xml" />');
expect(xml).toContain('<title><![CDATA[Threadplane 0.3.0]]></title>');
expect(xml).toContain('<guid isPermaLink="true">https://threadplane.ai/blog/threadplane-0-3-0</guid>');
expect(xml).toContain('<pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate>');
expect(xml).toContain('<description><![CDATA[AG-UI 1.0.]]></description>');
});

it("lists the post's picture as media:content with an absolute URL", () => {
const xml = buildBlogRss([post('![Flow](/blog/diagrams/flow.svg)')], origin);
expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"');
expect(xml).toContain('<media:content url="https://threadplane.ai/blog/diagrams/flow.svg" medium="image" type="image/svg+xml" />');
});

it('lists nothing for a post without a picture', () => {
expect(buildBlogRss([post('Body.')], origin)).not.toContain('<media:content');
});
});
67 changes: 67 additions & 0 deletions apps/website/src/lib/blog-rss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Post } from './blog';

const MARKDOWN_IMAGE = /!\[[^\]]*\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/;
const IMG_TAG_SRC = /<img\b[^>]*\ssrc=["']([^"']+)["']/i;

const IMAGE_TYPES: Record<string, string> = {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/** The post's picture: frontmatter `image`, else its first Markdown image or `<img>`, 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 `
<media:content url="${escapeXml(url.href)}" medium="image"${type ? ` type="${type}"` : ''} />`;
}

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 ` <item>
<title><![CDATA[${p.frontmatter.title}]]></title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${p.frontmatter.description}]]></description>${mediaContent(p, origin)}
</item>`;
})
.join('\n');

return `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>${escapeXml('Threadplane Blog')}</title>
<link>${origin}/blog</link>
<atom:link href="${origin}/blog/rss.xml" rel="self" type="application/rss+xml" />
<description>${escapeXml('Writing on agent UI for Angular.')}</description>
<language>en</language>
${items}
</channel>
</rss>
`;
}
2 changes: 2 additions & 0 deletions apps/website/src/lib/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading