feat(podcast): Add Podcast 2.0 support and remove external feed dependency; implement internal RSS/Atom/JSON generators and update tests/README
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartfeed',
|
||||
version: '1.1.1',
|
||||
version: '1.2.0',
|
||||
description: 'A library for creating and parsing various feed formats.'
|
||||
}
|
||||
|
||||
@@ -131,48 +131,162 @@ export class Feed {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the internal feed object with all items
|
||||
* @private
|
||||
* @returns Configured feed object
|
||||
* Escapes special XML characters
|
||||
* @protected
|
||||
* @param str - String to escape
|
||||
* @returns Escaped string
|
||||
*/
|
||||
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}`,
|
||||
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
|
||||
* @private
|
||||
* @returns Atom 1.0 XML string
|
||||
*/
|
||||
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',
|
||||
title: this.options.title,
|
||||
home_page_url: `https://${this.options.domain}`,
|
||||
feed_url: `https://${this.options.domain}/feed.json`,
|
||||
description: this.options.description,
|
||||
icon: '',
|
||||
favicon: '',
|
||||
author: {
|
||||
name: this.options.company,
|
||||
email: this.options.companyEmail,
|
||||
link: this.options.companyDomain,
|
||||
url: this.options.companyDomain,
|
||||
},
|
||||
description: this.options.description,
|
||||
generator: '@push.rocks/smartfeed',
|
||||
language: 'en',
|
||||
});
|
||||
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,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
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;
|
||||
return JSON.stringify(jsonFeed, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +299,7 @@ export class Feed {
|
||||
* ```
|
||||
*/
|
||||
public exportRssFeedString(): string {
|
||||
return this.getFeedObject().rss2();
|
||||
return this.generateRss2();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,7 +311,7 @@ export class Feed {
|
||||
* ```
|
||||
*/
|
||||
public exportAtomFeed(): string {
|
||||
return this.getFeedObject().atom1();
|
||||
return this.generateAtom1();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +324,6 @@ export class Feed {
|
||||
* ```
|
||||
*/
|
||||
public exportJsonFeed(): string {
|
||||
return this.getFeedObject().json1();
|
||||
return this.generateJsonFeed();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,36 @@ export class PodcastFeed extends Feed {
|
||||
throw new Error('iTunes type must be either "episodic" or "serial"');
|
||||
}
|
||||
|
||||
// Validate Podcast 2.0 fields
|
||||
// Validate podcast GUID (required for Podcast 2.0 compatibility)
|
||||
validation.validateRequiredFields(
|
||||
optionsArg,
|
||||
['podcastGuid'],
|
||||
'Podcast feed options'
|
||||
);
|
||||
|
||||
if (!optionsArg.podcastGuid || typeof optionsArg.podcastGuid !== 'string' || optionsArg.podcastGuid.trim() === '') {
|
||||
throw new Error('Podcast GUID is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
// Validate podcast medium if provided
|
||||
if (optionsArg.podcastMedium) {
|
||||
const validMediums = ['podcast', 'music', 'video', 'film', 'audiobook', 'newsletter', 'blog'];
|
||||
if (!validMediums.includes(optionsArg.podcastMedium)) {
|
||||
throw new Error(`Podcast medium must be one of: ${validMediums.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate podcast locked and owner
|
||||
if (optionsArg.podcastLocked && !optionsArg.podcastLockOwner) {
|
||||
throw new Error('Podcast lock owner (email or contact) is required when podcast is locked');
|
||||
}
|
||||
|
||||
if (optionsArg.podcastLockOwner) {
|
||||
// Validate it's a valid email
|
||||
validation.validateEmail(optionsArg.podcastLockOwner);
|
||||
}
|
||||
|
||||
this.podcastOptions = optionsArg;
|
||||
}
|
||||
|
||||
@@ -361,6 +391,25 @@ export class PodcastFeed extends Feed {
|
||||
rss += `<itunes:type>${this.podcastOptions.itunesType}</itunes:type>\n`;
|
||||
}
|
||||
|
||||
// Podcast 2.0 namespace tags
|
||||
rss += `<podcast:guid>${this.escapeXml(this.podcastOptions.podcastGuid)}</podcast:guid>\n`;
|
||||
|
||||
if (this.podcastOptions.podcastMedium) {
|
||||
rss += `<podcast:medium>${this.podcastOptions.podcastMedium}</podcast:medium>\n`;
|
||||
} else {
|
||||
// Default to 'podcast' if not specified
|
||||
rss += `<podcast:medium>podcast</podcast:medium>\n`;
|
||||
}
|
||||
|
||||
if (this.podcastOptions.podcastLocked !== undefined) {
|
||||
const lockedValue = this.podcastOptions.podcastLocked ? 'yes' : 'no';
|
||||
if (this.podcastOptions.podcastLockOwner) {
|
||||
rss += `<podcast:locked owner="${this.escapeXml(this.podcastOptions.podcastLockOwner)}">${lockedValue}</podcast:locked>\n`;
|
||||
} else {
|
||||
rss += `<podcast:locked>${lockedValue}</podcast:locked>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Episodes
|
||||
for (const episode of this.episodes) {
|
||||
rss += '<item>\n';
|
||||
@@ -434,18 +483,4 @@ export class PodcastFeed extends Feed {
|
||||
|
||||
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, ''');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,5 @@ export { tsclass };
|
||||
|
||||
// third party scope
|
||||
import rssParser from 'rss-parser';
|
||||
import * as feed from 'feed';
|
||||
|
||||
export { rssParser, feed };
|
||||
export { rssParser };
|
||||
|
||||
Reference in New Issue
Block a user