init
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
# Podcast RSS Generator
|
||||||
|
|
||||||
|
A simple, data-agnostic JavaScript utility to generate a valid podcast RSS 2.0 feed from your own data objects.
|
||||||
|
|
||||||
|
### Core Concept: Server vs. Browser
|
||||||
|
|
||||||
|
This package is a JavaScript utility that can run anywhere. However, a real podcast feed **must** be a publicly accessible file on a **server**. Podcast platforms like Spotify and Apple Podcasts need a static URL to check for new episodes.
|
||||||
|
|
||||||
|
This means the correct way to use this library is in a **server-side environment** like a **Next.js API Route** or an **Express** backend.
|
||||||
|
|
||||||
|
We have also included a **Pure React example for demonstration purposes**, showing how the library can be run in the browser to view or download the feed manually.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install podcast-rss
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The package exports a single function, `generatePodcastRss`. You provide it with your show's data, and it returns the complete XML feed as a string.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { generatePodcastRss } from 'podcast-rss-generator-js';
|
||||||
|
|
||||||
|
const feed = generatePodcastRss(podcast, episodes, options);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference: Complete List of Options
|
||||||
|
|
||||||
|
### `Podcast` Object Options
|
||||||
|
|
||||||
|
This object contains all the channel-level information for your podcast.
|
||||||
|
|
||||||
|
| Key | Type | Required? | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `title` | `string` | **Required** | The name of your podcast. |
|
||||||
|
| `description`| `string` | **Required** | A short description or summary of the show. |
|
||||||
|
| `image_url` | `string` | **Required** | URL to the podcast's square cover art (JPG/PNG, 1400x1400 to 3000x3000 pixels). |
|
||||||
|
| `podcast_slug`| `string` | **Required** | The URL-friendly slug used to build the feed's link. |
|
||||||
|
| `owner` | `string` | **Required** | The author or host of the podcast. Used for `<itunes:author>`. |
|
||||||
|
| `itunes_owner_name`|`string`| **Required** | The name of the podcast owner. Displayed in podcast clients. |
|
||||||
|
| `itunes_owner_email`|`string`| **Required** | The email of the podcast owner. Used for verification. |
|
||||||
|
| `itunes_category`|`Array<object>`| **Required**| An array of category objects. Ex: `[{ text: 'Technology' }]` or `[{ text: 'Business', subtext: 'Careers' }]`. |
|
||||||
|
| `id` | `string` | **Required** | A unique identifier for the podcast itself. Used for `<podcast:guid>`. A UUID is recommended. |
|
||||||
|
| `created_at`| `Date` or `string`| **Required**| The date the podcast was created. Used for `<pubDate>`. |
|
||||||
|
| `explicit` | `boolean` | **Required** | `true` if the podcast contains explicit content, otherwise `false`. |
|
||||||
|
| `language` | `string` | Optional | The two-letter ISO language code (e.g., `en`, `es`). Defaults to `en-us`. |
|
||||||
|
| `updated_at`| `Date` or `string`| Optional | The date the podcast was last updated. Defaults to `created_at`. |
|
||||||
|
| `locked` | `'yes'` or `'no'` | Optional | Whether the podcast is locked from being imported elsewhere. Defaults to `'no'`. |
|
||||||
|
|
||||||
|
### `Episode` Object Options
|
||||||
|
|
||||||
|
This is an array of objects, where each object represents a single episode.
|
||||||
|
|
||||||
|
| Key | Type | Required? | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `guid` | `string` | **Required** | A globally unique and permanent identifier for the episode. Using the audio file URL is not recommended. Use a UUID. |
|
||||||
|
| `title` | `string` | **Required** | The title of the episode. |
|
||||||
|
| `description`| `string` | **Required** | The episode's show notes. Can contain HTML. |
|
||||||
|
| `publication_date`|`Date` or `string`| **Required**| The date the episode was published. |
|
||||||
|
| `audio_url` | `string` | **Required** | The direct URL to the episode's audio file (e.g., `.mp3`, `.m4a`). |
|
||||||
|
| `audio_length`| `number` | **Required** | The size of the audio file **in bytes**. |
|
||||||
|
| `episode_slug`| `string` | **Required** | The URL-friendly slug for the episode page. |
|
||||||
|
| `image_url` | `string` | Optional | URL to episode-specific cover art. Overrides the main podcast image. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Example 1: Server-Side with Next.js (Recommended)
|
||||||
|
|
||||||
|
This is the ideal approach for modern React projects.
|
||||||
|
|
||||||
|
**File:** `app/podcasts/[slug]/rss.xml/route.js`
|
||||||
|
```javascript
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { generatePodcastRss } from 'podcast-rss-generator-js';
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
// In a real app, you would fetch this data from a database or CMS.
|
||||||
|
const podcastData = {
|
||||||
|
title: 'My Awesome Podcast',
|
||||||
|
description: 'A show about awesome things.',
|
||||||
|
image_url: 'https://example.com/images/cover.png',
|
||||||
|
podcast_slug: params.slug,
|
||||||
|
owner: 'Jane Doe',
|
||||||
|
itunes_owner_name: 'Jane Doe',
|
||||||
|
itunes_owner_email: '[email protected]',
|
||||||
|
itunes_category: [{ text: 'Technology' }],
|
||||||
|
id: 'a8b7c6d5-e4f3-a2b1-c0d9-e8f7a6b5c4d3',
|
||||||
|
created_at: new Date(),
|
||||||
|
explicit: false,
|
||||||
|
};
|
||||||
|
const episodes = [{
|
||||||
|
guid: 'unique-episode-id-123',
|
||||||
|
title: 'Our First Episode!',
|
||||||
|
description: 'The description for our first episode.',
|
||||||
|
publication_date: new Date(),
|
||||||
|
audio_url: 'https://example.com/audio/ep1.mp3',
|
||||||
|
audio_length: 34216300, // in bytes
|
||||||
|
episode_slug: 'our-first-episode',
|
||||||
|
}];
|
||||||
|
|
||||||
|
const rssFeed = generatePodcastRss(podcastData, episodes, {
|
||||||
|
siteUrl: 'https://www.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(rssFeed, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/rss+xml' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Client-Side Demonstration
|
||||||
|
|
||||||
|
> **⚠️ Important:** This example is for demonstration only. A podcast feed generated this way **cannot** be submitted to Spotify or Apple Podcasts because it does not have a permanent public URL. It only exists inside your user's browser.
|
||||||
|
|
||||||
|
This component shows how to run the generator in the browser and lets the user view or download the output.
|
||||||
|
|
||||||
|
**File:** `src/PodcastGeneratorComponent.js`
|
||||||
|
```javascript
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { generatePodcastRss } from 'podcast-rss-generator-js';
|
||||||
|
|
||||||
|
// --- Hardcode data for the demo ---
|
||||||
|
const podcastData = {
|
||||||
|
title: 'My React Podcast',
|
||||||
|
description: 'A demo podcast generated entirely in the browser.',
|
||||||
|
image_url: 'https://example.com/images/cover.png',
|
||||||
|
podcast_slug: 'my-react-podcast',
|
||||||
|
owner: 'React Developer',
|
||||||
|
itunes_owner_name: 'React Developer',
|
||||||
|
itunes_owner_email: '[email protected]',
|
||||||
|
itunes_category: [{ text: 'Technology' }],
|
||||||
|
id: 'a8b7c6d5-e4f3-a2b1-c0d9-e8f7a6b5c4d4',
|
||||||
|
created_at: new Date(),
|
||||||
|
explicit: false,
|
||||||
|
};
|
||||||
|
const episodes = [{
|
||||||
|
guid: 'demo-episode-1',
|
||||||
|
title: 'Hello, React!',
|
||||||
|
description: 'This episode was generated by a React component.',
|
||||||
|
publication_date: new Date(),
|
||||||
|
audio_url: 'https://example.com/audio/ep1.mp3',
|
||||||
|
audio_length: 34216300,
|
||||||
|
episode_slug: 'hello-react',
|
||||||
|
}];
|
||||||
|
|
||||||
|
function PodcastGeneratorComponent() {
|
||||||
|
const [rssText, setRssText] = useState('');
|
||||||
|
const [downloadLink, setDownloadLink] = useState('');
|
||||||
|
|
||||||
|
const handleGenerateRss = () => {
|
||||||
|
const feed = generatePodcastRss(podcastData, episodes, {
|
||||||
|
siteUrl: 'https://www.example.com',
|
||||||
|
});
|
||||||
|
setRssText(feed);
|
||||||
|
|
||||||
|
const blob = new Blob([feed], { type: 'application/rss+xml' });
|
||||||
|
setDownloadLink(URL.createObjectURL(blob));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '20px', borderRadius: '8px' }}>
|
||||||
|
<h2>Podcast Feed Generator (Client-Side Demo)</h2>
|
||||||
|
<p>Click the button to generate the XML feed inside the browser.</p>
|
||||||
|
<button onClick={handleGenerateRss} style={{ padding: '10px 15px', marginBottom: '20px' }}>
|
||||||
|
Generate RSS Feed
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{rssText && (
|
||||||
|
<div>
|
||||||
|
<h3>Generated XML Output:</h3>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={rssText}
|
||||||
|
style={{ width: '95%', height: '250px' }}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href={downloadLink}
|
||||||
|
download="rss.xml"
|
||||||
|
style={{ display: 'inline-block', marginTop: '10px' }}
|
||||||
|
>
|
||||||
|
Download rss.xml file
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PodcastGeneratorComponent;
|
||||||
|
```
|
||||||
Generated
+3844
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@auggie2lbcf/podcast-rss",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A simple and flexible podcast rss builder for Node.js",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
|
||||||
|
"prepublishOnly": "npm test"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"podcast",
|
||||||
|
"rss",
|
||||||
|
"generator",
|
||||||
|
"xml",
|
||||||
|
"feed",
|
||||||
|
"itunes"
|
||||||
|
],
|
||||||
|
"author": "Auggie2LBCF",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/auggie2lbcf/podcast-rss.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/auggie2lbcf/podcast-rss/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/auggie2lbcf/podcast-rss#readme",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"jest": "^30.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
// src/index.js
|
||||||
|
|
||||||
|
import { escapeXml } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Podcast
|
||||||
|
* @property {string} id - A unique identifier for the podcast (for podcast:guid).
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} podcast_slug - The URL-friendly slug for the podcast.
|
||||||
|
* @property {string} image_url
|
||||||
|
* @property {string} [language='en-us']
|
||||||
|
* @property {string|Date} created_at
|
||||||
|
* @property {string|Date} [updated_at]
|
||||||
|
* @property {string} owner - The author or host of the podcast.
|
||||||
|
* @property {boolean} explicit
|
||||||
|
* @property {string} itunes_owner_name
|
||||||
|
* @property {string} itunes_owner_email
|
||||||
|
* @property {'yes'|'no'} [locked='no']
|
||||||
|
* @property {Array<{text: string, subtext?: string}>} itunes_category
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Episode
|
||||||
|
* @property {string} guid - A unique, permanent identifier for the episode.
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string|Date} publication_date
|
||||||
|
* @property {string} episode_slug - The URL-friendly slug for the episode.
|
||||||
|
* @property {string} audio_url
|
||||||
|
* @property {number} audio_length - File size in bytes.
|
||||||
|
* @property {string} [image_url] - Episode-specific image URL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} FeedOptions
|
||||||
|
* @property {string} siteUrl - The base URL of your site (e.g., 'https://yourdomain.com').
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a podcast RSS feed XML string from provided data objects.
|
||||||
|
*
|
||||||
|
* @param {Podcast} podcast - The main podcast data object.
|
||||||
|
* @param {Episode[]} episodes - An array of episode data objects.
|
||||||
|
* @param {FeedOptions} options - Configuration options for the feed.
|
||||||
|
* @returns {string} The complete RSS feed XML as a string.
|
||||||
|
*/
|
||||||
|
export function generatePodcastRss(podcast, episodes, options) {
|
||||||
|
const { siteUrl } = options;
|
||||||
|
|
||||||
|
// Construct URLs and dates
|
||||||
|
const podcastBaseUrl = `${siteUrl}/podcasts/${podcast.podcast_slug}`;
|
||||||
|
const podcastFeedUrl = `${podcastBaseUrl}/rss.xml`;
|
||||||
|
const lastBuildDate = new Date(podcast.updated_at || podcast.created_at).toUTCString();
|
||||||
|
|
||||||
|
// Build the XML string for the channel
|
||||||
|
let rssFeed = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>${escapeXml(podcast.title)}</title>
|
||||||
|
<link>${podcastBaseUrl}</link>
|
||||||
|
<description>${escapeXml(podcast.description)}</description>
|
||||||
|
<language>${podcast.language || 'en-us'}</language>
|
||||||
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||||
|
<pubDate>${lastBuildDate}</pubDate>
|
||||||
|
<itunes:image href="${escapeXml(podcast.image_url)}"/>
|
||||||
|
<itunes:author>${escapeXml(podcast.owner)}</itunes:author>
|
||||||
|
<itunes:explicit>${podcast.explicit ? 'true' : 'false'}</itunes:explicit>
|
||||||
|
<itunes:owner>
|
||||||
|
<itunes:name>${escapeXml(podcast.itunes_owner_name)}</itunes:name>
|
||||||
|
<itunes:email>${escapeXml(podcast.itunes_owner_email)}</itunes:email>
|
||||||
|
</itunes:owner>
|
||||||
|
${podcast.itunes_category.map(cat =>
|
||||||
|
cat.subtext
|
||||||
|
? `<itunes:category text="${escapeXml(cat.text)}"><itunes:category text="${escapeXml(cat.subtext)}"/></itunes:category>`
|
||||||
|
: `<itunes:category text="${escapeXml(cat.text)}"/>`
|
||||||
|
).join('\n ')}
|
||||||
|
<atom:link href="${podcastFeedUrl}" rel="self" type="application/rss+xml"/>
|
||||||
|
<podcast:locked>${podcast.locked || 'no'}</podcast:locked>
|
||||||
|
<podcast:guid>${podcast.id}</podcast:guid>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add each episode as an <item>
|
||||||
|
if (episodes) {
|
||||||
|
for (const episode of episodes) {
|
||||||
|
const episodeUrl = `${podcastBaseUrl}/episodes/${episode.episode_slug}`;
|
||||||
|
const episodeImageUrl = episode.image_url || podcast.image_url;
|
||||||
|
const enclosureType = episode.audio_url.includes('.mp4') || episode.audio_url.includes('.m4a') ? 'audio/mp4' : 'audio/mpeg';
|
||||||
|
|
||||||
|
rssFeed += `
|
||||||
|
<item>
|
||||||
|
<title>${escapeXml(episode.title)}</title>
|
||||||
|
<guid isPermaLink="false">${episode.guid}</guid>
|
||||||
|
<link>${episodeUrl}</link>
|
||||||
|
<description>${escapeXml(episode.description)}</description>
|
||||||
|
<pubDate>${new Date(episode.publication_date).toUTCString()}</pubDate>
|
||||||
|
<enclosure url="${escapeXml(episode.audio_url)}" length="${episode.audio_length}" type="${enclosureType}"/>
|
||||||
|
<itunes:author>${escapeXml(podcast.owner)}</itunes:author>
|
||||||
|
<itunes:explicit>${podcast.explicit ? 'true' : 'false'}</itunes:explicit>
|
||||||
|
<itunes:image href="${escapeXml(episodeImageUrl)}"/>
|
||||||
|
</item>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tags and return
|
||||||
|
rssFeed += `
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
return rssFeed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// src/utils.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes special XML characters in a string.
|
||||||
|
* @param {string} text The text to escape.
|
||||||
|
* @returns {string} The escaped text.
|
||||||
|
*/
|
||||||
|
export function escapeXml(text) {
|
||||||
|
if (typeof text !== 'string') return '';
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { generatePodcastRss } from '../src/index.js';
|
||||||
|
|
||||||
|
// --- Mock Data for Tests ---
|
||||||
|
|
||||||
|
const mockPodcastData = {
|
||||||
|
title: 'Test & Tune Podcast',
|
||||||
|
description: 'A show about code & other things.',
|
||||||
|
image_url: 'https://example.com/cover.png',
|
||||||
|
podcast_slug: 'test-and-tune',
|
||||||
|
owner: 'Dev Team',
|
||||||
|
itunes_owner_name: 'Dev Team',
|
||||||
|
itunes_owner_email: '[email protected]',
|
||||||
|
itunes_category: [{ text: 'Technology' }],
|
||||||
|
id: '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d',
|
||||||
|
created_at: '2025-06-17T14:30:00.000Z',
|
||||||
|
explicit: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEpisodes = [
|
||||||
|
{
|
||||||
|
guid: 'episode-guid-1',
|
||||||
|
title: 'Episode 1: The First Test',
|
||||||
|
description: 'This is the show notes for episode one.',
|
||||||
|
publication_date: '2025-06-17T14:30:00.000Z',
|
||||||
|
audio_url: 'https://example.com/ep1.mp3',
|
||||||
|
audio_length: 12345678,
|
||||||
|
episode_slug: 'the-first-test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
guid: 'episode-guid-2',
|
||||||
|
title: 'Episode 2: XML Escaping & Special Characters',
|
||||||
|
description: 'A description with characters that need escaping: < > & " \' ',
|
||||||
|
publication_date: '2025-06-10T14:30:00.000Z',
|
||||||
|
audio_url: 'https://example.com/ep2.mp3?query=param&another=value',
|
||||||
|
audio_length: 87654321,
|
||||||
|
episode_slug: 'xml-escaping',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockFeedOptions = {
|
||||||
|
siteUrl: 'https://www.my-test-site.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- Test Suite ---
|
||||||
|
|
||||||
|
describe('generatePodcastRss', () => {
|
||||||
|
|
||||||
|
test('should generate a valid RSS feed with all required elements', () => {
|
||||||
|
const feed = generatePodcastRss(mockPodcastData, mockEpisodes, mockFeedOptions);
|
||||||
|
|
||||||
|
// Basic checks
|
||||||
|
expect(feed).toBeDefined();
|
||||||
|
expect(typeof feed).toBe('string');
|
||||||
|
expect(feed.startsWith('<?xml version="1.0" encoding="UTF-8"?>')).toBe(true);
|
||||||
|
|
||||||
|
// Channel-level checks
|
||||||
|
expect(feed).toContain('<rss');
|
||||||
|
expect(feed).toContain('<channel>');
|
||||||
|
|
||||||
|
// --- CORRECTED LINES ---
|
||||||
|
expect(feed).toContain(`<title>Test & Tune Podcast</title>`);
|
||||||
|
expect(feed).toContain(`<description>A show about code & other things.</description>`);
|
||||||
|
// ----------------------
|
||||||
|
|
||||||
|
expect(feed).toContain(`<link>${mockFeedOptions.siteUrl}/podcasts/${mockPodcastData.podcast_slug}</link>`);
|
||||||
|
expect(feed).toContain('<itunes:owner>');
|
||||||
|
expect(feed).toContain(`<itunes:name>${mockPodcastData.itunes_owner_name}</itunes:name>`);
|
||||||
|
expect(feed).toContain(`<itunes:email>${mockPodcastData.itunes_owner_email}</itunes:email>`);
|
||||||
|
expect(feed).toContain('</itunes:owner>');
|
||||||
|
expect(feed).toContain('</channel>');
|
||||||
|
expect(feed).toContain('</rss>');
|
||||||
|
|
||||||
|
// Item-level checks (for the first episode)
|
||||||
|
expect(feed).toContain('<item>');
|
||||||
|
expect(feed).toContain(`<title>${mockEpisodes[0].title}</title>`);
|
||||||
|
expect(feed).toContain(`<guid isPermaLink="false">${mockEpisodes[0].guid}</guid>`);
|
||||||
|
expect(feed).toContain(`<pubDate>${new Date(mockEpisodes[0].publication_date).toUTCString()}</pubDate>`);
|
||||||
|
expect(feed).toContain(`<enclosure url="${mockEpisodes[0].audio_url}" length="${mockEpisodes[0].audio_length}"`);
|
||||||
|
expect(feed).toContain('</item>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should correctly escape special XML characters', () => {
|
||||||
|
const episodeWithSpecialChars = mockEpisodes[1];
|
||||||
|
const feed = generatePodcastRss(mockPodcastData, [episodeWithSpecialChars], mockFeedOptions);
|
||||||
|
|
||||||
|
// Check escaped description
|
||||||
|
const expectedEscapedDescription = 'A description with characters that need escaping: < > & " ' ';
|
||||||
|
expect(feed).toContain(`<description>${expectedEscapedDescription}</description>`);
|
||||||
|
|
||||||
|
// Check escaped enclosure URL
|
||||||
|
const expectedEscapedUrl = 'https://example.com/ep2.mp3?query=param&another=value';
|
||||||
|
expect(feed).toContain(`<enclosure url="${expectedEscapedUrl}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle an empty episodes array gracefully', () => {
|
||||||
|
const feed = generatePodcastRss(mockPodcastData, [], mockFeedOptions);
|
||||||
|
|
||||||
|
// Should still be a valid feed structure
|
||||||
|
expect(feed).toContain('<channel>');
|
||||||
|
expect(feed).toContain(`</channel>`);
|
||||||
|
|
||||||
|
// Should NOT contain any <item> tags
|
||||||
|
expect(feed).not.toContain('<item>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate the correct number of item blocks', () => {
|
||||||
|
const feed = generatePodcastRss(mockPodcastData, mockEpisodes, mockFeedOptions);
|
||||||
|
|
||||||
|
// Use a regular expression to count occurrences of '<item>'
|
||||||
|
const itemMatches = feed.match(/<item>/g);
|
||||||
|
expect(itemMatches).not.toBeNull();
|
||||||
|
expect(itemMatches.length).toBe(mockEpisodes.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user