feat(smartfeed): Implement Smartfeed core: add feed validation, parsing, exporting and comprehensive tests
This commit is contained in:
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartfeed',
|
||||
version: '1.1.0',
|
||||
description: 'A library for creating and parsing various feed formats.'
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
436
ts/classes.podcast.ts
Normal file
436
ts/classes.podcast.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as validation from './validation.js';
|
||||
import { Feed, IFeedOptions, IFeedItem } from './classes.feed.js';
|
||||
|
||||
/**
|
||||
* iTunes podcast owner information
|
||||
*/
|
||||
export interface IPodcastOwner {
|
||||
/** Name of the podcast owner */
|
||||
name: string;
|
||||
/** Email of the podcast owner */
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for creating a podcast feed
|
||||
* Extends standard feed options with iTunes-specific fields
|
||||
*/
|
||||
export interface IPodcastFeedOptions extends IFeedOptions {
|
||||
/** iTunes category (e.g., 'Technology', 'Comedy', 'News') */
|
||||
itunesCategory: string;
|
||||
/** iTunes subcategory (optional) */
|
||||
itunesSubcategory?: string;
|
||||
/** Podcast author name */
|
||||
itunesAuthor: string;
|
||||
/** Podcast owner information */
|
||||
itunesOwner: IPodcastOwner;
|
||||
/** URL to podcast artwork (1400x1400 to 3000x3000 pixels, JPG or PNG) */
|
||||
itunesImage: string;
|
||||
/** Whether the podcast contains explicit content */
|
||||
itunesExplicit: boolean;
|
||||
/** Podcast type: episodic (default) or serial */
|
||||
itunesType?: 'episodic' | 'serial';
|
||||
/** Podcast summary (optional, more detailed than description) */
|
||||
itunesSummary?: string;
|
||||
/** Copyright notice (overrides default) */
|
||||
copyright?: string;
|
||||
/** Language code (overrides default 'en') */
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Person role in podcast episode (host, guest, etc.)
|
||||
*/
|
||||
export interface IPodcastPerson {
|
||||
/** Person's name */
|
||||
name: string;
|
||||
/** Role (e.g., 'host', 'guest', 'producer') */
|
||||
role?: string;
|
||||
/** URL to person's profile/website */
|
||||
href?: string;
|
||||
/** Image URL for the person */
|
||||
img?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapter marker in podcast episode
|
||||
*/
|
||||
export interface IPodcastChapter {
|
||||
/** Chapter start time in seconds */
|
||||
startTime: number;
|
||||
/** Chapter title */
|
||||
title: string;
|
||||
/** Chapter URL (optional) */
|
||||
href?: string;
|
||||
/** Chapter image URL (optional) */
|
||||
img?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript information for podcast episode
|
||||
*/
|
||||
export interface IPodcastTranscript {
|
||||
/** URL to transcript file */
|
||||
url: string;
|
||||
/** Transcript type (e.g., 'text/plain', 'text/html', 'application/srt') */
|
||||
type: string;
|
||||
/** Language code (e.g., 'en', 'es') */
|
||||
language?: string;
|
||||
/** Transcript relationship (e.g., 'captions') */
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Funding/donation information
|
||||
*/
|
||||
export interface IPodcastFunding {
|
||||
/** URL to funding/donation page */
|
||||
url: string;
|
||||
/** Funding message/call to action */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single podcast episode in the feed
|
||||
*/
|
||||
export interface IPodcastItem extends IFeedItem {
|
||||
/** URL to audio file (MP3, M4A, etc.) */
|
||||
audioUrl: string;
|
||||
/** MIME type of audio file (e.g., 'audio/mpeg', 'audio/x-m4a') */
|
||||
audioType: string;
|
||||
/** Size of audio file in bytes */
|
||||
audioLength: number;
|
||||
|
||||
// iTunes tags
|
||||
/** Episode duration in seconds */
|
||||
itunesDuration: number;
|
||||
/** Episode number (for episodic podcasts) */
|
||||
itunesEpisode?: number;
|
||||
/** Season number */
|
||||
itunesSeason?: number;
|
||||
/** Episode type: full, trailer, or bonus */
|
||||
itunesEpisodeType?: 'full' | 'trailer' | 'bonus';
|
||||
/** Whether episode contains explicit content */
|
||||
itunesExplicit?: boolean;
|
||||
/** Episode subtitle (short description) */
|
||||
itunesSubtitle?: string;
|
||||
/** Episode summary (can be longer than content) */
|
||||
itunesSummary?: string;
|
||||
|
||||
// Modern podcast namespace
|
||||
/** People involved in episode (hosts, guests, etc.) */
|
||||
persons?: IPodcastPerson[];
|
||||
/** Chapter markers */
|
||||
chapters?: IPodcastChapter[];
|
||||
/** Transcripts */
|
||||
transcripts?: IPodcastTranscript[];
|
||||
/** Funding/donation links */
|
||||
funding?: IPodcastFunding[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a podcast feed that can generate RSS with iTunes and Podcast namespaces
|
||||
* @example
|
||||
* ```typescript
|
||||
* const podcast = new PodcastFeed({
|
||||
* domain: 'podcast.example.com',
|
||||
* title: 'My Awesome Podcast',
|
||||
* description: 'A podcast about awesome things',
|
||||
* category: 'Technology',
|
||||
* company: 'Podcast Inc',
|
||||
* companyEmail: 'podcast@example.com',
|
||||
* companyDomain: 'https://example.com',
|
||||
* itunesCategory: 'Technology',
|
||||
* itunesAuthor: 'John Doe',
|
||||
* itunesOwner: { name: 'John Doe', email: 'john@example.com' },
|
||||
* itunesImage: 'https://example.com/artwork.jpg',
|
||||
* itunesExplicit: false
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class PodcastFeed extends Feed {
|
||||
public podcastOptions: IPodcastFeedOptions;
|
||||
public episodes: IPodcastItem[] = [];
|
||||
|
||||
/**
|
||||
* Creates a new PodcastFeed instance
|
||||
* @param optionsArg - Podcast feed configuration options
|
||||
* @throws Error if validation fails
|
||||
*/
|
||||
constructor(optionsArg: IPodcastFeedOptions) {
|
||||
super(optionsArg);
|
||||
|
||||
// Validate podcast-specific fields
|
||||
validation.validateRequiredFields(
|
||||
optionsArg,
|
||||
['itunesCategory', 'itunesAuthor', 'itunesOwner', 'itunesImage'],
|
||||
'Podcast feed options'
|
||||
);
|
||||
|
||||
// Validate iTunes owner
|
||||
validation.validateRequiredFields(
|
||||
optionsArg.itunesOwner,
|
||||
['name', 'email'],
|
||||
'iTunes owner'
|
||||
);
|
||||
validation.validateEmail(optionsArg.itunesOwner.email);
|
||||
|
||||
// Validate iTunes image URL
|
||||
validation.validateUrl(optionsArg.itunesImage, true);
|
||||
|
||||
// Validate iTunes type if provided
|
||||
if (optionsArg.itunesType && !['episodic', 'serial'].includes(optionsArg.itunesType)) {
|
||||
throw new Error('iTunes type must be either "episodic" or "serial"');
|
||||
}
|
||||
|
||||
this.podcastOptions = optionsArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an episode to the podcast feed
|
||||
* @param episodeArg - The podcast episode to add
|
||||
* @throws Error if validation fails
|
||||
* @example
|
||||
* ```typescript
|
||||
* podcast.addEpisode({
|
||||
* title: 'Episode 1: Getting Started',
|
||||
* authorName: 'John Doe',
|
||||
* imageUrl: 'https://example.com/episode1.jpg',
|
||||
* timestamp: Date.now(),
|
||||
* url: 'https://example.com/episode/1',
|
||||
* content: 'In this episode we discuss getting started',
|
||||
* audioUrl: 'https://example.com/audio/episode1.mp3',
|
||||
* audioType: 'audio/mpeg',
|
||||
* audioLength: 45678900,
|
||||
* itunesDuration: 3600,
|
||||
* itunesEpisode: 1,
|
||||
* itunesSeason: 1
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
public addEpisode(episodeArg: IPodcastItem): void {
|
||||
// Validate standard item fields first
|
||||
validation.validateRequiredFields(
|
||||
episodeArg,
|
||||
['title', 'timestamp', 'url', 'authorName', 'imageUrl', 'content', 'audioUrl', 'audioType', 'audioLength', 'itunesDuration'],
|
||||
'Podcast episode'
|
||||
);
|
||||
|
||||
// Validate URLs
|
||||
validation.validateUrl(episodeArg.url, true);
|
||||
validation.validateUrl(episodeArg.imageUrl, true);
|
||||
validation.validateUrl(episodeArg.audioUrl, true);
|
||||
|
||||
// Validate timestamp
|
||||
validation.validateTimestamp(episodeArg.timestamp);
|
||||
|
||||
// Validate audio file type
|
||||
if (!episodeArg.audioType.startsWith('audio/')) {
|
||||
throw new Error(`Invalid audio type: ${episodeArg.audioType}. Must start with 'audio/'`);
|
||||
}
|
||||
|
||||
// Validate audio length
|
||||
if (typeof episodeArg.audioLength !== 'number' || episodeArg.audioLength <= 0) {
|
||||
throw new Error('Audio length must be a positive number (bytes)');
|
||||
}
|
||||
|
||||
// Validate duration
|
||||
if (typeof episodeArg.itunesDuration !== 'number' || episodeArg.itunesDuration <= 0) {
|
||||
throw new Error('iTunes duration must be a positive number (seconds)');
|
||||
}
|
||||
|
||||
// Validate episode type if provided
|
||||
if (episodeArg.itunesEpisodeType && !['full', 'trailer', 'bonus'].includes(episodeArg.itunesEpisodeType)) {
|
||||
throw new Error('iTunes episode type must be "full", "trailer", or "bonus"');
|
||||
}
|
||||
|
||||
// Validate episode/season numbers if provided
|
||||
if (episodeArg.itunesEpisode !== undefined && (episodeArg.itunesEpisode < 1 || !Number.isInteger(episodeArg.itunesEpisode))) {
|
||||
throw new Error('iTunes episode number must be a positive integer');
|
||||
}
|
||||
|
||||
if (episodeArg.itunesSeason !== undefined && (episodeArg.itunesSeason < 1 || !Number.isInteger(episodeArg.itunesSeason))) {
|
||||
throw new Error('iTunes season number must be a positive integer');
|
||||
}
|
||||
|
||||
// Validate transcripts if provided
|
||||
if (episodeArg.transcripts) {
|
||||
for (const transcript of episodeArg.transcripts) {
|
||||
validation.validateUrl(transcript.url, true);
|
||||
if (!transcript.type) {
|
||||
throw new Error('Transcript type is required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate funding links if provided
|
||||
if (episodeArg.funding) {
|
||||
for (const funding of episodeArg.funding) {
|
||||
validation.validateUrl(funding.url, true);
|
||||
if (!funding.message) {
|
||||
throw new Error('Funding message is required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ID uniqueness (use URL as ID if not provided)
|
||||
const itemId = episodeArg.id || episodeArg.url;
|
||||
if (this.itemIds.has(itemId)) {
|
||||
throw new Error(
|
||||
`Duplicate episode ID: ${itemId}. Each episode must have a unique ID or URL. ` +
|
||||
`IDs should never change once published.`
|
||||
);
|
||||
}
|
||||
|
||||
this.itemIds.add(itemId);
|
||||
this.episodes.push(episodeArg);
|
||||
this.items.push(episodeArg); // Also add to base items array
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration in HH:MM:SS format for iTunes
|
||||
* @param seconds - Duration in seconds
|
||||
* @returns Formatted duration string
|
||||
*/
|
||||
private formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the podcast feed as RSS 2.0 with iTunes and Podcast namespaces
|
||||
* @returns RSS 2.0 XML string with podcast extensions
|
||||
*/
|
||||
public exportPodcastRss(): string {
|
||||
// Build RSS manually to include iTunes namespace
|
||||
let rss = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
rss += '<rss version="2.0" ';
|
||||
rss += 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" ';
|
||||
rss += 'xmlns:podcast="https://podcastindex.org/namespace/1.0" ';
|
||||
rss += 'xmlns:atom="http://www.w3.org/2005/Atom">\n';
|
||||
rss += '<channel>\n';
|
||||
|
||||
// Standard RSS fields
|
||||
rss += `<title>${this.escapeXml(this.podcastOptions.title)}</title>\n`;
|
||||
rss += `<link>https://${this.podcastOptions.domain}</link>\n`;
|
||||
rss += `<description>${this.escapeXml(this.podcastOptions.description)}</description>\n`;
|
||||
rss += `<language>${this.podcastOptions.language || 'en'}</language>\n`;
|
||||
rss += `<copyright>${this.escapeXml(this.podcastOptions.copyright || `All rights reserved, ${this.podcastOptions.company}`)}</copyright>\n`;
|
||||
rss += `<generator>@push.rocks/smartfeed</generator>\n`;
|
||||
rss += `<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>\n`;
|
||||
|
||||
// Atom self link
|
||||
rss += `<atom:link href="https://${this.podcastOptions.domain}/feed.xml" rel="self" type="application/rss+xml" />\n`;
|
||||
|
||||
// iTunes channel tags
|
||||
rss += `<itunes:author>${this.escapeXml(this.podcastOptions.itunesAuthor)}</itunes:author>\n`;
|
||||
rss += `<itunes:summary>${this.escapeXml(this.podcastOptions.itunesSummary || this.podcastOptions.description)}</itunes:summary>\n`;
|
||||
rss += `<itunes:explicit>${this.podcastOptions.itunesExplicit ? 'true' : 'false'}</itunes:explicit>\n`;
|
||||
rss += `<itunes:image href="${this.podcastOptions.itunesImage}" />\n`;
|
||||
rss += `<itunes:category text="${this.escapeXml(this.podcastOptions.itunesCategory)}"`;
|
||||
if (this.podcastOptions.itunesSubcategory) {
|
||||
rss += `>\n<itunes:category text="${this.escapeXml(this.podcastOptions.itunesSubcategory)}" />\n</itunes:category>\n`;
|
||||
} else {
|
||||
rss += ' />\n';
|
||||
}
|
||||
rss += `<itunes:owner>\n`;
|
||||
rss += `<itunes:name>${this.escapeXml(this.podcastOptions.itunesOwner.name)}</itunes:name>\n`;
|
||||
rss += `<itunes:email>${this.podcastOptions.itunesOwner.email}</itunes:email>\n`;
|
||||
rss += `</itunes:owner>\n`;
|
||||
|
||||
if (this.podcastOptions.itunesType) {
|
||||
rss += `<itunes:type>${this.podcastOptions.itunesType}</itunes:type>\n`;
|
||||
}
|
||||
|
||||
// Episodes
|
||||
for (const episode of this.episodes) {
|
||||
rss += '<item>\n';
|
||||
rss += `<title>${this.escapeXml(episode.title)}</title>\n`;
|
||||
rss += `<link>${episode.url}</link>\n`;
|
||||
rss += `<guid isPermaLink="false">${episode.id || episode.url}</guid>\n`;
|
||||
rss += `<pubDate>${new Date(episode.timestamp).toUTCString()}</pubDate>\n`;
|
||||
rss += `<description><![CDATA[${episode.content}]]></description>\n`;
|
||||
|
||||
// Audio enclosure
|
||||
rss += `<enclosure url="${episode.audioUrl}" length="${episode.audioLength}" type="${episode.audioType}" />\n`;
|
||||
|
||||
// iTunes episode tags
|
||||
rss += `<itunes:title>${this.escapeXml(episode.title)}</itunes:title>\n`;
|
||||
rss += `<itunes:author>${this.escapeXml(episode.authorName)}</itunes:author>\n`;
|
||||
rss += `<itunes:duration>${this.formatDuration(episode.itunesDuration)}</itunes:duration>\n`;
|
||||
rss += `<itunes:explicit>${episode.itunesExplicit !== undefined ? (episode.itunesExplicit ? 'true' : 'false') : 'false'}</itunes:explicit>\n`;
|
||||
|
||||
if (episode.itunesSubtitle) {
|
||||
rss += `<itunes:subtitle>${this.escapeXml(episode.itunesSubtitle)}</itunes:subtitle>\n`;
|
||||
}
|
||||
|
||||
if (episode.itunesSummary) {
|
||||
rss += `<itunes:summary>${this.escapeXml(episode.itunesSummary)}</itunes:summary>\n`;
|
||||
}
|
||||
|
||||
if (episode.itunesEpisode !== undefined) {
|
||||
rss += `<itunes:episode>${episode.itunesEpisode}</itunes:episode>\n`;
|
||||
}
|
||||
|
||||
if (episode.itunesSeason !== undefined) {
|
||||
rss += `<itunes:season>${episode.itunesSeason}</itunes:season>\n`;
|
||||
}
|
||||
|
||||
if (episode.itunesEpisodeType) {
|
||||
rss += `<itunes:episodeType>${episode.itunesEpisodeType}</itunes:episodeType>\n`;
|
||||
}
|
||||
|
||||
rss += `<itunes:image href="${episode.imageUrl}" />\n`;
|
||||
|
||||
// Modern podcast namespace
|
||||
if (episode.persons && episode.persons.length > 0) {
|
||||
for (const person of episode.persons) {
|
||||
rss += `<podcast:person role="${person.role || 'guest'}"`;
|
||||
if (person.href) rss += ` href="${person.href}"`;
|
||||
if (person.img) rss += ` img="${person.img}"`;
|
||||
rss += `>${this.escapeXml(person.name)}</podcast:person>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (episode.transcripts && episode.transcripts.length > 0) {
|
||||
for (const transcript of episode.transcripts) {
|
||||
rss += `<podcast:transcript url="${transcript.url}" type="${transcript.type}"`;
|
||||
if (transcript.language) rss += ` language="${transcript.language}"`;
|
||||
if (transcript.rel) rss += ` rel="${transcript.rel}"`;
|
||||
rss += ' />\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (episode.funding && episode.funding.length > 0) {
|
||||
for (const funding of episode.funding) {
|
||||
rss += `<podcast:funding url="${funding.url}">${this.escapeXml(funding.message)}</podcast:funding>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
rss += '</item>\n';
|
||||
}
|
||||
|
||||
rss += '</channel>\n';
|
||||
rss += '</rss>';
|
||||
|
||||
return rss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes XML special characters
|
||||
* @param str - String to escape
|
||||
* @returns Escaped string
|
||||
*/
|
||||
private escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,91 @@
|
||||
import { Feed } from './smartfeed.classes.feed.js';
|
||||
import type { IFeedOptions } from './smartfeed.classes.feed.js';
|
||||
import * as plugins from './smartfeed.plugins.js';
|
||||
import { Feed } from './classes.feed.js';
|
||||
import type { IFeedOptions } from './classes.feed.js';
|
||||
import { PodcastFeed } from './classes.podcast.js';
|
||||
import type { IPodcastFeedOptions } from './classes.podcast.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
/**
|
||||
* Main class for creating and parsing various feed formats (RSS, Atom, JSON Feed)
|
||||
* @example
|
||||
* ```typescript
|
||||
* const smartfeed = new Smartfeed();
|
||||
* const feed = smartfeed.createFeed({
|
||||
* 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 Smartfeed {
|
||||
public createFeed(optionsArg: IFeedOptions) {
|
||||
/**
|
||||
* Creates a new Feed instance with the provided configuration
|
||||
* @param optionsArg - Feed configuration options
|
||||
* @returns A new Feed instance
|
||||
* @throws Error if validation fails
|
||||
* @example
|
||||
* ```typescript
|
||||
* const feed = smartfeed.createFeed({
|
||||
* domain: 'example.com',
|
||||
* title: 'My Blog',
|
||||
* description: 'Latest news and updates',
|
||||
* category: 'Technology',
|
||||
* company: 'Example Inc',
|
||||
* companyEmail: 'hello@example.com',
|
||||
* companyDomain: 'https://example.com'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
public createFeed(optionsArg: IFeedOptions): Feed {
|
||||
const feedVersion = new Feed(optionsArg);
|
||||
return feedVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a feed from a standardized article object (@tsclass/tsclass).content.IArticle
|
||||
* Creates a new PodcastFeed instance with iTunes and Podcast namespace support
|
||||
* @param optionsArg - Podcast feed configuration options
|
||||
* @returns A new PodcastFeed instance
|
||||
* @throws Error if validation fails
|
||||
* @example
|
||||
* ```typescript
|
||||
* const podcast = smartfeed.createPodcastFeed({
|
||||
* domain: 'podcast.example.com',
|
||||
* title: 'My Podcast',
|
||||
* description: 'An awesome podcast about tech',
|
||||
* category: 'Technology',
|
||||
* company: 'Podcast Inc',
|
||||
* companyEmail: 'podcast@example.com',
|
||||
* companyDomain: 'https://example.com',
|
||||
* itunesCategory: 'Technology',
|
||||
* itunesAuthor: 'John Doe',
|
||||
* itunesOwner: { name: 'John Doe', email: 'john@example.com' },
|
||||
* itunesImage: 'https://example.com/artwork.jpg',
|
||||
* itunesExplicit: false
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
public createPodcastFeed(optionsArg: IPodcastFeedOptions): PodcastFeed {
|
||||
const podcastFeed = new PodcastFeed(optionsArg);
|
||||
return podcastFeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Atom feed from an array of standardized article objects
|
||||
* Uses the @tsclass/tsclass IArticle interface for article format
|
||||
* @param optionsArg - Feed configuration options
|
||||
* @param articleArray - Array of article objects conforming to @tsclass/tsclass IArticle interface
|
||||
* @returns Promise resolving to Atom feed XML string
|
||||
* @throws Error if validation fails for feed options or articles
|
||||
* @example
|
||||
* ```typescript
|
||||
* const feedString = await smartfeed.createFeedFromArticleArray(
|
||||
* feedOptions,
|
||||
* articles
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
public async createFeedFromArticleArray(
|
||||
optionsArg: IFeedOptions,
|
||||
@@ -31,8 +107,17 @@ export class Smartfeed {
|
||||
}
|
||||
|
||||
/**
|
||||
* allows the parsing of a rss feed string
|
||||
* @param rssFeedString
|
||||
* Parses an RSS or Atom feed from a string
|
||||
* @param rssFeedString - The RSS/Atom feed XML string to parse
|
||||
* @returns Promise resolving to parsed feed object
|
||||
* @throws Error if feed parsing fails
|
||||
* @example
|
||||
* ```typescript
|
||||
* const feedString = '<rss>...</rss>';
|
||||
* const parsed = await smartfeed.parseFeedFromString(feedString);
|
||||
* console.log(parsed.title);
|
||||
* console.log(parsed.items);
|
||||
* ```
|
||||
*/
|
||||
public async parseFeedFromString(rssFeedString: string) {
|
||||
const parser = new plugins.rssParser();
|
||||
@@ -41,8 +126,16 @@ export class Smartfeed {
|
||||
}
|
||||
|
||||
/**
|
||||
* allows the parsing of a feed from urls
|
||||
* @param urlArg
|
||||
* Parses an RSS or Atom feed from a URL
|
||||
* @param urlArg - The absolute URL of the RSS/Atom feed
|
||||
* @returns Promise resolving to parsed feed object
|
||||
* @throws Error if feed fetch or parsing fails
|
||||
* @example
|
||||
* ```typescript
|
||||
* const parsed = await smartfeed.parseFeedFromUrl('https://example.com/feed.xml');
|
||||
* console.log(parsed.title);
|
||||
* console.log(parsed.items);
|
||||
* ```
|
||||
*/
|
||||
public async parseFeedFromUrl(urlArg: string) {
|
||||
const parser = new plugins.rssParser();
|
||||
|
||||
18
ts/index.ts
18
ts/index.ts
@@ -1,2 +1,16 @@
|
||||
export * from './smartfeed.classes.smartfeed.js';
|
||||
export * from './smartfeed.classes.feed.js';
|
||||
// Export classes
|
||||
export * from './classes.smartfeed.js';
|
||||
export * from './classes.feed.js';
|
||||
export * from './classes.podcast.js';
|
||||
|
||||
// Ensure interfaces are explicitly exported
|
||||
export type { IFeedOptions, IFeedItem } from './classes.feed.js';
|
||||
export type {
|
||||
IPodcastFeedOptions,
|
||||
IPodcastItem,
|
||||
IPodcastOwner,
|
||||
IPodcastPerson,
|
||||
IPodcastChapter,
|
||||
IPodcastTranscript,
|
||||
IPodcastFunding,
|
||||
} from './classes.podcast.js';
|
||||
|
||||
Reference in New Issue
Block a user