8 Commits
v1.1.0 ... main

20 changed files with 2142 additions and 170 deletions

View File

@@ -1,5 +1,43 @@
# Changelog
## 2025-11-01 - 1.4.0 - feat(feed)
Support custom feedUrl for feeds and use it as the self-link in RSS/Atom/JSON; update docs
- Add optional feedUrl option to IFeedOptions (ts/classes.feed.ts).
- Use feedUrl as the atom:link rel="self" href in RSS and as the <link rel="self"> in Atom when provided.
- Expose feedUrl as the JSON Feed "feed_url" value (ts/classes.feed.ts).
- PodcastFeed now uses podcastOptions.feedUrl for its atom self-link (ts/classes.podcast.ts).
- Update README: add a Custom Feed URL section and mention feedUrl in the API docs (readme.md).
## 2025-10-31 - 1.3.0 - feat(parsing)
Replace rss-parser with fast-xml-parser and add native feed parser; update Smartfeed to use parseFeedXML and adjust plugins/tests
- Replaced dependency on rss-parser with fast-xml-parser (package.json + deno.lock).
- Added ts/lib/feedparser.ts: a new native XML-based feed parser that detects and parses RSS 2.0, Atom 1.0 and RSS 1.0 (RDF) into a unified IParsedFeed structure.
- Updated Smartfeed parsing API to use parseFeedXML for parseFeedFromString and to fetch + parse XML in parseFeedFromUrl.
- Updated ts/plugins.ts to export fast-xml-parser's XMLParser instead of rss-parser.
- Implemented feed parsing utilities: content extraction, snippet creation, date normalization, enclosure/category handling and atom:link/feed metadata extraction.
- Added and/or updated comprehensive tests for creation, export, parsing, validation, podcast (Podcast 2.0) features to exercise the new parser and related behaviors.
## 2025-10-31 - 1.2.0 - feat(podcast)
Add Podcast 2.0 support and remove external 'feed' dependency; implement internal RSS/Atom/JSON generators and update tests/README
- Add Podcast 2.0 fields and validation: podcastGuid (required), podcastMedium, podcastLocked and podcastLockOwner
- Include Podcast 2.0 tags in RSS export (podcast:guid, podcast:medium, podcast:locked, podcast:person, podcast:transcript, podcast:funding)
- Remove dependency on the external 'feed' package and replace with internal feed generation for RSS, Atom and JSON Feed
- Update ts/plugins.ts to stop exporting the removed 'feed' plugin
- Update numerous tests to provide podcastGuid and exercise Podcast 2.0 features
- Documentation updated (readme.md) to document Podcast 2.0 support and examples
## 2025-10-31 - 1.1.1 - fix(podcast)
Improve podcast episode validation, make Feed.itemIds protected, expand README and add tests
- PodcastFeed.addEpisode: validate iTunes duration separately (require itunesDuration) and ensure it is a positive number; audioLength must be a positive number; moved itunesDuration out of the generic required-fields list to allow proper numeric validation and clearer errors.
- Feed: changed itemIds from private to protected so subclasses (e.g. PodcastFeed) can access and enforce duplicate ID checks across episodes/items.
- Documentation: major README overhaul with Quick Start, Podcast examples, API reference, validation & security notes, best practices, and TypeScript usage examples.
- Tests: added comprehensive podcast tests (advanced features and validation) and updated/expanded test coverage for feed creation, export, parsing and validation to cover transcripts, funding, persons, explicit flags, and more.
- This is a backwards-compatible bugfix and documentation/test update; no breaking public API changes intended.
## 2025-10-31 - 1.1.0 - feat(smartfeed)
Implement Smartfeed core: add feed validation, parsing, exporting and comprehensive tests

15
deno.lock generated
View File

@@ -7,8 +7,7 @@
"npm:@pushrocks/smartfile@^10.0.26": "10.0.26",
"npm:@tsclass/tsclass@^9.3.0": "9.3.0",
"npm:@types/node@^24.9.2": "24.9.2",
"npm:feed@^5.1.0": "5.1.0",
"npm:rss-parser@^3.10.0": "3.13.0"
"npm:fast-xml-parser@^4.5.0": "4.5.3"
},
"npm": {
"@api.global/typedrequest-interfaces@2.0.2": {
@@ -1616,7 +1615,7 @@
"integrity": "sha512-02uhXxQamgfBo3T12FsAdfyElnpoWuDUb08B2AE60DbIaukVx/7Mi17xwobApY1flNSr5StZDt8N8vxPhBhIXw==",
"dependencies": [
"@tsclass/tsclass@3.0.48",
"feed@4.2.2",
"feed",
"rss-parser"
],
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartfeed/-/smartfeed-1.0.11.tgz"
@@ -4449,13 +4448,6 @@
],
"tarball": "https://verdaccio.lossless.digital/feed/-/feed-4.2.2.tgz"
},
"feed@5.1.0": {
"integrity": "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==",
"dependencies": [
"xml-js"
],
"tarball": "https://verdaccio.lossless.digital/feed/-/feed-5.1.0.tgz"
},
"fflate@0.8.2": {
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"tarball": "https://verdaccio.lossless.digital/fflate/-/fflate-0.8.2.tgz"
@@ -7489,8 +7481,7 @@
"npm:@pushrocks/smartfile@^10.0.26",
"npm:@tsclass/tsclass@^9.3.0",
"npm:@types/node@^24.9.2",
"npm:feed@^5.1.0",
"npm:rss-parser@^3.10.0"
"npm:fast-xml-parser@^4.5.0"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartfeed",
"version": "1.1.0",
"version": "1.4.0",
"private": false,
"description": "A library for creating and parsing various feed formats.",
"main": "dist_ts/index.js",
@@ -21,8 +21,7 @@
},
"dependencies": {
"@tsclass/tsclass": "^9.3.0",
"feed": "^5.1.0",
"rss-parser": "^3.10.0"
"fast-xml-parser": "^4.5.0"
},
"browserslist": [
"last 1 chrome versions"

17
pnpm-lock.yaml generated
View File

@@ -11,12 +11,9 @@ importers:
'@tsclass/tsclass':
specifier: ^9.3.0
version: 9.3.0
feed:
specifier: ^5.1.0
version: 5.1.0
rss-parser:
specifier: ^3.10.0
version: 3.13.0
fast-xml-parser:
specifier: ^4.5.0
version: 4.5.3
devDependencies:
'@git.zone/tsbuild':
specifier: ^2.6.8
@@ -2080,10 +2077,6 @@ packages:
resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==}
engines: {node: '>=0.4.0'}
feed@5.1.0:
resolution: {integrity: sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==}
engines: {node: '>=20', pnpm: '>=10'}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@@ -6889,10 +6882,6 @@ snapshots:
dependencies:
xml-js: 1.6.11
feed@5.1.0:
dependencies:
xml-js: 1.6.11
fflate@0.8.2: {}
figures@6.1.0:

446
readme.md
View File

