/**
* RSS Feed Generation Utilities
*/
export interface RSSChannelConfig {
title: string;
link: string;
description: string;
language?: string;
selfLink?: string;
copyright?: string;
managingEditor?: string;
webMaster?: string;
generator?: string;
ttl?: number;
}
export interface RSSItem {
title: string;
link: string;
guid?: string;
pubDate: Date | string;
description?: string;
content?: string;
author?: string;
categories?: string[];
enclosure?: {
url: string;
length?: number;
type?: string;
};
comments?: string;
source?: {
url: string;
title: string;
};
}
export function escapeXml(unsafe: string): string {
return unsafe.replace(/&/g, '&').replace(//g, '>');
}
export function escapeXmlAttribute(unsafe: string): string {
return unsafe
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
export function normalizeCharacters(text: string): string {
return text
.replace(/\u2018|\u2019|\u201A|\u201B/g, "'")
.replace(/\u201C|\u201D|\u201E|\u201F/g, '"')
.replace(/\u2013/g, '-')
.replace(/\u2014/g, '--')
.replace(/\u00A0/g, ' ')
.replace(/\u2026/g, '...')
.replace(/\u2022/g, '*')
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/ /g, ' ')
.replace(/—/g, '--')
.replace(/–/g, '-')
.replace(/…/g, '...')
.replace(/’/g, "'")
.replace(/‘/g, "'")
.replace(/”/g, '"')
.replace(/“/g, '"');
}
export function formatRSSDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toUTCString();
}
export function generateRSSItem(item: RSSItem): string {
const guid = item.guid || item.link;
const pubDate = formatRSSDate(item.pubDate);
const title = escapeXml(normalizeCharacters(item.title));
const description = item.description ? escapeXml(normalizeCharacters(item.description)) : '';
const content = item.content ? normalizeCharacters(item.content) : '';
const author = item.author ? escapeXml(normalizeCharacters(item.author)) : '';
const categories =
item.categories
?.map((cat) => ` ${escapeXml(normalizeCharacters(cat))}`)
.join('\n') || '';
let enclosure = '';
if (item.enclosure) {
const length = item.enclosure.length ? ` length="${item.enclosure.length}"` : '';
const type = item.enclosure.type ? ` type="${escapeXmlAttribute(item.enclosure.type)}"` : '';
enclosure = ` `;
}
let source = '';
if (item.source) {
source = ` ${escapeXml(normalizeCharacters(item.source.title))}`;
}
return ` -
${title}
${escapeXmlAttribute(item.link)}
${escapeXmlAttribute(guid)}
${pubDate}${description ? `\n ${description}` : ''}${content ? `\n ` : ''}${author ? `\n ${author}` : ''}${item.comments ? `\n ${escapeXmlAttribute(item.comments)}` : ''}${categories ? `\n${categories}` : ''}${enclosure ? `\n${enclosure}` : ''}${source ? `\n${source}` : ''}
`;
}
export function generateRSSFeed(config: RSSChannelConfig, items: RSSItem[]): string {
const language = config.language || 'en';
const generator = config.generator || 'SvelteKit with AT Protocol';
const lastBuildDate = formatRSSDate(new Date());
const title = escapeXml(normalizeCharacters(config.title));
const link = escapeXmlAttribute(config.link);
const description = escapeXml(normalizeCharacters(config.description));
const generatorText = escapeXml(normalizeCharacters(generator));
const atomLink = config.selfLink
? ` `
: '';
const optionalFields = [];
if (config.copyright)
optionalFields.push(
` ${escapeXml(normalizeCharacters(config.copyright))}`
);
if (config.managingEditor)
optionalFields.push(
` ${escapeXml(normalizeCharacters(config.managingEditor))}`
);
if (config.webMaster)
optionalFields.push(
` ${escapeXml(normalizeCharacters(config.webMaster))}`
);
if (config.ttl) optionalFields.push(` ${config.ttl}`);
const itemsXml = items.map((item) => generateRSSItem(item)).join('\n');
return `
${title}
${link}
${description}
${language}${atomLink ? `\n${atomLink}` : ''}
${lastBuildDate}
${generatorText}${optionalFields.length > 0 ? `\n${optionalFields.join('\n')}` : ''}
${itemsXml}
`;
}
export function createRSSResponse(
feed: string,
options?: { cacheMaxAge?: number; status?: number }
): Response {
const cacheMaxAge = options?.cacheMaxAge ?? 3600;
const status = options?.status ?? 200;
return new Response(feed, {
status,
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': `public, max-age=${cacheMaxAge}`
}
});
}