From ac799ff0de3d23a7a9bafe2d79c3ca2412465711 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Tue, 6 Dec 2022 22:31:01 -0800 Subject: [PATCH 1/2] test(astro-rss): Compare XML using chai-xml Signed-off-by: Anders Kaseorg --- packages/astro-rss/package.json | 1 + packages/astro-rss/test/rss.test.js | 8 +++++--- pnpm-lock.yaml | 12 ++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/astro-rss/package.json b/packages/astro-rss/package.json index a349482852c6..582f244f0c8a 100644 --- a/packages/astro-rss/package.json +++ b/packages/astro-rss/package.json @@ -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": { diff --git a/packages/astro-rss/test/rss.test.js b/packages/astro-rss/test/rss.test.js index 8f4af327278b..e993d87f3629 100644 --- a/packages/astro-rss/test/rss.test.js +++ b/packages/astro-rss/test/rss.test.js @@ -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', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f41745f3f6dd..b0d8e9a3d5f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: @@ -10971,6 +10973,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'} From beeef3a1ed0e40ec89d09e48858aa9058aa0b378 Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Tue, 6 Dec 2022 22:24:53 -0800 Subject: [PATCH 2/2] fix(astro-rss): Generate feed with proper XML escaping Signed-off-by: Anders Kaseorg --- .changeset/hungry-snakes-live.md | 5 +++ packages/astro-rss/src/index.ts | 67 ++++++++++++++++---------------- 2 files changed, 39 insertions(+), 33 deletions(-) create mode 100644 .changeset/hungry-snakes-live.md diff --git a/.changeset/hungry-snakes-live.md b/.changeset/hungry-snakes-live.md new file mode 100644 index 000000000000..fb3c159612c3 --- /dev/null +++ b/.changeset/hungry-snakes-live.md @@ -0,0 +1,5 @@ +--- +'@astrojs/rss': patch +--- + +Generate RSS feed with proper XML escaping diff --git a/packages/astro-rss/src/index.ts b/packages/astro-rss/src/index.ts index bb6afd5625c2..8779ac00776b 100644 --- a/packages/astro-rss/src/index.ts +++ b/packages/astro-rss/src/index.ts @@ -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 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 { const { site } = rssOptions; - let xml = ``; + 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 += ``; + root['?xml-stylesheet'] = { '@_href': rssOptions.stylesheet, '@_encoding': 'UTF-8' }; } - xml += ` result.content)) { // the namespace to be added to the xmlns:content attribute to enable the 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 += ``; // title, description, customData - xml += `<![CDATA[${rssOptions.title}]]>`; - xml += ``; - xml += `${createCanonicalURL(site).href}`; - 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(`${rssOptions.customData}`).channel + ); // items - for (const result of items) { + root.rss.channel.item = items.map((result) => { validate(result); - xml += ``; - xml += `<![CDATA[${result.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 += `${itemLink}`; - xml += `${itemLink}`; - if (result.description) xml += ``; + 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 += `${result.pubDate.toUTCString()}`; + item.pubDate = result.pubDate.toUTCString(); } // include the full content of the post if the user supplies it if (typeof result.content === 'string') { - xml += ``; + item['content:encoded'] = result.content; } - if (typeof result.customData === 'string') xml += result.customData; - xml += ``; - } - - xml += ``; - - // 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(`${rssOptions.customData}`).item); + return item; + }); - return xml; + return new XMLBuilder(xmlOptions).build(root); } const requiredFields = Object.freeze(['link', 'title']);