Skip to content

Commit

Permalink
astro-rss: Generate feed with proper XML escaping (#5550)
Browse files Browse the repository at this point in the history
* test(astro-rss): Compare XML using chai-xml

Signed-off-by: Anders Kaseorg <andersk@mit.edu>

* fix(astro-rss): Generate feed with proper XML escaping

Signed-off-by: Anders Kaseorg <andersk@mit.edu>

Signed-off-by: Anders Kaseorg <andersk@mit.edu>
  • Loading branch information
andersk authored Dec 8, 2022
1 parent 1aeabe4 commit fe0da01
Show file tree
Hide file tree
Showing 5 changed files with 57 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/hungry-snakes-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/rss': patch
---

Generate RSS feed with proper XML escaping
1 change: 1 addition & 0 deletions packages/astro-rss/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"astro-scripts": "workspace:*",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"chai-xml": "^0.4.0",
"mocha": "^9.2.2"
},
"dependencies": {
Expand Down
67 changes: 34 additions & 33 deletions packages/astro-rss/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { XMLValidator } from 'fast-xml-parser';
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
import { createCanonicalURL, isValidURL } from './util.js';

type GlobResult = Record<string, () => Promise<{ [key: string]: any }>>;
Expand Down Expand Up @@ -100,15 +100,17 @@ export default async function getRSS(rssOptions: RSSOptions) {
/** Generate RSS 2.0 feed */
export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promise<string> {
const { site } = rssOptions;
let xml = `<?xml version="1.0" encoding="UTF-8"?>`;
const xmlOptions = { ignoreAttributes: false };
const parser = new XMLParser(xmlOptions);
const root: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' } };
if (typeof rssOptions.stylesheet === 'string') {
xml += `<?xml-stylesheet href="${rssOptions.stylesheet}" type="text/xsl"?>`;
root['?xml-stylesheet'] = { '@_href': rssOptions.stylesheet, '@_encoding': 'UTF-8' };
}
xml += `<rss version="2.0"`;
root.rss = { '@_version': '2.0' };
if (items.find((result) => result.content)) {
// the namespace to be added to the xmlns:content attribute to enable the <content> RSS feature
const XMLContentNamespace = 'http://purl.org/rss/1.0/modules/content/';
xml += ` xmlns:content="${XMLContentNamespace}"`;
root.rss['@_xmlns:content'] = XMLContentNamespace;
// Ensure that the user hasn't tried to manually include the necessary namespace themselves
if (rssOptions.xmlns?.content && rssOptions.xmlns.content === XMLContentNamespace) {
delete rssOptions.xmlns.content;
Expand All @@ -118,56 +120,55 @@ export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promi
// xmlns
if (rssOptions.xmlns) {
for (const [k, v] of Object.entries(rssOptions.xmlns)) {
xml += ` xmlns:${k}="${v}"`;
root.rss[`@_xmlns:${k}`] = v;
}
}
xml += `>`;
xml += `<channel>`;

// title, description, customData
xml += `<title><![CDATA[${rssOptions.title}]]></title>`;
xml += `<description><![CDATA[${rssOptions.description}]]></description>`;
xml += `<link>${createCanonicalURL(site).href}</link>`;
if (typeof rssOptions.customData === 'string') xml += rssOptions.customData;
root.rss.channel = {
title: rssOptions.title,
description: rssOptions.description,
link: createCanonicalURL(site).href,
};
if (typeof rssOptions.customData === 'string')
Object.assign(
root.rss.channel,
parser.parse(`<channel>${rssOptions.customData}</channel>`).channel
);
// items
for (const result of items) {
root.rss.channel.item = items.map((result) => {
validate(result);
xml += `<item>`;
xml += `<title><![CDATA[${result.title}]]></title>`;
// If the item's link is already a valid URL, don't mess with it.
const itemLink = isValidURL(result.link)
? result.link
: createCanonicalURL(result.link, site).href;
xml += `<link>${itemLink}</link>`;
xml += `<guid>${itemLink}</guid>`;
if (result.description) xml += `<description><![CDATA[${result.description}]]></description>`;
const item: any = {
title: result.title,
link: itemLink,
guid: itemLink,
};
if (result.description) {
item.description = result.description;
}
if (result.pubDate) {
// note: this should be a Date, but if user provided a string or number, we can work with that, too.
if (typeof result.pubDate === 'number' || typeof result.pubDate === 'string') {
result.pubDate = new Date(result.pubDate);
} else if (result.pubDate instanceof Date === false) {
throw new Error('[${filename}] rss.item().pubDate must be a Date');
}
xml += `<pubDate>${result.pubDate.toUTCString()}</pubDate>`;
item.pubDate = result.pubDate.toUTCString();
}
// include the full content of the post if the user supplies it
if (typeof result.content === 'string') {
xml += `<content:encoded><![CDATA[${result.content}]]></content:encoded>`;
item['content:encoded'] = result.content;
}
if (typeof result.customData === 'string') xml += result.customData;
xml += `</item>`;
}

xml += `</channel></rss>`;

// validate user’s inputs to see if it’s valid XML
const isValid = XMLValidator.validate(xml);
if (isValid !== true) {
// If valid XML, isValid will be `true`. Otherwise, this will be an error object. Throw.
throw new Error(isValid as any);
}
if (typeof rssOptions.customData === 'string')
Object.assign(item, parser.parse(`<item>${rssOptions.customData}</item>`).item);
return item;
});

return xml;
return new XMLBuilder(xmlOptions).build(root);
}

const requiredFields = Object.freeze(['link', 'title']);
Expand Down
8 changes: 5 additions & 3 deletions packages/astro-rss/test/rss.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import rss from '../dist/index.js';
import chai from 'chai';
import chaiPromises from 'chai-as-promised';
import chaiXml from 'chai-xml';

chai.use(chaiPromises);
chai.use(chaiXml);

const title = 'My RSS feed';
const description = 'This sure is a nice RSS feed';
Expand Down Expand Up @@ -49,7 +51,7 @@ describe('rss', () => {
site,
});

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

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

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

describe('glob result', () => {
Expand Down Expand Up @@ -97,7 +99,7 @@ describe('rss', () => {
site,
});

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

it('should fail on missing "title" key', () => {
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit fe0da01

Please sign in to comment.