A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import { describe, expect, it } from 'vitest';
2import { buildRssFeed, type FeedChannel, type FeedItem } from './rss';
3
4const channel: FeedChannel = {
5 title: 'My Publication',
6 link: 'https://skypress.blog/@alice.test/my-pub',
7 description: 'Notes from the quiet hours',
8 feedUrl: 'https://skypress.blog/@alice.test/my-pub/rss.xml',
9};
10
11function item( overrides: Partial< FeedItem > = {} ): FeedItem {
12 return {
13 title: 'Hello world',
14 link: 'https://skypress.blog/@alice.test/my-pub/abc123',
15 pubDate: new Date( '2026-06-09T12:00:00Z' ),
16 contentHtml: '<p>Hello world</p>',
17 ...overrides,
18 };
19}
20
21describe( 'buildRssFeed', () => {
22 it( 'emits a well-formed RSS 2.0 document with the content namespace', () => {
23 const xml = buildRssFeed( channel, [ item() ] );
24 expect( xml.startsWith( '<?xml version="1.0" encoding="UTF-8"?>' ) ).toBe( true );
25 expect( xml ).toContain( '<rss version="2.0"' );
26 expect( xml ).toContain(
27 'xmlns:content="http://purl.org/rss/1.0/modules/content/"'
28 );
29 expect( xml ).toContain( '<channel>' );
30 expect( xml ).toContain( '</channel>' );
31 expect( xml.trimEnd().endsWith( '</rss>' ) ).toBe( true );
32 } );
33
34 it( 'renders channel metadata, including a self-referencing atom:link', () => {
35 const xml = buildRssFeed( channel, [] );
36 expect( xml ).toContain( '<title>My Publication</title>' );
37 expect( xml ).toContain(
38 '<link>https://skypress.blog/@alice.test/my-pub</link>'
39 );
40 expect( xml ).toContain(
41 '<description>Notes from the quiet hours</description>'
42 );
43 expect( xml ).toContain(
44 '<atom:link href="https://skypress.blog/@alice.test/my-pub/rss.xml" rel="self" type="application/rss+xml"/>'
45 );
46 expect( xml ).toContain(
47 'xmlns:atom="http://www.w3.org/2005/Atom"'
48 );
49 } );
50
51 it( 'carries the full HTML inside a content:encoded CDATA section', () => {
52 const xml = buildRssFeed( channel, [
53 item( { contentHtml: '<p>Body with <a href="https://x.test">a link</a>.</p>' } ),
54 ] );
55 expect( xml ).toContain(
56 '<content:encoded><![CDATA[<p>Body with <a href="https://x.test">a link</a>.</p>]]></content:encoded>'
57 );
58 } );
59
60 it( 'XML-escapes text fields so markup in a title cannot break the feed', () => {
61 const xml = buildRssFeed( channel, [
62 item( { title: 'Tom & Jerry <3 "quotes"' } ),
63 ] );
64 expect( xml ).toContain(
65 '<title>Tom & Jerry <3 "quotes"</title>'
66 );
67 expect( xml ).not.toContain( '<3' );
68 } );
69
70 it( 'escapes quotes in attribute values so a value cannot break out of the attribute', () => {
71 const xml = buildRssFeed(
72 { ...channel, feedUrl: 'https://x.test/a"b/rss.xml' },
73 []
74 );
75 expect( xml ).toContain( 'href="https://x.test/a"b/rss.xml"' );
76 // The raw quote must not survive inside the attribute.
77 expect( xml ).not.toContain( 'a"b/rss.xml"' );
78 } );
79
80 it( 'splits a CDATA terminator in the body so it cannot close the section early', () => {
81 const xml = buildRssFeed( channel, [
82 item( { contentHtml: '<p>if (a[b]]> c) {}</p>' } ),
83 ] );
84 // The raw "]]>" must not survive verbatim inside the CDATA section.
85 expect( xml ).toContain( ']]]]><![CDATA[>' );
86 expect( xml ).not.toContain( 'a[b]]> c' );
87 } );
88
89 it( 'strips XML-1.0-illegal control characters from text fields and body', () => {
90 // Untrusted PDS content could carry control bytes that are illegal in XML 1.0
91 // even inside CDATA; a single one would make the whole feed unparseable.
92 const xml = buildRssFeed( channel, [
93 item( {
94 title: 'Be\x00fo\x01re\x08af\x1Fter',
95 description: 'Sum\x0Cmary',
96 contentHtml: '<p>Bo\x00dy</p>',
97 } ),
98 ] );
99 expect( xml ).toContain( '<title>Beforeafter</title>' );
100 expect( xml ).toContain( '<description>Summary</description>' );
101 expect( xml ).toContain( '<![CDATA[<p>Body</p>]]>' );
102 // No raw control byte survives anywhere in the document.
103 expect( /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test( xml ) ).toBe( false );
104 } );
105
106 it( 'preserves legal whitespace (tab, newline, carriage return) in content', () => {
107 const xml = buildRssFeed( channel, [
108 item( { contentHtml: '<pre>a\tb\nc\rd</pre>' } ),
109 ] );
110 expect( xml ).toContain( '<![CDATA[<pre>a\tb\nc\rd</pre>]]>' );
111 } );
112
113 it( 'formats pubDate as an RFC-822 date', () => {
114 const xml = buildRssFeed( channel, [ item() ] );
115 expect( xml ).toContain( '<pubDate>Tue, 09 Jun 2026 12:00:00 GMT</pubDate>' );
116 } );
117
118 it( 'uses the item link as the guid by default and marks it as a permalink', () => {
119 const xml = buildRssFeed( channel, [ item() ] );
120 expect( xml ).toContain(
121 '<guid isPermaLink="true">https://skypress.blog/@alice.test/my-pub/abc123</guid>'
122 );
123 } );
124
125 it( 'preserves item order and emits one <item> per entry', () => {
126 const xml = buildRssFeed( channel, [
127 item( { title: 'First', link: 'https://skypress.blog/@alice.test/my-pub/1' } ),
128 item( { title: 'Second', link: 'https://skypress.blog/@alice.test/my-pub/2' } ),
129 ] );
130 expect( ( xml.match( /<item>/g ) ?? [] ).length ).toBe( 2 );
131 expect( xml.indexOf( 'First' ) ).toBeLessThan( xml.indexOf( 'Second' ) );
132 } );
133
134 it( 'emits a valid, item-less channel when there are no articles', () => {
135 const xml = buildRssFeed( channel, [] );
136 expect( xml ).toContain( '<channel>' );
137 expect( xml ).not.toContain( '<item>' );
138 } );
139
140 it( 'includes an optional plain-text description per item when provided', () => {
141 const xml = buildRssFeed( channel, [
142 item( { description: 'A short summary & teaser' } ),
143 ] );
144 expect( xml ).toContain(
145 '<description>A short summary & teaser</description>'
146 );
147 } );
148} );