34 Commits
v1.0.4 ... main

Author SHA1 Message Date
bd07971bb9 1.4.0 2025-11-01 03:35:45 +00:00
4ed892ddc2 feat(feed): Support custom feedUrl for feeds and use it as the self-link in RSS/Atom/JSON; update docs 2025-11-01 03:35:45 +00:00
289483ba0a 1.3.0 2025-10-31 21:26:07 +00:00
645f9c0e64 feat(parsing): Replace rss-parser with fast-xml-parser and add native feed parser; update Smartfeed to use parseFeedXML and adjust plugins/tests 2025-10-31 21:26:07 +00:00
64c7414682 1.2.0 2025-10-31 21:04:50 +00:00
31c4460b34 feat(podcast): Add Podcast 2.0 support and remove external feed dependency; implement internal RSS/Atom/JSON generators and update tests/README 2025-10-31 21:04:50 +00:00
90eb13ee17 1.1.1 2025-10-31 19:36:23 +00:00
59c9ccf871 fix(podcast): Improve podcast episode validation, make Feed.itemIds protected, expand README and add tests 2025-10-31 19:36:23 +00:00
e444c80769 1.1.0 2025-10-31 19:17:04 +00:00
c27a46ac62 feat(smartfeed): Implement Smartfeed core: add feed validation, parsing, exporting and comprehensive tests 2025-10-31 19:17:04 +00:00
6d9538c5d2 feat: implement feed and validation utilities for smartfeed 2025-10-31 18:27:56 +00:00
05a597f473 chore: update feed dependency to version 5.1.0 and adjust import paths for consistency 2025-10-31 18:25:27 +00:00
622b74e628 chore: update README for clarity and formatting improvements
fix: update test imports to use new package path

refactor: improve feed class structure and formatting

refactor: enhance smartfeed class for better readability

chore: streamline plugin exports for consistency

chore: update TypeScript configuration for improved compatibility

ci: add workflows for handling tag and non-tag pushes
2025-10-31 17:07:13 +00:00
29b1420b1a update description 2024-05-29 14:13:06 +02:00
57182c012b update tsconfig 2024-04-14 17:35:04 +02:00
1719a57390 update npmextra.json: githost 2024-04-01 21:34:58 +02:00
e14a0d799a update npmextra.json: githost 2024-04-01 19:58:13 +02:00
6dd1486417 update npmextra.json: githost 2024-03-30 21:47:11 +01:00
eb89988447 switch to new org scheme 2023-07-11 00:40:27 +02:00
16ebffcb65 switch to new org scheme 2023-07-10 02:55:49 +02:00
d754001d5b 1.0.11 2020-12-10 17:33:02 +00:00
d1e47e325d fix(core): update 2020-12-10 17:33:01 +00:00
bcb850502d 1.0.10 2020-11-28 14:38:04 +00:00
60911f1a01 fix(core): update 2020-11-28 14:38:04 +00:00
87e9d844c4 1.0.9 2020-11-11 10:37:37 +00:00
ce60a8bb51 fix(core): update 2020-11-11 10:37:36 +00:00
04e5be65ec 1.0.8 2020-11-11 10:11:18 +00:00
599af94709 fix(core): update 2020-11-11 10:11:18 +00:00
b0719c481e 1.0.7 2020-11-01 19:39:43 +00:00
a03ceb5d8d fix(core): update 2020-11-01 19:39:42 +00:00
0f0eb2dbe8 1.0.6 2020-10-31 11:38:41 +00:00
2613d20cb8 fix(core): update 2020-10-31 11:38:41 +00:00
12304dca17 1.0.5 2020-10-25 22:11:01 +00:00
bf8536e6ca fix(core): update 2020-10-25 22:11:00 +00:00
35 changed files with 20797 additions and 11379 deletions

View File

@@ -0,0 +1,66 @@
name: Default (not tags)
on:
push:
tags-ignore:
- '**'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
jobs:
security:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Install pnpm and npmci
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
- name: Run npm prepare
run: npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
if: ${{ always() }}
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci npm build

