diff --git a/.changeset/fluffy-cups-travel.md b/.changeset/fluffy-cups-travel.md new file mode 100644 index 000000000000..aa22e3d911e8 --- /dev/null +++ b/.changeset/fluffy-cups-travel.md @@ -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 }; +``` diff --git a/examples/blog/src/pages/rss.xml.js b/examples/blog/src/pages/rss.xml.js index 3997cc45e30e..7d054b92ab34 100644 --- a/examples/blog/src/pages/rss.xml.js +++ b/examples/blog/src/pages/rss.xml.js @@ -7,7 +7,7 @@ export async function get(context) { return rss({ title: SITE_TITLE, description: SITE_DESCRIPTION, - site: context.site.href, + site: context.site, items: posts.map((post) => ({ ...post.data, link: `/blog/${post.slug}/`, diff --git a/packages/astro-rss/README.md b/packages/astro-rss/README.md index 3255aede2b22..7036f5f77c20 100644 --- a/packages/astro-rss/README.md +++ b/packages/astro-rss/README.md @@ -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). -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 // src/pages/rss.xml.js 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: 'Buzz’s Blog', description: 'A humble Astronaut’s guide to the stars', - // pull in the "site" from your project's astro.config - site: import.meta.env.SITE, - items: import.meta.glob('./blog/**/*.md'), + // Pull in your project "site" from the endpoint context + // https://docs.astro.build/en/reference/api-reference/#contextsite + 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 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', - // provide a base URL for RSS <item> links - site: import.meta.env.SITE, - // list of `<item>`s in output xml - items: import.meta.glob('./**/*.md'), - // include draft posts in the feed (default: false) - drafts: true, - // (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/' }, -}); +export function get(context) { + return rss({ + // `<title>` field in output xml + title: 'Buzz’s Blog', + // `<description>` field in output xml + description: 'A humble Astronaut’s guide to the stars', + // provide a base URL for RSS <item> links + site: context.site, + // list of `<item>`s in output xml + items: [...], + // include draft posts in the feed (default: false) + drafts: true, + // (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/' }, + }); +} ``` ### title @@ -77,13 +93,22 @@ The `<description>` attribute of your RSS feed's output xml. 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 -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: @@ -152,6 +177,62 @@ Will inject the following XML: <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: 'Buzz’s Blog', + description: 'A humble Astronaut’s 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]. diff --git a/packages/astro-rss/package.json b/packages/astro-rss/package.json index 348d7680d490..12a5ace701cb 100644 --- a/packages/astro-rss/package.json +++ b/packages/astro-rss/package.json @@ -35,6 +35,7 @@ "mocha": "^9.2.2" }, "dependencies": { - "fast-xml-parser": "^4.0.8" + "fast-xml-parser": "^4.0.8", + "kleur": "^4.1.5" } } diff --git a/packages/astro-rss/src/index.ts b/packages/astro-rss/src/index.ts index 667ab1c9dd6c..8157df30b987 100644 --- a/packages/astro-rss/src/index.ts +++ b/packages/astro-rss/src/index.ts @@ -1,113 +1,144 @@ +import { z } from 'astro/zod'; 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 = { - /** (required) Title of the RSS Feed */ - title: string; - /** (required) Description of the RSS Feed */ - description: string; +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']; /** * 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; - /** - * 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! + * 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.*` */ + site: z.infer<typeof rssOptionsValidator>['site']; + /** List of RSS feed items to render. */ items: RSSFeedItem[] | GlobResult; /** 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' */ - stylesheet?: string | boolean; + stylesheet?: z.infer<typeof rssOptionsValidator>['stylesheet']; /** Specify custom data in opening of file */ - customData?: string; + customData?: z.infer<typeof rssOptionsValidator>['customData']; /** Whether to include drafts or not */ - drafts?: boolean; + drafts?: z.infer<typeof rssOptionsValidator>['drafts']; }; type RSSFeedItem = { /** Link to item */ link: string; + /** Full content of the item. Should be valid HTML */ + content?: string; /** Title of item */ - title: string; + title: z.infer<typeof rssSchema>['title']; /** Publication date of item */ - pubDate: Date; + pubDate: z.infer<typeof rssSchema>['pubDate']; /** Item description */ - description?: string; - /** Full content of the item, should be valid HTML */ - content?: string; + description?: z.infer<typeof rssSchema>['description']; /** Append some other XML-valid data to this item */ - customData?: string; + customData?: z.infer<typeof rssSchema>['customData']; /** Whether draft or not */ - draft?: boolean; + draft?: z.infer<typeof rssSchema>['draft']; }; -type GenerateRSSArgs = { - rssOptions: RSSOptions; - items: RSSFeedItem[]; -}; +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()))); +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); -function isGlobResult(items: RSSOptions['items']): items is GlobResult { - return typeof items === 'object' && !items.length; + 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( + [ + `[RSS] Invalid or missing options:`, + ...parsedResult.error.errors.map((zodError) => zodError.message), + ].join('\n') + ); + throw formattedError; } -function mapGlobResult(items: GlobResult): Promise<RSSFeedItem[]> { +export function pagesGlobToRssItems(items: GlobResult): Promise<ValidatedRSSFeedItem[]> { return Promise.all( - Object.values(items).map(async (getInfo) => { + Object.entries(items).map(async ([filePath, getInfo]) => { const { url, frontmatter } = await getInfo(); if (url === undefined || url === null) { 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)) { - throw new Error(`[RSS] "${url}" is missing a "title" and/or "pubDate" in its frontmatter.`); + const parsedResult = rssFeedItemValidator.safeParse( + { ...frontmatter, link: url }, + { errorMap } + ); + + if (parsedResult.success) { + return parsedResult.data; } - return { - link: url, - title: frontmatter.title, - pubDate: frontmatter.pubDate, - description: frontmatter.description, - customData: frontmatter.customData, - draft: frontmatter.draft, - }; + 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; }) ); } -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 */ -export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promise<string> { +async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> { const { site } = rssOptions; + const items = rssOptions.drafts + ? rssOptions.items + : rssOptions.items.filter((item) => !item.draft); + const xmlOptions = { ignoreAttributes: false }; const parser = new XMLParser(xmlOptions); const root: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' } }; @@ -149,7 +180,6 @@ export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promi ); // items root.rss.channel.item = items.map((result) => { - validate(result); // If the item's link is already a valid URL, don't mess with it. const itemLink = isValidURL(result.link) ? result.link @@ -163,12 +193,6 @@ export async function generateRSS({ rssOptions, items }: GenerateRSSArgs): Promi item.description = result.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'); - } item.pubDate = result.pubDate.toUTCString(); } // 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); } - -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.` - ); - } - } -} diff --git a/packages/astro-rss/src/schema.ts b/packages/astro-rss/src/schema.ts new file mode 100644 index 000000000000..b24a1441f28c --- /dev/null +++ b/packages/astro-rss/src/schema.ts @@ -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(), +}); diff --git a/packages/astro-rss/src/util.ts b/packages/astro-rss/src/util.ts index 7218cc9341d8..ad0e40a68ccf 100644 --- a/packages/astro-rss/src/util.ts +++ b/packages/astro-rss/src/util.ts @@ -1,3 +1,5 @@ +import { z } from 'astro/zod'; + /** 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 @@ -21,3 +23,17 @@ function getUrlExtension(url: string) { const lastSlash = url.lastIndexOf('/'); 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 }; +}; diff --git a/packages/astro-rss/test/pagesGlobToRssItems.test.js b/packages/astro-rss/test/pagesGlobToRssItems.test.js new file mode 100644 index 000000000000..82af5ba125e2 --- /dev/null +++ b/packages/astro-rss/test/pagesGlobToRssItems.test.js @@ -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; + }); +}); diff --git a/packages/astro-rss/test/rss.test.js b/packages/astro-rss/test/rss.test.js index bd4c9cba789a..744471f8cd9a 100644 --- a/packages/astro-rss/test/rss.test.js +++ b/packages/astro-rss/test/rss.test.js @@ -2,43 +2,20 @@ import rss from '../dist/index.js'; import chai from 'chai'; import chaiPromises from 'chai-as-promised'; import chaiXml from 'chai-xml'; +import { + title, + description, + site, + phpFeedItem, + phpFeedItemWithContent, + phpFeedItemWithCustomData, + web1FeedItem, + web1FeedItemWithContent, +} from './test-utils.js'; chai.use(chaiPromises); 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 // ...and I gave up. Enjoy big strings! // prettier-ignore @@ -115,244 +92,63 @@ describe('rss', () => { chai.expect(body).xml.to.equal(validXmlWithXSLStylesheet); }); - 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, - site, - }); - - chai.expect(body).xml.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, - 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 entries marked as `draft`', async () => { + const { body } = await rss({ + title, + description, + items: [phpFeedItem, { ...web1FeedItem, draft: true }], + site, }); - 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); + }); - chai.expect(body).xml.to.equal(validXmlWithoutWeb1FeedResult); + it('should respect drafts option', async () => { + const { body } = await rss({ + title, + description, + drafts: true, + items: [phpFeedItem, { ...web1FeedItem, draft: true }], + site, + drafts: true, }); - 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); - }); + chai.expect(body).xml.to.equal(validXmlResult); }); - describe('errors', () => { - it('should provide a error message when a "site" option is missing', async () => { - try { - await rss({ - title, - description, - items: [phpFeedItem, web1FeedItem], - }); - - 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 () => { - try { - await rss({ - title: 'Your Website Title', - description: 'Your Website Description', - site: 'https://astro-demo', - items: [ - { - pubDate: new Date(), - title: 'Some title', - slug: 'foo', + it('Deprecated import.meta.glob mapping still works', async () => { + const globResult = { + './posts/php.md': () => + new Promise((resolve) => + resolve({ + url: phpFeedItem.link, + frontmatter: { + title: phpFeedItem.title, + pubDate: phpFeedItem.pubDate, + description: phpFeedItem.description, }, - ], - }); - chai.expect(false).to.equal(true, 'Should have errored'); - } catch (err) { - chai.expect(err.message).to.contain('Required field [link] is missing'); - } - }); - - it('should provide a good error message when passing glob result form outside pages/', async () => { - 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, - }, - }) - ), - }; + }) + ), + './posts/nested/web1.md': () => + new Promise((resolve) => + resolve({ + url: web1FeedItem.link, + 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' - ); - } + const { body } = await rss({ + title, + description, + items: globResult, + site, }); + + chai.expect(body).xml.to.equal(validXmlResult); }); }); diff --git a/packages/astro-rss/test/test-utils.js b/packages/astro-rss/test/test-utils.js new file mode 100644 index 000000000000..37f95214bb0e --- /dev/null +++ b/packages/astro-rss/test/test-utils.js @@ -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>`, +}; diff --git a/packages/astro-rss/tsconfig.json b/packages/astro-rss/tsconfig.json index 06900f0f0953..563e8cbfe186 100644 --- a/packages/astro-rss/tsconfig.json +++ b/packages/astro-rss/tsconfig.json @@ -5,6 +5,7 @@ "allowJs": true, "module": "ES2020", "outDir": "./dist", - "target": "ES2020" + "target": "ES2020", + "strictNullChecks": true } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7575f4441719..74a49a88f68f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -578,9 +578,11 @@ importers: chai-as-promised: ^7.1.1 chai-xml: ^0.4.0 fast-xml-parser: ^4.0.8 + kleur: ^4.1.5 mocha: ^9.2.2 dependencies: fast-xml-parser: 4.0.13 + kleur: 4.1.5 devDependencies: '@types/chai': 4.3.4 '@types/chai-as-promised': 7.1.5