Skip to content

Commit b6cd349

Browse files
authored
feat(web): add an RSS feed for the blog (#3790)
1 parent 7df7122 commit b6cd349

8 files changed

Lines changed: 152 additions & 11 deletions

File tree

‎packages/web/build/features.smoke.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const expectedPages = [
7474
...enabledRoutes(features)
7575
.map(({ pattern }) => pattern)
7676
.filter((pattern) => !pattern.includes('[') || pattern.endsWith('[...day]'))
77+
.filter((pattern) => !pattern.endsWith('.xml'))
7778
.map(pageFile),
7879
].sort();
7980

@@ -127,4 +128,12 @@ describe(`a build with ${[...off].join(', ')} off`, () => {
127128

128129
expect(build.chunks.filter((chunk) => offChunks.includes(chunk))).toEqual([]);
129130
});
131+
132+
it('builds the blog feed only with the blog on', () => {
133+
if (off.has('blog')) {
134+
expect(() => build.read('blog/rss.xml')).toThrow();
135+
} else {
136+
expect(build.read('blog/rss.xml')).toContain('<rss');
137+
}
138+
});
130139
});

‎packages/web/build/routes.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const ROUTES: readonly Route[] = [
1919
{ pattern: '/', entrypoint: 'index.astro' },
2020
{ pattern: '/blog', entrypoint: 'blog/index.astro', feature: 'blog' },
2121
{ pattern: '/blog/[id]', entrypoint: 'blog/[id].astro', feature: 'blog', content: 'blog' },
22+
{ pattern: '/blog/rss.xml', entrypoint: 'blog/rss.xml.ts', feature: 'blog' },
2223
{
2324
pattern: '/schedule/my-schedule',
2425
entrypoint: 'schedule/my-schedule.astro',

‎packages/web/package-lock.json‎

Lines changed: 16 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/web/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
},
3939
"devDependencies": {
4040
"@astrojs/check": "^0.9.10",
41+
"@astrojs/rss": "^4.0.19",
4142
"@awesome.me/astro-lit": "^5.0.4",
4243
"@babel/core": "^8.0.6",
4344
"@babel/plugin-proposal-decorators": "^8.0.2",

‎packages/web/src/html-sinks.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const ALLOWED: Record<string, string> = {
2020
'./illustrations/illustration.ts': 'built-in SVG files',
2121
'./layouts/base.astro': 'build-time scripts, styles and JSON escaped by serializeContent()',
2222
'./routes/index.astro': 'JSON-LD escaped by serializeJsonLd()',
23+
'./routes/blog/rss.xml.ts': 'markdown sanitized by renderMarkdown(), in a build-time template',
2324
'./routes/design.astro': 'the development-only design gallery',
2425
'./design/Icon.astro': 'built-in icon paths, in the development-only design gallery',
2526
};

‎packages/web/src/layouts/base.astro‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { AppHeader, demoBanner } from '../components/shell/app-header';
99
import { AppOverlays } from '../components/shell/app-overlays';
1010
import { loadContent } from '../data/content';
1111
import '../data/markdown';
12+
import { pageMetadata } from '../data/metadata';
1213
import { seedPage } from '../data/page';
1314
import { openGraphLocale } from '../data/structured-data';
1415
import { type Content, PAGE_CONTENT_ID, serializeContent } from '../store/content';
@@ -103,6 +104,14 @@ await demoBanner;
103104
{theme.colorScheme === 'system' && <script is:inline set:html={colorSchemeScript} />}
104105
{__HB_FEATURES__.demo && <script is:inline set:html={demoScript} />}
105106
<link href="manifest.json" rel="manifest" />
107+
{__HB_FEATURES__.blog && (
108+
<link
109+
href="blog/rss.xml"
110+
rel="alternate"
111+
title={pageMetadata('blog').title}
112+
type="application/rss+xml"
113+
/>
114+
)}
106115

107116
<meta content="yes" name="mobile-web-app-capable" />
108117
<meta content={resources.title} name="application-name" />
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { JSDOM } from 'jsdom';
2+
import { site } from 'virtual:hoverboard/site';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import type { Post } from '../../models/post';
5+
import { GET } from './rss.xml';
6+
7+
const posts = [
8+
{
9+
id: 'speakers & more',
10+
title: 'Speakers <announced>',
11+
brief: 'Meet them',
12+
content: 'Meet **them**.<script>alert(1)</script>',
13+
published: '2026-10-08',
14+
},
15+
{
16+
id: 'launch',
17+
title: 'Launch',
18+
brief: 'It is on',
19+
content: 'Only the start',
20+
source: '/data/posts/launch.md',
21+
published: '2026-09-01',
22+
},
23+
] as Post[];
24+
25+
vi.mock('../../data/content', () => ({ loadContent: async () => ({ blog: posts }) }));
26+
vi.mock('virtual:hoverboard/markdown', () => ({
27+
posts: { 'launch.md': 'See [the schedule](/schedule) and ![the hall](images/hall.jpg).' },
28+
}));
29+
30+
const feed = async () => (await GET({} as never)).text();
31+
32+
const contents = async () => {
33+
const { document } = new JSDOM(await feed(), { contentType: 'text/xml' }).window;
34+
return [...document.getElementsByTagName('content:encoded')].map((item) => item.textContent);
35+
};
36+
37+
describe('blog RSS feed', () => {
38+
it('lists every post with an absolute link and its date', async () => {
39+
const xml = await feed();
40+
41+
expect(xml).toContain(`<link>${new URL('blog', site.url).href}</link>`);
42+
expect(xml).toContain(`<link>${new URL('blog/launch', site.url).href}</link>`);
43+
expect(xml).toContain('<pubDate>Tue, 01 Sep 2026 00:00:00 GMT</pubDate>');
44+
expect(xml).toContain('<description>It is on</description>');
45+
expect(xml.match(/<item>/g)).toHaveLength(2);
46+
});
47+
48+
it('escapes post titles and links', async () => {
49+
const xml = await feed();
50+
51+
expect(xml).toContain('<title>Speakers &lt;announced&gt;</title>');
52+
expect(xml).toContain(`${new URL('blog/speakers%20', site.url).href}&amp;%20more</link>`);
53+
});
54+
55+
it('sets the language to the site locale', async () => {
56+
expect(await feed()).toContain(`<language>${site.locales.source}</language>`);
57+
});
58+
59+
it('has the whole post as sanitized HTML', async () => {
60+
expect(await feed()).toContain('xmlns:content="http://purl.org/rss/1.0/modules/content/"');
61+
expect((await contents())[0]).toBe('<p>Meet <strong>them</strong>.</p>\n');
62+
});
63+
64+
it('reads the post from its source file, with absolute links and images', async () => {
65+
const content = (await contents())[1];
66+
67+
expect(content).toContain(`<a href="${new URL('schedule', site.url).href}"`);
68+
expect(content).toContain(`<img src="${new URL('images/hall.jpg', site.url).href}"`);
69+
expect(content).not.toContain('Only the start');
70+
});
71+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import rss from '@astrojs/rss';
2+
import type { APIRoute } from 'astro';
3+
import { JSDOM } from 'jsdom';
4+
import { posts } from 'virtual:hoverboard/markdown';
5+
import { site } from 'virtual:hoverboard/site';
6+
import { loadContent } from '../../data/content';
7+
import '../../data/markdown';
8+
import { pageMetadata } from '../../data/metadata';
9+
import { withPostSource } from '../../data/posts';
10+
import { renderMarkdown } from '../../utils/markdown';
11+
12+
// Feed readers show posts away from the site, so its relative links and images need its URL.
13+
const withAbsoluteUrls = (html: string, document: Document): string => {
14+
const template = document.createElement('template');
15+
template.innerHTML = html;
16+
for (const element of template.content.querySelectorAll('[href], [src]')) {
17+
for (const name of ['href', 'src']) {
18+
const value = element.getAttribute(name);
19+
const url = value === null ? null : URL.parse(value, site.url);
20+
if (url) element.setAttribute(name, url.href);
21+
}
22+
}
23+
return template.innerHTML;
24+
};
25+
26+
export const GET: APIRoute = async () => {
27+
const { blog = [] } = await loadContent();
28+
const { title, description } = pageMetadata('blog');
29+
const { document } = new JSDOM('').window;
30+
return rss({
31+
title,
32+
description,
33+
site: new URL('blog', site.url).href,
34+
trailingSlash: false,
35+
customData: `<language>${site.locales.source}</language>`,
36+
items: blog.map((post) => ({
37+
title: post.title,
38+
link: new URL(`blog/${post.id}`, site.url).href,
39+
pubDate: new Date(post.published),
40+
description: post.brief,
41+
content: withAbsoluteUrls(renderMarkdown(withPostSource(post, posts).content), document),
42+
})),
43+
});
44+
};

0 commit comments

Comments
 (0)