View File

@@ -0,0 +1,124 @@
name: Default (tags)
on:
push:
tags:
- '*'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
jobs:
security:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
if: ${{ always() }}
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci npm build
release:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Release
run: |
npmci node install stable
npmci npm publish
metadata:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Code quality
run: |
npmci command npm install -g typescript
npmci npm install
- name: Trigger
run: npmci trigger
- name: Build docs and upload artifacts
run: |
npmci node install stable
npmci npm install
pnpm install -g @git.zone/tsdoc
npmci command tsdoc
continue-on-error: true

7
.gitignore vendored
View File

@@ -3,7 +3,6 @@
# artifacts
coverage/
public/
pages/
# installs
node_modules/
@@ -17,4 +16,8 @@ node_modules/
dist/
dist_*/
# custom
# AI
.claude/
.serena/
#------# custom

View File

@@ -1,137 +0,0 @@
# gitzone ci_default
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
cache:
paths:
- .npmci_cache/
key: '$CI_BUILD_STAGE'
stages:
- security
- test
- release
- metadata
# ====================
# security stage
# ====================
mirror:
stage: security
script:
- npmci git mirror
only:
- tags
tags:
- lossless
- docker
- notpriv
auditProductionDependencies:
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
stage: security
script:
- npmci npm prepare
- npmci command npm install --production --ignore-scripts
- npmci command npm config set registry https://registry.npmjs.org
- npmci command npm audit --audit-level=high --only=prod --production
tags:
- docker
auditDevDependencies:
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
stage: security
script:
- npmci npm prepare
- npmci command npm install --ignore-scripts
- npmci command npm config set registry https://registry.npmjs.org
- npmci command npm audit --audit-level=high --only=dev
tags:
- docker
allow_failure: true
# ====================
# test stage
# ====================
testStable:
stage: test
script:
- npmci npm prepare
- npmci node install stable
- npmci npm install
- npmci npm test
coverage: /\d+.?\d+?\%\s*coverage/
tags:
- docker
testBuild:
stage: test
script:
- npmci npm prepare
- npmci node install stable
- npmci npm install
- npmci command npm run build
coverage: /\d+.?\d+?\%\s*coverage/
tags:
- docker
release:
stage: release
script:
- npmci node install stable
- npmci npm publish
only:
- tags
tags:
- lossless
- docker
- notpriv
# ====================
# metadata stage
# ====================
codequality:
stage: metadata
allow_failure: true
only:
- tags
script:
- npmci command npm install -g tslint typescript
- npmci npm prepare
- npmci npm install
- npmci command "tslint -c tslint.json ./ts/**/*.ts"
tags:
- lossless
- docker
- priv
trigger:
stage: metadata
script:
- npmci trigger
only:
- tags
tags:
- lossless
- docker
- notpriv
pages:
stage: metadata
script:
- npmci node install lts
- npmci command npm install -g @gitzone/tsdoc
- npmci npm prepare
- npmci npm install
- npmci command tsdoc
tags:
- lossless
- docker
- notpriv
only:
- tags
artifacts:
expire_in: 1 week
paths:
- public
allow_failure: true

24
.vscode/launch.json vendored
View File

@@ -2,28 +2,10 @@
"version": "0.2.0",
"configurations": [
{
"name": "current file",
"type": "node",
"command": "npm test",
"name": "Run npm test",
"request": "launch",
"args": [
"${relativeFile}"
],
"runtimeArgs": ["-r", "@gitzone/tsrun"],
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"internalConsoleOptions": "openOnSessionStart"
},
{
"name": "test.ts",
"type": "node",
"request": "launch",
"args": [
"test/test.ts"
],
"runtimeArgs": ["-r", "@gitzone/tsrun"],
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"internalConsoleOptions": "openOnSessionStart"
"type": "node-terminal"
}
]
}

70
changelog.md Normal file
View File

@@ -0,0 +1,70 @@
# 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
- 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.

