Skip to content

Commit fe0da01

Browse files
authored
astro-rss: Generate feed with proper XML escaping (#5550)
* test(astro-rss): Compare XML using chai-xml Signed-off-by: Anders Kaseorg <[email protected]> * fix(astro-rss): Generate feed with proper XML escaping Signed-off-by: Anders Kaseorg <[email protected]> Signed-off-by: Anders Kaseorg <[email protected]>
1 parent 1aeabe4 commit fe0da01

5 files changed

Lines changed: 57 additions & 36 deletions

File tree

.changeset/hungry-snakes-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/rss': patch
3+
---
4+
5+
Generate RSS feed with proper XML escaping

packages/astro-rss/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"astro-scripts": "workspace:*",
3232
"chai": "^4.3.6",
3333
"chai-as-promised": "^7.1.1",
34+
"chai-xml": "^0.4.0",
3435
"mocha": "^9.2.2"
3536
},
3637
"dependencies": {

packages/astro-rss/src/index.ts

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { XMLValidator } from 'fast-xml-parser';
1+
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
22
import { createCanonicalURL, isValidURL } from './util.js';
33

44
type GlobResult = Record<string, () => Promise<{ [key: string]: any }>>;
@@ -100,15 +100,17 @@ export default async function getRSS(rssOptions: RSSOptions) {
100100
/** Generate RSS 2.0 feed */
101101
export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promise<string> {
102102
const { site } = rssOptions;
103-
let xml = `<?xml version="1.0" encoding="UTF-8"?>`;
103+
const xmlOptions = { ignoreAttributes: false };
104+
const parser = new XMLParser(xmlOptions);
105+
const root: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' } };
104106
if (typeof rssOptions.stylesheet === 'string') {
105-
xml += `<?xml-stylesheet href="${rssOptions.stylesheet}" type="text/xsl"?>`;
107+
root['?xml-stylesheet'] = { '@_href': rssOptions.stylesheet, '@_encoding': 'UTF-8' };
106108
}
107-
xml += `<rss version="2.0"`;
109+
root.rss = { '@_version': '2.0' };
108110
if (items.find((result) => result.content)) {
109111
// the namespace to be added to the xmlns:content attribute to enable the <content> RSS feature
110112
const XMLContentNamespace = 'http://purl.org/rss/1.0/modules/content/';
111-
xml += ` xmlns:content="${XMLContentNamespace}"`;
113+
root.rss['@_xmlns:content'] = XMLContentNamespace;
112114
// Ensure that the user hasn't tried to manually include the necessary namespace themselves
113115
if (rssOptions.xmlns?.content && rssOptions.xmlns.content === XMLContentNamespace) {
114116
delete rssOptions.xmlns.content;
@@ -118,56 +120,55 @@ export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promi
118120
// xmlns
119121
if (rssOptions.xmlns) {
120122
for (const [k, v] of Object.entries(rssOptions.xmlns)) {
121-
xml += ` xmlns:${k}="${v}"`;
123+
root.rss[`@_xmlns:${k}`] = v;
122124
}
123125
}
124-
xml += `>`;
125-
xml += `<channel>`;
126126

127127
// title, description, customData
128-
xml += `<title><![CDATA[${rssOptions.title}]]></title>`;
129-
xml += `<description><![CDATA[${rssOptions.description}]]></description>`;
130-
xml += `<link>${createCanonicalURL(site).href}</link>`;
131-
if (typeof rssOptions.customData === 'string') xml += rssOptions.customData;
128+
root.rss.channel = {
129+
title: rssOptions.title,
130+
description: rssOptions.description,
131+
link: createCanonicalURL(site).href,
132+
};
133+
if (typeof rssOptions.customData === 'string')
134+
Object.assign(
135+
root.rss.channel,
136+
parser.parse(`<channel>${rssOptions.customData}</channel>`).channel
137+
);
132138
// items
133-
for (const result of items) {
139+
root.rss.channel.item = items.map((result) => {
134140
validate(result);
135-
xml += `<item>`;
136-
xml += `<title><![CDATA[${result.title}]]></title>`;
137141
// If the item's link is already a valid URL, don't mess with it.
138142
const itemLink = isValidURL(result.link)
139143
? result.link
140144
: createCanonicalURL(result.link, site).href;
141-
xml += `<link>${itemLink}</link>`;
142-
xml += `<guid>${itemLink}</guid>`;
143-
if (result.description) xml += `<description><![CDATA[${result.description}]]></description>`;
145+
const item: any = {
146+
title: result.title,
147+
link: itemLink,
148+
guid: itemLink,
149+
};
150+
if (result.description) {
151+
item.description = result.description;
152+
}
144153
if (result.pubDate) {
145154
// note: this should be a Date, but if user provided a string or number, we can work with that, too.
146155
if (typeof result.pubDate === 'number' || typeof result.pubDate === 'string') {
147156
result.pubDate = new Date(result.pubDate);
148157
} else if (result.pubDate instanceof Date === false) {
149158
throw new Error('[${filename}] rss.item().pubDate must be a Date');
150159
}
151-
xml += `<pubDate>${result.pubDate.toUTCString()}</pubDate>`;
160+
item.pubDate = result.pubDate.toUTCString();
152161
}
153162
// include the full content of the post if the user supplies it
154163
if (typeof result.content === 'string') {
155-
xml += `<content:encoded><![CDATA[${result.content}]]></content:encoded>`;
164+
item['content:encoded'] = result.content;
156165
}
157-
if (typeof result.customData === 'string') xml += result.customData;
158-
xml += `</item>`;
159-
}
160-
161-
xml += `</channel></rss>`;
162-
163-
// validate user’s inputs to see if it’s valid XML
164-
const isValid = XMLValidator.validate(xml);
165-
if (isValid !== true) {
166-
// If valid XML, isValid will be `true`. Otherwise, this will be an error object. Throw.
167-
throw new Error(isValid as any);
168-
}
166+
if (typeof rssOptions.customData === 'string')
167+
Object.assign(item, parser.parse(`<item>${rssOptions.customData}</item>`).item);
168+
return item;
169+
});
169170

170-
return xml;
171+
return new XMLBuilder(xmlOptions).build(root);
171172
}
172173

173174
const requiredFields = Object.freeze(['link', 'title']);

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import rss from '../dist/index.js';
22
import chai from 'chai';
33
import chaiPromises from 'chai-as-promised';
4+
import chaiXml from 'chai-xml';
45

56
chai.use(chaiPromises);
7+
chai.use(chaiXml);
68

79
const title = 'My RSS feed';
810
const description = 'This sure is a nice RSS feed';
@@ -49,7 +51,7 @@ describe('rss', () => {
4951
site,
5052
});
5153

52-
chai.expect(body).to.equal(validXmlResult);
54+
chai.expect(body).xml.to.equal(validXmlResult);
5355
});
5456

5557
it('should generate on valid RSSFeedItem array with HTML content included', async () => {
@@ -60,7 +62,7 @@ describe('rss', () => {
6062
site,
6163
});
6264

63-
chai.expect(body).to.equal(validXmlWithContentResult);
65+
chai.expect(body).xml.to.equal(validXmlWithContentResult);
6466
});
6567

6668
describe('glob result', () => {
@@ -97,7 +99,7 @@ describe('rss', () => {
9799
site,
98100
});
99101

100-
chai.expect(body).to.equal(validXmlResult);
102+
chai.expect(body).xml.to.equal(validXmlResult);
101103
});
102104

103105
it('should fail on missing "title" key', () => {

pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)