my pkgs monorepo
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, '&').replace(/</g, '<').replace(/>/g, '>');
41}
42
43export function escapeXmlAttribute(unsafe: string): string {
44 return unsafe
45 .replace(/&/g, '&')
46 .replace(/</g, '<')
47 .replace(/>/g, '>')
48 .replace(/"/g, '"');
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(/'/g, "'")
61 .replace(/"/g, '"')
62 .replace(/ /g, ' ')
63 .replace(/—/g, '--')
64 .replace(/–/g, '-')
65 .replace(/…/g, '...')
66 .replace(/’/g, "'")
67 .replace(/‘/g, "'")
68 .replace(/”/g, '"')
69 .replace(/“/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}