This commit is contained in:
2025-06-17 10:04:42 -05:00
parent 6df82b2a5b
commit 09f90746af
7 changed files with 4327 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
@NAMESPACE:registry=https://npm.pkg.github.com
+198
View File
@@ -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;
```
+3844
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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
View File
@@ -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;
}
+16
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
+115
View File
@@ -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 &amp; Tune Podcast</title>`);
expect(feed).toContain(`<description>A show about code &amp; 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: &lt; &gt; &amp; &quot; &apos; ';
expect(feed).toContain(`<description>${expectedEscapedDescription}</description>`);
// Check escaped enclosure URL
const expectedEscapedUrl = 'https://example.com/ep2.mp3?query=param&amp;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);
});
});