diff --git a/scripts/build-rss.js b/scripts/build-rss.js index a5461f5e0ba..d238034f606 100644 --- a/scripts/build-rss.js +++ b/scripts/build-rss.js @@ -1,8 +1,8 @@ -const fs = require('fs') +const fs = require('fs').promises const json2xml = require('jgexml/json2xml') function getAllPosts() { - return require('../config/posts.json') + return require('../config/posts.json'); } function clean(s) { @@ -15,61 +15,84 @@ function clean(s) { return s } -module.exports = function rssFeed(type, title, desc, outputPath) { +module.exports = async function rssFeed(type, title, desc, outputPath) { + try { - const posts = getAllPosts()[`${type}`] - .sort((i1, i2) => { - const i1Date = new Date(i1.date) - const i2Date = new Date(i2.date) + let posts = getAllPosts()[`${type}`] + const missingDatePosts = posts.filter(post => !post.date); + posts = posts.filter(post => post.date); + posts.sort((i1, i2) => { + const i1Date = new Date(i1.date); + const i2Date = new Date(i2.date); + if (i1.featured && !i2.featured) return -1; + if (!i1.featured && i2.featured) return 1; + return i2Date - i1Date; + }); - if (i1.featured && !i2.featured) return -1 - if (!i1.featured && i2.featured) return 1 - return i2Date - i1Date - }) + if (missingDatePosts.length > 0) { + return Promise.reject(new Error('Missing date in post data')); + } - const base = 'https://www.asyncapi.com' - const tracking = '?utm_source=rss'; + const base = 'https://www.asyncapi.com' + const tracking = '?utm_source=rss'; - const feed = {} - const rss = {} - rss['@version'] = '2.0' - rss["@xmlns:atom"] = 'http://www.w3.org/2005/Atom' - rss.channel = {} - rss.channel.title = title - rss.channel.link = `${base}/${outputPath}` - rss.channel["atom:link"] = {} - rss.channel["atom:link"]["@rel"] = 'self' - rss.channel["atom:link"]["@href"] = rss.channel.link - rss.channel["atom:link"]["@type"] = 'application/rss+xml' - rss.channel.description = desc - rss.channel.language = 'en-gb'; - rss.channel.copyright = 'Made with :love: by the AsyncAPI Initiative.'; - rss.channel.webMaster = 'info@asyncapi.io (AsyncAPI Initiative)' - rss.channel.pubDate = new Date().toUTCString() - rss.channel.generator = 'next.js' - rss.channel.item = [] + const feed = {} + const rss = {} + rss['@version'] = '2.0' + rss["@xmlns:atom"] = 'http://www.w3.org/2005/Atom' + rss.channel = {} + rss.channel.title = title + rss.channel.link = `${base}/${outputPath}` + rss.channel["atom:link"] = {} + rss.channel["atom:link"]["@rel"] = 'self' + rss.channel["atom:link"]["@href"] = rss.channel.link + rss.channel["atom:link"]["@type"] = 'application/rss+xml' + rss.channel.description = desc + rss.channel.language = 'en-gb'; + rss.channel.copyright = 'Made with :love: by the AsyncAPI Initiative.'; + rss.channel.webMaster = 'info@asyncapi.io (AsyncAPI Initiative)' + rss.channel.pubDate = new Date().toUTCString() + rss.channel.generator = 'next.js' + rss.channel.item = [] - for (let post of posts) { - const link = `${base}${post.slug}${tracking}`; - const item = { title: post.title, description: clean(post.excerpt), link, category: type, guid: { '@isPermaLink': true, '': link }, pubDate: new Date(post.date).toUTCString() } - if (post.cover) { - const enclosure = {}; - enclosure["@url"] = base+post.cover; - enclosure["@length"] = 15026; // dummy value, anything works - enclosure["@type"] = 'image/jpeg'; - if (typeof enclosure["@url"] === 'string') { - let tmp = enclosure["@url"].toLowerCase(); - if (tmp.indexOf('.png')>=0) enclosure["@type"] = 'image/png'; - if (tmp.indexOf('.svg')>=0) enclosure["@type"] = 'image/svg+xml'; - if (tmp.indexOf('.webp')>=0) enclosure["@type"] = 'image/webp'; + for (let post of posts) { + if (!post.title || !post.slug || !post.excerpt || !post.date) { + return Promise.reject(new Error('Missing required fields in post data')); + } + const link = `${base}${post.slug}${tracking}`; + const { title, excerpt, date } = post; + const pubDate = new Date(date).toUTCString(); + const description = clean(excerpt); + const guid = { '@isPermaLink': true, '': link }; + const item = { + title, + description, + link, + category: type, + guid, + pubDate + }; + if (post.cover) { + const enclosure = {}; + enclosure["@url"] = base + post.cover; + enclosure["@length"] = 15026; // dummy value, anything works + enclosure["@type"] = 'image/jpeg'; + if (typeof enclosure["@url"] === 'string') { + let tmp = enclosure["@url"].toLowerCase(); + if (tmp.indexOf('.png') >= 0) enclosure["@type"] = 'image/png'; + if (tmp.indexOf('.svg') >= 0) enclosure["@type"] = 'image/svg+xml'; + if (tmp.indexOf('.webp') >= 0) enclosure["@type"] = 'image/webp'; + } + item.enclosure = enclosure; } - item.enclosure = enclosure; + rss.channel.item.push(item) } - rss.channel.item.push(item) - } - feed.rss = rss + feed.rss = rss - const xml = json2xml.getXml(feed,'@','',2) - fs.writeFileSync(`./public/${outputPath}`, xml, 'utf8') + const xml = json2xml.getXml(feed, '@', '', 2); + await fs.writeFile(`./public/${outputPath}`, xml, 'utf8'); + } catch (err) { + return Promise.reject(new Error(`Failed to generate RSS feed: ${err.message}`)); + } }; diff --git a/tests/build-rss.test.js b/tests/build-rss.test.js new file mode 100644 index 00000000000..91d1de40935 --- /dev/null +++ b/tests/build-rss.test.js @@ -0,0 +1,134 @@ +const fs = require('fs'); +const path = require('path'); +const rssFeed = require('../scripts/build-rss'); +const { mockRssData, title, type, desc, missingDateMockData, incompletePostMockData } = require('./fixtures/rssData'); + +describe('rssFeed', () => { + const testOutputDir = path.join(__dirname, '..', 'public', 'test-output'); + const outputPath = 'test-output/rss.xml'; + + beforeAll(() => { + if (!fs.existsSync(testOutputDir)) { + fs.mkdirSync(testOutputDir, { recursive: true }); + } + }); + + afterAll(() => { + if (fs.existsSync(testOutputDir)) { + fs.readdirSync(testOutputDir).forEach(file => { + fs.unlinkSync(path.join(testOutputDir, file)); + }); + fs.rmdirSync(testOutputDir); + } + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('should generate RSS feed and write to file', async () => { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined() + + const filePath = path.join(__dirname, '..', 'public', outputPath); + expect(fs.existsSync(filePath)).toBe(true); + const fileContent = fs.readFileSync(filePath, 'utf8'); + expect(fileContent).toContain(' { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined() + + + const filePath = path.join(__dirname, '..', 'public', outputPath); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + const itemTitles = fileContent.match(/(.*?)<\/title>/g); + + expect(itemTitles[1]).toContain('Test Post 1'); + expect(itemTitles[2]).toContain('Another Featured Post'); + expect(itemTitles[3]).toContain('Non-Featured Post 1'); + }); + + it('should sort posts by date in descending order', async () => { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined() + + const filePath = path.join(__dirname, '..', 'public', outputPath); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + const itemTitles = fileContent.match(/<title>(.*?)<\/title>/g); + + expect(itemTitles[1]).toContain('Test Post 1'); + expect(itemTitles[2]).toContain('Another Featured Post'); + expect(itemTitles[3]).toContain('Non-Featured Post 1'); + expect(itemTitles[4]).toContain('Non-Featured Post 3'); + expect(itemTitles[5]).toContain('Non-Featured Post 2'); + }); + + it('should set correct enclosure type based on image extension', async () => { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined() + + const filePath = path.join(__dirname, '..', 'public', outputPath); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + expect(fileContent).toContain('<enclosure url="https://www.asyncapi.com/img/test-cover.png"'); + expect(fileContent).toContain('type="image/png"'); + expect(fileContent).toContain('<enclosure url="https://www.asyncapi.com/img/test-cover.svg"'); + expect(fileContent).toContain('type="image/svg+xml"'); + expect(fileContent).toContain('<enclosure url="https://www.asyncapi.com/img/test-cover.webp"'); + expect(fileContent).toContain('type="image/webp"'); + }); + + it('should catch and handle errors when write operation fails', async () => { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + const invalidOutputPath = "invalid/path"; + + await expect(rssFeed(type, title, desc, invalidOutputPath)).rejects.toThrow(/ENOENT|EACCES/); + + }); + + it('should throw an error when posts.json is malformed', async () => { + jest.doMock('../config/posts.json', () => { + return { invalidKey: [] }; + }, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).rejects.toThrow('Failed to generate RSS feed'); + + }); + + it('should handle empty posts array', async () => { + const emptyMockData = { blog: [] }; + jest.doMock('../config/posts.json', () => emptyMockData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined() + + const filePath = path.join(__dirname, '..', 'public', outputPath); + const fileContent = fs.readFileSync(filePath, 'utf8'); + expect(fileContent).toContain('<rss version="2.0"'); + expect(fileContent).not.toContain('<item>'); + }); + + it('should throw an error when post is missing required fields', async () => { + + jest.doMock('../config/posts.json', () => incompletePostMockData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).rejects.toThrow('Missing required fields'); + + }); + + it('should throw an error when a post is missing a date field during sorting', async () => { + + jest.doMock('../config/posts.json', () => missingDateMockData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).rejects.toThrow('Missing date in post data'); + + }); +}); diff --git a/tests/fixtures/rssData.js b/tests/fixtures/rssData.js new file mode 100644 index 00000000000..1c49f443068 --- /dev/null +++ b/tests/fixtures/rssData.js @@ -0,0 +1,79 @@ +const mockRssData = { + blog: [ + { + title: 'Non-Featured Post 1', + slug: '/blog/non-featured-post-1', + excerpt: 'This is a non-featured post', + date: '2024-07-05', + featured: false, + }, + { + title: 'Test Post 1', + slug: '/blog/test-post-1', + excerpt: 'This is a featured test post', + date: '2024-07-07', + featured: true, + cover: '/img/test-cover.jpg', + }, + { + title: 'Another Featured Post', + slug: '/blog/another-featured-post', + excerpt: 'This is another featured post', + date: '2024-07-06', + featured: true, + cover: '/img/test-cover.svg', + }, + { + title: 'Non-Featured Post 2', + slug: '/blog/non-featured-post-2', + excerpt: 'This is another non-featured post', + date: '2024-07-03', + featured: false, + cover: '/img/test-cover.webp', + }, + { + title: 'Non-Featured Post 3', + slug: '/blog/non-featured-post-3', + excerpt: 'This is yet another non-featured post', + date: '2024-07-04', + featured: false, + cover: '/img/test-cover.png', + }, + ], +}; + +const missingDateMockData = { + blog: [ + { + title: 'Post without Date', + slug: '/blog/no-date-post', + excerpt: 'This post is missing a date', + featured: false, + }, + { + title: 'Valid Post', + slug: '/blog/valid-post', + excerpt: 'This post has a valid date', + date: '2024-07-05', + featured: true, + }, + ], +}; + +const incompletePostMockData = { + blog: [ + { + slug: '/blog/incomplete-post', + excerpt: 'This post is incomplete', + date: '2024-07-05', + featured: false, + }, + ], +}; + +const type = 'blog'; +const title = 'Test Blog RSS'; +const desc = 'Test blog RSS feed'; +const outputPath = 'test-output/blog.xml'; + +module.exports = { mockRssData, title, type, desc, outputPath, missingDateMockData, incompletePostMockData };