feat(smartfeed): Implement Smartfeed core: add feed validation, parsing, exporting and comprehensive tests
This commit is contained in:
32
changelog.md
Normal file
32
changelog.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-10-31 - 1.1.0 - feat(smartfeed)
|
||||||
|
Implement Smartfeed core: add feed validation, parsing, exporting and comprehensive tests
|
||||||
|
|
||||||
|
- Implement Feed class with full option validation, addItem validation (URLs, email, timestamp), duplicate ID protection, content sanitization and generation of RSS/Atom/JSON feeds.
|
||||||
|
- Add validation utilities (validateUrl, validateDomain, validateEmail, validateTimestamp, validateRequiredFields, sanitizeContent) in ts/validation.ts used across the module.
|
||||||
|
- Implement Smartfeed class functions: createFeed, createFeedFromArticleArray, parseFeedFromString and parseFeedFromUrl with rss-parser integration.
|
||||||
|
- Adjust module exports (ts/index.ts) and plugin imports (ts/plugins.ts) to match implemented classes.
|
||||||
|
- Add comprehensive test suite under test/ (creation, export, parsing, validation, integration) to exercise new functionality.
|
||||||
|
- Add deno.lock to lock dependency graph for reproducible builds.
|
||||||
|
|
||||||
|
## 2025-10-31 - 1.0.11 - smartfeed / feed
|
||||||
|
Add feed and validation utilities for the smartfeed plugin and perform related dependency, refactor, test, and CI updates.
|
||||||
|
|
||||||
|
- feat: implement feed and validation utilities for smartfeed to support improved feed generation and input validation.
|
||||||
|
- chore: bump feed dependency to v5.1.0 and adjust import paths for consistency with the updated package.
|
||||||
|
- refactor: improve Feed and SmartFeed class structure and formatting for readability and maintainability.
|
||||||
|
- fix: update test imports to use the new package path after refactor/import changes.
|
||||||
|
- chore: streamline plugin exports to a consistent structure.
|
||||||
|
- chore: update README for clarity and formatting improvements.
|
||||||
|
- chore: update TypeScript configuration for better compatibility.
|
||||||
|
- ci: add workflows to handle tag and non-tag pushes.
|
||||||
|
|
||||||
|
## 2020-10-25 to 2024-05-29 - 1.0.1..1.0.11 - housekeeping
|
||||||
|
Collection of minor releases, metadata updates and routine fixes made across multiple intermediate versions.
|
||||||
|
|
||||||
|
- Multiple small "fix(core): update" changes and routine release markers (1.0.2 → 1.0.11).
|
||||||
|
- Updates to package metadata and npmextra.json (githost) across several commits.
|
||||||
|
- Switch to new organization naming/scheme.
|
||||||
|
- Miscellaneous tsconfig and description updates.
|
||||||
|
- These changes were primarily maintenance, CI/package metadata, and release housekeeping.
|
||||||
101
test/test.creation.node+bun+deno.ts
Normal file
101
test/test.creation.node+bun+deno.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartfeed from '../ts/index.js';
|
||||||
|
|
||||||
|
tap.test('should create a Smartfeed instance', async () => {
|
||||||
|
const testSmartFeed = new smartfeed.Smartfeed();
|
||||||
|
expect(testSmartFeed).toBeInstanceOf(smartfeed.Smartfeed);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create a feed with valid options', async () => {
|
||||||
|
const testSmartFeed = new smartfeed.Smartfeed();
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Example Feed',
|
||||||
|
category: 'Technology',
|
||||||
|
company: 'Example Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'hello@example.com',
|
||||||
|
description: 'An example technology feed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(feed).toBeInstanceOf(smartfeed.Feed);
|
||||||
|
expect(feed.options.domain).toEqual('example.com');
|
||||||
|
expect(feed.options.title).toEqual('Example Feed');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create a feed with HTTPS URLs', async () => {
|
||||||
|
const testSmartFeed = new smartfeed.Smartfeed();
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'secure.example.com',
|
||||||
|
title: 'Secure Feed',
|
||||||
|
category: 'Security',
|
||||||
|
company: 'Secure Inc',
|
||||||
|
companyDomain: 'https://secure.example.com',
|
||||||
|
companyEmail: 'security@example.com',
|
||||||
|
description: 'A secure feed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(feed.options.companyDomain).toEqual('https://secure.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should add items to feed', async () => {
|
||||||
|
const testSmartFeed = new smartfeed.Smartfeed();
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'blog.example.com',
|
||||||
|
title: 'Example Blog',
|
||||||
|
category: 'Blogging',
|
||||||
|
company: 'Example Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'blog@example.com',
|
||||||
|
description: 'A blog about examples',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'First Post',
|
||||||
|
authorName: 'John Doe',
|
||||||
|
imageUrl: 'https://example.com/image1.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/posts/first',
|
||||||
|
content: 'This is the first post',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Second Post',
|
||||||
|
authorName: 'Jane Doe',
|
||||||
|
imageUrl: 'https://example.com/image2.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/posts/second',
|
||||||
|
content: 'This is the second post',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(feed.items.length).toEqual(2);
|
||||||
|
expect(feed.items[0].title).toEqual('First Post');
|
||||||
|
expect(feed.items[1].title).toEqual('Second Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should add items with custom IDs', async () => {
|
||||||
|
const testSmartFeed = new smartfeed.Smartfeed();
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Example Feed',
|
||||||
|
category: 'Technology',
|
||||||
|
company: 'Example Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'hello@example.com',
|
||||||
|
description: 'An example feed',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Post with custom ID',
|
||||||
|
authorName: 'John Doe',
|
||||||
|
imageUrl: 'https://example.com/image.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/posts/custom',
|
||||||
|
content: 'This post has a custom ID',
|
||||||
|
id: 'custom-id-123',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(feed.items.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
141
test/test.export.node+bun+deno.ts
Normal file
141
test/test.export.node+bun+deno.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartfeed from '../ts/index.js';
|
||||||
|
|
||||||
|
let testSmartFeed: smartfeed.Smartfeed;
|
||||||
|
let testFeed: smartfeed.Feed;
|
||||||
|
|
||||||
|
tap.test('setup', async () => {
|
||||||
|
testSmartFeed = new smartfeed.Smartfeed();
|
||||||
|
testFeed = testSmartFeed.createFeed({
|
||||||
|
domain: 'central.eu',
|
||||||
|
title: 'central.eu - ideas for Europe',
|
||||||
|
category: 'Politics',
|
||||||
|
company: 'Lossless GmbH',
|
||||||
|
companyDomain: 'https://lossless.com',
|
||||||
|
companyEmail: 'hello@lossless.com',
|
||||||
|
description: 'ideas for Europe',
|
||||||
|
});
|
||||||
|
|
||||||
|
testFeed.addItem({
|
||||||
|
title: 'A better European Union',
|
||||||
|
authorName: 'Phil',
|
||||||
|
imageUrl: 'https://central.eu/someimage.png',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://central.eu/article/somearticle',
|
||||||
|
content: 'somecontent',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should export RSS 2.0 feed', async () => {
|
||||||
|
const rssFeed = testFeed.exportRssFeedString();
|
||||||
|
|
||||||
|
expect(rssFeed).toInclude('<?xml version="1.0" encoding="utf-8"?>');
|
||||||
|
expect(rssFeed).toInclude('<rss version="2.0"');
|
||||||
|
expect(rssFeed).toInclude('<title>central.eu - ideas for Europe</title>');
|
||||||
|
expect(rssFeed).toInclude('<link>https://central.eu</link>');
|
||||||
|
expect(rssFeed).toInclude('<description>ideas for Europe</description>');
|
||||||
|
expect(rssFeed).toInclude('<generator>@push.rocks/smartfeed</generator>');
|
||||||
|
expect(rssFeed).toInclude('<category>Politics</category>');
|
||||||
|
expect(rssFeed).toInclude('<item>');
|
||||||
|
expect(rssFeed).toInclude('A better European Union');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should export Atom 1.0 feed', async () => {
|
||||||
|
const atomFeed = testFeed.exportAtomFeed();
|
||||||
|
|
||||||
|
expect(atomFeed).toInclude('<?xml version="1.0" encoding="utf-8"?>');
|
||||||
|
expect(atomFeed).toInclude('<feed xmlns="http://www.w3.org/2005/Atom">');
|
||||||
|
expect(atomFeed).toInclude('<title>central.eu - ideas for Europe</title>');
|
||||||
|
expect(atomFeed).toInclude('<subtitle>ideas for Europe</subtitle>');
|
||||||
|
expect(atomFeed).toInclude('<generator>@push.rocks/smartfeed</generator>');
|
||||||
|
expect(atomFeed).toInclude('<entry>');
|
||||||
|
expect(atomFeed).toInclude('A better European Union');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should export JSON Feed 1.0', async () => {
|
||||||
|
const jsonFeedString = testFeed.exportJsonFeed();
|
||||||
|
const jsonFeed = JSON.parse(jsonFeedString);
|
||||||
|
|
||||||
|
expect(jsonFeed.version).toEqual('https://jsonfeed.org/version/1');
|
||||||
|
expect(jsonFeed.title).toEqual('central.eu - ideas for Europe');
|
||||||
|
expect(jsonFeed.home_page_url).toEqual('https://central.eu');
|
||||||
|
expect(jsonFeed.description).toEqual('ideas for Europe');
|
||||||
|
expect(jsonFeed.items).toBeArray();
|
||||||
|
expect(jsonFeed.items.length).toEqual(1);
|
||||||
|
expect(jsonFeed.items[0].title).toEqual('A better European Union');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should include correct item data in RSS', async () => {
|
||||||
|
const rssFeed = testFeed.exportRssFeedString();
|
||||||
|
|
||||||
|
expect(rssFeed).toInclude('https://central.eu/article/somearticle');
|
||||||
|
expect(rssFeed).toInclude('https://central.eu/someimage.png');
|
||||||
|
expect(rssFeed).toInclude('somecontent');
|
||||||
|
expect(rssFeed).toInclude('<enclosure');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should include correct author information', async () => {
|
||||||
|
const rssFeed = testFeed.exportRssFeedString();
|
||||||
|
const atomFeed = testFeed.exportAtomFeed();
|
||||||
|
|
||||||
|
// RSS doesn't always include dc:creator, but Atom should have author
|
||||||
|
expect(atomFeed).toInclude('<name>Phil</name>');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle multiple items in export', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'multi.example.com',
|
||||||
|
title: 'Multi-item Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Testing multiple items',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
feed.addItem({
|
||||||
|
title: `Article ${i}`,
|
||||||
|
authorName: 'Author',
|
||||||
|
imageUrl: `https://example.com/image${i}.png`,
|
||||||
|
timestamp: Date.now() + i,
|
||||||
|
url: `https://example.com/article/${i}`,
|
||||||
|
content: `Content for article ${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rssFeed = feed.exportRssFeedString();
|
||||||
|
const jsonFeedString = feed.exportJsonFeed();
|
||||||
|
const jsonFeed = JSON.parse(jsonFeedString);
|
||||||
|
|
||||||
|
expect(jsonFeed.items.length).toEqual(5);
|
||||||
|
expect(rssFeed).toInclude('Article 1');
|
||||||
|
expect(rssFeed).toInclude('Article 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should export feed with custom item IDs', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Custom ID Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Testing custom IDs',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Article with custom ID',
|
||||||
|
authorName: 'Author',
|
||||||
|
imageUrl: 'https://example.com/image.png',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article/custom',
|
||||||
|
content: 'Content',
|
||||||
|
id: 'custom-uuid-123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rssFeed = feed.exportRssFeedString();
|
||||||
|
expect(rssFeed).toInclude('custom-uuid-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
74
test/test.integration.node+bun+deno.ts
Normal file
74
test/test.integration.node+bun+deno.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartfeed from '../ts/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test - Quick smoke test for the entire module
|
||||||
|
* For detailed tests, see:
|
||||||
|
* - test.creation.node+bun+deno.ts
|
||||||
|
* - test.validation.node+bun+deno.ts
|
||||||
|
* - test.export.node+bun+deno.ts
|
||||||
|
* - test.parsing.node+bun+deno.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
tap.test('integration: should create, populate, export, and parse a feed', async () => {
|
||||||
|
// Create instance
|
||||||
|
const smartfeedInstance = new smartfeed.Smartfeed();
|
||||||
|
expect(smartfeedInstance).toBeInstanceOf(smartfeed.Smartfeed);
|
||||||
|
|
||||||
|
// Create feed
|
||||||
|
const feed = smartfeedInstance.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Integration Test Feed',
|
||||||
|
category: 'Technology',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Full integration test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(feed).toBeInstanceOf(smartfeed.Feed);
|
||||||
|
|
||||||
|
// Add items
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Test Article 1',
|
||||||
|
authorName: 'Author',
|
||||||
|
imageUrl: 'https://example.com/image1.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article1',
|
||||||
|
content: 'Content 1',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Test Article 2',
|
||||||
|
authorName: 'Author',
|
||||||
|
imageUrl: 'https://example.com/image2.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article2',
|
||||||
|
content: 'Content 2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(feed.items.length).toEqual(2);
|
||||||
|
|
||||||
|
// Export RSS
|
||||||
|
const rssString = feed.exportRssFeedString();
|
||||||
|
expect(rssString).toInclude('Integration Test Feed');
|
||||||
|
expect(rssString).toInclude('Test Article 1');
|
||||||
|
expect(rssString).toInclude('Test Article 2');
|
||||||
|
|
||||||
|
// Export Atom
|
||||||
|
const atomString = feed.exportAtomFeed();
|
||||||
|
expect(atomString).toInclude('Integration Test Feed');
|
||||||
|
|
||||||
|
// Export JSON
|
||||||
|
const jsonString = feed.exportJsonFeed();
|
||||||
|
const jsonFeed = JSON.parse(jsonString);
|
||||||
|
expect(jsonFeed.title).toEqual('Integration Test Feed');
|
||||||
|
expect(jsonFeed.items.length).toEqual(2);
|
||||||
|
|
||||||
|
// Parse back
|
||||||
|
const parsed = await smartfeedInstance.parseFeedFromString(rssString);
|
||||||
|
expect(parsed.title).toEqual('Integration Test Feed');
|
||||||
|
expect(parsed.items.length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
179
test/test.parsing.node+bun+deno.ts
Normal file
179
test/test.parsing.node+bun+deno.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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 parse RSS feed from string', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Example Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Test Article',
|
||||||
|
authorName: 'John Doe',
|
||||||
|
imageUrl: 'https://example.com/image.png',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article/test',
|
||||||
|
content: 'Test content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rssFeed = feed.exportRssFeedString();
|
||||||
|
const parsedFeed = await testSmartFeed.parseFeedFromString(rssFeed);
|
||||||
|
|
||||||
|
expect(parsedFeed.title).toEqual('Example Feed');
|
||||||
|
expect(parsedFeed.description).toEqual('Test description');
|
||||||
|
expect(parsedFeed.items).toBeArray();
|
||||||
|
expect(parsedFeed.items.length).toEqual(1);
|
||||||
|
expect(parsedFeed.items[0].title).toEqual('Test Article');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should parse Atom feed from string', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Atom Test Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Atom test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Atom Article',
|
||||||
|
authorName: 'Jane Doe',
|
||||||
|
imageUrl: 'https://example.com/atom-image.png',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article/atom-test',
|
||||||
|
content: 'Atom content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const atomFeed = feed.exportAtomFeed();
|
||||||
|
const parsedFeed = await testSmartFeed.parseFeedFromString(atomFeed);
|
||||||
|
|
||||||
|
expect(parsedFeed.title).toEqual('Atom Test Feed');
|
||||||
|
expect(parsedFeed.items).toBeArray();
|
||||||
|
expect(parsedFeed.items.length).toEqual(1);
|
||||||
|
expect(parsedFeed.items[0].title).toEqual('Atom Article');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should parse feed with multiple items', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Multi-item Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Feed with multiple items',
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemCount = 10;
|
||||||
|
for (let i = 1; i <= itemCount; i++) {
|
||||||
|
feed.addItem({
|
||||||
|
title: `Article ${i}`,
|
||||||
|
authorName: `Author ${i}`,
|
||||||
|
imageUrl: `https://example.com/image${i}.png`,
|
||||||
|
timestamp: Date.now() + i,
|
||||||
|
url: `https://example.com/article/${i}`,
|
||||||
|
content: `Content ${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rssFeed = feed.exportRssFeedString();
|
||||||
|
const parsedFeed = await testSmartFeed.parseFeedFromString(rssFeed);
|
||||||
|
|
||||||
|
expect(parsedFeed.items.length).toEqual(itemCount);
|
||||||
|
expect(parsedFeed.items[0].title).toEqual('Article 1');
|
||||||
|
expect(parsedFeed.items[9].title).toEqual('Article 10');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should parse live RSS feed from URL', async () => {
|
||||||
|
try {
|
||||||
|
const parsedFeed = await testSmartFeed.parseFeedFromUrl(
|
||||||
|
'https://www.theverge.com/rss/index.xml'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsedFeed).toBeObject();
|
||||||
|
expect(parsedFeed.title).toBeString();
|
||||||
|
expect(parsedFeed.items).toBeArray();
|
||||||
|
expect(parsedFeed.items.length).toBeGreaterThan(0);
|
||||||
|
} catch (error) {
|
||||||
|
// Network errors are acceptable in tests
|
||||||
|
console.log('Network test skipped (expected in CI/offline):', error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should preserve feed metadata when parsing', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'meta.example.com',
|
||||||
|
title: 'Metadata Test Feed',
|
||||||
|
category: 'Metadata',
|
||||||
|
company: 'Metadata Inc',
|
||||||
|
companyDomain: 'https://meta.example.com',
|
||||||
|
companyEmail: 'meta@example.com',
|
||||||
|
description: 'Testing metadata preservation',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Meta Article',
|
||||||
|
authorName: 'Meta Author',
|
||||||
|
imageUrl: 'https://example.com/meta.png',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/meta',
|
||||||
|
content: 'Meta content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rssFeed = feed.exportRssFeedString();
|
||||||
|
const parsedFeed = await testSmartFeed.parseFeedFromString(rssFeed);
|
||||||
|
|
||||||
|
expect(parsedFeed.title).toEqual('Metadata Test Feed');
|
||||||
|
expect(parsedFeed.description).toEqual('Testing metadata preservation');
|
||||||
|
expect(parsedFeed.language).toEqual('en');
|
||||||
|
expect(parsedFeed.generator).toEqual('@push.rocks/smartfeed');
|
||||||
|
expect(parsedFeed.copyright).toInclude('Metadata Inc');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle feed with enclosures', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Enclosure Test',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Testing enclosures',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Article with image',
|
||||||
|
authorName: 'Author',
|
||||||
|
imageUrl: 'https://example.com/large-image.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article',
|
||||||
|
content: 'Content with enclosure',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rssFeed = feed.exportRssFeedString();
|
||||||
|
const parsedFeed = await testSmartFeed.parseFeedFromString(rssFeed);
|
||||||
|
|
||||||
|
// Enclosure support depends on the parser and feed format
|
||||||
|
expect(parsedFeed.items.length).toBeGreaterThan(0);
|
||||||
|
expect(parsedFeed.items[0].title).toEqual('Article with image');
|
||||||
|
|
||||||
|
// If enclosure exists, verify it
|
||||||
|
if (parsedFeed.items[0].enclosure) {
|
||||||
|
expect(parsedFeed.items[0].enclosure.url).toInclude('https://example.com/large-image.jpg');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
260
test/test.podcast.node+bun+deno.ts
Normal file
260
test/test.podcast.node+bun+deno.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartfeed from '../ts/index.js';
|
||||||
|
|
||||||
|
let testSmartFeed: smartfeed.Smartfeed;
|
||||||
|
let testPodcast: smartfeed.PodcastFeed;
|
||||||
|
|
||||||
|
tap.test('setup', async () => {
|
||||||
|
testSmartFeed = new smartfeed.Smartfeed();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create a podcast feed', async () => {
|
||||||
|
testPodcast = testSmartFeed.createPodcastFeed({
|
||||||
|
domain: 'podcast.example.com',
|
||||||
|
title: 'Test Podcast',
|
||||||
|
description: 'A test podcast about testing',
|
||||||
|
category: 'Technology',
|
||||||
|
company: 'Test Podcast Inc',
|
||||||
|
companyEmail: 'podcast@example.com',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
itunesCategory: 'Technology',
|
||||||
|
itunesAuthor: 'John Tester',
|
||||||
|
itunesOwner: {
|
||||||
|
name: 'John Tester',
|
||||||
|
email: 'john@example.com',
|
||||||
|
},
|
||||||
|
itunesImage: 'https://example.com/podcast-artwork.jpg',
|
||||||
|
itunesExplicit: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testPodcast).toBeInstanceOf(smartfeed.PodcastFeed);
|
||||||
|
expect(testPodcast.podcastOptions.itunesCategory).toEqual('Technology');
|
||||||
|
expect(testPodcast.podcastOptions.itunesAuthor).toEqual('John Tester');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create podcast feed with episodic type', async () => {
|
||||||
|
const podcast = testSmartFeed.createPodcastFeed({
|
||||||
|
domain: 'episodic.example.com',
|
||||||
|
title: 'Episodic Podcast',
|
||||||
|
description: 'An episodic podcast',
|
||||||
|
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: true,
|
||||||
|
itunesType: 'episodic',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(podcast.podcastOptions.itunesType).toEqual('episodic');
|
||||||
|
expect(podcast.podcastOptions.itunesExplicit).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create podcast feed with serial type', async () => {
|
||||||
|
const podcast = testSmartFeed.createPodcastFeed({
|
||||||
|
domain: 'serial.example.com',
|
||||||
|
title: 'Serial Podcast',
|
||||||
|
description: 'A serial podcast',
|
||||||
|
category: 'True Crime',
|
||||||
|
company: 'Crime Inc',
|
||||||
|
companyEmail: 'crime@example.com',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
itunesCategory: 'True Crime',
|
||||||
|
itunesAuthor: 'Detective',
|
||||||
|
itunesOwner: { name: 'Detective', email: 'detective@example.com' },
|
||||||
|
itunesImage: 'https://example.com/crime.jpg',
|
||||||
|
itunesExplicit: false,
|
||||||
|
itunesType: 'serial',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(podcast.podcastOptions.itunesType).toEqual('serial');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should add episode to podcast', async () => {
|
||||||
|
testPodcast.addEpisode({
|
||||||
|
title: 'Episode 1: Introduction',
|
||||||
|
authorName: 'John Tester',
|
||||||
|
imageUrl: 'https://example.com/episode1.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://podcast.example.com/episode/1',
|
||||||
|
content: 'In this episode, we introduce the podcast',
|
||||||
|
audioUrl: 'https://example.com/audio/episode1.mp3',
|
||||||
|
audioType: 'audio/mpeg',
|
||||||
|
audioLength: 45678900,
|
||||||
|
itunesDuration: 3600,
|
||||||
|
itunesEpisode: 1,
|
||||||
|
itunesSeason: 1,
|
||||||
|
itunesEpisodeType: 'full',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testPodcast.episodes.length).toEqual(1);
|
||||||
|
expect(testPodcast.episodes[0].title).toEqual('Episode 1: Introduction');
|
||||||
|
expect(testPodcast.episodes[0].itunesEpisode).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should add multiple episodes', async () => {
|
||||||
|
const podcast = testSmartFeed.createPodcastFeed({
|
||||||
|
domain: 'multi.example.com',
|
||||||
|
title: 'Multi-Episode Podcast',
|
||||||
|
description: 'Podcast with multiple episodes',
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
podcast.addEpisode({
|
||||||
|
title: `Episode ${i}`,
|
||||||
|
authorName: 'Teacher',
|
||||||
|
imageUrl: `https://example.com/episode${i}.jpg`,
|
||||||
|
timestamp: Date.now() + i,
|
||||||
|
url: `https://example.com/episode/${i}`,
|
||||||
|
content: `Content for episode ${i}`,
|
||||||
|
audioUrl: `https://example.com/audio/episode${i}.mp3`,
|
||||||
|
audioType: 'audio/mpeg',
|
||||||
|
audioLength: 40000000 + i * 1000000,
|
||||||
|
itunesDuration: 3000 + i * 100,
|
||||||
|
itunesEpisode: i,
|
||||||
|
itunesSeason: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(podcast.episodes.length).toEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should add episode with trailer type', async () => {
|
||||||
|
const podcast = testSmartFeed.createPodcastFeed({
|
||||||
|
domain: 'trailer.example.com',
|
||||||
|
title: 'Podcast with Trailer',
|
||||||
|
description: 'Podcast with trailer episode',
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
podcast.addEpisode({
|
||||||
|
title: 'Season 1 Trailer',
|
||||||
|
authorName: 'Reporter',
|
||||||
|
imageUrl: 'https://example.com/trailer.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/episode/trailer',
|
||||||
|
content: 'Trailer for season 1',
|
||||||
|
audioUrl: 'https://example.com/audio/trailer.mp3',
|
||||||
|
audioType: 'audio/mpeg',
|
||||||
|
audioLength: 5000000,
|
||||||
|
itunesDuration: 300,
|
||||||
|
itunesEpisodeType: 'trailer',
|
||||||
|
itunesSeason: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(podcast.episodes[0].itunesEpisodeType).toEqual('trailer');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should add episode with bonus type', async () => {
|
||||||
|
const podcast = testSmartFeed.createPodcastFeed({
|
||||||
|
domain: 'bonus.example.com',
|
||||||
|
title: 'Podcast with Bonus',
|
||||||
|
description: 'Podcast with bonus episode',
|
||||||
|
category: 'Business',
|
||||||
|
company: 'Business Inc',
|
||||||
|
companyEmail: 'business@example.com',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
itunesCategory: 'Business',
|
||||||
|
itunesAuthor: 'Entrepreneur',
|
||||||
|
itunesOwner: { name: 'Entrepreneur', email: 'entrepreneur@example.com' },
|
||||||
|
itunesImage: 'https://example.com/business.jpg',
|
||||||
|
itunesExplicit: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
podcast.addEpisode({
|
||||||
|
title: 'Bonus: Behind the Scenes',
|
||||||
|
authorName: 'Entrepreneur',
|
||||||
|
imageUrl: 'https://example.com/bonus.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/episode/bonus',
|
||||||
|
content: 'Bonus behind the scenes content',
|
||||||
|
audioUrl: 'https://example.com/audio/bonus.mp3',
|
||||||
|
audioType: 'audio/mpeg',
|
||||||
|
audioLength: 8000000,
|
||||||
|
itunesDuration: 600,
|
||||||
|
itunesEpisodeType: 'bonus',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(podcast.episodes[0].itunesEpisodeType).toEqual('bonus');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should support M4A audio format', async () => {
|
||||||
|
const podcast = testSmartFeed.createPodcastFeed({
|
||||||
|
domain: 'm4a.example.com',
|
||||||
|
title: 'M4A Podcast',
|
||||||
|
description: 'Podcast with M4A audio',
|
||||||
|
category: 'Music',
|
||||||
|
company: 'Music Inc',
|
||||||
|
companyEmail: 'music@example.com',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
itunesCategory: 'Music',
|
||||||
|
itunesAuthor: 'Musician',
|
||||||
|
itunesOwner: { name: 'Musician', email: 'musician@example.com' },
|
||||||
|
itunesImage: 'https://example.com/music.jpg',
|
||||||
|
itunesExplicit: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
podcast.addEpisode({
|
||||||
|
title: 'Musical Episode',
|
||||||
|
authorName: 'Musician',
|
||||||
|
imageUrl: 'https://example.com/musical.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/episode/musical',
|
||||||
|
content: 'A musical episode',
|
||||||
|
audioUrl: 'https://example.com/audio/episode.m4a',
|
||||||
|
audioType: 'audio/x-m4a',
|
||||||
|
audioLength: 50000000,
|
||||||
|
itunesDuration: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(podcast.episodes[0].audioType).toEqual('audio/x-m4a');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should export podcast RSS with iTunes namespace', async () => {
|
||||||
|
const rss = testPodcast.exportPodcastRss();
|
||||||
|
|
||||||
|
expect(rss).toInclude('<?xml version="1.0" encoding="UTF-8"?>');
|
||||||
|
expect(rss).toInclude('xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"');
|
||||||
|
expect(rss).toInclude('<itunes:author>John Tester</itunes:author>');
|
||||||
|
expect(rss).toInclude('<itunes:owner>');
|
||||||
|
expect(rss).toInclude('<itunes:name>John Tester</itunes:name>');
|
||||||
|
expect(rss).toInclude('<itunes:email>john@example.com</itunes:email>');
|
||||||
|
expect(rss).toInclude('<itunes:category text="Technology"');
|
||||||
|
expect(rss).toInclude('<itunes:image href="https://example.com/podcast-artwork.jpg"');
|
||||||
|
expect(rss).toInclude('<itunes:explicit>false</itunes:explicit>');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should include episode in RSS export', async () => {
|
||||||
|
const rss = testPodcast.exportPodcastRss();
|
||||||
|
|
||||||
|
expect(rss).toInclude('Episode 1: Introduction');
|
||||||
|
expect(rss).toInclude('https://example.com/audio/episode1.mp3');
|
||||||
|
expect(rss).toInclude('<enclosure url="https://example.com/audio/episode1.mp3"');
|
||||||
|
expect(rss).toInclude('length="45678900"');
|
||||||
|
expect(rss).toInclude('type="audio/mpeg"');
|
||||||
|
expect(rss).toInclude('<itunes:duration>01:00:00</itunes:duration>');
|
||||||
|
expect(rss).toInclude('<itunes:episode>1</itunes:episode>');
|
||||||
|
expect(rss).toInclude('<itunes:season>1</itunes:season>');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
42
test/test.ts
42
test/test.ts
@@ -1,42 +0,0 @@
|
|||||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as smartfeed from '../ts/index.js';
|
|
||||||
|
|
||||||
let testSmartFeed: smartfeed.Smartfeed;
|
|
||||||
|
|
||||||
tap.test('should create a feedVersion', async () => {
|
|
||||||
testSmartFeed = new smartfeed.Smartfeed();
|
|
||||||
expect(testSmartFeed).toBeInstanceOf(smartfeed.Smartfeed);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('should create a feed', async () => {
|
|
||||||
const feed = testSmartFeed.createFeed({
|
|
||||||
domain: 'central.eu',
|
|
||||||
title: 'central.eu - ideas for Europe',
|
|
||||||
category: 'Politics',
|
|
||||||
company: 'Lossless GmbH',
|
|
||||||
companyDomain: 'https://lossless.com',
|
|
||||||
companyEmail: 'hello@lossless.com',
|
|
||||||
description: 'ideas for Europe',
|
|
||||||
});
|
|
||||||
feed.addItem({
|
|
||||||
title: 'A better European Union',
|
|
||||||
authorName: 'Phil',
|
|
||||||
imageUrl: 'https://central.eu/someimage.png',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
url: 'https://central.eu/article/somearticle',
|
|
||||||
content: 'somecontent',
|
|
||||||
});
|
|
||||||
const rssFeed = feed.exportRssFeedString();
|
|
||||||
console.log(rssFeed);
|
|
||||||
const parsedFeed = await testSmartFeed.parseFeedFromString(rssFeed);
|
|
||||||
console.log(parsedFeed);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('should parse a Url', async () => {
|
|
||||||
const result = await testSmartFeed.parseFeedFromUrl(
|
|
||||||
'https://www.theverge.com/rss/index.xml',
|
|
||||||
);
|
|
||||||
// console.log(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.start();
|
|
||||||
271
test/test.validation.node+bun+deno.ts
Normal file
271
test/test.validation.node+bun+deno.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
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 feed options', async () => {
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
testSmartFeed.createFeed({
|
||||||
|
domain: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
company: '',
|
||||||
|
companyEmail: '',
|
||||||
|
companyDomain: '',
|
||||||
|
} as any);
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('validation failed');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate domain format', async () => {
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
testSmartFeed.createFeed({
|
||||||
|
domain: 'not a domain!',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('Invalid domain format');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate email format', async () => {
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyEmail: 'not-an-email',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('Invalid email');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate company domain URL', async () => {
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
companyDomain: 'not-a-url',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('Invalid or relative URL');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate item URLs', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Test Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Test',
|
||||||
|
authorName: 'John',
|
||||||
|
imageUrl: 'not-a-url',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'also-not-a-url',
|
||||||
|
content: 'content',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('Invalid or relative URL');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate relative URLs', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Test Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Test',
|
||||||
|
authorName: 'John',
|
||||||
|
imageUrl: '/images/test.jpg',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: '/posts/test',
|
||||||
|
content: 'content',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('Invalid or relative URL');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate duplicate IDs', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Test Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemData = {
|
||||||
|
title: 'Test Article',
|
||||||
|
authorName: 'John',
|
||||||
|
imageUrl: 'https://example.com/image.png',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article/test',
|
||||||
|
content: 'content',
|
||||||
|
};
|
||||||
|
|
||||||
|
feed.addItem(itemData);
|
||||||
|
|
||||||
|
// Try to add same item again (same URL = same ID)
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
feed.addItem(itemData);
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('Duplicate item ID');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate duplicate custom IDs', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Test Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.addItem({
|
||||||
|
title: 'First Article',
|
||||||
|
authorName: 'John',
|
||||||
|
imageUrl: 'https://example.com/image1.png',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article/first',
|
||||||
|
content: 'first content',
|
||||||
|
id: 'custom-id-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to add another item with same custom ID
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Second Article',
|
||||||
|
authorName: 'Jane',
|
||||||
|
imageUrl: 'https://example.com/image2.png',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: 'https://example.com/article/second',
|
||||||
|
content: 'second content',
|
||||||
|
id: 'custom-id-1',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('Duplicate item ID');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate timestamp', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Test Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
feed.addItem({
|
||||||
|
title: 'Test',
|
||||||
|
authorName: 'John',
|
||||||
|
imageUrl: 'https://example.com/image.png',
|
||||||
|
timestamp: -1,
|
||||||
|
url: 'https://example.com/article/test',
|
||||||
|
content: 'content',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('cannot be negative');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should validate required item fields', async () => {
|
||||||
|
const feed = testSmartFeed.createFeed({
|
||||||
|
domain: 'example.com',
|
||||||
|
title: 'Test Feed',
|
||||||
|
category: 'Test',
|
||||||
|
company: 'Test Inc',
|
||||||
|
companyDomain: 'https://example.com',
|
||||||
|
companyEmail: 'test@example.com',
|
||||||
|
description: 'Test description',
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
feed.addItem({
|
||||||
|
title: '',
|
||||||
|
authorName: '',
|
||||||
|
imageUrl: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: '',
|
||||||
|
content: '',
|
||||||
|
} as any);
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
expect(error.message).toInclude('validation failed');
|
||||||
|
}
|
||||||
|
expect(errorThrown).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
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 {
|
export interface IFeedOptions {
|
||||||
|
/** The domain of the feed (e.g., 'example.com') */
|
||||||
domain: string;
|
domain: string;
|
||||||
|
/** The title of the feed */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** A description of the feed content */
|
||||||
description: string;
|
description: string;
|
||||||
|
/** The category of the feed (e.g., 'Technology', 'News') */
|
||||||
category: string;
|
category: string;
|
||||||
|
/** The company or organization name */
|
||||||
company: string;
|
company: string;
|
||||||
|
/** Contact email for the feed */
|
||||||
companyEmail: string;
|
companyEmail: string;
|
||||||
|
/** The company website URL (must be absolute) */
|
||||||
companyDomain: string;
|
companyDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single item/entry in the feed
|
||||||
|
*/
|
||||||
export interface IFeedItem {
|
export interface IFeedItem {
|
||||||
|
/** The title of the feed item */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Unix timestamp in milliseconds when the item was published */
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
/** Absolute URL to the full item/article */
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Name of the item author */
|
||||||
authorName: string;
|
authorName: string;
|
||||||
|
/** Absolute URL to the item's featured image */
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
/** The content/body of the item (will be sanitized) */
|
||||||
content: string;
|
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 {
|
export class Feed {
|
||||||
options: IFeedOptions;
|
options: IFeedOptions;
|
||||||
items: IFeedItem[] = [];
|
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) {
|
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;
|
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) {
|
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);
|
this.items.push(itemArg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the internal feed object with all items
|
||||||
|
* @private
|
||||||
|
* @returns Configured feed object
|
||||||
|
*/
|
||||||
private getFeedObject() {
|
private getFeedObject() {
|
||||||
const feed = new plugins.feed.Feed({
|
const feed = new plugins.feed.Feed({
|
||||||
copyright: `All rights reserved, ${this.options.company}`,
|
copyright: `All rights reserved, ${this.options.company}`,
|
||||||
@@ -39,21 +144,27 @@ export class Feed {
|
|||||||
author: {
|
author: {
|
||||||
name: this.options.company,
|
name: this.options.company,
|
||||||
email: this.options.companyEmail,
|
email: this.options.companyEmail,
|
||||||
link: this.options.companyEmail,
|
link: this.options.companyDomain,
|
||||||
},
|
},
|
||||||
description: this.options.description,
|
description: this.options.description,
|
||||||
generator: '@pushrocks/smartfeed',
|
generator: '@push.rocks/smartfeed',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
});
|
});
|
||||||
|
|
||||||
feed.addCategory(this.options.category);
|
feed.addCategory(this.options.category);
|
||||||
|
|
||||||
for (const itemArg of this.items) {
|
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({
|
feed.addItem({
|
||||||
title: itemArg.title,
|
title: itemArg.title,
|
||||||
date: new Date(itemArg.timestamp),
|
date: new Date(itemArg.timestamp),
|
||||||
link: itemArg.url.replace(/&/gm, '&'),
|
link: itemArg.url.replace(/&/gm, '&'),
|
||||||
image: itemArg.imageUrl.replace(/&/gm, '&'),
|
image: itemArg.imageUrl.replace(/&/gm, '&'),
|
||||||
content: itemArg.content,
|
content: sanitizedContent,
|
||||||
|
id: itemArg.id || itemArg.url,
|
||||||
author: [
|
author: [
|
||||||
{
|
{
|
||||||
name: itemArg.authorName,
|
name: itemArg.authorName,
|
||||||
@@ -64,14 +175,40 @@ export class Feed {
|
|||||||
return 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 {
|
public exportRssFeedString(): string {
|
||||||
return this.getFeedObject().rss2();
|
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 {
|
public exportAtomFeed(): string {
|
||||||
return this.getFeedObject().atom1();
|
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 {
|
public exportJsonFeed(): string {
|
||||||
return this.getFeedObject().json1();
|
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 { Feed } from './classes.feed.js';
|
||||||
import type { IFeedOptions } from './smartfeed.classes.feed.js';
|
import type { IFeedOptions } from './classes.feed.js';
|
||||||
import * as plugins from './smartfeed.plugins.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 {
|
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);
|
const feedVersion = new Feed(optionsArg);
|
||||||
return feedVersion;
|
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(
|
public async createFeedFromArticleArray(
|
||||||
optionsArg: IFeedOptions,
|
optionsArg: IFeedOptions,
|
||||||
@@ -31,8 +107,17 @@ export class Smartfeed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* allows the parsing of a rss feed string
|
* Parses an RSS or Atom feed from a string
|
||||||
* @param rssFeedString
|
* @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) {
|
public async parseFeedFromString(rssFeedString: string) {
|
||||||
const parser = new plugins.rssParser();
|
const parser = new plugins.rssParser();
|
||||||
@@ -41,8 +126,16 @@ export class Smartfeed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* allows the parsing of a feed from urls
|
* Parses an RSS or Atom feed from a URL
|
||||||
* @param urlArg
|
* @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) {
|
public async parseFeedFromUrl(urlArg: string) {
|
||||||
const parser = new plugins.rssParser();
|
const parser = new plugins.rssParser();
|
||||||
|
|||||||
18
ts/index.ts
18
ts/index.ts
@@ -1,2 +1,16 @@
|
|||||||
export * from './smartfeed.classes.smartfeed.js';
|
// Export classes
|
||||||
export * from './smartfeed.classes.feed.js';
|
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