my pkgs monorepo
at main 166 lines 5.6 kB view raw
1/** 2 * RSS Feed Generation Utilities 3 */ 4 5export interface RSSChannelConfig { 6 title: string; 7 link: string; 8 description: string; 9 language?: string; 10 selfLink?: string; 11 copyright?: string; 12 managingEditor?: string; 13 webMaster?: string; 14 generator?: string; 15 ttl?: number; 16} 17 18export interface RSSItem { 19 title: string; 20 link: string; 21 guid?: string; 22 pubDate: Date | string; 23 description?: string; 24 content?: string; 25 author?: string; 26 categories?: string[]; 27 enclosure?: { 28 url: string; 29 length?: number; 30 type?: string; 31 }; 32 comments?: string; 33 source?: { 34 url: string; 35 title: string; 36 }; 37} 38 39export function escapeXml(unsafe: string): string { 40 return unsafe.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 41} 42 43export function escapeXmlAttribute(unsafe: string): string { 44 return unsafe 45 .replace(/&/g, '&amp;') 46 .replace(/</g, '&lt;') 47 .replace(/>/g, '&gt;') 48 .replace(/"/g, '&quot;'); 49} 50 51export function normalizeCharacters(text: string): string { 52 return text 53 .replace(/\u2018|\u2019|\u201A|\u201B/g, "'") 54 .replace(/\u201C|\u201D|\u201E|\u201F/g, '"') 55 .replace(/\u2013/g, '-') 56 .replace(/\u2014/g, '--') 57 .replace(/\u00A0/g, ' ') 58 .replace(/\u2026/g, '...') 59 .replace(/\u2022/g, '*') 60 .replace(/&apos;/g, "'") 61 .replace(/&quot;/g, '"') 62 .replace(/&nbsp;/g, ' ') 63 .replace(/&mdash;/g, '--') 64 .replace(/&ndash;/g, '-') 65 .replace(/&hellip;/g, '...') 66 .replace(/&rsquo;/g, "'") 67 .replace(/&lsquo;/g, "'") 68 .replace(/&rdquo;/g, '"') 69 .replace(/&ldquo;/g, '"'); 70} 71 72export function formatRSSDate(date: Date | string): string { 73 const d = typeof date === 'string' ? new Date(date) : date; 74 return d.toUTCString(); 75} 76 77export function generateRSSItem(item: RSSItem): string { 78 const guid = item.guid || item.link; 79 const pubDate = formatRSSDate(item.pubDate); 80 const title = escapeXml(normalizeCharacters(item.title)); 81 const description = item.description ? escapeXml(normalizeCharacters(item.description)) : ''; 82 const content = item.content ? normalizeCharacters(item.content) : ''; 83 const author = item.author ? escapeXml(normalizeCharacters(item.author)) : ''; 84 const categories = 85 item.categories 86 ?.map((cat) => ` <category>${escapeXml(normalizeCharacters(cat))}</category>`) 87 .join('\n') || ''; 88 89 let enclosure = ''; 90 if (item.enclosure) { 91 const length = item.enclosure.length ? ` length="${item.enclosure.length}"` : ''; 92 const type = item.enclosure.type ? ` type="${escapeXmlAttribute(item.enclosure.type)}"` : ''; 93 enclosure = ` <enclosure url="${escapeXmlAttribute(item.enclosure.url)}"${length}${type} />`; 94 } 95 96 let source = ''; 97 if (item.source) { 98 source = ` <source url="${escapeXmlAttribute(item.source.url)}">${escapeXml(normalizeCharacters(item.source.title))}</source>`; 99 } 100 101 return ` <item> 102 <title>${title}</title> 103 <link>${escapeXmlAttribute(item.link)}</link> 104 <guid isPermaLink="true">${escapeXmlAttribute(guid)}</guid> 105 <pubDate>${pubDate}</pubDate>${description ? `\n <description>${description}</description>` : ''}${content ? `\n <content:encoded><![CDATA[${content}]]></content:encoded>` : ''}${author ? `\n <author>${author}</author>` : ''}${item.comments ? `\n <comments>${escapeXmlAttribute(item.comments)}</comments>` : ''}${categories ? `\n${categories}` : ''}${enclosure ? `\n${enclosure}` : ''}${source ? `\n${source}` : ''} 106 </item>`; 107} 108 109export function generateRSSFeed(config: RSSChannelConfig, items: RSSItem[]): string { 110 const language = config.language || 'en'; 111 const generator = config.generator || 'SvelteKit with AT Protocol'; 112 const lastBuildDate = formatRSSDate(new Date()); 113 const title = escapeXml(normalizeCharacters(config.title)); 114 const link = escapeXmlAttribute(config.link); 115 const description = escapeXml(normalizeCharacters(config.description)); 116 const generatorText = escapeXml(normalizeCharacters(generator)); 117 118 const atomLink = config.selfLink 119 ? ` <atom:link href="${escapeXmlAttribute(config.selfLink)}" rel="self" type="application/rss+xml" />` 120 : ''; 121 122 const optionalFields = []; 123 if (config.copyright) 124 optionalFields.push( 125 ` <copyright>${escapeXml(normalizeCharacters(config.copyright))}</copyright>` 126 ); 127 if (config.managingEditor) 128 optionalFields.push( 129 ` <managingEditor>${escapeXml(normalizeCharacters(config.managingEditor))}</managingEditor>` 130 ); 131 if (config.webMaster) 132 optionalFields.push( 133 ` <webMaster>${escapeXml(normalizeCharacters(config.webMaster))}</webMaster>` 134 ); 135 if (config.ttl) optionalFields.push(` <ttl>${config.ttl}</ttl>`); 136 137 const itemsXml = items.map((item) => generateRSSItem(item)).join('\n'); 138 139 return `<?xml version="1.0" encoding="UTF-8"?> 140<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"> 141 <channel> 142 <title>${title}</title> 143 <link>${link}</link> 144 <description>${description}</description> 145 <language>${language}</language>${atomLink ? `\n${atomLink}` : ''} 146 <lastBuildDate>${lastBuildDate}</lastBuildDate> 147 <generator>${generatorText}</generator>${optionalFields.length > 0 ? `\n${optionalFields.join('\n')}` : ''} 148${itemsXml} 149 </channel> 150</rss>`; 151} 152 153export function createRSSResponse( 154 feed: string, 155 options?: { cacheMaxAge?: number; status?: number } 156): Response { 157 const cacheMaxAge = options?.cacheMaxAge ?? 3600; 158 const status = options?.status ?? 200; 159 return new Response(feed, { 160 status, 161 headers: { 162 'Content-Type': 'application/rss+xml; charset=utf-8', 163 'Cache-Control': `public, max-age=${cacheMaxAge}` 164 } 165 }); 166}