[RSS] Get ready for content collections (#5851)

* chore: strictNullChecks for zod

* feat: expose `rssSchema` helper

* refactor: align types with schema types

* feat: break glob handler to globToRssItems util

* refactor: RSS options validation to Zod

* refactor: avoid intermediate type

* fix: allow numbers and dates in pubDate

* test: update glob and error tests

* feat: add rss to with-content starter

* fix: move globToRssItems back to internal behavior

* chore: JSON.stringify

* Revert "fix: move globToRssItems back to internal behavior"

This reverts commit 85305075e6444907455541b24bccbccd5016951a.

* test: missing url

* docs: `import.meta.env.SITE` -> `context.site`

* docs: update README to content collections example

* fix: url -> link

* docs: add `rssSchema` to README

* chore: consistent formatting

* docs: add `pagesGlobToRssItems()` reference

* chore: globToRssItems -> pagesGlobToRssItems

* chore: changeset

* fix: bad docs line highlighting

* fix: add collections export to example

* nit: remove "our"

* fix: are -> all

* fix: more README edits

* deps: kleur

* chore: add back import.meta.glob handling as deprecated

* docs: bump down to `minor`, update headline to be less content collections-y

* typo: suggest adding

* chore: support URL object on `site`

* docs: add await to pagesGlob ex

* docs: tighten `rssSchema` explainer

* docs: tighten pagesGlobToRssItems section

* docs: add content to README

* docs: replace examples with docs link

* docs: re-we

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Ben Holmes 2023-01-19 11:24:55 -05:00 committed by GitHub
parent 97267e3881
commit 81dce94f2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 459 additions and 384 deletions

View file

@ -0,0 +1,41 @@
---
'@astrojs/rss': minor
---
Update RSS config for readability and consistency with Astro 2.0.
#### Migration - `import.meta.glob()` handling
We have deprecated `items: import.meta.glob(...)` handling in favor of a separate `pagesGlobToRssItems()` helper. This simplifies our `items` configuration option to accept a single type, without losing existing functionality.
If you rely on our `import.meta.glob()` handling, we suggest adding the `pagesGlobToRssItems()` wrapper to your RSS config:
```diff
// src/pages/rss.xml.js
import rss, {
+ pagesGlobToRssItems
} from '@astrojs/rss';
export function get(context) {
return rss({
+ items: pagesGlobToRssItems(
import.meta.glob('./blog/*.{md,mdx}'),
+ ),
});
}
```
#### New `rssSchema` for content collections
`@astrojs/rss` now exposes an `rssSchema` for use with content collections. This ensures all RSS feed properties are present in your frontmatter:
```ts
import { defineCollection } from 'astro:content';
import { rssSchema } from '@astrojs/rss';
const blog = defineCollection({
schema: rssSchema,
});
export const collections = { blog };
```

View file

@ -7,7 +7,7 @@ export async function get(context) {
return rss({ return rss({
title: SITE_TITLE, title: SITE_TITLE,
description: SITE_DESCRIPTION, description: SITE_DESCRIPTION,
site: context.site.href, site: context.site,
items: posts.map((post) => ({ items: posts.map((post) => ({
...post.data, ...post.data,
link: `/blog/${post.slug}/`, link: `/blog/${post.slug}/`,

View file

@ -19,46 +19,62 @@ pnpm i @astrojs/rss
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). 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: For instance, say you need to generate an RSS feed for all posts under `src/content/blog/` using content collections.
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 `getCollection()`](https://docs.astro.build/en/guides/content-collections/#getcollection) to generate a feed from all documents in the `blog` collection:
```js ```js
// src/pages/rss.xml.js // src/pages/rss.xml.js
import rss from '@astrojs/rss'; import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export const get = () => rss({ export async function get(context) {
const posts = await getCollection('blog');
return rss({
title: 'Buzzs Blog', title: 'Buzzs Blog',
description: 'A humble Astronauts guide to the stars', description: 'A humble Astronauts guide to the stars',
// pull in the "site" from your project's astro.config // Pull in your project "site" from the endpoint context
site: import.meta.env.SITE, // https://docs.astro.build/en/reference/api-reference/#contextsite
items: import.meta.glob('./blog/**/*.md'), site: context.site,
items: posts.map(post => ({
// Assumes all RSS feed item properties are in post frontmatter
...post.data,
// Generate a `url` from each post `slug`
// This assumes all blog posts are rendered as `/blog/[slug]` routes
// https://docs.astro.build/en/guides/content-collections/#generating-pages-from-content-collections
link: `/blog/${post.slug}/`,
}))
}); });
}
``` ```
Read **[Astro's RSS docs][astro-rss]** for full usage examples. Read **[Astro's RSS docs][astro-rss]** for more on using content collections, and instructions for globbing entries in `/src/pages/`.
## `rss()` configuration options ## `rss()` configuration options
The `rss` default export offers a number of configuration options. Here's a quick reference: The `rss` default export offers a number of configuration options. Here's a quick reference:
```js ```js
rss({ export function get(context) {
// `<title>` field in output xml return rss({
title: 'Buzzs Blog', // `<title>` field in output xml
// `<description>` field in output xml title: 'Buzzs Blog',
description: 'A humble Astronauts guide to the stars', // `<description>` field in output xml
// provide a base URL for RSS <item> links description: 'A humble Astronauts guide to the stars',
site: import.meta.env.SITE, // provide a base URL for RSS <item> links
// list of `<item>`s in output xml site: context.site,
items: import.meta.glob('./**/*.md'), // list of `<item>`s in output xml
// include draft posts in the feed (default: false) items: [...],
drafts: true, // include draft posts in the feed (default: false)
// (optional) absolute path to XSL stylesheet in your project drafts: true,
stylesheet: '/rss-styles.xsl', // (optional) absolute path to XSL stylesheet in your project
// (optional) inject custom xml stylesheet: '/rss-styles.xsl',
customData: '<language>en-us</language>', // (optional) inject custom xml
// (optional) add arbitrary metadata to opening <rss> tag customData: '<language>en-us</language>',
xmlns: { h: 'http://www.w3.org/TR/html4/' }, // (optional) add arbitrary metadata to opening <rss> tag
}); xmlns: { h: 'http://www.w3.org/TR/html4/' },
});
}
``` ```
### title ### title
@ -77,13 +93,22 @@ The `<description>` attribute of your RSS feed's output xml.
Type: `string (required)` Type: `string (required)`
The base URL to use when generating RSS item links. We recommend using `import.meta.env.SITE` to pull in the "site" from your project's astro.config. Still, feel free to use a custom base URL if necessary. The base URL to use when generating RSS item links. 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.*`:
```ts
import rss from '@astrojs/rss';
export const get = (context) => rss({
site: context.site,
...
});
```
### items ### items
Type: `RSSFeedItem[] | GlobResult (required)` Type: `RSSFeedItem[] (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. A list of formatted RSS feed items. 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: When providing a formatted RSS item list, see the `RSSFeedItem` type reference below:
@ -152,6 +177,62 @@ Will inject the following XML:
<rss xmlns:h="http://www.w3.org/TR/html4/"... <rss xmlns:h="http://www.w3.org/TR/html4/"...
``` ```
### content
The `content` key contains the full content of the post as HTML. This allows you to make your entire post content available to RSS feed readers.
**Note:** Whenever you're using HTML content in XML, we suggest using a package like [`sanitize-html`](https://www.npmjs.com/package/sanitize-html) in order to make sure that your content is properly sanitized, escaped, and encoded.
[See our RSS documentation](https://docs.astro.build/en/guides/rss/#including-full-post-content) for examples using content collections and glob imports.
## `rssSchema`
When using content collections, you can configure your collection schema to enforce expected [`RSSFeedItem`](#items) properties. Import and apply `rssSchema` to ensure that each collection entry produces a valid RSS feed item:
```ts "schema: rssSchema,"
import { defineCollection } from 'astro:content';
import { rssSchema } from '@astrojs/rss';
const blog = defineCollection({
schema: rssSchema,
});
export const collections = { blog };
```
If you have an existing schema, you can merge extra properties using `extends()`:
```ts ".extends({ extraProperty: z.string() }),"
import { defineCollection } from 'astro:content';
import { rssSchema } from '@astrojs/rss';
const blog = defineCollection({
schema: rssSchema.extends({ extraProperty: z.string() }),
});
```
## `pagesGlobToRssItems()`
To create an RSS feed from documents in `src/pages/`, use the `pagesGlobToRssItems()` helper. This accepts an `import.meta.glob` result ([see Vite documentation](https://vitejs.dev/guide/features.html#glob-import)) and outputs an array of valid [`RSSFeedItem`s](#items).
This function assumes, but does not verify, you are globbing for items inside `src/pages/`, and all necessary feed properties are present in each document's frontmatter. If you encounter errors, verify each page frontmatter manually.
```ts "pagesGlobToRssItems"
// src/pages/rss.xml.js
import rss, { pagesGlobToRssItems } from '@astrojs/rss';
export async function get(context) {
return rss({
title: 'Buzzs Blog',
description: 'A humble Astronauts guide to the stars',
site: context.site,
items: await pagesGlobToRssItems(
import.meta.glob('./blog/*.{md,mdx}'),
),
});
}
```
--- ---
For more on building with Astro, [visit the Astro docs][astro-rss]. For more on building with Astro, [visit the Astro docs][astro-rss].

View file

@ -35,6 +35,7 @@
"mocha": "^9.2.2" "mocha": "^9.2.2"
}, },
"dependencies": { "dependencies": {
"fast-xml-parser": "^4.0.8" "fast-xml-parser": "^4.0.8",
"kleur": "^4.1.5"
} }
} }

View file

@ -1,113 +1,144 @@
import { z } from 'astro/zod';
import { XMLBuilder, XMLParser } from 'fast-xml-parser'; import { XMLBuilder, XMLParser } from 'fast-xml-parser';
import { createCanonicalURL, isValidURL } from './util.js'; import { rssSchema } from './schema.js';
import { createCanonicalURL, errorMap, isValidURL } from './util.js';
import { yellow } from 'kleur/colors';
type GlobResult = Record<string, () => Promise<{ [key: string]: any }>>; export { rssSchema };
type RSSOptions = { export type RSSOptions = {
/** (required) Title of the RSS Feed */ /** Title of the RSS Feed */
title: string; title: z.infer<typeof rssOptionsValidator>['title'];
/** (required) Description of the RSS Feed */ /** Description of the RSS Feed */
description: string; description: z.infer<typeof rssOptionsValidator>['description'];
/** /**
* Specify the base URL to use for RSS feed links. * Specify the base URL to use for RSS feed links.
* We recommend "import.meta.env.SITE" to pull in the "site" * We recommend using the [endpoint context object](https://docs.astro.build/en/reference/api-reference/#contextsite),
* from your project's astro.config. * which includes the `site` configured in your project's `astro.config.*`
*/
site: string;
/**
* List of RSS feed items to render. Accepts either:
* a) list of RSSFeedItems
* 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!
*/ */
site: z.infer<typeof rssOptionsValidator>['site'];
/** List of RSS feed items to render. */
items: RSSFeedItem[] | GlobResult; items: RSSFeedItem[] | GlobResult;
/** Specify arbitrary metadata on opening <xml> tag */ /** Specify arbitrary metadata on opening <xml> tag */
xmlns?: Record<string, string>; xmlns?: z.infer<typeof rssOptionsValidator>['xmlns'];
/** /**
* Specifies a local custom XSL stylesheet. Ex. '/public/custom-feed.xsl' * Specifies a local custom XSL stylesheet. Ex. '/public/custom-feed.xsl'
*/ */
stylesheet?: string | boolean; stylesheet?: z.infer<typeof rssOptionsValidator>['stylesheet'];
/** Specify custom data in opening of file */ /** Specify custom data in opening of file */
customData?: string; customData?: z.infer<typeof rssOptionsValidator>['customData'];
/** Whether to include drafts or not */ /** Whether to include drafts or not */
drafts?: boolean; drafts?: z.infer<typeof rssOptionsValidator>['drafts'];
}; };
type RSSFeedItem = { type RSSFeedItem = {
/** Link to item */ /** Link to item */
link: string; link: string;
/** Title of item */ /** Full content of the item. Should be valid HTML */
title: string;
/** Publication date of item */
pubDate: Date;
/** Item description */
description?: string;
/** Full content of the item, should be valid HTML */
content?: string; content?: string;
/** Title of item */
title: z.infer<typeof rssSchema>['title'];
/** Publication date of item */
pubDate: z.infer<typeof rssSchema>['pubDate'];
/** Item description */
description?: z.infer<typeof rssSchema>['description'];
/** Append some other XML-valid data to this item */ /** Append some other XML-valid data to this item */
customData?: string; customData?: z.infer<typeof rssSchema>['customData'];
/** Whether draft or not */ /** Whether draft or not */
draft?: boolean; draft?: z.infer<typeof rssSchema>['draft'];
}; };
type GenerateRSSArgs = { type ValidatedRSSFeedItem = z.infer<typeof rssFeedItemValidator>;
rssOptions: RSSOptions; type ValidatedRSSOptions = z.infer<typeof rssOptionsValidator>;
items: RSSFeedItem[]; type GlobResult = z.infer<typeof globResultValidator>;
};
function isGlobResult(items: RSSOptions['items']): items is GlobResult { const rssFeedItemValidator = rssSchema.extend({ link: z.string(), content: z.string().optional() });
return typeof items === 'object' && !items.length; const globResultValidator = z.record(z.function().returns(z.promise(z.any())));
const rssOptionsValidator = z.object({
title: z.string(),
description: z.string(),
site: z.preprocess(
url => url instanceof URL ? url.href : url,
z.string().url(),
),
items: z
.array(rssFeedItemValidator)
.or(globResultValidator)
.transform((items) => {
if (!Array.isArray(items)) {
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(),
});
export default async function getRSS(rssOptions: RSSOptions) {
const validatedRssOptions = await validateRssOptions(rssOptions);
return {
body: await generateRSS(validatedRssOptions),
};
} }
function mapGlobResult(items: GlobResult): Promise<RSSFeedItem[]> { async function validateRssOptions(rssOptions: RSSOptions) {
const parsedResult = await rssOptionsValidator.safeParseAsync(rssOptions, { errorMap });
if (parsedResult.success) {
return parsedResult.data;
}
const formattedError = new Error(
[
`[RSS] Invalid or missing options:`,
...parsedResult.error.errors.map((zodError) => zodError.message),
].join('\n')
);
throw formattedError;
}
export function pagesGlobToRssItems(items: GlobResult): Promise<ValidatedRSSFeedItem[]> {
return Promise.all( return Promise.all(
Object.values(items).map(async (getInfo) => { Object.entries(items).map(async ([filePath, getInfo]) => {
const { url, frontmatter } = await getInfo(); const { url, frontmatter } = await getInfo();
if (url === undefined || url === null) { if (url === undefined || url === null) {
throw new Error( throw new Error(
`[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` `[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`
); );
} }
if (!Boolean(frontmatter.title) || !Boolean(frontmatter.pubDate)) { const parsedResult = rssFeedItemValidator.safeParse(
throw new Error(`[RSS] "${url}" is missing a "title" and/or "pubDate" in its frontmatter.`); { ...frontmatter, link: url },
{ errorMap }
);
if (parsedResult.success) {
return parsedResult.data;
} }
return { const formattedError = new Error(
link: url, [
title: frontmatter.title, `[RSS] ${filePath} has invalid or missing frontmatter.\nFix the following properties:`,
pubDate: frontmatter.pubDate, ...parsedResult.error.errors.map((zodError) => zodError.message),
description: frontmatter.description, ].join('\n')
customData: frontmatter.customData, );
draft: frontmatter.draft, (formattedError as any).file = filePath;
}; throw formattedError;
}) })
); );
} }
export default async function getRSS(rssOptions: RSSOptions) {
const { site } = rssOptions;
let { items } = rssOptions;
if (!site) {
throw new Error('[RSS] the "site" option is required, but no value was given.');
}
if (isGlobResult(items)) {
items = await mapGlobResult(items);
if (!rssOptions.drafts) {
items = items.filter((item) => !item.draft);
}
}
return {
body: await generateRSS({
rssOptions,
items,
}),
};
}
/** Generate RSS 2.0 feed */ /** Generate RSS 2.0 feed */
export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promise<string> { async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> {
const { site } = rssOptions; const { site } = rssOptions;
const items = rssOptions.drafts
? rssOptions.items
: rssOptions.items.filter((item) => !item.draft);
const xmlOptions = { ignoreAttributes: false }; const xmlOptions = { ignoreAttributes: false };
const parser = new XMLParser(xmlOptions); const parser = new XMLParser(xmlOptions);
const root: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' } }; const root: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' } };
@ -149,7 +180,6 @@ export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promi
); );
// items // items
root.rss.channel.item = items.map((result) => { root.rss.channel.item = items.map((result) => {
validate(result);
// If the item's link is already a valid URL, don't mess with it. // If the item's link is already a valid URL, don't mess with it.
const itemLink = isValidURL(result.link) const itemLink = isValidURL(result.link)
? result.link ? result.link
@ -163,12 +193,6 @@ export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promi
item.description = result.description; item.description = result.description;
} }
if (result.pubDate) { 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');
}
item.pubDate = result.pubDate.toUTCString(); item.pubDate = result.pubDate.toUTCString();
} }
// include the full content of the post if the user supplies it // include the full content of the post if the user supplies it
@ -183,16 +207,3 @@ export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promi
return new XMLBuilder(xmlOptions).build(root); return new XMLBuilder(xmlOptions).build(root);
} }
const requiredFields = Object.freeze(['link', 'title']);
// Perform validation to make sure all required fields are passed.
function validate(item: RSSFeedItem) {
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.`
);
}
}
}

View file

@ -0,0 +1,9 @@
import { z } from 'astro/zod';
export const rssSchema = z.object({
title: z.string(),
pubDate: z.union([z.string(), z.number(), z.date()]).transform((value) => new Date(value)),
description: z.string().optional(),
customData: z.string().optional(),
draft: z.boolean().optional(),
});

View file

@ -1,3 +1,5 @@
import { z } from 'astro/zod';
/** Normalize URL to its canonical form */ /** Normalize URL to its canonical form */
export function createCanonicalURL(url: string, base?: string): URL { export function createCanonicalURL(url: string, base?: string): URL {
let pathname = url.replace(/\/index.html$/, ''); // index.html is not canonical let pathname = url.replace(/\/index.html$/, ''); // index.html is not canonical
@ -21,3 +23,17 @@ function getUrlExtension(url: string) {
const lastSlash = url.lastIndexOf('/'); const lastSlash = url.lastIndexOf('/');
return lastDot > lastSlash ? url.slice(lastDot + 1) : ''; return lastDot > lastSlash ? url.slice(lastDot + 1) : '';
} }
const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join('.');
export const errorMap: z.ZodErrorMap = (error, ctx) => {
if (error.code === 'invalid_type') {
const badKeyPath = JSON.stringify(flattenErrorPath(error.path));
if (error.received === 'undefined') {
return { message: `${badKeyPath} is required.` };
} else {
return { message: `${badKeyPath} should be ${error.expected}, not ${error.received}.` };
}
}
return { message: ctx.defaultError };
};

View file

@ -0,0 +1,85 @@
import chai from 'chai';
import chaiPromises from 'chai-as-promised';
import { phpFeedItem, web1FeedItem } from './test-utils.js';
import { pagesGlobToRssItems } from '../dist/index.js';
chai.use(chaiPromises);
describe('pagesGlobToRssItems', () => {
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 items = await pagesGlobToRssItems(globResult);
chai.expect(items.sort((a, b) => a.pubDate - b.pubDate)).to.deep.equal([
{
title: phpFeedItem.title,
link: phpFeedItem.link,
pubDate: new Date(phpFeedItem.pubDate),
description: phpFeedItem.description,
},
{
title: web1FeedItem.title,
link: web1FeedItem.link,
pubDate: new Date(web1FeedItem.pubDate),
description: web1FeedItem.description,
},
]);
});
it('should fail on missing "url"', () => {
const globResult = {
'./posts/php.md': () =>
new Promise((resolve) =>
resolve({
url: undefined,
frontmatter: {
pubDate: phpFeedItem.pubDate,
description: phpFeedItem.description,
},
})
),
};
return chai.expect(pagesGlobToRssItems(globResult)).to.be.rejected;
});
it('should fail on missing "title" key', () => {
const globResult = {
'./posts/php.md': () =>
new Promise((resolve) =>
resolve({
url: phpFeedItem.link,
frontmatter: {
title: undefined,
pubDate: phpFeedItem.pubDate,
description: phpFeedItem.description,
},
})
),
};
return chai.expect(pagesGlobToRssItems(globResult)).to.be.rejected;
});
});

View file

@ -2,43 +2,20 @@ import rss from '../dist/index.js';
import chai from 'chai'; import chai from 'chai';
import chaiPromises from 'chai-as-promised'; import chaiPromises from 'chai-as-promised';
import chaiXml from 'chai-xml'; import chaiXml from 'chai-xml';
import {
title,
description,
site,
phpFeedItem,
phpFeedItemWithContent,
phpFeedItemWithCustomData,
web1FeedItem,
web1FeedItemWithContent,
} from './test-utils.js';
chai.use(chaiPromises); chai.use(chaiPromises);
chai.use(chaiXml); chai.use(chaiXml);
const title = 'My RSS feed';
const description = 'This sure is a nice RSS feed';
const site = '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 phpFeedItemWithContent = {
...phpFeedItem,
content: `<h1>${phpFeedItem.title}</h1><p>${phpFeedItem.description}</p>`,
};
const phpFeedItemWithCustomData = {
...phpFeedItem,
customData: '<dc:creator><![CDATA[Buster Bluth]]></dc:creator>',
};
const web1FeedItem = {
// Should support empty string as a URL (possible for homepage route)
link: '',
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.',
};
const web1FeedItemWithContent = {
...web1FeedItem,
content: `<h1>${web1FeedItem.title}</h1><p>${web1FeedItem.description}</p>`,
};
// note: I spent 30 minutes looking for a nice node-based snapshot tool // note: I spent 30 minutes looking for a nice node-based snapshot tool
// ...and I gave up. Enjoy big strings! // ...and I gave up. Enjoy big strings!
// prettier-ignore // prettier-ignore
@ -115,244 +92,63 @@ describe('rss', () => {
chai.expect(body).xml.to.equal(validXmlWithXSLStylesheet); chai.expect(body).xml.to.equal(validXmlWithXSLStylesheet);
}); });
describe('glob result', () => { it('should filter out entries marked as `draft`', async () => {
it('should generate on valid result', async () => { const { body } = await rss({
const globResult = { title,
'./posts/php.md': () => description,
new Promise((resolve) => items: [phpFeedItem, { ...web1FeedItem, draft: true }],
resolve({ site,
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,
site,
});
chai.expect(body).xml.to.equal(validXmlResult);
}); });
it('should fail on missing "title" key', () => { chai.expect(body).xml.to.equal(validXmlWithoutWeb1FeedResult);
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,
site,
})
).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,
site,
})
).to.be.rejected;
});
it('should filter out draft', 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,
draft: true,
},
})
),
};
const { body } = await rss({
title,
description,
items: globResult,
site,
});
chai.expect(body).xml.to.equal(validXmlWithoutWeb1FeedResult);
});
it('should respect drafts option', 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,
draft: true,
},
})
),
};
const { body } = await rss({
title,
description,
items: globResult,
site,
drafts: true,
});
chai.expect(body).xml.to.equal(validXmlResult);
});
}); });
describe('errors', () => { it('should respect drafts option', async () => {
it('should provide a error message when a "site" option is missing', async () => { const { body } = await rss({
try { title,
await rss({ description,
title, drafts: true,
description, items: [phpFeedItem, { ...web1FeedItem, draft: true }],
items: [phpFeedItem, web1FeedItem], site,
}); drafts: true,
chai.expect(false).to.equal(true, 'Should have errored');
} catch (err) {
chai
.expect(err.message)
.to.contain('[RSS] the "site" option is required, but no value was given.');
}
}); });
it('should provide a good error message when a link is not provided', async () => { chai.expect(body).xml.to.equal(validXmlResult);
try { });
await rss({
title: 'Your Website Title', it('Deprecated import.meta.glob mapping still works', async () => {
description: 'Your Website Description', const globResult = {
site: 'https://astro-demo', './posts/php.md': () =>
items: [ new Promise((resolve) =>
{ resolve({
pubDate: new Date(), url: phpFeedItem.link,
title: 'Some title', frontmatter: {
slug: 'foo', title: phpFeedItem.title,
pubDate: phpFeedItem.pubDate,
description: phpFeedItem.description,
}, },
], })
}); ),
chai.expect(false).to.equal(true, 'Should have errored'); './posts/nested/web1.md': () =>
} catch (err) { new Promise((resolve) =>
chai.expect(err.message).to.contain('Required field [link] is missing'); resolve({
} url: web1FeedItem.link,
frontmatter: {
title: web1FeedItem.title,
pubDate: web1FeedItem.pubDate,
description: web1FeedItem.description,
},
})
),
};
const { body } = await rss({
title,
description,
items: globResult,
site,
}); });
it('should provide a good error message when passing glob result form outside pages/', async () => { chai.expect(body).xml.to.equal(validXmlResult);
const globResult = {
'./posts/php.md': () =>
new Promise((resolve) =>
resolve({
// "undefined" when outside pages/
url: undefined,
frontmatter: {
title: phpFeedItem.title,
pubDate: phpFeedItem.pubDate,
description: phpFeedItem.description,
},
})
),
'./posts/nested/web1.md': () =>
new Promise((resolve) =>
resolve({
url: undefined,
frontmatter: {
title: web1FeedItem.title,
pubDate: web1FeedItem.pubDate,
description: web1FeedItem.description,
},
})
),
};
try {
await rss({
title: 'Your Website Title',
description: 'Your Website Description',
site: 'https://astro-demo',
items: globResult,
});
chai.expect(false).to.equal(true, 'Should have errored');
} catch (err) {
chai
.expect(err.message)
.to.contain(
'you can only glob ".md" (or alternative extensions for markdown files like ".markdown") files within /pages'
);
}
});
}); });
}); });

