A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1/**
2 * Hand-rolled, dependency-free RSS 2.0 builder (Decision 0011).
3 *
4 * DEPENDENCY-FREE BY DESIGN, like `src/lib/blocks/render.ts`: the read path keeps the
5 * editor's weight (and any `@astrojs/rss` transitive deps) off the public feed route, and
6 * the XML escaping is test-locked so untrusted, PDS-sourced titles/HTML cannot break out of
7 * their elements. Full-content feeds carry the already-sanitised article HTML in
8 * `<content:encoded>` (the same HTML the reader injects — see AGENTS.md #6b).
9 */
10
11export interface FeedChannel {
12 /** Publication name. */
13 title: string;
14 /** The publication's public home URL. */
15 link: string;
16 /** Publication description (plain text; '' when absent). */
17 description: string;
18 /** The feed's own URL, emitted as a self-referencing `atom:link`. */
19 feedUrl?: string;
20 /** Newest item's date, for `lastBuildDate`. */
21 lastBuildDate?: Date;
22}
23
24export interface FeedItem {
25 title: string;
26 /** Canonical article URL — also the default `guid`. */
27 link: string;
28 pubDate: Date;
29 /** Full, already-sanitised article HTML for `<content:encoded>`. */
30 contentHtml: string;
31 /** Optional plain-text summary for `<description>`. */
32 description?: string;
33}
34
35/**
36 * Drop characters that are illegal in XML 1.0 even inside CDATA (the C0 controls except
37 * the three legal whitespace ones: tab `\x09`, newline `\x0A`, carriage return `\x0D`).
38 * Untrusted, PDS-sourced titles/HTML can carry a stray control byte, and a single one would
39 * make the whole feed unparseable for every subscriber — so strip them before anything else.
40 */
41function stripControlChars( value: string ): string {
42 return value.replace( /[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '' );
43}
44
45/** Escape element-content text (`& < >`) so it can never break out of its element. */
46function escapeXml( value: string ): string {
47 return stripControlChars( value )
48 .replace( /&/g, '&' )
49 .replace( /</g, '<' )
50 .replace( />/g, '>' );
51}
52
53/** Escape attribute-value text — element escaping plus quotes, which would close the attribute. */
54function escapeAttr( value: string ): string {
55 return escapeXml( value ).replace( /"/g, '"' ).replace( /'/g, ''' );
56}
57
58/**
59 * Wrap arbitrary HTML in a CDATA section. A literal `]]>` would close the section early
60 * (and is the one sequence CDATA cannot contain), so split it into two sections.
61 */
62function cdata( html: string ): string {
63 const safe = stripControlChars( html ).replace( /]]>/g, ']]]]><![CDATA[>' );
64 return `<![CDATA[${ safe }]]>`;
65}
66
67/** RFC-822 date (RSS pubDate format). `toUTCString()` yields a conformant `… GMT` string. */
68function rfc822( date: Date ): string {
69 return date.toUTCString();
70}
71
72function renderItem( entry: FeedItem ): string {
73 const parts = [
74 `<title>${ escapeXml( entry.title ) }</title>`,
75 `<link>${ escapeXml( entry.link ) }</link>`,
76 `<guid isPermaLink="true">${ escapeXml( entry.link ) }</guid>`,
77 `<pubDate>${ rfc822( entry.pubDate ) }</pubDate>`,
78 ];
79 if ( entry.description ) {
80 parts.push( `<description>${ escapeXml( entry.description ) }</description>` );
81 }
82 parts.push( `<content:encoded>${ cdata( entry.contentHtml ) }</content:encoded>` );
83 return `<item>${ parts.join( '' ) }</item>`;
84}
85
86/** Build a full-content RSS 2.0 feed document from channel metadata and items (in order). */
87export function buildRssFeed( channel: FeedChannel, items: FeedItem[] ): string {
88 const channelParts = [
89 `<title>${ escapeXml( channel.title ) }</title>`,
90 `<link>${ escapeXml( channel.link ) }</link>`,
91 `<description>${ escapeXml( channel.description ) }</description>`,
92 ];
93 if ( channel.feedUrl ) {
94 channelParts.push(
95 `<atom:link href="${ escapeAttr( channel.feedUrl ) }" rel="self" type="application/rss+xml"/>`
96 );
97 }
98 if ( channel.lastBuildDate ) {
99 channelParts.push( `<lastBuildDate>${ rfc822( channel.lastBuildDate ) }</lastBuildDate>` );
100 }
101 const body = channelParts.join( '' ) + items.map( renderItem ).join( '' );
102 return (
103 '<?xml version="1.0" encoding="UTF-8"?>' +
104 '<rss version="2.0" ' +
105 'xmlns:content="http://purl.org/rss/1.0/modules/content/" ' +
106 'xmlns:atom="http://www.w3.org/2005/Atom">' +
107 `<channel>${ body }</channel>` +
108 '</rss>'
109 );
110}