7488
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,17 +2,34 @@
"gitzone": {
"projectType": "npm",
"module": {
"githost": "gitlab.com",
"gitscope": "pushrocks",
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartfeed",
"shortDescription": "create and parse feeds",
"npmPackagename": "@pushrocks/smartfeed",
"npmPackagename": "@push.rocks/smartfeed",
"license": "MIT",
"projectDomain": "push.rocks"
"projectDomain": "push.rocks",
"description": "A library for creating and parsing various feed formats.",
"keywords": [
"RSS",
"Atom",
"feeds creation",
"feeds parsing",
"TypeScript",
"content syndication",
"RSS parser",
"feed generator",
"news feed",
"XML feeds",
"JSON feeds"
]
}
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public"
},
"tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
}
}
}

11035
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,27 @@
{
"name": "@pushrocks/smartfeed",
"version": "1.0.4",
"name": "@push.rocks/smartfeed",
"version": "1.4.0",
"private": false,
"description": "create and parse feeds",
"description": "A library for creating and parsing various feed formats.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"author": "Lossless GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --web)",
"build": "(tsbuild --web)"
"test": "(tstest test/ --verbose)",
"build": "(tsbuild --web)",
"buildDocs": "tsdoc"
},
"devDependencies": {
"@gitzone/tsbuild": "^2.1.25",
"@gitzone/tsbundle": "^1.0.78",
"@gitzone/tstest": "^1.0.44",
"@pushrocks/tapbundle": "^3.2.9",
"@types/node": "^14.11.2",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.15.0"
"@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tstest": "^2.7.0",
"@pushrocks/smartfile": "^10.0.26",
"@types/node": "^24.9.2"
},
"dependencies": {
"feed": "^4.2.1"
"@tsclass/tsclass": "^9.3.0",
"fast-xml-parser": "^4.5.0"
},
"browserslist": [
"last 1 chrome versions"
@@ -37,5 +37,31 @@
"cli.js",
"npmextra.json",
"readme.md"
]
],
"keywords": [
"RSS",
"Atom",
"feeds creation",
"feeds parsing",
"TypeScript",
"content syndication",
"RSS parser",
"feed generator",
"news feed",
"XML feeds",
"JSON feeds"
],
"homepage": "https://code.foss.global/push.rocks/smartfeed#readme",
"repository": {
"type": "git",
"url": "https://code.foss.global/push.rocks/smartfeed.git"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"bugs": {
"url": "https://code.foss.global/push.rocks/smartfeed/issues"
},
"type": "module",
"pnpm": {
"overrides": {}
}
}

8882
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
readme.hints.md Normal file
View File

@@ -0,0 +1 @@

435
readme.md
View File

