feat(smartfeed): Implement Smartfeed core: add feed validation, parsing, exporting and comprehensive tests
This commit is contained in:
@@ -1,35 +1,140 @@
|
||||
import * as plugins from './smartfeed.plugins.js';
|
||||
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}`,
|
||||
@@ -39,21 +144,27 @@ export class Feed {
|
||||
author: {
|
||||
name: this.options.company,
|
||||
email: this.options.companyEmail,
|
||||
link: this.options.companyEmail,
|
||||
link: this.options.companyDomain,
|
||||
},
|
||||
description: this.options.description,
|
||||
generator: '@pushrocks/smartfeed',
|
||||
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: itemArg.content,
|
||||
content: sanitizedContent,
|
||||
id: itemArg.id || itemArg.url,
|
||||
author: [
|
||||
{
|
||||
name: itemArg.authorName,
|
||||
@@ -64,14 +175,40 @@ export class Feed {
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user