View file

@ -0,0 +1,32 @@
export const title = 'My RSS feed';
export const description = 'This sure is a nice RSS feed';
export const site = 'https://example.com';
export 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.',
};
export const phpFeedItemWithContent = {
...phpFeedItem,
content: `<h1>${phpFeedItem.title}</h1><p>${phpFeedItem.description}</p>`,
};
export const phpFeedItemWithCustomData = {
...phpFeedItem,
customData: '<dc:creator><![CDATA[Buster Bluth]]></dc:creator>',
};
export const web1FeedItem = {
// Should support empty string as a URL (possible for homepage route)
link: '',
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.',
};
export const web1FeedItemWithContent = {
...web1FeedItem,
content: `<h1>${web1FeedItem.title}</h1><p>${web1FeedItem.description}</p>`,
};

View file

@ -5,6 +5,7 @@
"allowJs": true, "allowJs": true,
"module": "ES2020", "module": "ES2020",
"outDir": "./dist", "outDir": "./dist",
"target": "ES2020" "target": "ES2020",
"strictNullChecks": true
} }
} }

View file

@ -578,9 +578,11 @@ importers:
chai-as-promised: ^7.1.1 chai-as-promised: ^7.1.1
chai-xml: ^0.4.0 chai-xml: ^0.4.0
fast-xml-parser: ^4.0.8 fast-xml-parser: ^4.0.8
kleur: ^4.1.5
mocha: ^9.2.2 mocha: ^9.2.2
dependencies: dependencies:
fast-xml-parser: 4.0.13 fast-xml-parser: 4.0.13
kleur: 4.1.5
devDependencies: devDependencies:
'@types/chai': 4.3.4 '@types/chai': 4.3.4
'@types/chai-as-promised': 7.1.5 '@types/chai-as-promised': 7.1.5