Files
smartfeed/ts/classes.feed.ts

216 lines
5.7 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;
}
/**
* 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<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);
}
/**
* 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, '&amp;'),
image: itemArg.imageUrl.replace(/&/gm, '&amp;'),
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();
}
}