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 4.2 kB View raw
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, '&amp;' ) 49 .replace( /</g, '&lt;' ) 50 .replace( />/g, '&gt;' ); 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, '&quot;' ).replace( /'/g, '&apos;' ); 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}