A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 5.5 kB View raw
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 &amp; Jerry &lt;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&quot;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 &amp; teaser</description>' 146 ); 147 } ); 148} );