@@ -1,98 +1,400 @@
# @push.rocks/smartfeed
create and parse feeds
**The modern TypeScript library for creating and parsing RSS, Atom, and Podcast feeds** 🚀
## Install
`@push.rocks/smartfeed` is a powerful, type-safe feed management library that makes creating and parsing RSS 2.0, Atom 1.0, JSON Feed, and Podcast feeds ridiculously easy. Built with TypeScript from the ground up, it offers comprehensive validation, security features, and supports modern podcast standards including iTunes tags and the Podcast namespace.
To install `@push.rocks/smartfeed`, you need to have Node.js installed on your machine. After setting up Node.js, run the following command in your terminal:
## Features ✨
- 🎯 **Full TypeScript Support** - Complete type definitions for all feed formats
- 🌐 **Cross-Platform** - Works in Node.js, Bun, Deno, and browsers
- 📡 **Multiple Feed Formats** - RSS 2.0, Atom 1.0, JSON Feed 1.0, and Podcast RSS
- 🎙️ **Modern Podcast Support** - iTunes tags, Podcast 2.0 namespace (guid, medium, locked, persons, transcripts, funding)
- 🔒 **Built-in Validation** - Comprehensive validation for URLs, emails, domains, and timestamps
- 🛡️ **Security First** - XSS prevention, content sanitization, and secure defaults
- 📦 **Zero Config** - Works out of the box with sensible defaults
- 🔄 **Feed Parsing** - Parse existing RSS and Atom feeds from strings or URLs
- 🎨 **Flexible API** - Create feeds from scratch or from standardized article arrays
## Installation
```bash
npm install @push.rocks/smartfeed --save
pnpm install @push.rocks/smartfeed
```
## Usage
## Quick Start
`@push.rocks/smartfeed` is a powerful library for creating and parsing RSS and Atom feeds with ease. It leverages TypeScript for type safety and improved developer experience. Let's explore how you can utilize this library in your projects.
### Creating Feeds
You can create feeds by instantiating a `Smartfeed` object and configuring feed options and items. Heres an example of how to create an RSS feed:
```typescript
import { Smartfeed, IFeedOptions, IFeedItem } from '@push.rocks/smartfeed';
// Create a new Smartfeed instance
const mySmartfeed = new Smartfeed();
// Define feed options
const feedOptions: IFeedOptions = {
domain: 'example.com',
title: 'Example News',
description: 'Latest news from Example',
category: 'News',
company: 'Example Company',
companyEmail: 'contact@example.com',
companyDomain: 'https://example.com',
};
// Create a new feed with options
const myFeed = mySmartfeed.createFeed(feedOptions);
// Add items to the feed
const feedItem: IFeedItem = {
title: 'Breaking News: TypeScript Adoption Soars!',
timestamp: Date.now(), // Use current timestamp
url: 'https://example.com/news/typescript-adoption',
authorName: 'Jane Doe',
imageUrl: 'https://example.com/images/typescript-news.jpg',
content:
'In a recent survey, TypeScript has seen a significant increase in adoption among developers...',
};
// Add the item to the feed
myFeed.addItem(feedItem);
// Export the feed as an RSS string
const rssFeedString = myFeed.exportRssFeedString();
console.log(rssFeedString);
```
This code snippet creates an RSS feed for a news article. You can customize the `IFeedOptions` and `IFeedItem` objects to match your content.
### Parsing Feeds
`@push.rocks/smartfeed` also allows parsing of RSS and Atom feeds from a string or URL. Heres how you can parse a feed:
### Creating a Basic Feed
```typescript
import { Smartfeed } from '@push.rocks/smartfeed';
// Create a new Smartfeed instance
const mySmartfeed = new Smartfeed();
const smartfeed = new Smartfeed();
// Parsing a feed from a string
const rssString = `your RSS feed string here`;
mySmartfeed.parseFeedFromString(rssString).then((feed) => {
console.log(feed);
// Create a feed
const feed = smartfeed.createFeed({
domain: 'example.com',
title: 'Tech Insights',
description: 'Latest insights in technology and innovation',
category: 'Technology',
company: 'Example Inc',
companyEmail: 'hello@example.com',
companyDomain: 'https://example.com'
});
// Parsing a feed from a URL
const feedUrl = 'https://example.com/rss';
mySmartfeed.parseFeedFromUrl(feedUrl).then((feed) => {
console.log(feed);
// Add an item
feed.addItem({
title: 'TypeScript 5.0 Released',
timestamp: Date.now(),
url: 'https://example.com/posts/typescript-5',
authorName: 'Jane Developer',
imageUrl: 'https://example.com/images/typescript.jpg',
content: 'TypeScript 5.0 brings exciting new features...'
});
// Export as RSS, Atom, or JSON
const rss = feed.exportRssFeedString();
const atom = feed.exportAtomFeed();
const json = feed.exportJsonFeed();
```
### Custom Feed URL
You can specify a custom URL for your feed's self-reference instead of the default `https://${domain}/feed.xml`:
```typescript
const feed = smartfeed.createFeed({
domain: 'example.com',
title: 'My Blog',
description: 'Latest posts',
category: 'Technology',
company: 'Example Inc',
companyEmail: 'hello@example.com',
companyDomain: 'https://example.com',
feedUrl: 'https://cdn.example.com/feeds/main.xml' // Custom feed URL
});
// The feedUrl will be used in:
// - RSS: <atom:link href="..." rel="self">
// - Atom: <link href="..." rel="self">
// - JSON Feed: "feed_url" field
```
This is particularly useful when your feed is hosted on a CDN or different domain than your main site.
### Creating a Podcast Feed
```typescript
import { Smartfeed } from '@push.rocks/smartfeed';
const smartfeed = new Smartfeed();
const podcast = smartfeed.createPodcastFeed({
domain: 'podcast.example.com',
title: 'The Tech Show',
description: 'Weekly discussions about technology',
category: 'Technology',
company: 'Tech Media Inc',
companyEmail: 'podcast@example.com',
companyDomain: 'https://example.com',
// iTunes tags
itunesCategory: 'Technology',
itunesAuthor: 'John Host',
itunesOwner: {
name: 'John Host',
email: 'john@example.com'
},
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
itunesType: 'episodic',
// Podcast 2.0 tags
podcastGuid: '92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec', // Permanent GUID
podcastMedium: 'podcast', // or 'music', 'video', 'film', 'audiobook', 'newsletter', 'blog'
podcastLocked: true, // Prevent unauthorized imports
podcastLockOwner: 'john@example.com'
});
// Add an episode
podcast.addEpisode({
title: 'Episode 42: The Future of AI',
authorName: 'John Host',
imageUrl: 'https://example.com/episode42.jpg',
timestamp: Date.now(),
url: 'https://example.com/episodes/42',
content: 'In this episode, we explore the future of artificial intelligence...',
audioUrl: 'https://example.com/audio/episode42.mp3',
audioType: 'audio/mpeg',
audioLength: 45678900, // bytes
itunesDuration: 3600, // seconds
itunesEpisode: 42,
itunesSeason: 2,
itunesEpisodeType: 'full',
itunesExplicit: false,
// Modern podcast features
persons: [
{ name: 'John Host', role: 'host' },
{ name: 'Jane Guest', role: 'guest', href: 'https://example.com/jane' }
],
transcripts: [
{ url: 'https://example.com/transcripts/ep42.txt', type: 'text/plain' }
],
funding: [
{ url: 'https://example.com/support', message: 'Support the show!' }
]
});
// Export podcast RSS with iTunes and Podcast namespace
const podcastRss = podcast.exportPodcastRss();
```
### Parsing Existing Feeds
```typescript
import { Smartfeed } from '@push.rocks/smartfeed';
const smartfeed = new Smartfeed();
// Parse from URL
const feed = await smartfeed.parseFeedFromUrl('https://example.com/feed.xml');
console.log(feed.title);
console.log(feed.items.map(item => item.title));
// Parse from string
const xmlString = '<rss>...</rss>';
const parsedFeed = await smartfeed.parseFeedFromString(xmlString);
```
### Creating Feeds from Article Arrays
```typescript
import { Smartfeed } from '@push.rocks/smartfeed';
import type { IArticle } from '@tsclass/tsclass';
const smartfeed = new Smartfeed();
const articles: IArticle[] = [
// Your article objects conforming to @tsclass/tsclass IArticle interface
];
const feedOptions = {
domain: 'blog.example.com',
title: 'My Blog',
description: 'Thoughts on code and design',
category: 'Programming',
company: 'Example Inc',
companyEmail: 'blog@example.com',
companyDomain: 'https://example.com'
};
// Creates an Atom feed from articles
const atomFeed = await smartfeed.createFeedFromArticleArray(feedOptions, articles);
```
## API Reference
### Smartfeed Class
The main class for creating and parsing feeds.
#### `createFeed(options: IFeedOptions): Feed`
Creates a standard feed (RSS/Atom/JSON).
**Options:**
- `domain` (string) - Feed domain (e.g., 'example.com')
- `title` (string) - Feed title
- `description` (string) - Feed description
- `category` (string) - Feed category
- `company` (string) - Company/organization name
- `companyEmail` (string) - Contact email
- `companyDomain` (string) - Company website URL (absolute)
- `feedUrl` (string, optional) - Custom URL for the feed's self-reference (defaults to `https://${domain}/feed.xml`)
#### `createPodcastFeed(options: IPodcastFeedOptions): PodcastFeed`
Creates a podcast feed with iTunes and Podcast namespace support.
**iTunes Options:**
- `itunesCategory` (string) - iTunes category
- `itunesSubcategory` (string, optional) - iTunes subcategory
- `itunesAuthor` (string) - Podcast author
- `itunesOwner` (object) - Owner info with `name` and `email`
- `itunesImage` (string) - Artwork URL (1400x1400 to 3000x3000, JPG/PNG)
- `itunesExplicit` (boolean) - Explicit content flag
- `itunesType` ('episodic' | 'serial', optional) - Podcast type
- `itunesSummary` (string, optional) - Detailed summary
- `copyright` (string, optional) - Custom copyright
- `language` (string, optional) - Language code (default: 'en')
**Podcast 2.0 Options:**
- `podcastGuid` (string) - **Required.** Globally unique identifier (GUID) for the podcast
- `podcastMedium` ('podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog', optional) - Content medium type
- `podcastLocked` (boolean, optional) - Prevents unauthorized podcast imports (e.g., to other platforms)
- `podcastLockOwner` (string, optional) - Email of who can unlock (required if `podcastLocked` is true)
#### `parseFeedFromUrl(url: string): Promise<ParsedFeed>`
Parses an RSS or Atom feed from a URL.
#### `parseFeedFromString(xmlString: string): Promise<ParsedFeed>`
Parses an RSS or Atom feed from an XML string.
#### `createFeedFromArticleArray(options: IFeedOptions, articles: IArticle[]): Promise<string>`
Creates an Atom feed from an array of `@tsclass/tsclass` article objects.
### Feed Class
Represents a feed that can be exported in multiple formats.
#### `addItem(item: IFeedItem): void`
Adds an item to the feed.
**Item Properties:**
- `title` (string) - Item title
- `timestamp` (number) - Unix timestamp in milliseconds
- `url` (string) - Absolute URL to the item
- `authorName` (string) - Author name
- `imageUrl` (string) - Absolute URL to featured image
- `content` (string) - Item content/description
- `id` (string, optional) - Unique identifier (uses URL if not provided)
#### `exportRssFeedString(): string`
Exports the feed as RSS 2.0 XML.
#### `exportAtomFeed(): string`
Exports the feed as Atom 1.0 XML.
#### `exportJsonFeed(): string`
Exports the feed as JSON Feed 1.0.
### PodcastFeed Class
Extends `Feed` with podcast-specific functionality.
#### `addEpisode(episode: IPodcastItem): void`
Adds a podcast episode to the feed.
**Episode Properties (in addition to IFeedItem):**
- `audioUrl` (string) - Absolute URL to audio file
- `audioType` (string) - MIME type (e.g., 'audio/mpeg')
- `audioLength` (number) - File size in bytes
- `itunesDuration` (number) - Duration in seconds
- `itunesEpisode` (number, optional) - Episode number
- `itunesSeason` (number, optional) - Season number
- `itunesEpisodeType` ('full' | 'trailer' | 'bonus', optional)
- `itunesExplicit` (boolean, optional) - Explicit content flag
- `itunesSubtitle` (string, optional) - Short description
- `itunesSummary` (string, optional) - Detailed summary
- `persons` (array, optional) - People involved (hosts, guests)
- `chapters` (array, optional) - Chapter markers
- `transcripts` (array, optional) - Transcript links
- `funding` (array, optional) - Donation/support links
#### `exportPodcastRss(): string`
Exports the podcast feed as RSS 2.0 with iTunes and Podcast namespace extensions.
## Validation & Security
`@push.rocks/smartfeed` includes comprehensive validation to ensure feed integrity and security:
- **URL Validation** - All URLs must be absolute and use http/https protocols
- **Email Validation** - Email addresses are validated against RFC standards
- **Domain Validation** - Proper domain format checking
- **Timestamp Validation** - Ensures timestamps are valid and reasonable
- **Content Sanitization** - Prevents XSS attacks through proper XML escaping
- **Duplicate Detection** - Prevents duplicate item IDs in feeds
- **Required Field Checking** - Validates all required fields are present
## Best Practices
### Feed Item IDs
Feed item IDs should be permanent and never change once published. This allows feed readers to properly track which items have been read:
```typescript
feed.addItem({
id: 'post-2024-01-15-typescript-tips', // Permanent ID
title: 'TypeScript Tips',
url: 'https://example.com/posts/typescript-tips',
// ... other fields
});
```
This example demonstrates how to parse an RSS feed from a given string or URL. The `parseFeedFromString` and `parseFeedFromUrl` methods return a Promise that resolves to the parsed feed object.
If you don't provide an `id`, the `url` will be used. Make sure URLs don't change for published items.
### Comprehensive Feed Management
### HTTPS URLs
With `@push.rocks/smartfeed`, you have full control over creating and managing feeds. Beyond basic scenarios shown above, you can create feeds from arrays of articles, customize feed and item properties extensively, and export feeds in different formats (RSS, Atom, JSON).
Always use HTTPS URLs for security and privacy. The library will warn you if HTTP URLs are used:
For instance, to create a feed from an array of article objects conforming to `@tsclass/tsclass`'s `IArticle` interface, you can use the `createFeedFromArticleArray` method. Additionally, explore different export options available on the `Feed` class to suit your needs, whether it's RSS 2.0, Atom 1.0, or JSON Feed 1.0.
```typescript
// ✅ Good
imageUrl: 'https://example.com/image.jpg'
Remember, `@push.rocks/smartfeed` is designed to streamline feed creation and parsing with a focus on type safety and developer experience. Explore its comprehensive API to leverage the full potential of feed management in your applications.
// ⚠️ Will trigger a warning
imageUrl: 'http://example.com/image.jpg'
```
For complete usage and all available methods, refer to the TypeScript declarations and source code available in the package. Happy coding!
### Podcast Artwork
For podcast feeds, artwork should be:
- Square (1:1 aspect ratio)
- Between 1400x1400 and 3000x3000 pixels
- JPG or PNG format
- Maximum 512 KB file size (Apple Podcasts requirement)
### Podcast 2.0 Compatibility
The library fully supports the [Podcast 2.0 namespace](https://github.com/Podcastindex-org/podcast-namespace), making your feeds compatible with modern podcast platforms like:
- **Podcast Index** - The open podcast directory
- **Castopod** - Open-source podcast hosting platform
- **Podverse** - Open-source podcast app
- And other Podcast 2.0-compliant apps
**Key Podcast 2.0 Features:**
- `podcast:guid` - Permanent unique identifier for your podcast
- `podcast:medium` - Declare if your feed is a podcast, music, video, etc.
- `podcast:locked` - Protect your podcast from unauthorized imports
- `podcast:person` - List hosts, co-hosts, and guests with rich metadata
- `podcast:transcript` - Link to transcript files in various formats
- `podcast:funding` - Add donation/support links for your listeners
These features are included in the RSS export when you use `exportPodcastRss()`.
## TypeScript Support
Full TypeScript definitions are included. Import types as needed:
```typescript
import type {
IFeedOptions,
IFeedItem,
IPodcastFeedOptions,
IPodcastItem,
IPodcastOwner,
IPodcastPerson,
IPodcastChapter,
IPodcastTranscript,
IPodcastFunding
} from '@push.rocks/smartfeed';
```
## Why @push.rocks/smartfeed?
- **Type-Safe** - Catch errors at compile time, not runtime
- **Modern Standards** - Full support for latest podcast specifications
- **Secure by Default** - Built-in validation and sanitization
- **Developer Friendly** - Intuitive API with great error messages
- **Well Tested** - Comprehensive test suite ensuring reliability
- **Actively Maintained** - Regular updates and improvements
## License and Legal Information
@@ -106,7 +408,7 @@ This project is owned and maintained by Task Venture Capital GmbH. The names and
### Company Information
Task Venture Capital GmbH
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.

View File

@@ -0,0 +1,390 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartfeed from '../ts/index.js';
let testSmartFeed: smartfeed.Smartfeed;
let advancedPodcast: smartfeed.PodcastFeed;
tap.test('setup', async () => {
testSmartFeed = new smartfeed.Smartfeed();
advancedPodcast = testSmartFeed.createPodcastFeed({
domain: 'advanced.example.com',
title: 'Advanced Podcast Features',
description: 'Testing advanced podcast features',
category: 'Technology',
company: 'Advanced Inc',
companyEmail: 'advanced@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Tech Host',
itunesOwner: { name: 'Tech Host', email: 'host@example.com' },
itunesImage: 'https://example.com/podcast.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
});
tap.test('should add episode with persons (hosts and guests)', async () => {
advancedPodcast.addEpisode({
title: 'Episode with Guests',
authorName: 'Main Host',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/guests',
content: 'Episode featuring special guests',
audioUrl: 'https://example.com/audio/guests.mp3',
audioType: 'audio/mpeg',
audioLength: 50000000,
itunesDuration: 3600,
persons: [
{
name: 'Main Host',
role: 'host',
href: 'https://example.com/host',
img: 'https://example.com/host.jpg',
},
{
name: 'Special Guest 1',
role: 'guest',
href: 'https://example.com/guest1',
},
{
name: 'Special Guest 2',
role: 'guest',
},
],
});
expect(advancedPodcast.episodes[0].persons).toBeArray();
expect(advancedPodcast.episodes[0].persons?.length).toEqual(3);
expect(advancedPodcast.episodes[0].persons?.[0].role).toEqual('host');
expect(advancedPodcast.episodes[0].persons?.[1].role).toEqual('guest');
});
tap.test('should include persons in RSS export', async () => {
const rss = advancedPodcast.exportPodcastRss();
expect(rss).toInclude('xmlns:podcast="https://podcastindex.org/namespace/1.0"');
expect(rss).toInclude('<podcast:person role="host"');
expect(rss).toInclude('Main Host</podcast:person>');
expect(rss).toInclude('<podcast:person role="guest"');
expect(rss).toInclude('Special Guest 1</podcast:person>');
expect(rss).toInclude('href="https://example.com/host"');
});
tap.test('should add episode with transcripts', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'transcript.example.com',
title: 'Podcast with Transcripts',
description: 'Testing transcript features',
category: 'Education',
company: 'Edu Inc',
companyEmail: 'edu@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Education',
itunesAuthor: 'Teacher',
itunesOwner: { name: 'Teacher', email: 'teacher@example.com' },
itunesImage: 'https://example.com/edu.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
title: 'Episode with Transcript',
authorName: 'Teacher',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/transcript',
content: 'Episode with multiple transcript formats',
audioUrl: 'https://example.com/audio/episode.mp3',
audioType: 'audio/mpeg',
audioLength: 40000000,
itunesDuration: 2400,
transcripts: [
{
url: 'https://example.com/transcripts/episode.txt',
type: 'text/plain',
language: 'en',
},
{
url: 'https://example.com/transcripts/episode.srt',
type: 'application/srt',
language: 'en',
rel: 'captions',
},
{
url: 'https://example.com/transcripts/episode.vtt',
type: 'text/vtt',
language: 'en',
},
],
});
expect(podcast.episodes[0].transcripts).toBeArray();
expect(podcast.episodes[0].transcripts?.length).toEqual(3);
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:transcript url="https://example.com/transcripts/episode.txt"');
expect(rss).toInclude('type="text/plain"');
expect(rss).toInclude('language="en"');
expect(rss).toInclude('type="application/srt"');
expect(rss).toInclude('rel="captions"');
});
tap.test('should add episode with funding links', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'funding.example.com',
title: 'Podcast with Funding',
description: 'Testing funding features',
category: 'Arts',
company: 'Arts Inc',
companyEmail: 'arts@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Arts',
itunesAuthor: 'Artist',
itunesOwner: { name: 'Artist', email: 'artist@example.com' },
itunesImage: 'https://example.com/arts.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
title: 'Episode with Funding',
authorName: 'Artist',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/funding',
content: 'Support this podcast',
audioUrl: 'https://example.com/audio/episode.mp3',
audioType: 'audio/mpeg',
audioLength: 35000000,
itunesDuration: 2100,
funding: [
{
url: 'https://patreon.com/example',
message: 'Support us on Patreon',
},
{
url: 'https://buymeacoffee.com/example',
message: 'Buy me a coffee',
},
],
});
expect(podcast.episodes[0].funding).toBeArray();
expect(podcast.episodes[0].funding?.length).toEqual(2);
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:funding url="https://patreon.com/example">Support us on Patreon</podcast:funding>');
expect(rss).toInclude('<podcast:funding url="https://buymeacoffee.com/example">Buy me a coffee</podcast:funding>');
});
tap.test('should add episode with all advanced features', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'complete.example.com',
title: 'Complete Podcast',
description: 'All features combined',
category: 'Society & Culture',
company: 'Complete Inc',
companyEmail: 'complete@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Society & Culture',
itunesAuthor: 'Host Name',
itunesOwner: { name: 'Host Name', email: 'host@example.com' },
itunesImage: 'https://example.com/complete.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
title: 'Complete Feature Episode',
authorName: 'Host Name',
imageUrl: 'https://example.com/complete-episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/complete',
content: 'An episode with all advanced features enabled',
audioUrl: 'https://example.com/audio/complete.mp3',
audioType: 'audio/mpeg',
audioLength: 60000000,
itunesDuration: 4500,
itunesEpisode: 42,
itunesSeason: 2,
itunesEpisodeType: 'full',
itunesSubtitle: 'A subtitle for this episode',
itunesSummary: 'A longer summary describing this amazing episode in detail',
persons: [
{ name: 'Host Name', role: 'host', href: 'https://example.com/host' },
{ name: 'Co-Host', role: 'co-host' },
{ name: 'Guest Expert', role: 'guest' },
],
transcripts: [
{ url: 'https://example.com/transcript.txt', type: 'text/plain', language: 'en' },
],
funding: [
{ url: 'https://support.example.com', message: 'Support the show' },
],
});
expect(podcast.episodes.length).toEqual(1);
const rss = podcast.exportPodcastRss();
// Verify iTunes tags
expect(rss).toInclude('<itunes:episode>42</itunes:episode>');
expect(rss).toInclude('<itunes:season>2</itunes:season>');
expect(rss).toInclude('<itunes:episodeType>full</itunes:episodeType>');
expect(rss).toInclude('<itunes:subtitle>A subtitle for this episode</itunes:subtitle>');
expect(rss).toInclude('<itunes:summary>A longer summary describing this amazing episode in detail</itunes:summary>');
// Verify podcast namespace tags
expect(rss).toInclude('<podcast:person role="host"');
expect(rss).toInclude('<podcast:person role="co-host"');
expect(rss).toInclude('<podcast:person role="guest"');
expect(rss).toInclude('<podcast:transcript');
expect(rss).toInclude('<podcast:funding');
});
tap.test('should handle explicit content flag at episode level', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'explicit.example.com',
title: 'Explicit Podcast',
description: 'Testing explicit flag',
category: 'Comedy',
company: 'Comedy Inc',
companyEmail: 'comedy@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Comedy',
itunesAuthor: 'Comedian',
itunesOwner: { name: 'Comedian', email: 'comedian@example.com' },
itunesImage: 'https://example.com/comedy.jpg',
itunesExplicit: false, // Podcast is not explicit by default
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
title: 'Clean Episode',
authorName: 'Comedian',
imageUrl: 'https://example.com/clean.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/clean',
content: 'A clean episode',
audioUrl: 'https://example.com/audio/clean.mp3',
audioType: 'audio/mpeg',
audioLength: 30000000,
itunesDuration: 1800,
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
podcast.addEpisode({
title: 'Explicit Episode',
authorName: 'Comedian',
imageUrl: 'https://example.com/explicit.jpg',
timestamp: Date.now() + 1,
url: 'https://example.com/episode/explicit',
content: 'An explicit episode',
audioUrl: 'https://example.com/audio/explicit.mp3',
audioType: 'audio/mpeg',
audioLength: 30000000,
itunesDuration: 1800,
itunesExplicit: true, // This episode is explicit
podcastGuid: 'test-guid-auto',
});
const rss = podcast.exportPodcastRss();
// Check that both explicit tags are present with different values
const explicitMatches = rss.match(/<itunes:explicit>(true|false)<\/itunes:explicit>/g);
expect(explicitMatches).toBeArray();
expect(rss).toInclude('<itunes:explicit>false</itunes:explicit>'); // Clean episode
expect(rss).toInclude('<itunes:explicit>true</itunes:explicit>'); // Explicit episode
});
tap.test('should validate transcript URL', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
transcripts: [
{
url: 'not-a-url', // Invalid!
type: 'text/plain',
},
],
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('Invalid or relative URL');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate funding URL', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
funding: [
{
url: 'relative/path', // Invalid!
message: 'Support us',
},
],
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('Invalid or relative URL');
}
expect(errorThrown).toEqual(true);
});
export default tap.start();

