├── .gitignore ├── package.json ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gridsome-plugin-feed", 3 | "version": "1.0.0", 4 | "description": "Generate RSS/Atom/JSON feeds from generated Gridsome content.", 5 | "main": "index.js", 6 | "license": "ISC", 7 | "homepage": "https://github.com/onecrayon/gridsome-plugin-feed#readme", 8 | "repository": "https://github.com/onecrayon/gridsome-plugin-feed", 9 | "keywords": [ 10 | "gridsome", 11 | "gridsome-plugin" 12 | ], 13 | "dependencies": { 14 | "feed": "^2.0.4", 15 | "fs-extra": "^8.0.1", 16 | "micromatch": "^4.0.2", 17 | "moment": "^2.24.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gridsome-plugin-feed 2 | 3 | Generate an RSS, Atom, and/or JSON feed for your Gridsome site. 4 | 5 | ## Install 6 | 7 | - `yarn add gridsome-plugin-feed` 8 | - `npm install gridsome-plugin-feed` 9 | 10 | ## Example 11 | 12 | ```js 13 | module.exports = { 14 | plugins: [ 15 | { 16 | use: 'gridsome-plugin-feed', 17 | options: { 18 | // Required: array of `GraphQL` type names you wish to include 19 | contentTypes: ['BlogPost', 'NewsPost'], 20 | // Optional: any properties you wish to set for `Feed()` constructor 21 | // See https://www.npmjs.com/package/feed#example for available properties 22 | feedOptions: { 23 | title: 'My Awesome Blog Feed', 24 | description: 'Best blog feed evah.' 25 | }, 26 | // === All options after this point show their default values === 27 | // Optional; opt into which feeds you wish to generate, and set their output path 28 | rss: { 29 | enabled: true, 30 | output: '/feed.xml' 31 | }, 32 | atom: { 33 | enabled: false, 34 | output: '/feed.atom' 35 | }, 36 | json: { 37 | enabled: false, 38 | output: '/feed.json' 39 | }, 40 | // Optional: the maximum number of items to include in your feed 41 | maxItems: 25, 42 | // Optional: an array of properties passed to `Feed.addItem()` that will be parsed for 43 | // URLs in HTML (ensures that URLs are full `http` URLs rather than site-relative). 44 | // To disable this functionality, set to `null`. 45 | htmlFields: ['description', 'content'], 46 | // Optional: a method that accepts a node and returns true (include) or false (exclude) 47 | // Example: only past-dated nodes: `filterNodes: (node) => node.fields.date <= new Date()` 48 | filterNodes: (node) => true, 49 | // Optional: a method that accepts a node and returns an object for `Feed.addItem()` 50 | // See https://www.npmjs.com/package/feed#example for available properties 51 | // NOTE: `date` field MUST be a Javascript `Date` object 52 | nodeToFeedItem: (node) => ({ 53 | title: node.title, 54 | date: node.fields.date, 55 | content: node.content 56 | }) 57 | } 58 | } 59 | ] 60 | } 61 | ``` 62 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const url = require('url') 3 | const fs = require('fs-extra') 4 | const Feed = require('feed').Feed 5 | const moment = require('moment') 6 | 7 | function urlWithBase (path, base) { 8 | return new url.URL(path, base).href 9 | } 10 | 11 | function convertToSiteUrls (html, baseUrl) { 12 | // Currently playing it conservative and only modifying things that are explicitly relative URLs 13 | const relativeRefs = /(href|src)=("|')((?=\.{1,2}\/|\/).+?)\2/gi 14 | return html.replace(relativeRefs, (_, attribute, quote, relUrl) => { 15 | return [attribute, '=', quote, urlWithBase(relUrl, baseUrl), quote].join('') 16 | }) 17 | } 18 | 19 | function ensureExtension (path, extension) { 20 | if (path.endsWith(extension)) return path 21 | if (path.endsWith('/')) { 22 | return `${path.substring(0, path.length - 1)}${extension}` 23 | } 24 | return `${path}${extension}` 25 | } 26 | 27 | module.exports = function (api, options) { 28 | api.afterBuild(({ config }) => { 29 | if (!config.siteUrl) { 30 | throw new Error('Feed plugin is missing required global siteUrl config.') 31 | } 32 | if (!options.contentTypes || !options.contentTypes.length) { 33 | throw new Error('Feed plugin is missing required `options.contentTypes` setting.') 34 | } 35 | 36 | const store = api.store 37 | const pathPrefix = config.pathPrefix !== '/' ? config.pathPrefix : '' 38 | const siteUrl = config.siteUrl 39 | const siteHref = urlWithBase(pathPrefix, siteUrl) 40 | const feedOptions = { 41 | generator: 'Gridsome Feed Plugin', 42 | id: siteHref, 43 | link: siteHref, 44 | title: config.siteName, 45 | ...options.feedOptions, 46 | feedLinks: {} 47 | } 48 | const rssOutput = options.rss.enabled ? ensureExtension(options.rss.output, '.xml') : null 49 | const atomOutput = options.atom.enabled ? ensureExtension(options.atom.output, '.atom') : null 50 | const jsonOutput = options.json.enabled ? ensureExtension(options.json.output, '.json') : null 51 | if (rssOutput) { 52 | feedOptions.feedLinks.rss = urlWithBase(pathPrefix + rssOutput, siteUrl) 53 | } 54 | if (atomOutput) { 55 | feedOptions.feedLinks.atom = urlWithBase(pathPrefix + atomOutput, siteUrl) 56 | } 57 | if (jsonOutput) { 58 | feedOptions.feedLinks.json = urlWithBase(pathPrefix + jsonOutput, siteUrl) 59 | } 60 | const feed = new Feed(feedOptions) 61 | 62 | let nodes = [] 63 | for (const contentType of options.contentTypes) { 64 | const { collection } = store.getContentType(contentType) 65 | if (!collection.data || !collection.data.length) continue 66 | const items = collection.data.filter(options.filterNodes).map(node => options.nodeToFeedItem(node)) 67 | nodes.push(...items) 68 | } 69 | nodes.sort((a, b) => { 70 | const aDate = moment(a.date) 71 | const bDate = moment(b.date) 72 | if (aDate.isSame(bDate)) return 0 73 | return aDate.isBefore(bDate) ? 1 : -1 74 | }) 75 | if (options.maxItems && nodes.length > options.maxItems) { 76 | nodes = nodes.slice(0, options.maxItems) 77 | } 78 | 79 | for (const item of nodes) { 80 | item.id = urlWithBase(pathPrefix + item.path, siteUrl) 81 | item.link = item.id 82 | if (options.htmlFields && options.htmlFields.length) { 83 | for (const field of options.htmlFields) { 84 | if (!item[field]) continue 85 | item[field] = convertToSiteUrls(item[field], item.link) 86 | } 87 | } 88 | feed.addItem(item) 89 | } 90 | 91 | if (rssOutput) { 92 | console.log(`Generate RSS feed at ${rssOutput}`) 93 | fs.outputFile(path.join(config.outDir, rssOutput), feed.rss2()) 94 | } 95 | if (atomOutput) { 96 | console.log(`Generate Atom feed at ${atomOutput}`) 97 | fs.outputFile(path.join(config.outDir, atomOutput), feed.atom1()) 98 | } 99 | if (jsonOutput) { 100 | console.log(`Generate JSON feed at ${jsonOutput}`) 101 | fs.outputFile(path.join(config.outDir, jsonOutput), feed.json1()) 102 | } 103 | }) 104 | } 105 | 106 | module.exports.defaultOptions = () => ({ 107 | contentTypes: [], 108 | feedOptions: {}, 109 | rss: { 110 | enabled: true, 111 | output: '/feed.xml' 112 | }, 113 | atom: { 114 | enabled: false, 115 | output: '/feed.atom' 116 | }, 117 | json: { 118 | enabled: false, 119 | output: '/feed.json' 120 | }, 121 | maxItems: 25, 122 | htmlFields: ['description', 'content'], 123 | filterNodes: (node) => true, 124 | nodeToFeedItem: (node) => ({ 125 | title: node.title, 126 | date: node.fields.date, 127 | content: node.content 128 | }) 129 | }) 130 | --------------------------------------------------------------------------------