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

astro-rss: Generate feed with proper XML escaping #5550

Merged
merged 2 commits into from
Dec 8, 2022
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/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.