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; } /** * 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 = 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * 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 = '\n'; rss += '\n'; rss += '\n'; // Channel metadata rss += `${this.escapeXml(this.options.title)}\n`; rss += `https://${this.options.domain}\n`; rss += `${this.escapeXml(this.options.description)}\n`; rss += `en\n`; rss += `All rights reserved, ${this.escapeXml(this.options.company)}\n`; rss += `@push.rocks/smartfeed\n`; rss += `${this.formatRfc822Date(new Date())}\n`; rss += `${this.escapeXml(this.options.category)}\n`; // Atom self link rss += `\n`; // Items for (const item of this.items) { rss += '\n'; rss += `${this.escapeXml(item.title)}\n`; rss += `${item.url}\n`; rss += `${item.id || item.url}\n`; rss += `${this.formatRfc822Date(new Date(item.timestamp))}\n`; rss += `${this.escapeXml(item.content)}\n`; rss += `${this.options.companyEmail} (${this.escapeXml(item.authorName)})\n`; rss += `\n`; rss += '\n'; } rss += '\n'; rss += ''; return rss; } /** * Generates Atom 1.0 feed * @private * @returns Atom 1.0 XML string */ private generateAtom1(): string { let atom = '\n'; atom += '\n'; // Feed metadata atom += `https://${this.options.domain}\n`; atom += `${this.escapeXml(this.options.title)}\n`; atom += `${this.escapeXml(this.options.description)}\n`; atom += `\n`; atom += `\n`; atom += `${this.formatIso8601Date(new Date())}\n`; atom += `@push.rocks/smartfeed\n`; atom += '\n'; atom += `${this.escapeXml(this.options.company)}\n`; atom += `${this.options.companyEmail}\n`; atom += `${this.options.companyDomain}\n`; atom += '\n'; atom += '\n'; atom += `${this.escapeXml(this.options.category)}\n`; atom += '\n'; // Entries for (const item of this.items) { atom += '\n'; atom += `${item.id || item.url}\n`; atom += `${this.escapeXml(item.title)}\n`; atom += `\n`; atom += `${this.formatIso8601Date(new Date(item.timestamp))}\n`; atom += '\n'; atom += `${this.escapeXml(item.authorName)}\n`; atom += '\n'; atom += '\n'; atom += this.escapeXml(item.content); atom += '\n\n'; atom += `\n`; atom += '\n'; } atom += ''; 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: `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(); } }