Files
smartfeed/ts/classes.feed.ts

334 lines
9.9 KiB
TypeScript

import * as plugins from './plugins.js';
import * as validation from './validation.js';
/**
* Configuration options for creating a feed
*/
export interface IFeedOptions {
/** The domain of the feed (e.g., 'example.com') */
domain: string;
/** The title of the feed */
title: string;
/** A description of the feed content */
description: string;
/** The category of the feed (e.g., 'Technology', 'News') */
category: string;
/** The company or organization name */
company: string;
/** Contact email for the feed */
companyEmail: string;
/** The company website URL (must be absolute) */
companyDomain: string;
/** Optional: Custom URL for the feed's atom:link rel="self" (defaults to https://${domain}/feed.xml) */
feedUrl?: string;
}
/**
* Represents a single item/entry in the feed
*/
export interface IFeedItem {
/** The title of the feed item */
title: string;
/** Unix timestamp in milliseconds when the item was published */
timestamp: number;
/** Absolute URL to the full item/article */
url: string;
/** Name of the item author */
authorName: string;
/** Absolute URL to the item's featured image */
imageUrl: string;
/** The content/body of the item (will be sanitized) */
content: string;
/** Optional unique identifier for this item. If not provided, url will be used */
id?: string;
}
/**
* Represents a feed that can generate RSS, Atom, and JSON Feed formats
* @example
* ```typescript
* const feed = new Feed({
* domain: 'example.com',
* title: 'My Blog',
* description: 'A blog about technology',
* category: 'Technology',
* company: 'Example Inc',
* companyEmail: 'hello@example.com',
* companyDomain: 'https://example.com'
* });
* ```
*/
export class Feed {
options: IFeedOptions;
items: IFeedItem[] = [];
protected itemIds: Set<string> = new Set();
/**
* Creates a new Feed instance
* @param optionsArg - Feed configuration options
* @throws Error if validation fails
*/
constructor(optionsArg: IFeedOptions) {
// Validate required fields
validation.validateRequiredFields(
optionsArg,
['domain', 'title', 'description', 'category', 'company', 'companyEmail', 'companyDomain'],
'Feed options'
);
// Validate domain
validation.validateDomain(optionsArg.domain);
// Validate company email
validation.validateEmail(optionsArg.companyEmail);
// Validate company domain URL
validation.validateUrl(optionsArg.companyDomain, true);
this.options = optionsArg;
}
/**
* Adds an item to the feed
* @param itemArg - The feed item to add
* @throws Error if validation fails or ID is duplicate
* @example
* ```typescript
* feed.addItem({
* title: 'Hello World',
* timestamp: Date.now(),
* url: 'https://example.com/hello',
* authorName: 'John Doe',
* imageUrl: 'https://example.com/image.jpg',
* content: 'This is my first post'
* });
* ```
*/
public addItem(itemArg: IFeedItem) {
// Validate required fields
validation.validateRequiredFields(
itemArg,
['title', 'timestamp', 'url', 'authorName', 'imageUrl', 'content'],
'Feed item'
);
// Validate URLs
validation.validateUrl(itemArg.url, true);
validation.validateUrl(itemArg.imageUrl, true);
// Validate timestamp
validation.validateTimestamp(itemArg.timestamp);
// Validate ID uniqueness (use URL as ID if not provided)
const itemId = itemArg.id || itemArg.url;
if (this.itemIds.has(itemId)) {
throw new Error(
`Duplicate item ID: ${itemId}. Each item must have a unique ID or URL. ` +
`IDs should never change once published.`
);
}
this.itemIds.add(itemId);
this.items.push(itemArg);
}
/**
* Escapes special XML characters
* @protected
* @param str - String to escape
* @returns Escaped string
*/
protected escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Formats a Date object to RFC 822 format for RSS 2.0
* @private
* @param date - Date to format
* @returns RFC 822 formatted date string
*/
private formatRfc822Date(date: Date): string {
return date.toUTCString();
}
/**
* Formats a Date object to ISO 8601 format for Atom/JSON
* @private
* @param date - Date to format
* @returns ISO 8601 formatted date string
*/
private formatIso8601Date(date: Date): string {
return date.toISOString();
}
/**
* Generates RSS 2.0 feed
* @private
* @returns RSS 2.0 XML string
*/
private generateRss2(): string {
let rss = '<?xml version="1.0" encoding="utf-8"?>\n';
rss += '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n';
rss += '<channel>\n';
// Channel metadata
rss += `<title>${this.escapeXml(this.options.title)}</title>\n`;
rss += `<link>https://${this.options.domain}</link>\n`;
rss += `<description>${this.escapeXml(this.options.description)}</description>\n`;
rss += `<language>en</language>\n`;
rss += `<copyright>All rights reserved, ${this.escapeXml(this.options.company)}</copyright>\n`;
rss += `<generator>@push.rocks/smartfeed</generator>\n`;
rss += `<lastBuildDate>${this.formatRfc822Date(new Date())}</lastBuildDate>\n`;
rss += `<category>${this.escapeXml(this.options.category)}</category>\n`;
// Atom self link
const selfUrl = this.options.feedUrl || `https://${this.options.domain}/feed.xml`;
rss += `<atom:link href="${selfUrl}" rel="self" type="application/rss+xml" />\n`;
// Items
for (const item of this.items) {
rss += '<item>\n';
rss += `<title>${this.escapeXml(item.title)}</title>\n`;
rss += `<link>${item.url}</link>\n`;
rss += `<guid isPermaLink="true">${item.id || item.url}</guid>\n`;
rss += `<pubDate>${this.formatRfc822Date(new Date(item.timestamp))}</pubDate>\n`;
rss += `<description>${this.escapeXml(item.content)}</description>\n`;
rss += `<author>${this.options.companyEmail} (${this.escapeXml(item.authorName)})</author>\n`;
rss += `<enclosure url="${item.imageUrl}" type="image/jpeg" length="0" />\n`;
rss += '</item>\n';
}
rss += '</channel>\n';
rss += '</rss>';
return rss;
}
/**
* Generates Atom 1.0 feed
* @private
* @returns Atom 1.0 XML string
*/
private generateAtom1(): string {
let atom = '<?xml version="1.0" encoding="utf-8"?>\n';
atom += '<feed xmlns="http://www.w3.org/2005/Atom">\n';
// Feed metadata
atom += `<id>https://${this.options.domain}</id>\n`;
atom += `<title>${this.escapeXml(this.options.title)}</title>\n`;
atom += `<subtitle>${this.escapeXml(this.options.description)}</subtitle>\n`;
atom += `<link href="https://${this.options.domain}" />\n`;
const selfUrl = this.options.feedUrl || `https://${this.options.domain}/feed.xml`;
atom += `<link href="${selfUrl}" rel="self" />\n`;
atom += `<updated>${this.formatIso8601Date(new Date())}</updated>\n`;
atom += `<generator>@push.rocks/smartfeed</generator>\n`;
atom += '<author>\n';
atom += `<name>${this.escapeXml(this.options.company)}</name>\n`;
atom += `<email>${this.options.companyEmail}</email>\n`;
atom += `<uri>${this.options.companyDomain}</uri>\n`;
atom += '</author>\n';
atom += '<category>\n';
atom += `<term>${this.escapeXml(this.options.category)}</term>\n`;
atom += '</category>\n';
// Entries
for (const item of this.items) {
atom += '<entry>\n';
atom += `<id>${item.id || item.url}</id>\n`;
atom += `<title>${this.escapeXml(item.title)}</title>\n`;
atom += `<link href="${item.url}" />\n`;
atom += `<updated>${this.formatIso8601Date(new Date(item.timestamp))}</updated>\n`;
atom += '<author>\n';
atom += `<name>${this.escapeXml(item.authorName)}</name>\n`;
atom += '</author>\n';
atom += '<content type="html">\n';
atom += this.escapeXml(item.content);
atom += '\n</content>\n';
atom += `<link rel="enclosure" href="${item.imageUrl}" type="image/jpeg" />\n`;
atom += '</entry>\n';
}
atom += '</feed>';
return atom;
}
/**
* Generates JSON Feed 1.0
* @private
* @returns JSON Feed 1.0 string
*/
private generateJsonFeed(): string {
const jsonFeed = {
version: 'https://jsonfeed.org/version/1',
title: this.options.title,
home_page_url: `https://${this.options.domain}`,
feed_url: this.options.feedUrl || `https://${this.options.domain}/feed.json`,
description: this.options.description,
icon: '',
favicon: '',
author: {
name: this.options.company,
url: this.options.companyDomain,
},
items: this.items.map(item => ({
id: item.id || item.url,
url: item.url,
title: item.title,
content_html: item.content,
image: item.imageUrl,
date_published: this.formatIso8601Date(new Date(item.timestamp)),
author: {
name: item.authorName,
},
})),
};
return JSON.stringify(jsonFeed, null, 2);
}
/**
* Exports the feed as an RSS 2.0 formatted string
* @returns RSS 2.0 XML string
* @example
* ```typescript
* const rssString = feed.exportRssFeedString();
* console.log(rssString);
* ```
*/
public exportRssFeedString(): string {
return this.generateRss2();
}
/**
* Exports the feed as an Atom 1.0 formatted string
* @returns Atom 1.0 XML string
* @example
* ```typescript
* const atomString = feed.exportAtomFeed();
* ```
*/
public exportAtomFeed(): string {
return this.generateAtom1();
}
/**
* Exports the feed as a JSON Feed 1.0 formatted string
* @returns JSON Feed 1.0 string
* @example
* ```typescript
* const jsonFeed = feed.exportJsonFeed();
* const parsed = JSON.parse(jsonFeed);
* ```
*/
public exportJsonFeed(): string {
return this.generateJsonFeed();
}
}