diff options
author | 2022-05-03 18:26:13 -0400 | |
---|---|---|
committer | 2022-05-03 18:26:13 -0400 | |
commit | fbfb6190ab5da60a556a3d5c338c8237c376df84 (patch) | |
tree | 8a79a56685b2b2e03a240eb50d604b66efa1dcd2 /packages/astro-rss/src | |
parent | e2a037be944d4c00b4b909b25574ebbc245cc720 (diff) | |
download | astro-fbfb6190ab5da60a556a3d5c338c8237c376df84.tar.gz astro-fbfb6190ab5da60a556a3d5c338c8237c376df84.tar.zst astro-fbfb6190ab5da60a556a3d5c338c8237c376df84.zip |
Feat: `@astrojs/rss` package! (#3271)
* feat: introduce @astrojs/rss package!
* feat: add config "site" to env variable
* docs: add @astrojs/rss readme
* chore: changeset
* fix: testing script
* deps: add mocha, chai, chai-promises
* tests: add rss test!
* feat: add canonicalUrl arg
* chore: remove console.log
* fix: remove null check on env (breaks build)
* docs: stray `
* chore: update error message to doc link
* chore: remove getStylesheet
* docs: update stylesheet reference
Diffstat (limited to 'packages/astro-rss/src')
-rw-r--r-- | packages/astro-rss/src/index.ts | 154 | ||||
-rw-r--r-- | packages/astro-rss/src/util.ts | 19 |
2 files changed, 173 insertions, 0 deletions
diff --git a/packages/astro-rss/src/index.ts b/packages/astro-rss/src/index.ts new file mode 100644 index 000000000..c9e53f8ce --- /dev/null +++ b/packages/astro-rss/src/index.ts @@ -0,0 +1,154 @@ +import { XMLValidator } from 'fast-xml-parser'; +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; + /** + * List of RSS feed items to render. Accepts either: + * a) list of RSSFeedItems + * b) import.meta.glob result. You can only glob ".md" files within src/pages/ when using this method! + */ + 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; + /** + * Specify the base URL to use for RSS feed links. + * Defaults to "site" in your project's astro.config + */ + canonicalUrl?: string; +}; + +type RSSFeedItem = { + /** Link to item */ + link: string; + /** Title of item */ + title: string; + /** Publication date of item */ + pubDate: Date; + /** Item description */ + description?: string; + /** Append some other XML-valid data to this item */ + customData?: string; +}; + +type GenerateRSSArgs = { + site: string; + 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(); + if (!Boolean(url)) { + throw new Error( + `[RSS] When passing an import.meta.glob result directly, you can only glob ".md" 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` + ); + } + 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) { + const site = rssOptions.canonicalUrl ?? (import.meta as any).env.SITE; + if (!site) { + throw new Error( + `RSS requires a canonical URL. Either add a "site" to your project's astro.config, or supply the canonicalUrl argument.` + ); + } + let { items } = rssOptions; + if (isGlobResult(items)) { + items = await mapGlobResult(items); + } + return { + body: await generateRSS({ + site, + rssOptions, + items, + }), + }; +} + +/** Generate RSS 2.0 feed */ +export async function generateRSS({ site, rssOptions, items }: GenerateRSSArgs): Promise<string> { + let xml = `<?xml version="1.0" encoding="UTF-8"?>`; + if (typeof rssOptions.stylesheet === 'string') { + xml += `<?xml-stylesheet href="${rssOptions.stylesheet}" type="text/xsl"?>`; + } + xml += `<rss version="2.0"`; + + // xmlns + if (rssOptions.xmlns) { + for (const [k, v] of Object.entries(rssOptions.xmlns)) { + xml += ` xmlns:${k}="${v}"`; + } + } + xml += `>`; + xml += `<channel>`; + + // title, description, customData + xml += `<title><![CDATA[${rssOptions.title}]]></title>`; + xml += `<description><![CDATA[${rssOptions.description}]]></description>`; + xml += `<link>${createCanonicalURL(site).href}</link>`; + if (typeof rssOptions.customData === 'string') xml += rssOptions.customData; + // items + for (const result of items) { + xml += `<item>`; + xml += `<title><![CDATA[${result.title}]]></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 += `<link>${itemLink}</link>`; + xml += `<guid>${itemLink}</guid>`; + if (result.description) xml += `<description><![CDATA[${result.description}]]></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') { + result.pubDate = new Date(result.pubDate); + } else if (result.pubDate instanceof Date === false) { + throw new Error('[${filename}] rss.item().pubDate must be a Date'); + } + xml += `<pubDate>${result.pubDate.toUTCString()}</pubDate>`; + } + if (typeof result.customData === 'string') xml += result.customData; + xml += `</item>`; + } + + xml += `</channel></rss>`; + + // 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); + } + + return xml; +} diff --git a/packages/astro-rss/src/util.ts b/packages/astro-rss/src/util.ts new file mode 100644 index 000000000..0dad6b239 --- /dev/null +++ b/packages/astro-rss/src/util.ts @@ -0,0 +1,19 @@ +import npath from 'path-browserify'; + +/** Normalize URL to its canonical form */ +export function createCanonicalURL(url: string, base?: string): URL { + let pathname = url.replace(/\/index.html$/, ''); // index.html is not canonical + pathname = pathname.replace(/\/1\/?$/, ''); // neither is a trailing /1/ (impl. detail of collections) + if (!npath.extname(pathname)) pathname = pathname.replace(/(\/+)?$/, '/'); // add trailing slash if there’s no extension + pathname = pathname.replace(/\/+/g, '/'); // remove duplicate slashes (URL() won’t) + return new URL(pathname, base); +} + +/** Check if a URL is already valid */ +export function isValidURL(url: string): boolean { + try { + new URL(url); + return true; + } catch (e) {} + return false; +} |