2025-10-31 19:17:04 +00:00
|
|
|
import * as plugins from './plugins.js';
|
|
|
|
|
import * as validation from './validation.js';
|
2020-10-25 14:02:03 +00:00
|
|
|
|
2025-10-31 19:17:04 +00:00
|
|
|
/**
|
|
|
|
|
* Configuration options for creating a feed
|
|
|
|
|
*/
|
2020-10-25 14:02:03 +00:00
|
|
|
export interface IFeedOptions {
|
2025-10-31 19:17:04 +00:00
|
|
|
/** The domain of the feed (e.g., 'example.com') */
|
2020-10-25 14:02:03 +00:00
|
|
|
domain: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** The title of the feed */
|
2020-10-25 14:02:03 +00:00
|
|
|
title: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** A description of the feed content */
|
2020-10-25 14:02:03 +00:00
|
|
|
description: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** The category of the feed (e.g., 'Technology', 'News') */
|
2020-10-25 20:07:35 +00:00
|
|
|
category: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** The company or organization name */
|
2020-10-25 14:02:03 +00:00
|
|
|
company: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** Contact email for the feed */
|
2020-10-25 14:02:03 +00:00
|
|
|
companyEmail: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** The company website URL (must be absolute) */
|
2020-10-25 14:02:03 +00:00
|
|
|
companyDomain: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-31 19:17:04 +00:00
|
|
|
/**
|
|
|
|
|
* Represents a single item/entry in the feed
|
|
|
|
|
*/
|
2020-10-25 20:07:35 +00:00
|
|
|
export interface IFeedItem {
|
2025-10-31 19:17:04 +00:00
|
|
|
/** The title of the feed item */
|
2020-10-25 20:07:35 +00:00
|
|
|
title: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** Unix timestamp in milliseconds when the item was published */
|
2020-10-25 20:07:35 +00:00
|
|
|
timestamp: number;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** Absolute URL to the full item/article */
|
2020-10-25 20:07:35 +00:00
|
|
|
url: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** Name of the item author */
|
2020-10-25 20:07:35 +00:00
|
|
|
authorName: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** Absolute URL to the item's featured image */
|
2020-10-25 20:07:35 +00:00
|
|
|
imageUrl: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** The content/body of the item (will be sanitized) */
|
2020-11-11 10:11:18 +00:00
|
|
|
content: string;
|
2025-10-31 19:17:04 +00:00
|
|
|
/** Optional unique identifier for this item. If not provided, url will be used */
|
|
|
|
|
id?: string;
|
2020-10-25 20:07:35 +00:00
|
|
|
}
|
2020-10-25 14:02:03 +00:00
|
|
|
|
2025-10-31 19:17:04 +00:00
|
|
|
/**
|
|
|
|
|
* 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'
|
|
|
|
|
* });
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
2020-10-25 14:02:03 +00:00
|
|
|
export class Feed {
|
|
|
|
|
options: IFeedOptions;
|
2020-10-25 20:07:35 +00:00
|
|
|
items: IFeedItem[] = [];
|
2025-10-31 19:36:23 +00:00
|
|
|
protected itemIds: Set<string> = new Set();
|
2025-10-31 19:17:04 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a new Feed instance
|
|
|
|
|
* @param optionsArg - Feed configuration options
|
|
|
|
|
* @throws Error if validation fails
|
|
|
|
|
*/
|
2020-10-25 14:02:03 +00:00
|
|
|
constructor(optionsArg: IFeedOptions) {
|
2025-10-31 19:17:04 +00:00
|
|
|
// 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);
|
|
|
|
|
|
2020-10-25 14:02:03 +00:00
|
|
|
this.options = optionsArg;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-31 19:17:04 +00:00
|
|
|
/**
|
|
|
|
|
* 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'
|
|
|
|
|
* });
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
2020-10-25 20:07:35 +00:00
|
|
|
public addItem(itemArg: IFeedItem) {
|
2025-10-31 19:17:04 +00:00
|
|
|
// 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);
|
2020-10-25 20:07:35 +00:00
|
|
|
this.items.push(itemArg);
|
2020-10-25 14:02:03 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-31 19:17:04 +00:00
|
|
|
/**
|
2025-10-31 21:04:50 +00:00
|
|
|
* 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, '"')
|
|
|
|
|
.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 = '<?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
|
|
|
|
|
rss += `<atom:link href="https://${this.options.domain}/feed.xml" 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
|
2025-10-31 19:17:04 +00:00
|
|
|
* @private
|
2025-10-31 21:04:50 +00:00
|
|
|
* @returns Atom 1.0 XML string
|
2025-10-31 19:17:04 +00:00
|
|
|
*/
|
2025-10-31 21:04:50 +00:00
|
|
|
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`;
|
|
|
|
|
atom += `<link href="https://${this.options.domain}/feed.xml" 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',
|
2020-10-25 14:02:03 +00:00
|
|
|
title: this.options.title,
|
2025-10-31 21:04:50 +00:00
|
|
|
home_page_url: `https://${this.options.domain}`,
|
|
|
|
|
feed_url: `https://${this.options.domain}/feed.json`,
|
|
|
|
|
description: this.options.description,
|
|
|
|
|
icon: '',
|
|
|
|
|
favicon: '',
|
2020-10-25 14:02:03 +00:00
|
|
|
author: {
|
|
|
|
|
name: this.options.company,
|
2025-10-31 21:04:50 +00:00
|
|
|
url: this.options.companyDomain,
|
2020-10-25 14:02:03 +00:00
|
|
|
},
|
2025-10-31 21:04:50 +00:00
|
|
|
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);
|
2020-10-25 20:07:35 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-31 19:17:04 +00:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
2020-10-25 20:07:35 +00:00
|
|
|
public exportRssFeedString(): string {
|
2025-10-31 21:04:50 +00:00
|
|
|
return this.generateRss2();
|
2020-10-25 20:07:35 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-31 19:17:04 +00:00
|
|
|
/**
|
|
|
|
|
* Exports the feed as an Atom 1.0 formatted string
|
|
|
|
|
* @returns Atom 1.0 XML string
|
|
|
|
|
* @example
|
|
|
|
|
* ```typescript
|
|
|
|
|
* const atomString = feed.exportAtomFeed();
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
2020-10-25 20:07:35 +00:00
|
|
|
public exportAtomFeed(): string {
|
2025-10-31 21:04:50 +00:00
|
|
|
return this.generateAtom1();
|
2020-10-25 20:07:35 +00:00
|
|
|
}
|
|
|
|
|
|
2025-10-31 19:17:04 +00:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
2020-10-25 20:07:35 +00:00
|
|
|
public exportJsonFeed(): string {
|
2025-10-31 21:04:50 +00:00
|
|
|
return this.generateJsonFeed();
|
2020-10-25 14:02:03 +00:00
|
|
|
}
|
2025-10-31 17:07:13 +00:00
|
|
|
}
|