# Feed Generator: RSS, Atom, JSON

![Build Status](https://github.com/bliztek/feed-generator/actions/workflows/ci.yml/badge.svg)
[![Codecov](https://codecov.io/gh/bliztek/feed-generator/graph/badge.svg?token=4GLOVKLC2W)](https://codecov.io/gh/bliztek/feed-generator)
![npm](https://img.shields.io/npm/v/@bliztek/feed-generator)
![License](https://img.shields.io/npm/l/@bliztek/feed-generator)
![Downloads](https://img.shields.io/npm/dm/@bliztek/feed-generator)
![Bundle Size](https://img.shields.io/bundlephobia/min/@bliztek/feed-generator)

A simple and lightweight Node.js library for generating **RSS 2.0**, **Atom**, and **JSON Feed** formats. Define your feed once, output to any format. Zero dependencies. Podcast-ready with full iTunes namespace support.

---

## Features

- **One unified API** -- define your feed once, generate RSS, Atom, or JSON Feed output.
- **Fluent builder** -- construct feeds incrementally with a chainable `FeedBuilder` API.
- **Syndication-ready** -- RSS `content:encoded` and `media:content` for full-content syndication to platforms like Dev.to.
- **Custom extensions** -- inject arbitrary XML namespaces, elements, and JSON Feed properties (Dublin Core, GeoRSS, etc.).
- **Podcast support** -- full iTunes namespace for Apple Podcasts, Spotify, and other directories.
- **Content sanitization** -- opt-in whitelist-based HTML sanitization with XSS, `data:` URI, and CDATA injection protection.
- **Input validation** -- validates URLs, dates, enclosures, and RSS-specific fields with structured `FeedValidationError` errors.
- **XSLT stylesheets** -- optional `<?xml-stylesheet?>` processing instruction for browser-friendly feeds.
- **WebSub hub** -- declare a hub link for real-time push notifications across all formats.
- **Compact output** -- toggle between pretty-printed and minified output.
- **Feed pagination** -- built-in support for paginated feeds across all formats.
- **ESM + CJS** -- dual module build with proper `exports` field and tree-shaking support.
- Type-safe with full TypeScript support. Zero external dependencies.

---

## Installation

```bash
npm install @bliztek/feed-generator
# or
pnpm add @bliztek/feed-generator
```

---

## Quick Start

```typescript
import { generateFeed } from "@bliztek/feed-generator";

const feed = {
  title: "My Blog",
  link: "https://example.com",
  description: "The latest articles from my blog.",
  items: [
    {
      id: "https://example.com/post-1",
      title: "First Post",
      link: "https://example.com/post-1",
      content: "<p>The full content of the first post.</p>",
      summary: "A summary of the first post.",
      date: "2024-12-14T10:00:00Z",
    },
  ],
};

// Generate a single format
const rss = generateFeed(feed, "rss");
const atom = generateFeed(feed, "atom");
const json = generateFeed(feed, "json");
```

### Generate All Formats at Once

```typescript
import { generateFeeds } from "@bliztek/feed-generator";

const { rss, atom, json } = generateFeeds(feed);
```

### FeedBuilder (Fluent API)

```typescript
import { FeedBuilder } from "@bliztek/feed-generator";

const rss = new FeedBuilder()
  .title("My Blog")
  .link("https://example.com")
  .description("The latest articles from my blog.")
  .language("en-us")
  .ttl(60)
  .addItem({
    id: "post-1",
    title: "First Post",
    link: "https://example.com/post-1",
    content: "<p>Hello world!</p>",
    date: "2024-12-14T10:00:00Z",
  })
  .generate("rss");

// Or generate all formats at once
const all = new FeedBuilder()
  .title("My Blog")
  .link("https://example.com")
  .description("Latest posts.")
  .addItem({ id: "1", title: "Post" })
  .generateAll();
// all.rss, all.atom, all.json

// Or get the raw Feed object
const feedData = new FeedBuilder()
  .title("My Blog")
  .link("https://example.com")
  .addItem({ id: "1", title: "Post" })
  .build();
```

---

## Options

Both `generateFeed` and `generateFeeds` accept an optional options argument:

```typescript
type FeedOptions = {
  sanitize?: boolean;    // Sanitize HTML content (default: false)
  pretty?: boolean;      // Pretty-print output (default: true)
  stylesheet?: string;   // XSLT stylesheet URL for RSS/Atom (ignored for JSON)
};
```

### Content Sanitization

When `sanitize: true` is set, HTML content in items is processed through a whitelist-based sanitizer that:

- **Allows** safe tags: `<p>`, `<a>`, `<img>`, `<strong>`, `<em>`, `<ul>`, `<table>`, and ~40 others
- **Strips with content**: `<script>`, `<style>`, `<svg>`, `<math>`, `<iframe>`, `<object>`, `<embed>`, `<applet>`
- **Strips tags only** (keeps text): any other tag not in the allowlist
- **Blocks dangerous URLs**: `javascript:`, `data:`, and `vbscript:` in `href`, `src`, and `action` attributes
- **Removes event handlers**: all `on*` attributes (`onclick`, `onerror`, etc.)
- **Filters attributes**: only safe attributes are kept per tag (e.g., `<a>` keeps `href`, `title`, `rel`, `target`)

```typescript
const safeFeed = generateFeed(feed, "rss", { sanitize: true });
```

### Compact Output

```typescript
// Minified XML/JSON -- no indentation or newlines
const compact = generateFeed(feed, "rss", { pretty: false });
```

---

## Error Handling

All validation errors throw `FeedValidationError` with structured `field` and `reason` properties:

```typescript
import { generateFeed, FeedValidationError } from "@bliztek/feed-generator";

try {
  generateFeed(feed, "rss");
} catch (e) {
  if (e instanceof FeedValidationError) {
    console.log(e.field);   // "title", "items[0].link", etc.
    console.log(e.reason);  // Human-readable description
  }
}
```

The following validations are performed automatically:

- Feed `title` and `link` are required
- Feed `link` must be a valid URL
- RSS feeds require `description`
- All URLs are validated (`link`, `nextUrl`, `image.url`, item `link`, enclosure `url`)
- All dates are validated (`updated`, item `date`, item `updated`)
- RSS enclosures require `length`
- `ttl` must be a non-negative integer
- `skipHours` values must be integers 0--23
- `skipDays` values must be valid day names (Monday--Sunday)
- Item `image` must be a valid URL
- `feedLinks.hub` must be a valid URL
- Extension `xmlNamespaces` keys must start with `xmlns:`
- Extension `jsonProperties` keys must start with `_` (JSON Feed spec)

---

## Podcast Feeds

Generate podcast RSS feeds with full iTunes namespace support:

```typescript
const podcastFeed = {
  title: "My Podcast",
  link: "https://example.com/podcast",
  description: "A podcast about tech.",
  authors: [{ name: "Jane Doe", email: "jane@example.com" }],
  podcast: {
    type: "episodic",
    explicit: false,
    owner: { name: "Jane Doe", email: "jane@example.com" },
    category: [
      { name: "Technology", subcategory: "Software How-To" },
      { name: "Education" },
    ],
    image: "https://example.com/podcast/cover.jpg",
  },
  items: [
    {
      id: "ep1",
      title: "Episode 1: Getting Started",
      link: "https://example.com/podcast/ep1",
      summary: "An introduction to the show.",
      date: "2024-12-14T10:00:00Z",
      enclosures: [
        {
          url: "https://example.com/podcast/ep1.mp3",
          type: "audio/mpeg",
          length: 50000000,
        },
      ],
      podcast: {
        duration: 1830,         // seconds, or "30:30" string
        episode: 1,
        season: 1,
        episodeType: "full",    // "full" | "trailer" | "bonus"
        explicit: false,
      },
    },
  ],
};

const rss = generateFeed(podcastFeed, "rss");
// Includes xmlns:itunes, <itunes:category>, <itunes:duration>, etc.
```

### Podcast Types

```typescript
type PodcastFeed = {
  type?: "episodic" | "serial";
  explicit?: boolean;
  owner?: { name: string; email: string };
  category?: string | { name: string; subcategory?: string }[];
  image?: string;
  newFeedUrl?: string;
  block?: boolean;
  complete?: boolean;
};

type PodcastItem = {
  duration?: number | string;  // seconds or "HH:MM:SS"
  explicit?: boolean;
  episode?: number;
  season?: number;
  episodeType?: "full" | "trailer" | "bonus";
  image?: string;
  block?: boolean;
};
```

---

## Feed Pagination

All three formats support pagination via `nextUrl`:

```typescript
const page1 = {
  title: "My Blog",
  link: "https://example.com",
  description: "Latest posts.",
  nextUrl: "https://example.com/feed?page=2",
  items: [/* ... */],
};

// RSS: <atom:link rel="next" href="..." />
// Atom: <link rel="next" href="..." />
// JSON: "next_url": "..."
```

---

## Custom Extensions

Inject arbitrary XML namespaces and elements into RSS/Atom output, or custom properties into JSON Feed, without waiting for library support:

```typescript
import { generateFeed, XmlNode } from "@bliztek/feed-generator";

const feed = {
  title: "My Blog",
  link: "https://example.com",
  description: "A blog with custom extensions.",
  extensions: {
    xmlNamespaces: {
      "xmlns:georss": "http://www.georss.org/georss",
    },
    xmlElements: [
      { tag: "georss:point", children: ["45.256 -71.92"] } as XmlNode,
    ],
    jsonProperties: {
      _custom_source: "my-cms",  // JSON Feed extensions must use _-prefixed keys
    },
  },
  items: [
    {
      id: "1",
      title: "Post",
      content: "<p>Hello</p>",
      extensions: {
        xmlElements: [
          { tag: "georss:point", children: ["40.714 -74.006"] } as XmlNode,
        ],
        jsonProperties: { _reading_time: 5 },
      },
    },
  ],
};

const rss = generateFeed(feed, "rss");
// RSS output includes xmlns:georss and <georss:point> elements
```

Validation enforces that XML namespace keys start with `xmlns:` and JSON Feed extension keys start with `_` per the JSON Feed spec.

---

## XSLT Stylesheets

Make RSS/Atom feeds render nicely in browsers by adding an XSLT stylesheet reference:

```typescript
const rss = generateFeed(feed, "rss", { stylesheet: "/feed.xsl" });
// Output: <?xml version="1.0" encoding="UTF-8"?>
//         <?xml-stylesheet type="text/xsl" href="/feed.xsl"?>
//         <rss ...>
```

The `stylesheet` option is ignored for JSON Feed output.

---

## WebSub Hub

Declare a WebSub hub for real-time push notifications of feed updates:

```typescript
const feed = {
  title: "My Blog",
  link: "https://example.com",
  description: "A blog.",
  feedLinks: {
    rss: "https://example.com/rss.xml",
    hub: "https://hub.example.com",
  },
  items: [/* ... */],
};

// RSS: <atom:link rel="hub" href="https://hub.example.com" />
// Atom: <link rel="hub" href="https://hub.example.com" />
// JSON: "hubs": [{ "type": "WebSub", "url": "https://hub.example.com" }]
```

---

## Feed Data Reference

### `Feed`

| Field | Type | Required | Description |
|---|---|---|---|
| `title` | `string` | Yes | Feed title |
| `link` | `string` | Yes | Site URL (validated) |
| `description` | `string` | RSS only | Feed description (required for RSS) |
| `id` | `string` | No | Feed ID (defaults to `link`) |
| `language` | `string` | No | Language code (e.g., `"en-us"`) |
| `copyright` | `string` | No | Copyright notice |
| `updated` | `string \| Date` | No | Last update (auto-derived from items if omitted) |
| `generator` | `string` | No | Generator name |
| `image` | `FeedImage` | No | Feed image/logo |
| `favicon` | `string` | No | Favicon URL (JSON Feed) |
| `authors` | `FeedAuthor[]` | No | Feed authors |
| `categories` | `FeedCategory[]` | No | Feed categories |
| `items` | `FeedItem[]` | Yes | Feed items/entries |
| `ttl` | `number` | No | Time-to-live in minutes (RSS cache hint) |
| `skipHours` | `number[]` | No | Hours (0--23) aggregators can skip (RSS) |
| `skipDays` | `string[]` | No | Day names aggregators can skip (RSS) |
| `nextUrl` | `string` | No | Next page URL for paginated feeds |
| `podcast` | `PodcastFeed` | No | Podcast metadata (RSS only) |
| `feedLinks` | `object` | No | Self-referencing URLs (`{ rss?, atom?, json?, hub? }`) |
| `extensions` | `FeedExtensions` | No | Custom XML namespaces, elements, and JSON properties |

### `FeedItem`

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | Yes | Unique item identifier |
| `title` | `string` | No | Item title |
| `link` | `string` | No | Item URL (validated) |
| `content` | `string` | No | HTML content |
| `contentText` | `string` | No | Plain text content (JSON Feed) |
| `summary` | `string` | No | Short summary |
| `date` | `string \| Date` | No | Publication date (validated) |
| `updated` | `string \| Date` | No | Last modified date (validated) |
| `authors` | `FeedAuthor[]` | No | Item authors (all rendered in RSS) |
| `categories` | `FeedCategory[]` | No | Item categories/tags |
| `image` | `string` | No | Item image URL (RSS, Atom, JSON Feed) |
| `enclosures` | `FeedEnclosure[]` | No | Attached media (podcasts, etc.) |
| `language` | `string` | No | Item language (JSON Feed) |
| `podcast` | `PodcastItem` | No | Podcast episode metadata (RSS only) |
| `extensions` | `FeedItemExtensions` | No | Custom XML elements and JSON properties |

### Supporting Types

```typescript
type FeedAuthor = {
  name: string;
  url?: string;
  email?: string;
  avatar?: string;
};

type FeedCategory = {
  name: string;
  domain?: string;
  label?: string;
};

type FeedEnclosure = {
  url: string;
  type: string;       // MIME type
  length?: number;     // Size in bytes (required for RSS)
  title?: string;
  duration?: number;   // Duration in seconds
};

type FeedImage = {
  url: string;
  title?: string;
  link?: string;
  width?: number;
  height?: number;
};
```

---

## API Reference

### `generateFeed(feed, format, options?): string`

Generate a feed in a single format.

- **`format`**: `"rss"` | `"atom"` | `"json"`
- **`options`**: `{ sanitize?: boolean, pretty?: boolean, stylesheet?: string }`
- **Returns**: XML string (RSS/Atom) or JSON string
- **Throws**: `FeedValidationError` if feed data is invalid

### `generateFeeds(feed, options?): FeedOutput`

Generate all three formats at once.

- **Returns**: `{ rss: string, atom: string, json: string }`
- **Throws**: `FeedValidationError` if feed data is invalid

### `FeedBuilder`

Fluent builder for constructing feeds incrementally.

| Method | Description |
|---|---|
| `.title(s)` | Set feed title |
| `.link(s)` | Set feed link |
| `.description(s)` | Set feed description |
| `.id(s)` | Set feed ID |
| `.language(s)` | Set language code |
| `.copyright(s)` | Set copyright notice |
| `.updated(s)` | Set last update date |
| `.generator(s)` | Set generator name |
| `.image(img)` | Set feed image |
| `.favicon(s)` | Set favicon URL |
| `.author(a)` | Add an author (can be called multiple times) |
| `.category(c)` | Add a category (can be called multiple times) |
| `.nextUrl(s)` | Set pagination URL |
| `.ttl(n)` | Set TTL in minutes |
| `.skipHours(h[])` | Set skip hours |
| `.skipDays(d[])` | Set skip days |
| `.podcast(p)` | Set podcast metadata |
| `.feedLinks(links)` | Set self-referencing URLs |
| `.hub(url)` | Set WebSub hub URL |
| `.extensions(ext)` | Set custom extensions |
| `.stylesheet(url)` | Set XSLT stylesheet URL |
| `.addItem(item)` | Add a single item |
| `.addItems(items)` | Add multiple items |
| `.build()` | Return the raw `Feed` object |
| `.generate(format, options?)` | Render a single format |
| `.generateAll(options?)` | Render all three formats |

### `FeedValidationError`

Structured error class thrown on invalid feed data.

- **`field`**: `string` -- path to the invalid field (e.g., `"items[0].link"`)
- **`reason`**: `string` -- human-readable description of the issue

---

## Example Output

### RSS

```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>My Blog</title>
    <link>https://example.com</link>
    <description>The latest articles from my blog.</description>
    <item>
      <title>First Post</title>
      <link>https://example.com/post-1</link>
      <guid isPermaLink="true">https://example.com/post-1</guid>
      <description><![CDATA[<p>The full content of the first post.</p>]]></description>
      <content:encoded><![CDATA[<p>The full content of the first post.</p>]]></content:encoded>
      <pubDate>Sat, 14 Dec 2024 10:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>
```

### Atom

```xml
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title type="text">My Blog</title>
  <link href="https://example.com" rel="alternate" type="text/html" />
  <id>https://example.com</id>
  <updated>2024-12-14T10:00:00.000Z</updated>
  <entry>
    <title type="text">First Post</title>
    <link href="https://example.com/post-1" rel="alternate" type="text/html" />
    <id>https://example.com/post-1</id>
    <published>2024-12-14T10:00:00.000Z</published>
    <content type="html">&lt;p&gt;The full content of the first post.&lt;/p&gt;</content>
    <summary type="text">A summary of the first post.</summary>
  </entry>
</feed>
```

### JSON Feed

```json
{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "My Blog",
  "home_page_url": "https://example.com",
  "items": [
    {
      "id": "https://example.com/post-1",
      "url": "https://example.com/post-1",
      "title": "First Post",
      "content_html": "<p>The full content of the first post.</p>",
      "summary": "A summary of the first post.",
      "date_published": "2024-12-14T10:00:00.000Z"
    }
  ]
}
```

---

## Feed Validation

- **RSS/Atom:** [W3C Feed Validator](https://validator.w3.org/feed/)
- **JSON Feed:** [JSON Feed Validator](https://validator.jsonfeed.org/)

---

## Upgrading from 2.0

v2.1 is fully backwards compatible. No code changes are required.

**What changes in output:**
- RSS feeds with `item.content` now include an additional `<content:encoded>` element alongside the existing `<description>`. The `<description>` element is unchanged.
- RSS feeds with `item.image` now include `<media:content>` elements and the `xmlns:media` namespace declaration.
- Atom feeds with `item.image` now include an enclosure `<link>` element.

These are additive changes. Existing feed consumers will not be affected. If you need the previous output exactly, pin to `2.0.x`.

**New optional features** (opt-in, no effect unless used):
- `extensions` on `Feed` and `FeedItem` for custom namespaces and elements
- `stylesheet` in `FeedOptions` for XSLT support
- `feedLinks.hub` for WebSub hub declarations

See the full [CHANGELOG](./CHANGELOG.md) for details.

---

## Contributing

Contributions are welcome! If you'd like to improve this package:

1. Fork the repository.
2. Create a new branch:
   ```bash
   git checkout -b feature-name
   ```
3. Make your changes and commit:
   ```bash
   git commit -m "Add new feature"
   ```
4. Push your branch and open a Pull Request.

---

## More Information

This package is built and maintained by [Bliztek](https://bliztek.com). We design and develop customized websites and web applications to help businesses of all sizes achieve their goals. Our solutions prioritize scalability, security, and performance to drive growth and ensure long-term success.

- Follow [@Dev4TheWeb](https://x.com/Dev4TheWeb) on Twitter/X for updates from the creator
- Follow [@Bliztek](https://x.com/bliztek) on Twitter/X for updates from the company side
- Read our [Company blog](https://bliztek.com/blog) to learn more about our contributions to open source!
- Read my [Personal blog](https://stevendevelops.com) to learn more about what I do!

---

## License

This package is licensed under the [MIT License](./LICENSE).