View File

@@ -25,6 +25,7 @@ tap.test('should create a podcast feed', async () => {
},
itunesImage: 'https://example.com/podcast-artwork.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-001',
});
expect(testPodcast).toBeInstanceOf(smartfeed.PodcastFeed);
@@ -46,6 +47,7 @@ tap.test('should create podcast feed with episodic type', async () => {
itunesOwner: { name: 'Comedian', email: 'comedian@example.com' },
itunesImage: 'https://example.com/comedy.jpg',
itunesExplicit: true,
podcastGuid: 'test-podcast-guid-002',
itunesType: 'episodic',
});
@@ -67,6 +69,7 @@ tap.test('should create podcast feed with serial type', async () => {
itunesOwner: { name: 'Detective', email: 'detective@example.com' },
itunesImage: 'https://example.com/crime.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-003',
itunesType: 'serial',
});
@@ -109,6 +112,7 @@ tap.test('should add multiple episodes', async () => {
itunesOwner: { name: 'Teacher', email: 'teacher@example.com' },
itunesImage: 'https://example.com/edu.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-004',
});
for (let i = 1; i <= 5; i++) {
@@ -145,6 +149,7 @@ tap.test('should add episode with trailer type', async () => {
itunesOwner: { name: 'Reporter', email: 'reporter@example.com' },
itunesImage: 'https://example.com/news.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-005',
});
podcast.addEpisode({
@@ -179,6 +184,7 @@ tap.test('should add episode with bonus type', async () => {
itunesOwner: { name: 'Entrepreneur', email: 'entrepreneur@example.com' },
itunesImage: 'https://example.com/business.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-006',
});
podcast.addEpisode({
@@ -212,6 +218,7 @@ tap.test('should support M4A audio format', async () => {
itunesOwner: { name: 'Musician', email: 'musician@example.com' },
itunesImage: 'https://example.com/music.jpg',
itunesExplicit: false,
podcastGuid: 'test-podcast-guid-007',
});
podcast.addEpisode({

View File

@@ -0,0 +1,418 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartfeed from '../ts/index.js';
let testSmartFeed: smartfeed.Smartfeed;
tap.test('setup', async () => {
testSmartFeed = new smartfeed.Smartfeed();
});
tap.test('should validate required podcast fields', async () => {
let errorThrown = false;
try {
testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
// Missing iTunes required fields
} as any);
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('validation failed');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate iTunes owner email', async () => {
let errorThrown = false;
try {
testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'not-an-email' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-validation-guid-001',
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('Invalid email');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate iTunes image URL', async () => {
let errorThrown = false;
try {
testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'not-a-url',
itunesExplicit: false,
podcastGuid: 'test-validation-guid-002',
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('Invalid or relative URL');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate iTunes type', async () => {
let errorThrown = false;
try {
testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
itunesType: 'invalid' as any,
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('must be either "episodic" or "serial"');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate episode audio URL', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test Podcast',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'not-a-url',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('Invalid or relative URL');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate audio type', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test Podcast',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'video/mp4', // Wrong type!
audioLength: 1000000,
itunesDuration: 600,
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('Invalid audio type');
expect(error.message).toInclude('Must start with \'audio/\'');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate audio length', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test Podcast',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: -100, // Invalid!
itunesDuration: 600,
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('must be a positive number');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate duration', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test Podcast',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 0, // Invalid!
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('duration must be a positive number');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate episode type', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test Podcast',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
itunesEpisodeType: 'invalid' as any,
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('must be "full", "trailer", or "bonus"');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate episode number', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test Podcast',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
itunesEpisode: 0, // Invalid!
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('episode number must be a positive integer');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate season number', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test Podcast',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
let errorThrown = false;
try {
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
itunesSeason: -1, // Invalid!
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('season number must be a positive integer');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate duplicate episode IDs', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test Podcast',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-auto',
});
const episodeData = {
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Content',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
};
podcast.addEpisode(episodeData);
let errorThrown = false;
try {
podcast.addEpisode(episodeData);
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('Duplicate episode ID');
}
expect(errorThrown).toEqual(true);
});
export default tap.start();

342
test/test.podcast2.all.ts Normal file
View File

@@ -0,0 +1,342 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartfeed from '../ts/index.js';
let testSmartFeed: smartfeed.Smartfeed;
tap.test('setup', async () => {
testSmartFeed = new smartfeed.Smartfeed();
});
tap.test('should create podcast with podcast:guid', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'podcast2.example.com',
title: 'Podcast 2.0 Test',
description: 'Testing Podcast 2.0 features',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Tech Author',
itunesOwner: { name: 'Tech Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: '92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec',
});
expect(podcast).toBeInstanceOf(smartfeed.PodcastFeed);
expect(podcast.podcastOptions.podcastGuid).toEqual('92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec');
});
tap.test('should require podcast:guid', async () => {
let errorThrown = false;
try {
testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
// Missing podcastGuid
} as any);
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('podcastGuid');
}
expect(errorThrown).toEqual(true);
});
tap.test('should create podcast with medium type', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'music.example.com',
title: 'Music Podcast',
description: 'A music podcast',
category: 'Music',
company: 'Music Inc',
companyEmail: 'music@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Music',
itunesAuthor: 'DJ',
itunesOwner: { name: 'DJ', email: 'dj@example.com' },
itunesImage: 'https://example.com/music.jpg',
itunesExplicit: false,
podcastGuid: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
podcastMedium: 'music',
});
expect(podcast.podcastOptions.podcastMedium).toEqual('music');
});
tap.test('should validate podcast medium type', async () => {
let errorThrown = false;
try {
testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-123',
podcastMedium: 'invalid' as any,
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('medium must be one of');
}
expect(errorThrown).toEqual(true);
});
tap.test('should create locked podcast', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'locked.example.com',
title: 'Locked Podcast',
description: 'A locked podcast',
category: 'News',
company: 'News Inc',
companyEmail: 'news@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'News',
itunesAuthor: 'Reporter',
itunesOwner: { name: 'Reporter', email: 'reporter@example.com' },
itunesImage: 'https://example.com/news.jpg',
itunesExplicit: false,
podcastGuid: 'locked-guid-456',
podcastLocked: true,
podcastLockOwner: 'owner@example.com',
});
expect(podcast.podcastOptions.podcastLocked).toEqual(true);
expect(podcast.podcastOptions.podcastLockOwner).toEqual('owner@example.com');
});
tap.test('should require lock owner when podcast is locked', async () => {
let errorThrown = false;
try {
testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-789',
podcastLocked: true,
// Missing podcastLockOwner
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('lock owner');
expect(error.message).toInclude('required when podcast is locked');
}
expect(errorThrown).toEqual(true);
});
tap.test('should validate lock owner email format', async () => {
let errorThrown = false;
try {
testSmartFeed.createPodcastFeed({
domain: 'test.com',
title: 'Test',
description: 'Test',
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
itunesImage: 'https://example.com/image.jpg',
itunesExplicit: false,
podcastGuid: 'test-guid-abc',
podcastLocked: true,
podcastLockOwner: 'not-an-email',
});
} catch (error) {
errorThrown = true;
expect(error.message).toInclude('email');
}
expect(errorThrown).toEqual(true);
});
tap.test('should include podcast:guid in RSS export', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'export.example.com',
title: 'Export Test',
description: 'Testing RSS export',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: 'export-test-guid-123',
});
podcast.addEpisode({
title: 'Episode 1',
authorName: 'Author',
imageUrl: 'https://example.com/episode.jpg',
timestamp: Date.now(),
url: 'https://example.com/episode/1',
content: 'Test episode',
audioUrl: 'https://example.com/audio.mp3',
audioType: 'audio/mpeg',
audioLength: 1000000,
itunesDuration: 600,
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:guid>export-test-guid-123</podcast:guid>');
expect(rss).toInclude('xmlns:podcast="https://podcastindex.org/namespace/1.0"');
});
tap.test('should include podcast:medium in RSS export', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'video.example.com',
title: 'Video Podcast',
description: 'A video podcast',
category: 'Video',
company: 'Video Inc',
companyEmail: 'video@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Video',
itunesAuthor: 'Videographer',
itunesOwner: { name: 'Videographer', email: 'video@example.com' },
itunesImage: 'https://example.com/video.jpg',
itunesExplicit: false,
podcastGuid: 'video-guid-def',
podcastMedium: 'video',
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:medium>video</podcast:medium>');
});
tap.test('should default to podcast medium when not specified', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'default.example.com',
title: 'Default Podcast',
description: 'Testing default medium',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: 'default-guid-ghi',
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:medium>podcast</podcast:medium>');
});
tap.test('should include podcast:locked in RSS export', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'locked-export.example.com',
title: 'Locked Export Test',
description: 'Testing locked RSS export',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: 'locked-export-guid-jkl',
podcastLocked: true,
podcastLockOwner: 'lock@example.com',
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:locked owner="lock@example.com">yes</podcast:locked>');
});
tap.test('should include podcast:locked as "no" when not locked', async () => {
const podcast = testSmartFeed.createPodcastFeed({
domain: 'unlocked.example.com',
title: 'Unlocked Podcast',
description: 'Testing unlocked RSS export',
category: 'Technology',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: 'unlocked-guid-mno',
podcastLocked: false,
});
const rss = podcast.exportPodcastRss();
expect(rss).toInclude('<podcast:locked>no</podcast:locked>');
});
tap.test('should support all medium types', async () => {
const mediums: Array<'podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog'> = [
'podcast',
'music',
'video',
'film',
'audiobook',
'newsletter',
'blog',
];
for (const medium of mediums) {
const podcast = testSmartFeed.createPodcastFeed({
domain: `${medium}.example.com`,
title: `${medium} Test`,
description: `Testing ${medium} medium`,
category: 'Test',
company: 'Test Inc',
companyEmail: 'test@example.com',
companyDomain: 'https://example.com',
itunesCategory: 'Technology',
itunesAuthor: 'Author',
itunesOwner: { name: 'Author', email: 'author@example.com' },
itunesImage: 'https://example.com/artwork.jpg',
itunesExplicit: false,
podcastGuid: `${medium}-guid`,
podcastMedium: medium,
});
expect(podcast.podcastOptions.podcastMedium).toEqual(medium);
}
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartfeed',
version: '1.1.0',
version: '1.4.0',
description: 'A library for creating and parsing various feed formats.'
}

View File

@@ -19,6 +19,8 @@ export interface IFeedOptions {
companyEmail: string;
/** The company website URL (must be absolute) */
companyDomain: string;
/** Optional: Custom URL for the feed's atom:link rel="self" (defaults to https://${domain}/feed.xml) */
feedUrl?: string;
}
/**
@@ -59,7 +61,7 @@ export interface IFeedItem {
export class Feed {
options: IFeedOptions;
items: IFeedItem[] = [];
private itemIds: Set<string> = new Set();
protected itemIds: Set<string> = new Set();
/**
* Creates a new Feed instance
@@ -131,48 +133,164 @@ 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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
const selfUrl = this.options.feedUrl || `https://${this.options.domain}/feed.xml`;
rss += `<atom:link href="${selfUrl}" 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`;
const selfUrl = this.options.feedUrl || `https://${this.options.domain}/feed.xml`;
atom += `<link href="${selfUrl}" 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: this.options.feedUrl || `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, '&amp;'),
image: itemArg.imageUrl.replace(/&/gm, '&amp;'),
content: sanitizedContent,
id: itemArg.id || itemArg.url,
author: [
{
name: itemArg.authorName,
},
],
});
}
return feed;
return JSON.stringify(jsonFeed, null, 2);
}
/**
@@ -185,7 +303,7 @@ export class Feed {
* ```
*/
public exportRssFeedString(): string {
return this.getFeedObject().rss2();
return this.generateRss2();
}
/**
@@ -197,7 +315,7 @@ export class Feed {
* ```
*/
public exportAtomFeed(): string {
return this.getFeedObject().atom1();
return this.generateAtom1();
}
/**
@@ -210,6 +328,6 @@ export class Feed {
* ```
*/
public exportJsonFeed(): string {
return this.getFeedObject().json1();
return this.generateJsonFeed();
}
}

