2023-01-19 16:24:55 +00:00
import { z } from 'astro/zod' ;
2022-12-08 13:10:36 +00:00
import { XMLBuilder , XMLParser } from 'fast-xml-parser' ;
2023-01-19 16:27:24 +00:00
import { yellow } from 'kleur/colors' ;
2023-01-19 16:24:55 +00:00
import { rssSchema } from './schema.js' ;
import { createCanonicalURL , errorMap , isValidURL } from './util.js' ;
2022-05-03 22:26:13 +00:00
2023-01-19 16:24:55 +00:00
export { rssSchema } ;
2022-05-03 22:26:13 +00:00
2023-01-19 16:24:55 +00:00
export type RSSOptions = {
/** Title of the RSS Feed */
title : z.infer < typeof rssOptionsValidator > [ 'title' ] ;
/** Description of the RSS Feed */
description : z.infer < typeof rssOptionsValidator > [ 'description' ] ;
2022-05-05 22:03:25 +00:00
/ * *
* Specify the base URL to use for RSS feed links .
2023-01-19 16:24:55 +00:00
* We recommend using the [ endpoint context object ] ( https : //docs.astro.build/en/reference/api-reference/#contextsite),
* which includes the ` site ` configured in your project ' s ` astro.config.* `
2022-05-03 22:26:13 +00:00
* /
2023-01-19 16:24:55 +00:00
site : z.infer < typeof rssOptionsValidator > [ 'site' ] ;
/** List of RSS feed items to render. */
2022-05-03 22:26:13 +00:00
items : RSSFeedItem [ ] | GlobResult ;
/** Specify arbitrary metadata on opening <xml> tag */
2023-01-19 16:24:55 +00:00
xmlns? : z.infer < typeof rssOptionsValidator > [ 'xmlns' ] ;
2022-05-03 22:26:13 +00:00
/ * *
* Specifies a local custom XSL stylesheet . Ex . '/public/custom-feed.xsl'
* /
2023-01-19 16:24:55 +00:00
stylesheet? : z.infer < typeof rssOptionsValidator > [ 'stylesheet' ] ;
2022-05-03 22:26:13 +00:00
/** Specify custom data in opening of file */
2023-01-19 16:24:55 +00:00
customData? : z.infer < typeof rssOptionsValidator > [ 'customData' ] ;
2022-12-27 15:50:11 +00:00
/** Whether to include drafts or not */
2023-01-19 16:24:55 +00:00
drafts? : z.infer < typeof rssOptionsValidator > [ 'drafts' ] ;
2023-03-09 07:57:03 +00:00
trailingSlash? : z.infer < typeof rssOptionsValidator > [ 'trailingSlash' ] ;
2022-05-03 22:26:13 +00:00
} ;
type RSSFeedItem = {
/** Link to item */
link : string ;
2023-01-19 16:24:55 +00:00
/** Full content of the item. Should be valid HTML */
2023-03-30 09:25:36 +00:00
content? : string | undefined ;
2022-05-03 22:26:13 +00:00
/** Title of item */
2023-01-19 16:24:55 +00:00
title : z.infer < typeof rssSchema > [ 'title' ] ;
2022-05-03 22:26:13 +00:00
/** Publication date of item */
2023-01-19 16:24:55 +00:00
pubDate : z.infer < typeof rssSchema > [ 'pubDate' ] ;
2022-05-03 22:26:13 +00:00
/** Item description */
2023-01-19 16:24:55 +00:00
description? : z.infer < typeof rssSchema > [ 'description' ] ;
2022-05-03 22:26:13 +00:00
/** Append some other XML-valid data to this item */
2023-01-19 16:24:55 +00:00
customData? : z.infer < typeof rssSchema > [ 'customData' ] ;
2022-12-27 15:50:11 +00:00
/** Whether draft or not */
2023-01-19 16:24:55 +00:00
draft? : z.infer < typeof rssSchema > [ 'draft' ] ;
2022-05-03 22:26:13 +00:00
} ;
2023-01-19 16:24:55 +00:00
type ValidatedRSSFeedItem = z . infer < typeof rssFeedItemValidator > ;
type ValidatedRSSOptions = z . infer < typeof rssOptionsValidator > ;
type GlobResult = z . infer < typeof globResultValidator > ;
const rssFeedItemValidator = rssSchema . extend ( { link : z.string ( ) , content : z.string ( ) . optional ( ) } ) ;
const globResultValidator = z . record ( z . function ( ) . returns ( z . promise ( z . any ( ) ) ) ) ;
2023-03-09 07:57:03 +00:00
2023-01-19 16:24:55 +00:00
const rssOptionsValidator = z . object ( {
title : z.string ( ) ,
description : z.string ( ) ,
2023-01-19 16:27:24 +00:00
site : z.preprocess ( ( url ) = > ( url instanceof URL ? url.href : url ) , z . string ( ) . url ( ) ) ,
2023-01-19 16:24:55 +00:00
items : z
. array ( rssFeedItemValidator )
. or ( globResultValidator )
. transform ( ( items ) = > {
if ( ! Array . isArray ( items ) ) {
2023-01-24 17:57:25 +00:00
// eslint-disable-next-line
2023-01-19 16:24:55 +00:00
console . warn (
yellow (
'[RSS] Passing a glob result directly has been deprecated. Please migrate to the `pagesGlobToRssItems()` helper: https://docs.astro.build/en/guides/rss/'
)
) ;
return pagesGlobToRssItems ( items ) ;
}
return items ;
} ) ,
xmlns : z.record ( z . string ( ) ) . optional ( ) ,
drafts : z.boolean ( ) . default ( false ) ,
stylesheet : z.union ( [ z . string ( ) , z . boolean ( ) ] ) . optional ( ) ,
customData : z.string ( ) . optional ( ) ,
2023-03-09 07:57:03 +00:00
trailingSlash : z.boolean ( ) . default ( true ) ,
2023-01-19 16:24:55 +00:00
} ) ;
export default async function getRSS ( rssOptions : RSSOptions ) {
const validatedRssOptions = await validateRssOptions ( rssOptions ) ;
2022-05-03 22:26:13 +00:00
2023-01-19 16:24:55 +00:00
return {
body : await generateRSS ( validatedRssOptions ) ,
} ;
}
async function validateRssOptions ( rssOptions : RSSOptions ) {
const parsedResult = await rssOptionsValidator . safeParseAsync ( rssOptions , { errorMap } ) ;
if ( parsedResult . success ) {
return parsedResult . data ;
}
const formattedError = new Error (
2023-02-16 21:34:56 +00:00
[
` [RSS] Invalid or missing options: ` ,
. . . parsedResult . error . errors . map (
( zodError ) = > ` ${ zodError . message } ( ${ zodError . path . join ( '.' ) } ) `
) ,
] . join ( '\n' )
) ;
2023-01-19 16:24:55 +00:00
throw formattedError ;
2022-05-03 22:26:13 +00:00
}
2023-01-19 16:24:55 +00:00
export function pagesGlobToRssItems ( items : GlobResult ) : Promise < ValidatedRSSFeedItem [ ] > {
2022-05-03 22:26:13 +00:00
return Promise . all (
2023-01-19 16:24:55 +00:00
Object . entries ( items ) . map ( async ( [ filePath , getInfo ] ) = > {
2022-05-03 22:26:13 +00:00
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 (
2023-01-19 16:24:55 +00:00
` [RSS] You can only glob entries within 'src/pages/' when passing import.meta.glob() directly. 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
) ;
}
2023-01-19 16:24:55 +00:00
const parsedResult = rssFeedItemValidator . safeParse (
{ . . . frontmatter , link : url } ,
{ errorMap }
) ;
if ( parsedResult . success ) {
return parsedResult . data ;
2022-05-03 22:26:13 +00:00
}
2023-01-19 16:24:55 +00:00
const formattedError = new Error (
[
` [RSS] ${ filePath } has invalid or missing frontmatter. \ nFix the following properties: ` ,
. . . parsedResult . error . errors . map ( ( zodError ) = > zodError . message ) ,
] . join ( '\n' )
) ;
( formattedError as any ) . file = filePath ;
throw formattedError ;
2022-05-03 22:26:13 +00:00
} )
) ;
}
/** Generate RSS 2.0 feed */
2023-01-19 16:24:55 +00:00
async function generateRSS ( rssOptions : ValidatedRSSOptions ) : Promise < string > {
2022-05-05 22:03:25 +00:00
const { site } = rssOptions ;
2023-01-19 16:24:55 +00:00
const items = rssOptions . drafts
? rssOptions . items
: rssOptions . items . filter ( ( item ) = > ! item . draft ) ;
2023-03-13 21:34:23 +00:00
const xmlOptions = {
ignoreAttributes : false ,
// Avoid correcting self-closing tags to standard tags
// when using `customData`
// https://github.com/withastro/astro/issues/5794
suppressEmptyNode : true ,
} ;
2022-12-08 13:10:36 +00:00
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 ) ;
2022-12-14 13:42:13 +00:00
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 ,
2023-03-09 07:57:03 +00:00
link : createCanonicalURL ( site , rssOptions . trailingSlash , undefined ) . href ,
2022-12-08 13:10:36 +00:00
} ;
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-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
2023-03-09 07:57:03 +00:00
: createCanonicalURL ( result . link , rssOptions . trailingSlash , 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 ) {
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
}