@@ -1,39 +1,416 @@
# @pushrocks/smartfeed
create and parse feeds
# @push.rocks/smartfeed
## Availabililty and Links
* [npmjs.org (npm package)](https://www.npmjs.com/package/@pushrocks/smartfeed)
* [gitlab.com (source)](https://gitlab.com/pushrocks/smartfeed)
* [github.com (source mirror)](https://github.com/pushrocks/smartfeed)
* [docs (typedoc)](https://pushrocks.gitlab.io/smartfeed/)
**The modern TypeScript library for creating and parsing RSS, Atom, and Podcast feeds** 🚀
## Status for master
`@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.
Status Category | Status Badge
-- | --
GitLab Pipelines | [![pipeline status](https://gitlab.com/pushrocks/smartfeed/badges/master/pipeline.svg)](https://lossless.cloud)
GitLab Pipline Test Coverage | [![coverage report](https://gitlab.com/pushrocks/smartfeed/badges/master/coverage.svg)](https://lossless.cloud)
npm | [![npm downloads per month](https://badgen.net/npm/dy/@pushrocks/smartfeed)](https://lossless.cloud)
Snyk | [![Known Vulnerabilities](https://badgen.net/snyk/pushrocks/smartfeed)](https://lossless.cloud)
TypeScript Support | [![TypeScript](https://badgen.net/badge/TypeScript/>=%203.x/blue?icon=typescript)](https://lossless.cloud)
node Support | [![node](https://img.shields.io/badge/node->=%2010.x.x-blue.svg)](https://nodejs.org/dist/latest-v10.x/docs/api/)
Code Style | [![Code Style](https://badgen.net/badge/style/prettier/purple)](https://lossless.cloud)
PackagePhobia (total standalone install weight) | [![PackagePhobia](https://badgen.net/packagephobia/install/@pushrocks/smartfeed)](https://lossless.cloud)
PackagePhobia (package size on registry) | [![PackagePhobia](https://badgen.net/packagephobia/publish/@pushrocks/smartfeed)](https://lossless.cloud)
BundlePhobia (total size when bundled) | [![BundlePhobia](https://badgen.net/bundlephobia/minzip/@pushrocks/smartfeed)](https://lossless.cloud)
Platform support | [![Supports Windows 10](https://badgen.net/badge/supports%20Windows%2010/yes/green?icon=windows)](https://lossless.cloud) [![Supports Mac OS X](https://badgen.net/badge/supports%20Mac%20OS%20X/yes/green?icon=apple)](https://lossless.cloud)
## Features ✨
## Usage
- 🎯 **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
Use TypeScript for best in class intellisense
## Installation
## Contribution
```bash
pnpm install @push.rocks/smartfeed
```
We are always happy for code contributions. If you are not the code contributing type that is ok. Still, maintaining Open Source repositories takes considerable time and thought. If you like the quality of what we do and our modules are useful to you we would appreciate a little monthly contribution: You can [contribute one time](https://lossless.link/contribute-onetime) or [contribute monthly](https://lossless.link/contribute). :)
## Quick Start
For further information read the linked docs at the top of this readme.
### Creating a Basic Feed
> MIT licensed | **&copy;** [Lossless GmbH](https://lossless.gmbh)
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
```typescript
import { Smartfeed } from '@push.rocks/smartfeed';
[![repo-footer](https://lossless.gitlab.io/publicrelations/repofooter.svg)](https://maintainedby.lossless.com)
const smartfeed = new Smartfeed();
// 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'
});
// 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
});
```
If you don't provide an `id`, the `url` will be used. Make sure URLs don't change for published items.
### HTTPS URLs
Always use HTTPS URLs for security and privacy. The library will warn you if HTTP URLs are used:
```typescript
// ✅ Good
imageUrl: 'https://example.com/image.jpg'
// ⚠️ Will trigger a warning
imageUrl: 'http://example.com/image.jpg'
```
### 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
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
### Company Information
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.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

101
test/test.creation.all.ts Normal file
View 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.all.ts Normal file
View 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();

View 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.all.ts Normal file
View 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();

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();

267
test/test.podcast.all.ts Normal file
View File

@@ -0,0 +1,267 @@
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,
podcastGuid: 'test-podcast-guid-001',
});
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,
podcastGuid: 'test-podcast-guid-002',
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,
podcastGuid: 'test-podcast-guid-003',
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,
podcastGuid: 'test-podcast-guid-004',
});
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,
podcastGuid: 'test-podcast-guid-005',
});
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,
podcastGuid: 'test-podcast-guid-006',
});
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,
podcastGuid: 'test-podcast-guid-007',
});
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();

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

@@ -1,32 +0,0 @@
import { expect, tap } from '@pushrocks/tapbundle';
import * as smartfeed from '../ts/index';
let testSmartFeed: smartfeed.Smartfeed;
tap.test('should create a feedVersion', async () => {
testSmartFeed = new smartfeed.Smartfeed();
expect(testSmartFeed).to.be.instanceOf(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'
});
const rssFeed = feed.exportRssFeedString();
console.log(rssFeed);
});
tap.start();

271
test/test.validation.all.ts Normal file
View 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
View File

@@ -0,0 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@push.rocks/smartfeed',
version: '1.4.0',
description: 'A library for creating and parsing various feed formats.'
}

333
ts/classes.feed.ts Normal file
View File

@@ -0,0 +1,333 @@
import * as plugins from './plugins.js';
import * as validation from './validation.js';
/**
* Configuration options for creating a feed
*/
export interface IFeedOptions {
/** The domain of the feed (e.g., 'example.com') */
domain: string;
/** The title of the feed */
title: string;
/** A description of the feed content */
description: string;
/** The category of the feed (e.g., 'Technology', 'News') */
category: string;
/** The company or organization name */
company: string;
/** Contact email for the feed */
companyEmail: string;
/** The company website URL (must be absolute) */
companyDomain: string;
/** Optional: Custom URL for the feed's atom:link rel="self" (defaults to https://${domain}/feed.xml) */
feedUrl?: string;
}
/**
* Represents a single item/entry in the feed
*/
export interface IFeedItem {
/** The title of the feed item */
title: string;
/** Unix timestamp in milliseconds when the item was published */
timestamp: number;
/** Absolute URL to the full item/article */
url: string;
/** Name of the item author */
authorName: string;
/** Absolute URL to the item's featured image */
imageUrl: string;
/** The content/body of the item (will be sanitized) */
content: string;
/** Optional unique identifier for this item. If not provided, url will be used */
id?: string;
}
/**
* Represents a feed that can generate RSS, Atom, and JSON Feed formats
* @example
* ```typescript
* const feed = new Feed({
* domain: 'example.com',
* title: 'My Blog',
* description: 'A blog about technology',
* category: 'Technology',
* company: 'Example Inc',
* companyEmail: 'hello@example.com',
* companyDomain: 'https://example.com'
* });
* ```
*/
export class Feed {
options: IFeedOptions;
items: IFeedItem[] = [];
protected itemIds: Set<string> = new Set();
/**
* Creates a new Feed instance
* @param optionsArg - Feed configuration options
* @throws Error if validation fails
*/
constructor(optionsArg: IFeedOptions) {
// Validate required fields
validation.validateRequiredFields(
optionsArg,
['domain', 'title', 'description', 'category', 'company', 'companyEmail', 'companyDomain'],
'Feed options'
);
// Validate domain
validation.validateDomain(optionsArg.domain);
// Validate company email
validation.validateEmail(optionsArg.companyEmail);
// Validate company domain URL
validation.validateUrl(optionsArg.companyDomain, true);
this.options = optionsArg;
}
/**
* Adds an item to the feed
* @param itemArg - The feed item to add
* @throws Error if validation fails or ID is duplicate
* @example
* ```typescript
* feed.addItem({
* title: 'Hello World',
* timestamp: Date.now(),
* url: 'https://example.com/hello',
* authorName: 'John Doe',
* imageUrl: 'https://example.com/image.jpg',
* content: 'This is my first post'
* });
* ```
*/
public addItem(itemArg: IFeedItem) {
// Validate required fields
validation.validateRequiredFields(
itemArg,
['title', 'timestamp', 'url', 'authorName', 'imageUrl', 'content'],
'Feed item'
);
// Validate URLs
validation.validateUrl(itemArg.url, true);
validation.validateUrl(itemArg.imageUrl, true);
// Validate timestamp
validation.validateTimestamp(itemArg.timestamp);
// Validate ID uniqueness (use URL as ID if not provided)
const itemId = itemArg.id || itemArg.url;
if (this.itemIds.has(itemId)) {
throw new Error(
`Duplicate item ID: ${itemId}. Each item must have a unique ID or URL. ` +
`IDs should never change once published.`
);
}
this.itemIds.add(itemId);
this.items.push(itemArg);
}
/**
* Escapes special XML characters
* @protected
* @param str - String to escape
* @returns Escaped string
*/
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,
url: this.options.companyDomain,
},
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,
},
})),
};
return JSON.stringify(jsonFeed, null, 2);
}
/**
* Exports the feed as an RSS 2.0 formatted string
* @returns RSS 2.0 XML string
* @example
* ```typescript
* const rssString = feed.exportRssFeedString();
* console.log(rssString);
* ```
*/
public exportRssFeedString(): string {
return this.generateRss2();
}
/**
* Exports the feed as an Atom 1.0 formatted string
* @returns Atom 1.0 XML string
* @example
* ```typescript
* const atomString = feed.exportAtomFeed();
* ```
*/
public exportAtomFeed(): string {
return this.generateAtom1();
}
/**
* Exports the feed as a JSON Feed 1.0 formatted string
* @returns JSON Feed 1.0 string
* @example
* ```typescript
* const jsonFeed = feed.exportJsonFeed();
* const parsed = JSON.parse(jsonFeed);
* ```
*/
public exportJsonFeed(): string {
return this.generateJsonFeed();
}
}

487
ts/classes.podcast.ts Normal file
View File

@@ -0,0 +1,487 @@
import * as plugins from './plugins.js';
import * as validation from './validation.js';
import { Feed } from './classes.feed.js';
import type { 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 and Podcast 2.0 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;
// 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;
}
/**
* 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"');
}
// 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;
}
/**
* 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
// Note: itunesDuration is validated separately to allow for proper numeric validation
validation.validateRequiredFields(
episodeArg,
['title', 'timestamp', 'url', 'authorName', 'imageUrl', 'content', 'audioUrl', 'audioType', 'audioLength'],
'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 (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)');
}
// 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
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`;
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`;
}
// 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';
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;
}
}

147
ts/classes.smartfeed.ts Normal file
View File

@@ -0,0 +1,147 @@
import { Feed } from './classes.feed.js';
import type { IFeedOptions } from './classes.feed.js';
import { PodcastFeed } from './classes.podcast.js';
import type { IPodcastFeedOptions } from './classes.podcast.js';
import * as plugins from './plugins.js';
import { parseFeedXML } from './lib/feedparser.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 {
/**
* Creates a new Feed instance with the provided configuration
* @param optionsArg - Feed configuration options
* @returns A new Feed instance
* @throws Error if validation fails
* @example
* ```typescript
* const feed = smartfeed.createFeed({
* domain: 'example.com',
* title: 'My Blog',
* description: 'Latest news and updates',
* category: 'Technology',
* company: 'Example Inc',
* companyEmail: 'hello@example.com',
* companyDomain: 'https://example.com'
* });
* ```
*/
public createFeed(optionsArg: IFeedOptions): Feed {
const feedVersion = new Feed(optionsArg);
return feedVersion;
}
/**
* Creates a new PodcastFeed instance with iTunes and Podcast namespace support
* @param optionsArg - Podcast feed configuration options
* @returns A new PodcastFeed instance
* @throws Error if validation fails
* @example
* ```typescript
* const podcast = smartfeed.createPodcastFeed({
* domain: 'podcast.example.com',
* title: 'My Podcast',
* description: 'An awesome podcast about tech',
* category: 'Technology',
* company: 'Podcast Inc',
* companyEmail: 'podcast@example.com',
* companyDomain: 'https://example.com',
* itunesCategory: 'Technology',
* itunesAuthor: 'John Doe',
* itunesOwner: { name: 'John Doe', email: 'john@example.com' },
* itunesImage: 'https://example.com/artwork.jpg',
* itunesExplicit: false
* });
* ```
*/
public createPodcastFeed(optionsArg: IPodcastFeedOptions): PodcastFeed {
const podcastFeed = new PodcastFeed(optionsArg);
return podcastFeed;
}
/**
* Creates an Atom feed from an array of standardized article objects
* Uses the @tsclass/tsclass IArticle interface for article format
* @param optionsArg - Feed configuration options
* @param articleArray - Array of article objects conforming to @tsclass/tsclass IArticle interface
* @returns Promise resolving to Atom feed XML string
* @throws Error if validation fails for feed options or articles
* @example
* ```typescript
* const feedString = await smartfeed.createFeedFromArticleArray(
* feedOptions,
* articles
* );
* ```
*/
public async createFeedFromArticleArray(
optionsArg: IFeedOptions,
articleArray: plugins.tsclass.content.IArticle[],
): Promise<string> {
const feed = this.createFeed(optionsArg);
for (const article of articleArray) {
feed.addItem({
authorName: `${article.author.firstName} ${article.author.surName}`,
timestamp: article.timestamp,
imageUrl: article.featuredImageUrl,
title: article.title,
url: article.url,
content: article.content,
});
}
const feedXmlString = feed.exportAtomFeed();
return feedXmlString;
}
/**
* Parses an RSS or Atom feed from a string
* @param rssFeedString - The RSS/Atom feed XML string to parse
* @returns Promise resolving to parsed feed object
* @throws Error if feed parsing fails
* @example
* ```typescript
* const feedString = '<rss>...</rss>';
* const parsed = await smartfeed.parseFeedFromString(feedString);
* console.log(parsed.title);
* console.log(parsed.items);
* ```
*/
public async parseFeedFromString(rssFeedString: string) {
return parseFeedXML(rssFeedString);
}
/**
* Parses an RSS or Atom feed from a URL
* @param urlArg - The absolute URL of the RSS/Atom feed
* @returns Promise resolving to parsed feed object
* @throws Error if feed fetch or parsing fails
* @example
* ```typescript
* const parsed = await smartfeed.parseFeedFromUrl('https://example.com/feed.xml');
* console.log(parsed.title);
* console.log(parsed.items);
* ```
*/
public async parseFeedFromUrl(urlArg: string) {
const 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);
}
}

View File

@@ -1,9 +1,16 @@
import { Feed, IFeedOptions } from './smartfeed.classes.feed';
import * as plugins from './smartfeed.plugins';
// Export classes
export * from './classes.smartfeed.js';
export * from './classes.feed.js';
export * from './classes.podcast.js';
export class Smartfeed {
public createFeed(optionsArg: IFeedOptions) {
const feedVersion = new Feed(optionsArg);
return feedVersion;
}
}
// 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';

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');
}
}

9
ts/plugins.ts Normal file
View File

@@ -0,0 +1,9 @@
// tsclass scope
import * as tsclass from '@tsclass/tsclass';
export { tsclass };
// third party scope
import { XMLParser } from 'fast-xml-parser';
export { XMLParser };

View File

@@ -1,73 +0,0 @@
import * as plugins from './smartfeed.plugins';
export interface IFeedOptions {
domain: string;
title: string;
description: string;
category: string;
company: string;
companyEmail: string;
companyDomain: string;
}
export interface IFeedItem {
title: string;
timestamp: number;
url: string;
authorName: string;
imageUrl: string;
}
export class Feed {
options: IFeedOptions;
items: IFeedItem[] = [];
constructor(optionsArg: IFeedOptions) {
this.options = optionsArg;
}
public addItem(itemArg: IFeedItem) {
this.items.push(itemArg);
}
private getFeedObject() {
const feed = new plugins.feed.Feed({
copyright: `All rights reserved, ${this.options.company}`,
id: this.options.domain,
title: this.options.title,
author: {
name: this.options.company,
email: this.options.companyEmail,
link: this.options.companyEmail
},
description: this.options.description,
generator: '@pushrocks/smartfeed',
language: "en"
});
feed.addCategory(this.options.category);
for (const itemArg of this.items) {
feed.addItem({
title: itemArg.title,
date: new Date(itemArg.timestamp),
link: itemArg.url,
image: itemArg.imageUrl,
author: [{
name: itemArg.authorName
}]
});
}
return feed;
}
public exportRssFeedString(): string {
return this.getFeedObject().rss2();
}
public exportAtomFeed(): string {
return this.getFeedObject().atom1();
}
public exportJsonFeed(): string {
return this.getFeedObject().json1();
}
}

View File

@@ -1,6 +0,0 @@
// third party scope
import * as feed from 'feed';
export {
feed
};

167
ts/validation.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* Validation utilities for smartfeed
* Provides security, validation, and sanitization functions
*/
/**
* Validates that a URL is absolute and optionally prefers HTTPS
* @param url - The URL to validate
* @param preferHttps - Whether to warn if not HTTPS
* @returns Validated URL
* @throws Error if URL is invalid or not absolute
*/
export function validateUrl(url: string, preferHttps: boolean = true): string {
if (!url || typeof url !== 'string') {
throw new Error('URL must be a non-empty string');
}
// Check if URL is absolute
try {
const parsedUrl = new URL(url);
// Validate protocol
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only http and https are supported.`);
}
// Prefer HTTPS for security
if (preferHttps && parsedUrl.protocol === 'http:') {
console.warn(`Warning: URL '${url}' uses HTTP instead of HTTPS. HTTPS is recommended for security and privacy.`);
}
return url;
} catch (error) {
if (error instanceof TypeError) {
throw new Error(`Invalid or relative URL: ${url}. All URLs must be absolute (e.g., https://example.com/path)`);
}
throw error;
}
}
/**
* Sanitizes HTML content to prevent XSS attacks
* This is a basic implementation - for production use, consider a dedicated library
* @param content - The content to sanitize
* @returns Sanitized content
*/
export function sanitizeContent(content: string): string {
if (!content || typeof content !== 'string') {
return '';
}
// Basic HTML entity encoding to prevent XSS
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}
/**
* Validates that required fields are present and non-empty
* @param obj - Object to validate
* @param requiredFields - Array of required field names
* @param objectName - Name of object for error messages
* @throws Error if validation fails
*/
export function validateRequiredFields(
obj: Record<string, any>,
requiredFields: string[],
objectName: string = 'Object'
): void {
const missingFields: string[] = [];
for (const field of requiredFields) {
if (!obj[field] || (typeof obj[field] === 'string' && obj[field].trim() === '')) {
missingFields.push(field);
}
}
if (missingFields.length > 0) {
throw new Error(
`${objectName} validation failed: Missing or empty required fields: ${missingFields.join(', ')}`
);
}
}
/**
* Validates an email address format
* @param email - Email to validate
* @returns Validated email
* @throws Error if email is invalid
*/
export function validateEmail(email: string): string {
if (!email || typeof email !== 'string') {
throw new Error('Email must be a non-empty string');
}
// Basic email validation regex
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error(`Invalid email address: ${email}`);
}
return email;
}
/**
* Validates a timestamp
* @param timestamp - Timestamp to validate (milliseconds since epoch)
* @returns Validated timestamp
* @throws Error if timestamp is invalid
*/
export function validateTimestamp(timestamp: number): number {
if (typeof timestamp !== 'number' || isNaN(timestamp)) {
throw new Error('Timestamp must be a valid number');
}
if (timestamp < 0) {
throw new Error('Timestamp cannot be negative');
}
// Check if timestamp is reasonable (not in far future)
const now = Date.now();
const tenYearsFromNow = now + (10 * 365 * 24 * 60 * 60 * 1000);
if (timestamp > tenYearsFromNow) {
console.warn(`Warning: Timestamp ${timestamp} is more than 10 years in the future`);
}
return timestamp;
}
/**
* Validates that a domain is properly formatted
* @param domain - Domain to validate
* @returns Validated domain
* @throws Error if domain is invalid
*/
export function validateDomain(domain: string): string {
if (!domain || typeof domain !== 'string') {
throw new Error('Domain must be a non-empty string');
}
// Basic domain validation
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i;
if (!domainRegex.test(domain)) {
throw new Error(`Invalid domain format: ${domain}`);
}
return domain;
}
/**
* Creates a validation error with context
* @param message - Error message
* @param context - Additional context information
* @returns Error object
*/
export function createValidationError(message: string, context?: Record<string, any>): Error {
const error = new Error(message);
if (context) {
Object.assign(error, { context });
}
return error;
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
},
"exclude": ["dist_*/**/*.d.ts"]
}

View File

@@ -1,17 +0,0 @@
{
"extends": ["tslint:latest", "tslint-config-prettier"],
"rules": {
"semicolon": [true, "always"],
"no-console": false,
"ordered-imports": false,
"object-literal-sort-keys": false,
"member-ordering": {
"options":{
"order": [
"static-method"
]
}
}
},
"defaultSeverity": "warning"
}