Skip to content

Commit 4ea716e

Browse files
authored
Adds extra elements to RSS items. (#6707)
1 parent c6f1264 commit 4ea716e

6 files changed

Lines changed: 221 additions & 23 deletions

File tree

.changeset/quiet-cougars-fix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/rss': minor
3+
---
4+
5+
Added extra elements to the RSS items, including categories and enclosure

packages/astro-rss/README.md

Lines changed: 136 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -112,24 +112,7 @@ Type: `RSSFeedItem[] (required)`
112112

113113
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.
114114

115-
When providing a formatted RSS item list, see the `RSSFeedItem` type reference below:
116-
117-
```ts
118-
type RSSFeedItem = {
119-
/** Link to item */
120-
link: string;
121-
/** Title of item */
122-
title: string;
123-
/** Publication date of item */
124-
pubDate: Date;
125-
/** Item description */
126-
description?: string;
127-
/** Full content of the item, should be valid HTML */
128-
content?: string;
129-
/** Append some other XML-valid data to this item */
130-
customData?: string;
131-
};
132-
```
115+
When providing a formatted RSS item list, see the [`RSSFeedItem` type reference](#rssfeeditem).
133116

134117
### drafts
135118

@@ -202,6 +185,141 @@ export const get = () => rss({
202185
});
203186
```
204187
188+
## `RSSFeedItem`
189+
190+
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).
191+
192+
An example feed item might look like:
193+
194+
```js
195+
const item = {
196+
title: "Alpha Centauri: so close you can touch it",
197+
link: "/blog/alpha-centuari",
198+
pubDate: new Date("2023-06-04"),
199+
description: "Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.",
200+
categories: ["stars", "space"]
201+
}
202+
```
203+
204+
### `title`
205+
206+
Type: `string (required)`
207+
208+
The title of the item in the feed.
209+
210+
### `link`
211+
212+
Type: `string (required)`
213+
214+
The URL of the item on the web.
215+
216+
### `pubDate`
217+
218+
Type: `Date (required)`
219+
220+
Indicates when the item was published.
221+
222+
### `description`
223+
224+
Type: `string (optional)`
225+
226+
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).
227+
228+
### `content`
229+
230+
Type: `string (optional)`
231+
232+
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.
233+
234+
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).
235+
236+
### `categories`
237+
238+
Type: `string[] (optional)`
239+
240+
A list of any tags or categories to categorize your content. They will be output as multiple `<category>` elements.
241+
242+
### `author`
243+
244+
Type: `string (optional)`
245+
246+
The email address of the item author. This is useful for indicating the author of a post on multi-author blogs.
247+
248+
### `commentsUrl`
249+
250+
Type: `string (optional)`
251+
252+
The URL of a web page that contains comments on the item.
253+
254+
### `source`
255+
256+
Type: `object (optional)`
257+
258+
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.
259+
260+
```js
261+
const item = {
262+
title: "Alpha Centauri: so close you can touch it",
263+
link: "/blog/alpha-centuari",
264+
pubDate: new Date("2023-06-04"),
265+
description: "Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.",
266+
source: {
267+
title: "The Galactic Times",
268+
url: "https://galactictimes.space/feed.xml"
269+
}
270+
}
271+
```
272+
273+
#### `source.title`
274+
275+
Type: `string (required)`
276+
277+
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.)
278+
279+
#### `source.url`
280+
281+
Type: `string (required)`
282+
283+
The URL of the original feed in which the item was published.
284+
285+
### `enclosure`
286+
287+
Type: `object (optional)`
288+
289+
An object to specify properties for an included media source (e.g. a podcast) with three required values: `url`, `length`, and `type`.
290+
291+
```js
292+
const item = {
293+
title: "Alpha Centauri: so close you can touch it",
294+
link: "/blog/alpha-centuari",
295+
pubDate: new Date("2023-06-04"),
296+
description: "Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.",
297+
enclosure: {
298+
url: "/media/alpha-centauri.aac",
299+
length: 124568,
300+
type: "audio/aac"
301+
}
302+
}
303+
```
304+
305+
#### `enclosure.url`
306+
307+
Type: `string (required)`
308+
309+
The URL where the media can be found. If the media is hosted outside of your own domain you must provide a full URL.
310+
311+
#### `enclosure.length`
312+
313+
Type: `number (required)`
314+
315+
The size of the file found at the `url` in bytes.
316+
317+
#### `enclosure.type`
318+
319+
Type: `string (required)`
320+
321+
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`.
322+
205323
## `rssSchema`
206324

207325
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:

packages/astro-rss/src/index.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ type RSSFeedItem = {
4747
customData?: z.infer<typeof rssSchema>['customData'];
4848
/** Whether draft or not */
4949
draft?: z.infer<typeof rssSchema>['draft'];
50+
/** Categories or tags related to the item */
51+
categories?: z.infer<typeof rssSchema>['categories'];
52+
/** The item author's email address */
53+
author?: z.infer<typeof rssSchema>['author'];
54+
/** A URL of a page for comments related to the item */
55+
commentsUrl?: z.infer<typeof rssSchema>['commentsUrl'];
56+
/** The RSS channel that the item came from */
57+
source?: z.infer<typeof rssSchema>['source'];
58+
/** A media object that belongs to the item */
59+
enclosure?: z.infer<typeof rssSchema>['enclosure'];
5060
};
5161

5262
type ValidatedRSSFeedItem = z.infer<typeof rssFeedItemValidator>;
@@ -148,6 +158,7 @@ async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> {
148158
// when using `customData`
149159
// https://github.com/withastro/astro/issues/5794
150160
suppressEmptyNode: true,
161+
suppressBooleanAttributes: false,
151162
};
152163
const parser = new XMLParser(xmlOptions);
153164
const root: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' } };
@@ -196,7 +207,7 @@ async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> {
196207
const item: any = {
197208
title: result.title,
198209
link: itemLink,
199-
guid: itemLink,
210+
guid: { '#text': itemLink, '@_isPermaLink': 'true' },
200211
};
201212
if (result.description) {
202213
item.description = result.description;
@@ -211,6 +222,30 @@ async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> {
211222
if (typeof result.customData === 'string') {
212223
Object.assign(item, parser.parse(`<item>${result.customData}</item>`).item);
213224
}
225+
if (Array.isArray(result.categories)) {
226+
item.category = result.categories;
227+
}
228+
if (typeof result.author === 'string') {
229+
item.author = result.author;
230+
}
231+
if (typeof result.commentsUrl === 'string') {
232+
item.comments = isValidURL(result.commentsUrl)
233+
? result.commentsUrl
234+
: createCanonicalURL(result.commentsUrl, rssOptions.trailingSlash, site).href;
235+
}
236+
if (result.source) {
237+
item.source = parser.parse(
238+
`<source url="${result.source.url}">${result.source.title}</source>`
239+
).source;
240+
}
241+
if (result.enclosure) {
242+
const enclosureURL = isValidURL(result.enclosure.url)
243+
? result.enclosure.url
244+
: createCanonicalURL(result.enclosure.url, rssOptions.trailingSlash, site).href;
245+
item.enclosure = parser.parse(
246+
`<enclosure url="${enclosureURL}" length="${result.enclosure.length}" type="${result.enclosure.type}"/>`
247+
).enclosure;
248+
}
214249
return item;
215250
});
216251

packages/astro-rss/src/schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,15 @@ export const rssSchema = z.object({
66
description: z.string().optional(),
77
customData: z.string().optional(),
88
draft: z.boolean().optional(),
9+
categories: z.array(z.string()).optional(),
10+
author: z.string().optional(),
11+
commentsUrl: z.string().optional(),
12+
source: z.object({ url: z.string().url(), title: z.string() }).optional(),
13+
enclosure: z
14+
.object({
15+
url: z.string(),
16+
length: z.number().positive().int().finite(),
17+
type: z.string(),
18+
})
19+
.optional(),
920
});

packages/astro-rss/test/rss.test.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
phpFeedItemWithCustomData,
1212
web1FeedItem,
1313
web1FeedItemWithContent,
14+
web1FeedItemWithAllData,
1415
} from './test-utils.js';
1516

1617
chai.use(chaiPromises);
@@ -19,13 +20,15 @@ chai.use(chaiXml);
1920
// note: I spent 30 minutes looking for a nice node-based snapshot tool
2021
// ...and I gave up. Enjoy big strings!
2122
// prettier-ignore
22-
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>`;
23+
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>`;
2324
// prettier-ignore
24-
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>`;
25+
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>`;
2526
// prettier-ignore
26-
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>`;
27+
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>`;
2728
// prettier-ignore
28-
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>`;
29+
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>`;
30+
// prettier-ignore
31+
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>`;
2932
// prettier-ignore
3033
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>`;
3134
// prettier-ignore
@@ -54,6 +57,17 @@ describe('rss', () => {
5457
chai.expect(body).xml.to.equal(validXmlWithContentResult);
5558
});
5659

60+
it('should generate on valid RSSFeedItem array with all RSS content included', async () => {
61+
const { body } = await rss({
62+
title,
63+
description,
64+
items: [phpFeedItem, web1FeedItemWithAllData],
65+
site,
66+
});
67+
68+
chai.expect(body).xml.to.equal(validXmlResultWithAllData);
69+
});
70+
5771
it('should generate on valid RSSFeedItem array with custom data included', async () => {
5872
const { body } = await rss({
5973
xmlns: {

packages/astro-rss/test/test-utils.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,18 @@ export const web1FeedItemWithContent = {
3030
...web1FeedItem,
3131
content: `<h1>${web1FeedItem.title}</h1><p>${web1FeedItem.description}</p>`,
3232
};
33+
export const web1FeedItemWithAllData = {
34+
...web1FeedItem,
35+
categories: ['web1', 'history'],
36+
author: '[email protected]',
37+
commentsUrl: 'http://example.com/comments',
38+
source: {
39+
url: 'http://example.com/source',
40+
title: 'The Web 1.0 blog',
41+
},
42+
enclosure: {
43+
url: '/podcast.mp3',
44+
length: 256,
45+
type: 'audio/mpeg',
46+
},
47+
};

0 commit comments

Comments
 (0)