From fbfb6190ab5da60a556a3d5c338c8237c376df84 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Tue, 3 May 2022 18:26:13 -0400 Subject: [PATCH] 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 --- .changeset/beige-gifts-yawn.md | 6 + packages/astro-rss/README.md | 147 +++++++++++++++++++++++ packages/astro-rss/package.json | 39 +++++++ packages/astro-rss/src/index.ts | 154 +++++++++++++++++++++++++ packages/astro-rss/src/util.ts | 19 +++ packages/astro-rss/test/rss.test.js | 122 ++++++++++++++++++++ packages/astro-rss/tsconfig.json | 10 ++ packages/astro/src/core/create-vite.ts | 3 + pnpm-lock.yaml | 38 ++++++ 9 files changed, 538 insertions(+) create mode 100644 .changeset/beige-gifts-yawn.md create mode 100644 packages/astro-rss/README.md create mode 100644 packages/astro-rss/package.json create mode 100644 packages/astro-rss/src/index.ts create mode 100644 packages/astro-rss/src/util.ts create mode 100644 packages/astro-rss/test/rss.test.js create mode 100644 packages/astro-rss/tsconfig.json diff --git a/.changeset/beige-gifts-yawn.md b/.changeset/beige-gifts-yawn.md new file mode 100644 index 000000000..10bcfe8b9 --- /dev/null +++ b/.changeset/beige-gifts-yawn.md @@ -0,0 +1,6 @@ +--- +'astro': patch +'@astrojs/rss': patch +--- + +Introduce new @astrojs/rss package for RSS feed generation! This also adds a new global env variable for your project's configured "site": import.meta.env.SITE. This is consumed by the RSS feed helper to generate the correct canonical URL. diff --git a/packages/astro-rss/README.md b/packages/astro-rss/README.md new file mode 100644 index 000000000..4e1edd42c --- /dev/null +++ b/packages/astro-rss/README.md @@ -0,0 +1,147 @@ +# @astrojs/rss 📖 + +This package brings fast RSS feed generation to blogs and other content sites built with [Astro](https://astro.build/). For more information about RSS feeds in general, see [aboutfeeds.com](https://aboutfeeds.com/). + +## Installation + +Install the `@astrojs/rss` package into any Astro project using your preferred package manager: + +```bash +# npm +npm i @astrojs/rss +# yarn +yarn add @astrojs/rss +# pnpm +pnpm i @astrojs/rss +``` + +## Example usage + +The `@astrojs/rss` package provides helpers for generating RSS feeds within [Astro endpoints][astro-endpoints]. This unlocks both static builds _and_ on-demand generation when using an [SSR adapter](https://docs.astro.build/en/guides/server-side-rendering/#enabling-ssr-in-your-project). + +For instance, say you need to generate an RSS feed for all posts under `src/pages/blog/`. Start by [adding a `site` to your project's `astro.config` for link generation](https://docs.astro.build/en/reference/configuration-reference/#site). Then, create an `rss.xml.js` file under your project's `src/pages/` directory, and use [Vite's `import.meta.glob` helper](https://vitejs.dev/guide/features.html#glob-import) like so: + +```js +// src/pages/rss.xml.js +import rss from '@astrojs/rss'; + +export const get = () => rss({ + title: 'Buzz’s Blog', + description: 'A humble Astronaut’s guide to the stars', + items: import.meta.glob('./blog/**/*.md'), + }); +``` + +Read **[Astro's RSS docs][astro-rss]** for full usage examples. + +## `rss()` configuration options + +The `rss` default export offers a number of configuration options. Here's a quick reference: + +```js +rss({ + // `` field in output xml + title: 'Buzz’s Blog', + // `<description>` field in output xml + description: 'A humble Astronaut’s guide to the stars', + // list of `<item>`s in output xml + items: import.meta.glob('./**/*.md'), + // (optional) absolute path to XSL stylesheet in your project + stylesheet: '/rss-styles.xsl', + // (optional) inject custom xml + customData: '<language>en-us</language>', + // (optional) add arbitrary metadata to opening <rss> tag + xmlns: { h: 'http://www.w3.org/TR/html4/' }, + // (optional) provide a canonical URL + // defaults to the "site" configured in your project's astro.config + canonicalUrl: 'https://stargazers.club', +}); +``` + +### title + +Type: `string (required)` + +The `<title>` attribute of your RSS feed's output xml. + +### description + +Type: `string (required)` + +The `<description>` attribute of your RSS feed's output xml. + +### items + +Type: `RSSFeedItem[] | GlobResult (required)` + +Either a list of formatted RSS feed items or the result of [Vite's `import.meta.glob` helper](https://vitejs.dev/guide/features.html#glob-import). See [Astro's RSS items documentation](https://docs.astro.build/en/guides/rss/#generating-items) for usage examples to choose the best option for you. + +When providing a formatted RSS item list, see the `RSSFeedItem` type reference below: + +```ts +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; +}; +``` + +### stylesheet + +Type: `string (optional)` + +An absolute path to an XSL stylesheet in your project. If you don’t have an RSS stylesheet in mind, we recommend the [Pretty Feed v3 default stylesheet](https://github.com/genmon/aboutfeeds/blob/main/tools/pretty-feed-v3.xsl), which you can download from GitHub and save into your project's `public/` directory. + +### customData + +Type: `string (optional)` + +A string of valid XML to be injected between your feed's `<description>` and `<item>` tags. This is commonly used to set a language for your feed: + +```js +import rss from '@astrojs/rss'; + +export const get = () => rss({ + ... + customData: '<language>en-us</language>', + }); +``` + +### xmlns + +Type: `Record<string, string> (optional)` + +An object mapping a set of `xmlns` suffixes to strings of metadata on the opening `<rss>` tag. + +For example, this object: + +```js +rss({ + ... + xmlns: { h: 'http://www.w3.org/TR/html4/' }, +}) +``` + +Will inject the following XML: + +```xml +<rss xmlns:h="http://www.w3.org/TR/html4/"... +``` + +### canonicalUrl + +Type: `string (optional)` + +The base URL to use when generating RSS item links. This defaults to the [`site` configured in your project's `astro.config`](https://docs.astro.build/en/reference/configuration-reference/#site). We recommend using `site` instead of `canonicalUrl`, though we provide this option if an override is necessary. + +For more on building with Astro, [visit the Astro docs][astro-rss]. + +[astro-rss]: https://docs.astro.build/en/guides/rss/#using-astrojsrss-recommended +[astro-endpoints]: https://docs.astro.build/en/core-concepts/astro-pages/#non-html-pages diff --git a/packages/astro-rss/package.json b/packages/astro-rss/package.json new file mode 100644 index 000000000..d8fa7b9da --- /dev/null +++ b/packages/astro-rss/package.json @@ -0,0 +1,39 @@ +{ + "name": "@astrojs/rss", + "description": "Add RSS feeds to your Astro projects", + "version": "0.1.0", + "type": "module", + "types": "./dist/index.d.ts", + "author": "withastro", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/withastro/astro.git", + "directory": "packages/astro-rss" + }, + "bugs": "https://github.com/withastro/astro/issues", + "homepage": "https://astro.build", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "astro-scripts build \"src/**/*.ts\" && tsc", + "build:ci": "astro-scripts build \"src/**/*.ts\"", + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "mocha --exit --timeout 20000" + }, + "devDependencies": { + "@types/chai": "^4.3.1", + "@types/chai-as-promised": "^7.1.5", + "@types/mocha": "^9.1.1", + "astro": "workspace:*", + "astro-scripts": "workspace:*", + "chai": "^4.3.6", + "chai-as-promised": "^7.1.1", + "mocha": "^9.2.2" + }, + "dependencies": { + "fast-xml-parser": "^4.0.7" + } +} 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}]]>`; + xml += ``; + xml += `${createCanonicalURL(site).href}`; + if (typeof rssOptions.customData === 'string') xml += rssOptions.customData; + // items + for (const result of items) { + xml += ``; + xml += `<![CDATA[${result.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 += `${itemLink}`; + xml += `${itemLink}`; + if (result.description) xml += ``; + 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 += `${result.pubDate.toUTCString()}`; + } + if (typeof result.customData === 'string') xml += result.customData; + xml += ``; + } + + xml += ``; + + // 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; +} diff --git a/packages/astro-rss/test/rss.test.js b/packages/astro-rss/test/rss.test.js new file mode 100644 index 000000000..522d4803f --- /dev/null +++ b/packages/astro-rss/test/rss.test.js @@ -0,0 +1,122 @@ +import rss from '../dist/index.js'; +import chai from 'chai'; +import chaiPromises from 'chai-as-promised'; + +chai.use(chaiPromises); + +const title = 'My RSS feed'; +const description = 'This sure is a nice RSS feed'; +const canonicalUrl = 'https://example.com'; + +const phpFeedItem = { + link: '/php', + title: 'Remember PHP?', + pubDate: '1994-05-03', + description: 'PHP is a general-purpose scripting language geared toward web development. It was originally created by Danish-Canadian programmer Rasmus Lerdorf in 1994.', +}; + +const web1FeedItem = { + link: '/web1', + title: 'Web 1.0', + pubDate: '1997-05-03', + description: 'Web 1.0 is the term used for the earliest version of the Internet as it emerged from its origins with Defense Advanced Research Projects Agency (DARPA) and became, for the first time, a global network representing the future of digital communications.', +}; + +// note: I spent 30 minutes looking for a nice node-based snapshot tool +// ...and I gave up. Enjoy big strings! +const validXmlResult = `<![CDATA[My RSS feed]]>https://example.com/<![CDATA[Remember PHP?]]>https://example.com/php/https://example.com/php/Tue, 03 May 1994 00:00:00 GMT<![CDATA[Web 1.0]]>https://example.com/web1/https://example.com/web1/Sat, 03 May 1997 00:00:00 GMT`; + +describe('rss', () => { + it('should fail on missing "site" and/or "canonicalUrl"', () => { + return chai.expect( + rss({ + title, + description, + items: [], + }) + ).to.be.rejected; + }); + + it('should generate on valid RSSFeedItem array', async () => { + const { body } = await rss({ + title, + description, + items: [phpFeedItem, web1FeedItem], + canonicalUrl, + }); + + chai.expect(body).to.equal(validXmlResult); + }) + + describe('glob result', () => { + it('should generate on valid result', async () => { + const globResult = { + './posts/php.md': () => new Promise(resolve => resolve({ + url: phpFeedItem.link, + frontmatter: { + title: phpFeedItem.title, + pubDate: phpFeedItem.pubDate, + description: phpFeedItem.description, + }, + })), + './posts/nested/web1.md': () => new Promise(resolve => resolve({ + url: web1FeedItem.link, + frontmatter: { + title: web1FeedItem.title, + pubDate: web1FeedItem.pubDate, + description: web1FeedItem.description, + }, + })), + }; + + const { body } = await rss({ + title, + description, + items: globResult, + canonicalUrl, + }); + + chai.expect(body).to.equal(validXmlResult); + }); + + it('should fail on missing "title" key', () => { + const globResult = { + './posts/php.md': () => new Promise(resolve => resolve({ + url: phpFeedItem.link, + frontmatter: { + pubDate: phpFeedItem.pubDate, + description: phpFeedItem.description, + }, + })), + }; + return chai.expect( + rss({ + title, + description, + items: globResult, + canonicalUrl, + }) + ).to.be.rejected; + }); + + it('should fail on missing "pubDate" key', () => { + const globResult = { + './posts/php.md': () => new Promise(resolve => resolve({ + url: phpFeedItem.link, + frontmatter: { + title: phpFeedItem.title, + description: phpFeedItem.description, + }, + })), + }; + return chai.expect( + rss({ + title, + description, + items: globResult, + canonicalUrl, + }) + ).to.be.rejected; + }); + }); +}) diff --git a/packages/astro-rss/tsconfig.json b/packages/astro-rss/tsconfig.json new file mode 100644 index 000000000..06900f0f0 --- /dev/null +++ b/packages/astro-rss/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "module": "ES2020", + "outDir": "./dist", + "target": "ES2020" + } +} diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 01a8e486b..b793d0e27 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -71,6 +71,9 @@ export async function createVite( publicDir: fileURLToPath(astroConfig.publicDir), root: fileURLToPath(astroConfig.root), envPrefix: 'PUBLIC_', + define: { + 'import.meta.env.SITE': astroConfig.site ? `'${astroConfig.site}'` : 'undefined', + }, server: { force: true, // force dependency rebuild (TODO: enabled only while next is unstable; eventually only call in "production" mode?) hmr: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92c0483be..b1fadb2a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -634,6 +634,29 @@ importers: devDependencies: prismjs: 1.28.0 + packages/astro-rss: + specifiers: + '@types/chai': ^4.3.1 + '@types/chai-as-promised': ^7.1.5 + '@types/mocha': ^9.1.1 + astro: workspace:* + astro-scripts: workspace:* + chai: ^4.3.6 + chai-as-promised: ^7.1.1 + fast-xml-parser: ^4.0.7 + mocha: ^9.2.2 + dependencies: + fast-xml-parser: 4.0.7 + devDependencies: + '@types/chai': 4.3.1 + '@types/chai-as-promised': 7.1.5 + '@types/mocha': 9.1.1 + astro: link:../astro + astro-scripts: link:../../scripts + chai: 4.3.6 + chai-as-promised: 7.1.1_chai@4.3.6 + mocha: 9.2.2 + packages/astro/test/fixtures/0-css: specifiers: '@astrojs/react': workspace:* @@ -3883,6 +3906,12 @@ packages: '@babel/types': 7.17.0 dev: true + /@types/chai-as-promised/7.1.5: + resolution: {integrity: sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ==} + dependencies: + '@types/chai': 4.3.1 + dev: true + /@types/chai/4.3.1: resolution: {integrity: sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==} dev: true @@ -4976,6 +5005,15 @@ packages: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} dev: false + /chai-as-promised/7.1.1_chai@4.3.6: + resolution: {integrity: sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==} + peerDependencies: + chai: '>= 2.1.2 < 5' + dependencies: + chai: 4.3.6 + check-error: 1.0.2 + dev: true + /chai/4.3.6: resolution: {integrity: sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==} engines: {node: '>=4'}