View File

@@ -1,6 +1,7 @@
import * as plugins from './plugins.js';
import * as validation from './validation.js';
import { Feed, IFeedOptions, IFeedItem } from './classes.feed.js';
import { Feed } from './classes.feed.js';
import type { IFeedOptions, IFeedItem } from './classes.feed.js';
/**
* iTunes podcast owner information
@@ -14,7 +15,7 @@ export interface IPodcastOwner {
/**
* Configuration options for creating a podcast feed
* Extends standard feed options with iTunes-specific fields
* Extends standard feed options with iTunes-specific and Podcast 2.0 fields
*/
export interface IPodcastFeedOptions extends IFeedOptions {
/** iTunes category (e.g., 'Technology', 'Comedy', 'News') */
@@ -37,6 +38,16 @@ export interface IPodcastFeedOptions extends IFeedOptions {
copyright?: string;
/** Language code (overrides default 'en') */
language?: string;
// Podcast 2.0 namespace fields
/** Globally unique identifier for the podcast (GUID) - required for Podcast 2.0 */
podcastGuid: string;
/** The medium of the podcast content (defaults to 'podcast') */
podcastMedium?: 'podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog';
/** Whether the podcast is locked to prevent unauthorized imports (defaults to false) */
podcastLocked?: boolean;
/** Email/contact of who can unlock the podcast if locked (required if podcastLocked is true) */
podcastLockOwner?: string;
}
/**
@@ -184,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;
}
@@ -211,9 +252,10 @@ export class PodcastFeed extends Feed {
*/
public addEpisode(episodeArg: IPodcastItem): void {
// Validate standard item fields first
// Note: itunesDuration is validated separately to allow for proper numeric validation
validation.validateRequiredFields(
episodeArg,
['title', 'timestamp', 'url', 'authorName', 'imageUrl', 'content', 'audioUrl', 'audioType', 'audioLength', 'itunesDuration'],
['title', 'timestamp', 'url', 'authorName', 'imageUrl', 'content', 'audioUrl', 'audioType', 'audioLength'],
'Podcast episode'
);
@@ -235,7 +277,10 @@ export class PodcastFeed extends Feed {
throw new Error('Audio length must be a positive number (bytes)');
}
// Validate duration
// Validate duration (must be provided and be a positive number)
if (episodeArg.itunesDuration === undefined || episodeArg.itunesDuration === null) {
throw new Error('iTunes duration is required');
}
if (typeof episodeArg.itunesDuration !== 'number' || episodeArg.itunesDuration <= 0) {
throw new Error('iTunes duration must be a positive number (seconds)');
}
@@ -324,7 +369,8 @@ export class PodcastFeed extends Feed {
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`;
const selfUrl = this.podcastOptions.feedUrl || `https://${this.podcastOptions.domain}/feed.xml`;
rss += `<atom:link href="${selfUrl}" rel="self" type="application/rss+xml" />\n`;
// iTunes channel tags
rss += `<itunes:author>${this.escapeXml(this.podcastOptions.itunesAuthor)}</itunes:author>\n`;
@@ -346,6 +392,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';
@@ -419,18 +484,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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}

View File

@@ -3,6 +3,7 @@ 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';
import { parseFeedXML } from './lib/feedparser.js';
/**
* Main class for creating and parsing various feed formats (RSS, Atom, JSON Feed)
@@ -120,9 +121,7 @@ export class Smartfeed {
* ```
*/
public async parseFeedFromString(rssFeedString: string) {
const parser = new plugins.rssParser();
const resultingFeed = await parser.parseString(rssFeedString);
return resultingFeed;
return parseFeedXML(rssFeedString);
}
/**
@@ -138,8 +137,11 @@ export class Smartfeed {
* ```
*/
public async parseFeedFromUrl(urlArg: string) {
const parser = new plugins.rssParser();
const resultingFeed = await parser.parseURL(urlArg);
return resultingFeed;
const response = await fetch(urlArg);
if (!response.ok) {
throw new Error(`Failed to fetch feed: ${response.status} ${response.statusText}`);
}
const xmlString = await response.text();
return parseFeedXML(xmlString);
}
}

326
ts/lib/feedparser.ts Normal file
View File

@@ -0,0 +1,326 @@
import * as plugins from '../plugins.js';
/**
* Parsed feed structure compatible with rss-parser output
*/
export interface IParsedFeed {
title?: string;
description?: string;
link?: string;
feedUrl?: string;
image?: {
link?: string;
url?: string;
title?: string;
};
items: IParsedItem[];
[key: string]: any;
}
/**
* Parsed item structure compatible with rss-parser output
*/
export interface IParsedItem {
title?: string;
link?: string;
pubDate?: string;
author?: string;
content?: string;
contentSnippet?: string;
id?: string;
isoDate?: string;
[key: string]: any;
}
/**
* Gets text content from XML element, handling both direct text and CDATA
*/
function getContent(element: any): string {
if (!element) return '';
if (typeof element === 'string') return element;
if (element['#text']) return element['#text'];
if (element._) return element._;
return String(element);
}
/**
* Creates a snippet from HTML content (removes tags, truncates)
*/
function getSnippet(html: string, maxLength: number = 200): string {
if (!html) return '';
// Remove HTML tags
let text = html.replace(/<[^>]+>/g, '');
// Decode common HTML entities
text = text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
// Truncate
if (text.length > maxLength) {
text = text.substring(0, maxLength) + '...';
}
return text.trim();
}
/**
* Formats date to ISO string, handling various date formats
*/
function toISODate(dateString: string): string | undefined {
if (!dateString) return undefined;
try {
const date = new Date(dateString.trim());
return date.toISOString();
} catch (e) {
return undefined;
}
}
/**
* Parses RSS 2.0 feed
*/
function parseRSS2(xmlObj: any): IParsedFeed {
const channel = xmlObj.rss?.channel;
if (!channel) {
throw new Error('Invalid RSS 2.0 feed: missing channel element');
}
const feed: IParsedFeed = {
items: [],
};
// Channel metadata
if (channel.title) feed.title = getContent(channel.title);
if (channel.description) feed.description = getContent(channel.description);
if (channel.link) feed.link = getContent(channel.link);
if (channel.language) feed.language = getContent(channel.language);
if (channel.copyright) feed.copyright = getContent(channel.copyright);
if (channel.generator) feed.generator = getContent(channel.generator);
if (channel.lastBuildDate) feed.lastBuildDate = getContent(channel.lastBuildDate);
// Feed URL from atom:link
if (channel['atom:link']) {
const atomLinks = Array.isArray(channel['atom:link']) ? channel['atom:link'] : [channel['atom:link']];
for (const link of atomLinks) {
if (link['@_rel'] === 'self' && link['@_href']) {
feed.feedUrl = link['@_href'];
break;
}
}
}
// Image
if (channel.image) {
feed.image = {};
if (channel.image.url) feed.image.url = getContent(channel.image.url);
if (channel.image.title) feed.image.title = getContent(channel.image.title);
if (channel.image.link) feed.image.link = getContent(channel.image.link);
}
// Items
const items = channel.item ? (Array.isArray(channel.item) ? channel.item : [channel.item]) : [];
feed.items = items.map((xmlItem: any) => {
const item: IParsedItem = {};
if (xmlItem.title) item.title = getContent(xmlItem.title);
if (xmlItem.link) item.link = getContent(xmlItem.link);
if (xmlItem.description) {
item.content = getContent(xmlItem.description);
item.contentSnippet = getSnippet(item.content);
}
if (xmlItem.pubDate) {
item.pubDate = getContent(xmlItem.pubDate);
item.isoDate = toISODate(item.pubDate);
}
if (xmlItem.author) item.author = getContent(xmlItem.author);
if (xmlItem['dc:creator']) item.author = getContent(xmlItem['dc:creator']);
// ID/GUID
if (xmlItem.guid) {
const guid = xmlItem.guid;
item.id = typeof guid === 'object' && guid['#text'] ? guid['#text'] : getContent(guid);
}
if (!item.id && xmlItem.link) {
item.id = getContent(xmlItem.link);
}
// Enclosure
if (xmlItem.enclosure && xmlItem.enclosure['@_url']) {
item.enclosure = {
url: xmlItem.enclosure['@_url'],
type: xmlItem.enclosure['@_type'],
length: xmlItem.enclosure['@_length'],
};
}
// Categories
if (xmlItem.category) {
item.categories = Array.isArray(xmlItem.category)
? xmlItem.category.map((cat: any) => getContent(cat))
: [getContent(xmlItem.category)];
}
return item;
});
return feed;
}
/**
* Parses Atom 1.0 feed
*/
function parseAtom(xmlObj: any): IParsedFeed {
const atomFeed = xmlObj.feed;
if (!atomFeed) {
throw new Error('Invalid Atom feed: missing feed element');
}
const feed: IParsedFeed = {
items: [],
};
// Feed metadata
if (atomFeed.title) feed.title = getContent(atomFeed.title);
if (atomFeed.subtitle) feed.description = getContent(atomFeed.subtitle);
if (atomFeed.id) feed.feedUrl = getContent(atomFeed.id);
// Links
if (atomFeed.link) {
const links = Array.isArray(atomFeed.link) ? atomFeed.link : [atomFeed.link];
for (const link of links) {
if (link['@_rel'] === 'alternate' && link['@_href']) {
feed.link = link['@_href'];
}
if (link['@_rel'] === 'self' && link['@_href']) {
feed.feedUrl = link['@_href'];
}
}
}
// Entries
const entries = atomFeed.entry ? (Array.isArray(atomFeed.entry) ? atomFeed.entry : [atomFeed.entry]) : [];
feed.items = entries.map((entry: any) => {
const item: IParsedItem = {};
if (entry.title) item.title = getContent(entry.title);
if (entry.id) item.id = getContent(entry.id);
// Link
if (entry.link) {
const links = Array.isArray(entry.link) ? entry.link : [entry.link];
for (const link of links) {
if (link['@_rel'] === 'alternate' && link['@_href']) {
item.link = link['@_href'];
break;
}
if (!item.link && link['@_href']) {
item.link = link['@_href'];
}
}
}
// Dates
if (entry.published) {
item.pubDate = getContent(entry.published);
item.isoDate = toISODate(item.pubDate);
} else if (entry.updated) {
item.pubDate = getContent(entry.updated);
item.isoDate = toISODate(item.pubDate);
}
// Author
if (entry.author && entry.author.name) {
item.author = getContent(entry.author.name);
}
// Content
if (entry.content) {
item.content = getContent(entry.content);
item.contentSnippet = getSnippet(item.content);
} else if (entry.summary) {
item.content = getContent(entry.summary);
item.contentSnippet = getSnippet(item.content);
}
return item;
});
return feed;
}
/**
* Parses RSS 1.0 (RDF) feed
*/
function parseRSS1(xmlObj: any): IParsedFeed {
const rdf = xmlObj['rdf:RDF'];
if (!rdf) {
throw new Error('Invalid RSS 1.0 feed: missing rdf:RDF element');
}
const feed: IParsedFeed = {
items: [],
};
const channel = rdf.channel;
if (channel) {
if (channel.title) feed.title = getContent(channel.title);
if (channel.description) feed.description = getContent(channel.description);
if (channel.link) feed.link = getContent(channel.link);
}
// Items
const items = rdf.item ? (Array.isArray(rdf.item) ? rdf.item : [rdf.item]) : [];
feed.items = items.map((xmlItem: any) => {
const item: IParsedItem = {};
if (xmlItem.title) item.title = getContent(xmlItem.title);
if (xmlItem.link) item.link = getContent(xmlItem.link);
if (xmlItem.description) {
item.content = getContent(xmlItem.description);
item.contentSnippet = getSnippet(item.content);
}
if (xmlItem['dc:date']) {
item.pubDate = getContent(xmlItem['dc:date']);
item.isoDate = toISODate(item.pubDate);
}
if (xmlItem['dc:creator']) {
item.author = getContent(xmlItem['dc:creator']);
}
if (xmlItem['@_rdf:about']) {
item.id = xmlItem['@_rdf:about'];
}
return item;
});
return feed;
}
/**
* Detects feed type and parses accordingly
*/
export function parseFeedXML(xmlString: string): IParsedFeed {
const parser = new plugins.XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
textNodeName: '#text',
parseAttributeValue: false,
});
const xmlObj = parser.parse(xmlString);
// Detect feed type
if (xmlObj.rss && xmlObj.rss.channel) {
// RSS 2.0 or 0.9x
return parseRSS2(xmlObj);
} else if (xmlObj.feed) {
// Atom 1.0
return parseAtom(xmlObj);
} else if (xmlObj['rdf:RDF']) {
// RSS 1.0 (RDF)
return parseRSS1(xmlObj);
} else {
throw new Error('Feed not recognized as RSS or Atom');
}
}

View File

@@ -4,7 +4,6 @@ import * as tsclass from '@tsclass/tsclass';
export { tsclass };
// third party scope
import rssParser from 'rss-parser';
import * as feed from 'feed';
import { XMLParser } from 'fast-xml-parser';
export { rssParser, feed };
export { XMLParser };