Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds extra elements to RSS items. #6707

Merged
merged 8 commits into from
Apr 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-cougars-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/rss': minor
---

Added extra elements to the RSS items, including categories and enclosure
154 changes: 136 additions & 18 deletions packages/astro-rss/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,24 +112,7 @@ Type: `RSSFeedItem[] (required)`

A list of formatted RSS feed items. See [Astro's RSS items documentation](https://docs.astro.build/en/guides/rss/#generating-items) for usage examples to choose the best option for you.

When providing a formatted RSS item list, see the `RSSFeedItem` type reference below:

```ts
type RSSFeedItem = {
/** Link to item */
link: string;
/** Title of item */
title: string;
/** Publication date of item */
pubDate: Date;
/** Item description */
description?: string;
/** Full content of the item, should be valid HTML */
content?: string;
/** Append some other XML-valid data to this item */
customData?: string;
};
```
When providing a formatted RSS item list, see the [`RSSFeedItem` type reference](#rssfeeditem).

### drafts

Expand Down Expand Up @@ -202,6 +185,141 @@ export const get = () => rss({
});
```

## `RSSFeedItem`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like how well you've restructured the docs and how well you explained this


An `RSSFeedItem` is a single item in the list of items in your feed. It represents a story, with `link`, `title`, and `pubDate` fields. There are further optional fields defined below. You can also check the definitions for the fields in the [RSS spec](https://validator.w3.org/feed/docs/rss2.html#ltpubdategtSubelementOfLtitemgt).

An example feed item might look like:

```js
const item = {
title: "Alpha Centauri: so close you can touch it",
link: "/blog/alpha-centuari",
pubDate: new Date("2023-06-04"),
description: "Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.",
categories: ["stars", "space"]
}
```

### `title`

Type: `string (required)`

The title of the item in the feed.

### `link`

Type: `string (required)`

The URL of the item on the web.

### `pubDate`

Type: `Date (required)`

Indicates when the item was published.

### `description`

Type: `string (optional)`

A synopsis of your item when you are publishing the full content of the item in the `content` field. The `description` may alternatively be the full content of the item in the feed if you are not using the `content` field (entity-coded HTML is permitted).

### `content`

Type: `string (optional)`

The full text content of the item suitable for presentation as HTML. If used, you should also provide a short article summary in the `description` field.

See the [recommendations from the RSS spec for how to use and differentiate between `description` and `content`](https://www.rssboard.org/rss-profile#namespace-elements-content-encoded).

### `categories`

Type: `string[] (optional)`

A list of any tags or categories to categorize your content. They will be output as multiple `<category>` elements.

### `author`

Type: `string (optional)`

The email address of the item author. This is useful for indicating the author of a post on multi-author blogs.

### `commentsUrl`

Type: `string (optional)`

The URL of a web page that contains comments on the item.

### `source`

Type: `object (optional)`

An object that defines the `title` and `url` of the original feed for items that have been republished from another source. Both are required properties of `source` for proper attribution.

```js
const item = {
title: "Alpha Centauri: so close you can touch it",
link: "/blog/alpha-centuari",
pubDate: new Date("2023-06-04"),
description: "Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.",
source: {
title: "The Galactic Times",
url: "https://galactictimes.space/feed.xml"
}
}
```

#### `source.title`

Type: `string (required)`

The name of the original feed in which the item was published. (Note that this is the feed's title, not the individual article title.)

#### `source.url`

Type: `string (required)`

The URL of the original feed in which the item was published.

### `enclosure`

Type: `object (optional)`

An object to specify properties for an included media source (e.g. a podcast) with three required values: `url`, `length`, and `type`.

```js
const item = {
title: "Alpha Centauri: so close you can touch it",
link: "/blog/alpha-centuari",
pubDate: new Date("2023-06-04"),
description: "Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.",
enclosure: {
url: "/media/alpha-centauri.aac",
length: 124568,
type: "audio/aac"
}
}
```

#### `enclosure.url`

Type: `string (required)`

The URL where the media can be found. If the media is hosted outside of your own domain you must provide a full URL.

#### `enclosure.length`

Type: `number (required)`

The size of the file found at the `url` in bytes.

#### `enclosure.type`

Type: `string (required)`

The [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) for the media item found at the `url`.

## `rssSchema`

When using content collections, you can configure your collection schema to enforce expected [`RSSFeedItem`](#items) properties. Import and apply `rssSchema` to ensure that each collection entry produces a valid RSS feed item:
Expand Down
37 changes: 36 additions & 1 deletion packages/astro-rss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ type RSSFeedItem = {
customData?: z.infer<typeof rssSchema>['customData'];
/** Whether draft or not */
draft?: z.infer<typeof rssSchema>['draft'];
/** Categories or tags related to the item */
categories?: z.infer<typeof rssSchema>['categories'];
/** The item author's email address */
author?: z.infer<typeof rssSchema>['author'];
/** A URL of a page for comments related to the item */
commentsUrl?: z.infer<typeof rssSchema>['commentsUrl'];
/** The RSS channel that the item came from */
source?: z.infer<typeof rssSchema>['source'];
/** A media object that belongs to the item */
enclosure?: z.infer<typeof rssSchema>['enclosure'];
};

type ValidatedRSSFeedItem = z.infer<typeof rssFeedItemValidator>;
Expand Down Expand Up @@ -148,6 +158,7 @@ async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> {
// when using `customData`
// https://github.com/withastro/astro/issues/5794
suppressEmptyNode: true,
suppressBooleanAttributes: false,
};
const parser = new XMLParser(xmlOptions);
const root: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' } };
Expand Down Expand Up @@ -196,7 +207,7 @@ async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> {
const item: any = {
title: result.title,
link: itemLink,
guid: itemLink,
guid: { '#text': itemLink, '@_isPermaLink': 'true' },
};
if (result.description) {
item.description = result.description;
Expand All @@ -211,6 +222,30 @@ async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> {
if (typeof result.customData === 'string') {
Object.assign(item, parser.parse(`<item>${result.customData}</item>`).item);
}
if (Array.isArray(result.categories)) {
item.category = result.categories;
}
if (typeof result.author === 'string') {
item.author = result.author;
}
if (typeof result.commentsUrl === 'string') {
item.comments = isValidURL(result.commentsUrl)
? result.commentsUrl
: createCanonicalURL(result.commentsUrl, rssOptions.trailingSlash, site).href;
}
if (result.source) {
item.source = parser.parse(
`<source url="${result.source.url}">${result.source.title}</source>`
).source;
}
if (result.enclosure) {
const enclosureURL = isValidURL(result.enclosure.url)
? result.enclosure.url
: createCanonicalURL(result.enclosure.url, rssOptions.trailingSlash, site).href;
item.enclosure = parser.parse(
`<enclosure url="${enclosureURL}" length="${result.enclosure.length}" type="${result.enclosure.type}"/>`
).enclosure;
}
return item;
});

Expand Down
11 changes: 11 additions & 0 deletions packages/astro-rss/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,15 @@ export const rssSchema = z.object({
description: z.string().optional(),
customData: z.string().optional(),
draft: z.boolean().optional(),
categories: z.array(z.string()).optional(),
author: z.string().optional(),
commentsUrl: z.string().optional(),
source: z.object({ url: z.string().url(), title: z.string() }).optional(),
enclosure: z
.object({
url: z.string(),
length: z.number().positive().int().finite(),
type: z.string(),
})
.optional(),
});
22 changes: 18 additions & 4 deletions packages/astro-rss/test/rss.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
phpFeedItemWithCustomData,
web1FeedItem,
web1FeedItemWithContent,
web1FeedItemWithAllData,
} from './test-utils.js';

chai.use(chaiPromises);
Expand All @@ -19,13 +20,15 @@ chai.use(chaiXml);
// note: I spent 30 minutes looking for a nice node-based snapshot tool
// ...and I gave up. Enjoy big strings!
// prettier-ignore
const validXmlResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItem.title}]]></title><link>${site}${phpFeedItem.link}/</link><guid>${site}${phpFeedItem.link}/</guid><description><![CDATA[${phpFeedItem.description}]]></description><pubDate>${new Date(phpFeedItem.pubDate).toUTCString()}</pubDate></item><item><title><![CDATA[${web1FeedItem.title}]]></title><link>${site}${web1FeedItem.link}/</link><guid>${site}${web1FeedItem.link}/</guid><description><![CDATA[${web1FeedItem.description}]]></description><pubDate>${new Date(web1FeedItem.pubDate).toUTCString()}</pubDate></item></channel></rss>`;
const validXmlResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItem.title}]]></title><link>${site}${phpFeedItem.link}/</link><guid isPermaLink="true">${site}${phpFeedItem.link}/</guid><description><![CDATA[${phpFeedItem.description}]]></description><pubDate>${new Date(phpFeedItem.pubDate).toUTCString()}</pubDate></item><item><title><![CDATA[${web1FeedItem.title}]]></title><link>${site}${web1FeedItem.link}/</link><guid isPermaLink="true">${site}${web1FeedItem.link}/</guid><description><![CDATA[${web1FeedItem.description}]]></description><pubDate>${new Date(web1FeedItem.pubDate).toUTCString()}</pubDate></item></channel></rss>`;
// prettier-ignore
const validXmlWithoutWeb1FeedResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItem.title}]]></title><link>${site}${phpFeedItem.link}/</link><guid>${site}${phpFeedItem.link}/</guid><description><![CDATA[${phpFeedItem.description}]]></description><pubDate>${new Date(phpFeedItem.pubDate).toUTCString()}</pubDate></item></channel></rss>`;
const validXmlWithoutWeb1FeedResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItem.title}]]></title><link>${site}${phpFeedItem.link}/</link><guid isPermaLink="true">${site}${phpFeedItem.link}/</guid><description><![CDATA[${phpFeedItem.description}]]></description><pubDate>${new Date(phpFeedItem.pubDate).toUTCString()}</pubDate></item></channel></rss>`;
// prettier-ignore
const validXmlWithContentResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItemWithContent.title}]]></title><link>${site}${phpFeedItemWithContent.link}/</link><guid>${site}${phpFeedItemWithContent.link}/</guid><description><![CDATA[${phpFeedItemWithContent.description}]]></description><pubDate>${new Date(phpFeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${phpFeedItemWithContent.content}]]></content:encoded></item><item><title><![CDATA[${web1FeedItemWithContent.title}]]></title><link>${site}${web1FeedItemWithContent.link}/</link><guid>${site}${web1FeedItemWithContent.link}/</guid><description><![CDATA[${web1FeedItemWithContent.description}]]></description><pubDate>${new Date(web1FeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${web1FeedItemWithContent.content}]]></content:encoded></item></channel></rss>`;
const validXmlWithContentResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItemWithContent.title}]]></title><link>${site}${phpFeedItemWithContent.link}/</link><guid isPermaLink="true">${site}${phpFeedItemWithContent.link}/</guid><description><![CDATA[${phpFeedItemWithContent.description}]]></description><pubDate>${new Date(phpFeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${phpFeedItemWithContent.content}]]></content:encoded></item><item><title><![CDATA[${web1FeedItemWithContent.title}]]></title><link>${site}${web1FeedItemWithContent.link}/</link><guid isPermaLink="true">${site}${web1FeedItemWithContent.link}/</guid><description><![CDATA[${web1FeedItemWithContent.description}]]></description><pubDate>${new Date(web1FeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${web1FeedItemWithContent.content}]]></content:encoded></item></channel></rss>`;
// prettier-ignore
const validXmlWithCustomDataResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItemWithCustomData.title}]]></title><link>${site}${phpFeedItemWithCustomData.link}/</link><guid>${site}${phpFeedItemWithCustomData.link}/</guid><description><![CDATA[${phpFeedItemWithCustomData.description}]]></description><pubDate>${new Date(phpFeedItemWithCustomData.pubDate).toUTCString()}</pubDate>${phpFeedItemWithCustomData.customData}</item><item><title><![CDATA[${web1FeedItemWithContent.title}]]></title><link>${site}${web1FeedItemWithContent.link}/</link><guid>${site}${web1FeedItemWithContent.link}/</guid><description><![CDATA[${web1FeedItemWithContent.description}]]></description><pubDate>${new Date(web1FeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${web1FeedItemWithContent.content}]]></content:encoded></item></channel></rss>`;
const validXmlResultWithAllData = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItem.title}]]></title><link>${site}${phpFeedItem.link}/</link><guid isPermaLink="true">${site}${phpFeedItem.link}/</guid><description><![CDATA[${phpFeedItem.description}]]></description><pubDate>${new Date(phpFeedItem.pubDate).toUTCString()}</pubDate></item><item><title><![CDATA[${web1FeedItemWithAllData.title}]]></title><link>${site}${web1FeedItemWithAllData.link}/</link><guid isPermaLink="true">${site}${web1FeedItemWithAllData.link}/</guid><description><![CDATA[${web1FeedItemWithAllData.description}]]></description><pubDate>${new Date(web1FeedItemWithAllData.pubDate).toUTCString()}</pubDate><category>${web1FeedItemWithAllData.categories[0]}</category><category>${web1FeedItemWithAllData.categories[1]}</category><author>${web1FeedItemWithAllData.author}</author><comments>${web1FeedItemWithAllData.commentsUrl}</comments><source url="${web1FeedItemWithAllData.source.url}">${web1FeedItemWithAllData.source.title}</source><enclosure url="${site}${web1FeedItemWithAllData.enclosure.url}" length="${web1FeedItemWithAllData.enclosure.length}" type="${web1FeedItemWithAllData.enclosure.type}"/></item></channel></rss>`;
// prettier-ignore
const validXmlWithCustomDataResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItemWithCustomData.title}]]></title><link>${site}${phpFeedItemWithCustomData.link}/</link><guid isPermaLink="true">${site}${phpFeedItemWithCustomData.link}/</guid><description><![CDATA[${phpFeedItemWithCustomData.description}]]></description><pubDate>${new Date(phpFeedItemWithCustomData.pubDate).toUTCString()}</pubDate>${phpFeedItemWithCustomData.customData}</item><item><title><![CDATA[${web1FeedItemWithContent.title}]]></title><link>${site}${web1FeedItemWithContent.link}/</link><guid isPermaLink="true">${site}${web1FeedItemWithContent.link}/</guid><description><![CDATA[${web1FeedItemWithContent.description}]]></description><pubDate>${new Date(web1FeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${web1FeedItemWithContent.content}]]></content:encoded></item></channel></rss>`;
// prettier-ignore
const validXmlWithStylesheet = `<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/feedstylesheet.css"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link></channel></rss>`;
// prettier-ignore
Expand Down Expand Up @@ -54,6 +57,17 @@ describe('rss', () => {
chai.expect(body).xml.to.equal(validXmlWithContentResult);
});

it('should generate on valid RSSFeedItem array with all RSS content included', async () => {
const { body } = await rss({
title,
description,
items: [phpFeedItem, web1FeedItemWithAllData],
site,
});

chai.expect(body).xml.to.equal(validXmlResultWithAllData);
});

it('should generate on valid RSSFeedItem array with custom data included', async () => {
const { body } = await rss({
xmlns: {
Expand Down
15 changes: 15 additions & 0 deletions packages/astro-rss/test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,18 @@ export const web1FeedItemWithContent = {
...web1FeedItem,
content: `<h1>${web1FeedItem.title}</h1><p>${web1FeedItem.description}</p>`,
};
export const web1FeedItemWithAllData = {
...web1FeedItem,
categories: ['web1', 'history'],
author: 'test@example.com',
commentsUrl: 'http://example.com/comments',
source: {
url: 'http://example.com/source',
title: 'The Web 1.0 blog',
},
enclosure: {
url: '/podcast.mp3',
length: 256,
type: 'audio/mpeg',
},
};