Skip to content

Commit f935bfc

Browse files
committed
feat: provide both Atom and RSS feeds, renamed endpoints, WebSub hub links
Endpoints: - /atom.xml + /rss.xml — all diffs - /feed/{feedId}/atom.xml + /feed/{feedId}/rss.xml — per source - /article/{id}/atom.xml + /article/{id}/rss.xml — per article - Replaced atom-builder.ts with feed-builder.ts (builds both formats) - Shared feed-entries.ts for consistent entry construction - WebSub hub link included in Atom (<link rel="hub">) and RSS (<atom:link rel="hub">) - Autodiscovery <link> tags for both formats in layout head - Footer links to both Atom and RSS - RSS icon on homepage links to rss.xml
1 parent 185ac0d commit f935bfc

16 files changed

Lines changed: 384 additions & 226 deletions

File tree

src/lib/server/services/atom-builder.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { buildAtomFeed } from './atom-builder';
2+
import { buildAtomFeed, buildRssFeed } from './feed-builder';
33

44
describe('buildAtomFeed', () => {
55
it('generates valid Atom XML with entries', () => {
@@ -72,4 +72,56 @@ describe('buildAtomFeed', () => {
7272
expect(xml).toContain('rel="self"');
7373
expect(xml).toContain('type="application/atom+xml"');
7474
});
75+
76+
it('includes WebSub hub link when provided', () => {
77+
const xml = buildAtomFeed({
78+
id: 'https://example.com/atom.xml',
79+
title: 'Test',
80+
link: 'https://example.com',
81+
updated: '2026-03-29T10:00:00Z',
82+
entries: [],
83+
hubUrl: 'https://hub.example.com'
84+
});
85+
86+
expect(xml).toContain('rel="hub"');
87+
expect(xml).toContain('https://hub.example.com');
88+
});
89+
});
90+
91+
describe('buildRssFeed', () => {
92+
it('generates valid RSS 2.0 XML', () => {
93+
const xml = buildRssFeed({
94+
id: 'https://example.com/rss.xml',
95+
title: 'Test RSS',
96+
link: 'https://example.com',
97+
updated: '2026-03-29T10:00:00Z',
98+
entries: [{
99+
id: 'https://example.com/diff/1',
100+
title: 'Content changed: Article',
101+
link: 'https://example.com/diff/1',
102+
updated: '2026-03-29T10:00:00Z',
103+
summary: 'Test summary'
104+
}]
105+
});
106+
107+
expect(xml).toContain('<rss version="2.0"');
108+
expect(xml).toContain('<channel>');
109+
expect(xml).toContain('<item>');
110+
expect(xml).toContain('<pubDate>');
111+
expect(xml).toContain('Test RSS');
112+
});
113+
114+
it('includes WebSub hub in RSS', () => {
115+
const xml = buildRssFeed({
116+
id: 'https://example.com/rss.xml',
117+
title: 'Test',
118+
link: 'https://example.com',
119+
updated: '2026-03-29T10:00:00Z',
120+
entries: [],
121+
hubUrl: 'https://hub.example.com'
122+
});
123+
124+
expect(xml).toContain('rel="hub"');
125+
expect(xml).toContain('https://hub.example.com');
126+
});
75127
});

src/lib/server/services/atom-builder.ts

Lines changed: 0 additions & 40 deletions
This file was deleted.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
export interface FeedEntry {
2+
id: string;
3+
title: string;
4+
link: string;
5+
updated: string;
6+
summary: string;
7+
}
8+
9+
export interface FeedOptions {
10+
id: string;
11+
title: string;
12+
link: string;
13+
updated: string;
14+
entries: FeedEntry[];
15+
hubUrl?: string;
16+
}
17+
18+
const escape = (s: string) => s
19+
.replace(/&/g, '&amp;')
20+
.replace(/</g, '&lt;')
21+
.replace(/>/g, '&gt;')
22+
.replace(/"/g, '&quot;');
23+
24+
export function buildAtomFeed(opts: FeedOptions): string {
25+
const hub = opts.hubUrl
26+
? `\n <link href="${escape(opts.hubUrl)}" rel="hub" />`
27+
: '';
28+
29+
const entries = opts.entries.map(e => ` <entry>
30+
<id>${escape(e.id)}</id>
31+
<title>${escape(e.title)}</title>
32+
<link href="${escape(e.link)}" rel="alternate" />
33+
<updated>${e.updated}</updated>
34+
<summary type="html">${escape(e.summary)}</summary>
35+
</entry>`).join('\n');
36+
37+
return `<?xml version="1.0" encoding="UTF-8"?>
38+
<feed xmlns="http://www.w3.org/2005/Atom">
39+
<id>${escape(opts.id)}</id>
40+
<title>${escape(opts.title)}</title>
41+
<link href="${escape(opts.link)}" rel="alternate" />
42+
<link href="${escape(opts.id)}" rel="self" type="application/atom+xml" />${hub}
43+
<updated>${opts.updated}</updated>
44+
<generator>NewsDiff</generator>
45+
${entries}
46+
</feed>`;
47+
}
48+
49+
export function buildRssFeed(opts: FeedOptions): string {
50+
const hub = opts.hubUrl
51+
? `\n <atom:link href="${escape(opts.hubUrl)}" rel="hub" />`
52+
: '';
53+
54+
const selfLink = `\n <atom:link href="${escape(opts.id)}" rel="self" type="application/rss+xml" />`;
55+
56+
const items = opts.entries.map(e => ` <item>
57+
<guid isPermaLink="true">${escape(e.id)}</guid>
58+
<title>${escape(e.title)}</title>
59+
<link>${escape(e.link)}</link>
60+
<pubDate>${new Date(e.updated).toUTCString()}</pubDate>
61+
<description>${escape(e.summary)}</description>
62+
</item>`).join('\n');
63+
64+
return `<?xml version="1.0" encoding="UTF-8"?>
65+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
66+
<channel>
67+
<title>${escape(opts.title)}</title>
68+
<link>${escape(opts.link)}</link>
69+
<description>${escape(opts.title)}</description>
70+
<lastBuildDate>${opts.updated ? new Date(opts.updated).toUTCString() : new Date().toUTCString()}</lastBuildDate>
71+
<generator>NewsDiff</generator>${selfLink}${hub}
72+
${items}
73+
</channel>
74+
</rss>`;
75+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { FeedEntry } from './feed-builder';
2+
3+
/**
4+
* Convert a diff record (with relations) to a feed entry.
5+
*/
6+
export function diffToFeedEntry(diff: any, origin: string): FeedEntry {
7+
const title = diff.newVersion?.title || diff.oldVersion?.title || 'Untitled';
8+
const feedName = diff.article?.feed?.name || '';
9+
const changes = [
10+
diff.titleChanged ? 'headline' : '',
11+
diff.contentChanged ? 'content' : ''
12+
].filter(Boolean).join(' & ');
13+
const changeDesc = changes ? `${changes.charAt(0).toUpperCase() + changes.slice(1)} changed` : 'Updated';
14+
15+
return {
16+
id: `${origin}/diff/${diff.id}`,
17+
title: `${changeDesc}: ${title}`,
18+
link: `${origin}/diff/${diff.id}`,
19+
updated: new Date(diff.createdAt).toISOString(),
20+
summary: `${changeDesc} in "${title}" (${feedName}). +${diff.charsAdded} / -${diff.charsRemoved} chars.`
21+
};
22+
}

src/routes/+layout.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535

3636
<svelte:head>
3737
{#if botActorUrl}<link rel="me" href={botActorUrl} />{/if}
38-
<link rel="alternate" type="application/atom+xml" title="NewsDiff — All Diffs" href="/feed.xml" />
38+
<link rel="alternate" type="application/atom+xml" title="NewsDiff — All Diffs (Atom)" href="/atom.xml" />
39+
<link rel="alternate" type="application/rss+xml" title="NewsDiff — All Diffs (RSS)" href="/rss.xml" />
3940
</svelte:head>
4041

4142
<header>
@@ -76,7 +77,7 @@
7677
</a>
7778
{/if}
7879
</div>
79-
<p><a href="https://github.com/rmdes/newsdiff">Source code</a> · <a href="/feed.xml">Atom feed</a></p>
80+
<p><a href="https://github.com/rmdes/newsdiff">Source code</a> · <a href="/atom.xml">Atom</a> · <a href="/rss.xml">RSS</a></p>
8081
</footer>
8182

8283
<!-- Fediverse instance picker modal (shared across pages) -->

src/routes/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
style={data.feedFilter === String(feed.id) ? `background: ${feedColor(feed.name)};` : `border: 1px solid ${feedColor(feed.name)}; color: ${feedColor(feed.name)}; background: transparent;`}
4646
>{feed.name}</a>
4747
{/each}
48-
<a href={data.feedFilter ? `/feed/${data.feedFilter}.xml` : '/feed.xml'}
48+
<a href={data.feedFilter ? `/feed/${data.feedFilter}/rss.xml` : '/rss.xml'}
4949
class="rss-icon" title="Subscribe to this feed (Atom)">
5050
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256">
5151
<rect width="256" height="256" rx="55" ry="55" fill="#f26522"/>

src/routes/article/[id]/+page.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
<a href={article.url} target="_blank" rel="noopener">{article.url}</a>
1111
<span>{article.feed.name}</span>
1212
<span>{article.versions.length} version{article.versions.length !== 1 ? 's' : ''}</span>
13-
<a href="/article/{article.id}/feed.xml" class="feed-link">Atom feed</a>
13+
<a href="/article/{article.id}/atom.xml" class="feed-link">Atom</a>
14+
<a href="/article/{article.id}/rss.xml" class="feed-link">RSS</a>
1415
</div>
1516

1617
{#if article.diffs.length > 0}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { RequestHandler } from './$types';
2+
import { db } from '$lib/server/db';
3+
import { diffs, articles } from '$lib/server/db/schema';
4+
import { eq, desc } from 'drizzle-orm';
5+
import { error } from '@sveltejs/kit';
6+
import { buildAtomFeed } from '$lib/server/services/feed-builder';
7+
import { diffToFeedEntry } from '$lib/server/services/feed-entries';
8+
9+
export const GET: RequestHandler = async ({ params, url }) => {
10+
const articleId = Number(params.id);
11+
if (isNaN(articleId)) throw error(404, 'Not found');
12+
13+
const origin = process.env.ORIGIN || url.origin;
14+
const article = await db.query.articles.findFirst({ where: eq(articles.id, articleId), with: { feed: true } });
15+
if (!article) throw error(404, 'Article not found');
16+
17+
const articleDiffs = await db.query.diffs.findMany({
18+
where: eq(diffs.articleId, articleId),
19+
with: { article: { with: { feed: true } }, oldVersion: true, newVersion: true },
20+
orderBy: [desc(diffs.createdAt)]
21+
});
22+
23+
const latestTitle = articleDiffs[0]?.newVersion?.title || article.url;
24+
const entries = articleDiffs.map(d => diffToFeedEntry(d, origin));
25+
26+
const xml = buildAtomFeed({
27+
id: `${origin}/article/${articleId}/atom.xml`,
28+
title: `NewsDiff — ${latestTitle}`,
29+
link: `${origin}/article/${articleId}`,
30+
updated: entries[0]?.updated || new Date().toISOString(),
31+
entries
32+
});
33+
34+
return new Response(xml, {
35+
headers: { 'Content-Type': 'application/atom+xml; charset=utf-8', 'Cache-Control': 'public, max-age=300' }
36+
});
37+
};

src/routes/article/[id]/feed.xml/+server.ts

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { RequestHandler } from './$types';
2+
import { db } from '$lib/server/db';
3+
import { diffs, articles } from '$lib/server/db/schema';
4+
import { eq, desc } from 'drizzle-orm';
5+
import { error } from '@sveltejs/kit';
6+
import { buildRssFeed } from '$lib/server/services/feed-builder';
7+
import { diffToFeedEntry } from '$lib/server/services/feed-entries';
8+
9+
export const GET: RequestHandler = async ({ params, url }) => {
10+
const articleId = Number(params.id);
11+
if (isNaN(articleId)) throw error(404, 'Not found');
12+
13+
const origin = process.env.ORIGIN || url.origin;
14+
const article = await db.query.articles.findFirst({ where: eq(articles.id, articleId), with: { feed: true } });
15+
if (!article) throw error(404, 'Article not found');
16+
17+
const articleDiffs = await db.query.diffs.findMany({
18+
where: eq(diffs.articleId, articleId),
19+
with: { article: { with: { feed: true } }, oldVersion: true, newVersion: true },
20+
orderBy: [desc(diffs.createdAt)]
21+
});
22+
23+
const latestTitle = articleDiffs[0]?.newVersion?.title || article.url;
24+
const entries = articleDiffs.map(d => diffToFeedEntry(d, origin));
25+
26+
const xml = buildRssFeed({
27+
id: `${origin}/article/${articleId}/rss.xml`,
28+
title: `NewsDiff — ${latestTitle}`,
29+
link: `${origin}/article/${articleId}`,
30+
updated: entries[0]?.updated || new Date().toISOString(),
31+
entries
32+
});
33+
34+
return new Response(xml, {
35+
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=300' }
36+
});
37+
};

0 commit comments

Comments
 (0)