216 lines
5.7 KiB
TypeScript
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, '&'),
|
|
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();
|
|
}
|
|
}
|