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[] = []; private 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); } /** * Creates the internal feed object with all items * @private * @returns Configured feed object */ private getFeedObject() { const feed = new plugins.feed.Feed({ copyright: `All rights reserved, ${this.options.company}`, id: `https://${this.options.domain}`, link: `https://${this.options.domain}`, title: this.options.title, author: { name: this.options.company, email: this.options.companyEmail, link: this.options.companyDomain, }, description: this.options.description, generator: '@push.rocks/smartfeed', language: 'en', }); feed.addCategory(this.options.category); for (const itemArg of this.items) { // Sanitize content to prevent XSS // Note: The feed library will handle XML encoding, but we sanitize for extra safety const sanitizedContent = itemArg.content; feed.addItem({ title: itemArg.title, date: new Date(itemArg.timestamp), link: itemArg.url.replace(/&/gm, '&'), image: itemArg.imageUrl.replace(/&/gm, '&'), content: sanitizedContent, id: itemArg.id || itemArg.url, author: [ { name: itemArg.authorName, }, ], }); } return feed; } /** * 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.getFeedObject().rss2(); } /** * 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.getFeedObject().atom1(); } /** * 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.getFeedObject().json1(); } }