astro-rss: Generate feed with proper XML escaping (#5550)
* 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>
This commit is contained in:
parent
1aeabe4170
commit
fe0da0185a
5 changed files with 57 additions and 36 deletions
5
.changeset/hungry-snakes-live.md
Normal file
5
.changeset/hungry-snakes-live.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@astrojs/rss': patch
|
||||
---
|
||||
|
||||
Generate RSS feed with proper XML escaping
|
|
@ -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": {
|
||||
|
|
|
@ -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 }>>;
|
||||
|
@ -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;
|
||||
|
@ -118,29 +120,36 @@ 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') {
|
||||
|
@ -148,26 +157,18 @@ export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promi
|
|||
} 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>`;
|
||||
}
|
||||
if (typeof rssOptions.customData === 'string')
|
||||
Object.assign(item, parser.parse(`<item>${rssOptions.customData}</item>`).item);
|
||||
return 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);
|
||||
}
|
||||
|
||||
return xml;
|
||||
return new XMLBuilder(xmlOptions).build(root);
|
||||
}
|
||||
|
||||
const requiredFields = Object.freeze(['link', 'title']);
|
||||
|
|
|
@ -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';
|
||||
|
@ -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 () => {
|
||||
|
@ -60,7 +62,7 @@ describe('rss', () => {
|
|||
site,
|
||||
});
|
||||
|
||||
chai.expect(body).to.equal(validXmlWithContentResult);
|
||||
chai.expect(body).xml.to.equal(validXmlWithContentResult);
|
||||
});
|
||||
|
||||
describe('glob result', () => {
|
||||
|
@ -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', () => {
|
||||
|
|
|
@ -590,6 +590,7 @@ importers:
|
|||
astro-scripts: workspace:*
|
||||
chai: ^4.3.6
|
||||
chai-as-promised: ^7.1.1
|
||||
chai-xml: ^0.4.0
|
||||
fast-xml-parser: ^4.0.8
|
||||
mocha: ^9.2.2
|
||||
dependencies:
|
||||
|
@ -602,6 +603,7 @@ importers:
|
|||
astro-scripts: link:../../scripts
|
||||
chai: 4.3.7
|
||||
chai-as-promised: 7.1.1_chai@4.3.7
|
||||
chai-xml: 0.4.0_chai@4.3.7
|
||||
mocha: 9.2.2
|
||||
|
||||
packages/astro/e2e/fixtures/_deps/astro-linked-lib:
|
||||
|
@ -10977,6 +10979,16 @@ packages:
|
|||
check-error: 1.0.2
|
||||
dev: true
|
||||
|
||||
/chai-xml/0.4.0_chai@4.3.7:
|
||||
resolution: {integrity: sha512-VjFPW64Hcp9CuuZbAC26cBWi+DPhyWOW8yxNpfQX3W+jQLPJxN/sm5FAaW+FOKTzsNeIFQpt5yhGbZA5s/pEyg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
peerDependencies:
|
||||
chai: '>=1.10.0 '
|
||||
dependencies:
|
||||
chai: 4.3.7
|
||||
xml2js: 0.4.23
|
||||
dev: true
|
||||
|
||||
/chai/4.3.7:
|
||||
resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
Loading…
Reference in a new issue