2022-12-08 13:10:36 +00:00
import { XMLBuilder , XMLParser } from 'fast-xml-parser' ;
2022-05-03 22:26:13 +00:00
import { createCanonicalURL , isValidURL } from './util.js' ;
type GlobResult = Record < string , ( ) = > Promise < { [ key : string ] : any } >> ;
type RSSOptions = {
/** (required) Title of the RSS Feed */
title : string ;
/** (required) Description of the RSS Feed */
description : string ;
2022-05-05 22:03:25 +00:00
/ * *
* Specify the base URL to use for RSS feed links .
* We recommend "import.meta.env.SITE" to pull in the "site"
* from your project ' s astro . config .
* /
site : string ;
2022-05-03 22:26:13 +00:00
/ * *
* List of RSS feed items to render . Accepts either :
* a ) list of RSSFeedItems
2022-10-26 14:18:49 +00:00
* b ) import . meta . glob result . You can only glob ".md" ( or alternative extensions for markdown files like ".markdown" ) files within src / pages / when using this method !
2022-05-03 22:26:13 +00:00
* /
items : RSSFeedItem [ ] | GlobResult ;
/** Specify arbitrary metadata on opening <xml> tag */
xmlns? : Record < string , string > ;
/ * *
* Specifies a local custom XSL stylesheet . Ex . '/public/custom-feed.xsl'
* /
stylesheet? : string | boolean ;
/** Specify custom data in opening of file */
customData? : string ;
} ;
type RSSFeedItem = {
/** Link to item */
link : string ;
/** Title of item */
title : string ;
/** Publication date of item */
pubDate : Date ;
/** Item description */
description? : string ;
2022-12-06 23:19:55 +00:00
/** Full content of the item, should be valid HTML */
content? : string ;
2022-05-03 22:26:13 +00:00
/** Append some other XML-valid data to this item */
customData? : string ;
} ;
type GenerateRSSArgs = {
rssOptions : RSSOptions ;
items : RSSFeedItem [ ] ;
} ;
function isGlobResult ( items : RSSOptions [ 'items' ] ) : items is GlobResult {
return typeof items === 'object' && ! items . length ;
}
function mapGlobResult ( items : GlobResult ) : Promise < RSSFeedItem [ ] > {
return Promise . all (
Object . values ( items ) . map ( async ( getInfo ) = > {
const { url , frontmatter } = await getInfo ( ) ;
2022-09-12 19:05:57 +00:00
if ( url === undefined || url === null ) {
2022-05-03 22:26:13 +00:00
throw new Error (
2022-10-26 14:18:49 +00:00
` [RSS] When passing an import.meta.glob result directly, you can only glob ".md" (or alternative extensions for markdown files like ".markdown") files within /pages! Consider mapping the result to an array of RSSFeedItems. See the RSS docs for usage examples: https://docs.astro.build/en/guides/rss/#2-list-of-rss-feed-objects `
2022-05-03 22:26:13 +00:00
) ;
}
if ( ! Boolean ( frontmatter . title ) || ! Boolean ( frontmatter . pubDate ) ) {
throw new Error ( ` [RSS] " ${ url } " is missing a "title" and/or "pubDate" in its frontmatter. ` ) ;
}
return {
link : url ,
title : frontmatter.title ,
pubDate : frontmatter.pubDate ,
description : frontmatter.description ,
customData : frontmatter.customData ,
} ;
} )
) ;
}
export default async function getRSS ( rssOptions : RSSOptions ) {
2022-07-18 22:01:04 +00:00
const { site } = rssOptions ;
2022-05-03 22:26:13 +00:00
let { items } = rssOptions ;
2022-07-18 22:01:04 +00:00
if ( ! site ) {
throw new Error ( '[RSS] the "site" option is required, but no value was given.' ) ;
}
2022-05-03 22:26:13 +00:00
if ( isGlobResult ( items ) ) {
items = await mapGlobResult ( items ) ;
}
2022-07-18 22:02:35 +00:00
2022-05-03 22:26:13 +00:00
return {
body : await generateRSS ( {
rssOptions ,
items ,
} ) ,
} ;
}
/** Generate RSS 2.0 feed */
2022-05-05 22:03:25 +00:00
export async function generateRSS ( { rssOptions , items } : GenerateRSSArgs ) : Promise < string > {
const { site } = rssOptions ;
2022-12-08 13:10:36 +00:00
const xmlOptions = { ignoreAttributes : false } ;
const parser = new XMLParser ( xmlOptions ) ;
const root : any = { '?xml' : { '@_version' : '1.0' , '@_encoding' : 'UTF-8' } } ;
2022-05-03 22:26:13 +00:00
if ( typeof rssOptions . stylesheet === 'string' ) {
2022-12-14 13:39:48 +00:00
const isXSL = /\.xsl$/i . test ( rssOptions . stylesheet ) ;
root [ '?xml-stylesheet' ] = { '@_href' : rssOptions . stylesheet , . . . ( isXSL && { '@_type' : 'text/xsl' } ) } ;
2022-05-03 22:26:13 +00:00
}
2022-12-08 13:10:36 +00:00
root . rss = { '@_version' : '2.0' } ;
2022-12-06 23:19:55 +00:00
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/' ;
2022-12-08 13:10:36 +00:00
root . rss [ '@_xmlns:content' ] = XMLContentNamespace ;
2022-12-06 23:19:55 +00:00
// 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 ;
}
}
2022-05-03 22:26:13 +00:00
// xmlns
if ( rssOptions . xmlns ) {
for ( const [ k , v ] of Object . entries ( rssOptions . xmlns ) ) {
2022-12-08 13:10:36 +00:00
root . rss [ ` @_xmlns: ${ k } ` ] = v ;
2022-05-03 22:26:13 +00:00
}
}
// title, description, customData
2022-12-08 13:10:36 +00:00
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
) ;
2022-05-03 22:26:13 +00:00
// items
2022-12-08 13:10:36 +00:00
root . rss . channel . item = items . map ( ( result ) = > {
2022-07-13 20:37:17 +00:00
validate ( result ) ;
2022-05-03 22:26:13 +00:00
// 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 ;
2022-12-08 13:10:36 +00:00
const item : any = {
title : result.title ,
link : itemLink ,
guid : itemLink ,
} ;
if ( result . description ) {
item . description = result . description ;
}
2022-05-03 22:26:13 +00:00
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' ) ;
}
2022-12-08 13:10:36 +00:00
item . pubDate = result . pubDate . toUTCString ( ) ;
2022-05-03 22:26:13 +00:00
}
2022-12-06 23:19:55 +00:00
// include the full content of the post if the user supplies it
if ( typeof result . content === 'string' ) {
2022-12-08 13:10:36 +00:00
item [ 'content:encoded' ] = result . content ;
2022-12-06 23:19:55 +00:00
}
2022-12-13 13:45:35 +00:00
if ( typeof result . customData === 'string' ) {
Object . assign ( item , parser . parse ( ` <item> ${ result . customData } </item> ` ) . item ) ;
}
2022-12-08 13:10:36 +00:00
return item ;
} ) ;
2022-05-03 22:26:13 +00:00
2022-12-08 13:10:36 +00:00
return new XMLBuilder ( xmlOptions ) . build ( root ) ;
2022-05-03 22:26:13 +00:00
}
2022-07-13 20:37:17 +00:00
const requiredFields = Object . freeze ( [ 'link' , 'title' ] ) ;
// Perform validation to make sure all required fields are passed.
function validate ( item : RSSFeedItem ) {
2022-07-13 20:39:29 +00:00
for ( const field of requiredFields ) {
if ( ! ( field in item ) ) {
throw new Error (
` @astrojs/rss: Required field [ ${ field } ] is missing. RSS cannot be generated without it. `
) ;
2022-07-13 20:37:17 +00:00
}
}
}