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 ` - <![CDATA[${p.frontmatter.title}]]> - ${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('![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'))).toBe('/blog/diagrams/flow.svg'); + expect(postImage(post('
\n \n
\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('https://threadplane.ai/blog'); + expect(xml).toContain(''); + expect(xml).toContain('<![CDATA[Threadplane 0.3.0]]>'); + 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('![Flow](/blog/diagrams/flow.svg)')], 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 ` + <![CDATA[${p.frontmatter.title}]]> + ${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 {