aboutsummaryrefslogtreecommitdiff
path: root/packages/astro-rss
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro-rss')
-rw-r--r--packages/astro-rss/CHANGELOG.md415
-rw-r--r--packages/astro-rss/README.md316
-rw-r--r--packages/astro-rss/package.json39
-rw-r--r--packages/astro-rss/src/index.ts267
-rw-r--r--packages/astro-rss/src/schema.ts25
-rw-r--r--packages/astro-rss/src/util.ts53
-rw-r--r--packages/astro-rss/test/pagesGlobToRssItems.test.js122
-rw-r--r--packages/astro-rss/test/rss.test.js302
-rw-r--r--packages/astro-rss/test/test-utils.js69
-rw-r--r--packages/astro-rss/tsconfig.json7
10 files changed, 1615 insertions, 0 deletions
diff --git a/packages/astro-rss/CHANGELOG.md b/packages/astro-rss/CHANGELOG.md
new file mode 100644
index 000000000..540c4c6c6
--- /dev/null
+++ b/packages/astro-rss/CHANGELOG.md
@@ -0,0 +1,415 @@
+# @astrojs/rss
+
+## 4.0.12
+
+### Patch Changes
+
+- [#13867](https://github.com/withastro/astro/pull/13867) [`c947c28`](https://github.com/withastro/astro/commit/c947c28ae7c385c4bee0a2dd44006b9cd690f690) Thanks [@Adriel-M](https://github.com/Adriel-M)! - Fixes a missing type attribute when providing a XSLT stylesheet
+
+## 4.0.11
+
+### Patch Changes
+
+- [#12829](https://github.com/withastro/astro/pull/12829) [`ebe2aa9`](https://github.com/withastro/astro/commit/ebe2aa95c7f4a6559cec8b82d155da34a57bdd53) Thanks [@SapphicMoe](https://github.com/SapphicMoe)! - Revert incorrect Content-Type header applied for RSS XML file
+
+## 4.0.10
+
+### Patch Changes
+
+- [#12644](https://github.com/withastro/astro/pull/12644) [`5b9b618`](https://github.com/withastro/astro/commit/5b9b6181839d8ae0ad0a0d475257b7e09f748950) Thanks [@kunyan](https://github.com/kunyan)! - Sends the standard RSS content type response header, with UTF-8 charset
+
+## 4.0.9
+
+### Patch Changes
+
+- [#12157](https://github.com/withastro/astro/pull/12157) [`925cff3`](https://github.com/withastro/astro/commit/925cff31bc040874e73decd6a6b3a5ba84c60258) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Improves README configuration reference.
+
+## 4.0.8
+
+### Patch Changes
+
+- [#12137](https://github.com/withastro/astro/pull/12137) [`50dd88b`](https://github.com/withastro/astro/commit/50dd88bc6611243e3f1b2df643af6d0b551fe140) Thanks [@ArmandPhilippot](https://github.com/ArmandPhilippot)! - Fixes an error that occurred when the optional `pubDate` property was missing in an item.
+
+- [#12137](https://github.com/withastro/astro/pull/12137) [`50dd88b`](https://github.com/withastro/astro/commit/50dd88bc6611243e3f1b2df643af6d0b551fe140) Thanks [@ArmandPhilippot](https://github.com/ArmandPhilippot)! - Fixes an error where docs incorrectly stated the `title`, `link` and `pubDate` properties of RSS items was required.
+
+## 4.0.7
+
+### Patch Changes
+
+- [#11299](https://github.com/withastro/astro/pull/11299) [`8ce66f2`](https://github.com/withastro/astro/commit/8ce66f2ef7328546d823f1076f9bab4217a6be7d) Thanks [@ematipico](https://github.com/ematipico)! - Fixes an issue where the `pagesGlobToRssItems` returned an incorrect type for `items`
+
+## 4.0.6
+
+### Patch Changes
+
+- [#11050](https://github.com/withastro/astro/pull/11050) [`841df1f`](https://github.com/withastro/astro/commit/841df1f1b192f39849509cda49b7243940cc30f9) Thanks [@mingjunlu](https://github.com/mingjunlu)! - Fixes an issue where trailing slash is not removed even if the `trailingSlash` option is set to `false`.
+
+## 4.0.5
+
+### Patch Changes
+
+- [#9967](https://github.com/withastro/astro/pull/9967) [`8b8f26fdf2af2a769f4846bdaaf4cf6b30f9e37c`](https://github.com/withastro/astro/commit/8b8f26fdf2af2a769f4846bdaaf4cf6b30f9e37c) Thanks [@madcampos](https://github.com/madcampos)! - Allows `enclosure' to have a length of 0
+
+## 4.0.4
+
+### Patch Changes
+
+- [#9797](https://github.com/withastro/astro/pull/9797) [`457e8b6422704ba23347c766a8bb9c101c2aba0b`](https://github.com/withastro/astro/commit/457e8b6422704ba23347c766a8bb9c101c2aba0b) Thanks [@wkillerud](https://github.com/wkillerud)! - Restores `rssSchema` to a zod object
+
+## 4.0.3
+
+### Patch Changes
+
+- [#9746](https://github.com/withastro/astro/pull/9746) [`7356336d18c916804001bdf64bff5445d82baceb`](https://github.com/withastro/astro/commit/7356336d18c916804001bdf64bff5445d82baceb) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Fixes `rssSchema` definition to allow calling standard zod object methods (like `extend`)
+
+## 4.0.2
+
+### Patch Changes
+
+- [#9610](https://github.com/withastro/astro/pull/9610) [`24663c9695385fed9ece57bf4aecdca3a8581e70`](https://github.com/withastro/astro/commit/24663c9695385fed9ece57bf4aecdca3a8581e70) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Fixes the RSS schema to make the `title` optional if the description is already provided. It also makes `pubDate` and `link` optional, as specified in the RSS specification.
+
+## 4.0.1
+
+### Patch Changes
+
+- [#9299](https://github.com/withastro/astro/pull/9299) [`edfae50e6`](https://github.com/withastro/astro/commit/edfae50e6ea494f49c6d4fbf4bd4481870f994b1) Thanks [@cdvillard](https://github.com/cdvillard)! - Improves the `@astrojs/rss` error message thrown when the object passed to the `items` property is missing any of the three required keys or if one of those keys is mistyped.
+
+## 4.0.0
+
+### Major Changes
+
+- [#9168](https://github.com/withastro/astro/pull/9168) [`153a5abb9`](https://github.com/withastro/astro/commit/153a5abb905042ac68b712514dc9ec387d3e6b17) Thanks [@bluwy](https://github.com/bluwy)! - Removes the deprecated (in v3.0) `drafts` option as the feature is deprecated in Astro 3.0
+
+## 4.0.0-beta.0
+
+### Major Changes
+
+- [#9168](https://github.com/withastro/astro/pull/9168) [`153a5abb9`](https://github.com/withastro/astro/commit/153a5abb905042ac68b712514dc9ec387d3e6b17) Thanks [@bluwy](https://github.com/bluwy)! - Removes the `drafts` option as the feature is deprecated in Astro 3.0
+
+## 3.0.0
+
+### Major Changes
+
+- [#8188](https://github.com/withastro/astro/pull/8188) [`d0679a666`](https://github.com/withastro/astro/commit/d0679a666f37da0fca396d42b9b32bbb25d29312) Thanks [@ematipico](https://github.com/ematipico)! - Remove support for Node 16. The lowest supported version by Astro and all integrations is now v18.14.1. As a reminder, Node 16 will be deprecated on the 11th September 2023.
+
+- [#8179](https://github.com/withastro/astro/pull/8179) [`6011d52d3`](https://github.com/withastro/astro/commit/6011d52d38e43c3e3d52bc3bc41a60e36061b7b7) Thanks [@matthewp](https://github.com/matthewp)! - Astro 3.0 Release Candidate
+
+- [#8198](https://github.com/withastro/astro/pull/8198) [`cb95aa5f8`](https://github.com/withastro/astro/commit/cb95aa5f8e0b04eba1a56e3e4a7901d40f1c854b) Thanks [@bluwy](https://github.com/bluwy)! - Update the `rss()` default export to return a `Response` instead of a simple object, which is deprecated in Astro 3.0. If you were directly returning the `rss()` result from an endpoint before, this breaking change should not affect you.
+
+ You can also import `getRssString()` to get the RSS string directly and use it to return your own Response:
+
+ ```ts
+ // src/pages/rss.xml.js
+ import { getRssString } from '@astrojs/rss';
+
+ export async function get(context) {
+ const rssString = await getRssString({
+ title: 'Buzz’s Blog',
+ ...
+ });
+
+ return new Response(rssString, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ },
+ });
+ }
+ ```
+
+### Patch Changes
+
+- [#8099](https://github.com/withastro/astro/pull/8099) [`732111cdc`](https://github.com/withastro/astro/commit/732111cdce441639db31f40f621df48442d00969) Thanks [@bluwy](https://github.com/bluwy)! - Deprecate the `markdown.drafts` configuration option.
+
+ If you'd like to create draft pages that are visible in dev but not in production, you can [migrate to content collections](https://docs.astro.build/en/guides/content-collections/#migrating-from-file-based-routing) and [manually filter out pages](https://docs.astro.build/en/guides/content-collections/#filtering-collection-queries) with the `draft: true` frontmatter property instead.
+
+## 3.0.0-rc.2
+
+### Major Changes
+
+- [#8198](https://github.com/withastro/astro/pull/8198) [`cb95aa5f8`](https://github.com/withastro/astro/commit/cb95aa5f8e0b04eba1a56e3e4a7901d40f1c854b) Thanks [@bluwy](https://github.com/bluwy)! - Update the `rss()` default export to return a `Response` instead of a simple object, which is deprecated in Astro 3.0. If you were directly returning the `rss()` result from an endpoint before, this breaking change should not affect you.
+
+ You can also import `getRssString()` to get the RSS string directly and use it to return your own Response:
+
+ ```ts
+ // src/pages/rss.xml.js
+ import { getRssString } from '@astrojs/rss';
+
+ export async function get(context) {
+ const rssString = await getRssString({
+ title: 'Buzz’s Blog',
+ ...
+ });
+
+ return new Response(rssString, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ },
+ });
+ }
+ ```
+
+## 3.0.0-rc.1
+
+### Major Changes
+
+- [#8179](https://github.com/withastro/astro/pull/8179) [`6011d52d3`](https://github.com/withastro/astro/commit/6011d52d38e43c3e3d52bc3bc41a60e36061b7b7) Thanks [@matthewp](https://github.com/matthewp)! - Astro 3.0 Release Candidate
+
+### Patch Changes
+
+- [#8099](https://github.com/withastro/astro/pull/8099) [`732111cdc`](https://github.com/withastro/astro/commit/732111cdce441639db31f40f621df48442d00969) Thanks [@bluwy](https://github.com/bluwy)! - Deprecate the `markdown.drafts` configuration option.
+
+ If you'd like to create draft pages that are visible in dev but not in production, you can [migrate to content collections](https://docs.astro.build/en/guides/content-collections/#migrating-from-file-based-routing) and [manually filter out pages](https://docs.astro.build/en/guides/content-collections/#filtering-collection-queries) with the `draft: true` frontmatter property instead.
+
+## 3.0.0-beta.0
+
+### Major Changes
+
+- [`1eae2e3f7`](https://github.com/withastro/astro/commit/1eae2e3f7d693c9dfe91c8ccfbe606d32bf2fb81) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Remove support for Node 16. The lowest supported version by Astro and all integrations is now v18.14.1. As a reminder, Node 16 will be deprecated on the 11th September 2023.
+
+## 2.4.4
+
+### Patch Changes
+
+- [#7964](https://github.com/withastro/astro/pull/7964) [`51028f85c`](https://github.com/withastro/astro/commit/51028f85c68944872a65b4bc0b8fcb6c3f3cf496) Thanks [@DerTimonius](https://github.com/DerTimonius)! - Add URL to RSSOptions.site type
+
+## 2.4.3
+
+### Patch Changes
+
+- [#7153](https://github.com/withastro/astro/pull/7153) [`e17ed0727`](https://github.com/withastro/astro/commit/e17ed0727ef1acb512c77723a1b641326de8ca84) Thanks [@AkashRajpurohit](https://github.com/AkashRajpurohit)! - exposes RSSFeedItem type
+
+## 2.4.2
+
+### Patch Changes
+
+- [#7066](https://github.com/withastro/astro/pull/7066) [`a37e67b52`](https://github.com/withastro/astro/commit/a37e67b520dc35dbf40313c77490a97446de2f74) Thanks [@TheOtterlord](https://github.com/TheOtterlord)! - Fix pubDate schema transformation
+
+- [#7104](https://github.com/withastro/astro/pull/7104) [`826e02890`](https://github.com/withastro/astro/commit/826e0289005f645b902375b98d5549c6a95ccafa) Thanks [@bluwy](https://github.com/bluwy)! - Specify `"files"` field to only publish necessary files
+
+## 2.4.1
+
+### Patch Changes
+
+- [#6970](https://github.com/withastro/astro/pull/6970) [`b5482cee2`](https://github.com/withastro/astro/commit/b5482cee2387149ff397447e546130ba3dea58db) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix: remove accidental stripping of trailing `/1/` on canonical URLs
+
+## 2.4.0
+
+### Minor Changes
+
+- [#6707](https://github.com/withastro/astro/pull/6707) [`4ea716e56`](https://github.com/withastro/astro/commit/4ea716e5692d23361e9301330ce52733b3d05b01) Thanks [@philnash](https://github.com/philnash)! - Added extra elements to the RSS items, including categories and enclosure
+
+## 2.3.2
+
+### Patch Changes
+
+- [#6614](https://github.com/withastro/astro/pull/6614) [`b1b9b1390`](https://github.com/withastro/astro/commit/b1b9b1390f95c6ae91389eba55f7563b911bccc7) Thanks [@aivarsliepa](https://github.com/aivarsliepa)! - Fixes `RSSOptions` type error when using `strictest` Typescript tsconfig
+
+## 2.3.1
+
+### Patch Changes
+
+- [#6538](https://github.com/withastro/astro/pull/6538) [`400ef26c9`](https://github.com/withastro/astro/commit/400ef26c998a586b29c2f3931e63c1c5801d3bea) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Preserve self-closing tags in `customData` option
+
+## 2.3.0
+
+### Minor Changes
+
+- [#6453](https://github.com/withastro/astro/pull/6453) [`2e362042c`](https://github.com/withastro/astro/commit/2e362042c222298fd6cd80a64c1d7b7f3f608a79) Thanks [@ematipico](https://github.com/ematipico)! - Added `trailingSlash` option to control whether or not the emitted URLs should have trailing slashes.
+
+ ```js
+ import rss from '@astrojs/rss';
+
+ export const get = () =>
+ rss({
+ trailingSlash: false,
+ });
+ ```
+
+ By passing `false`, the emitted links won't have trailing slashes.
+
+## 2.2.0
+
+### Minor Changes
+
+- [#6213](https://github.com/withastro/astro/pull/6213) [`afbbc4d5b`](https://github.com/withastro/astro/commit/afbbc4d5bfafc1779bac00b41c2a1cb1c90f2808) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Updated compilation settings to disable downlevelling for Node 14
+
+## 2.1.1
+
+### Patch Changes
+
+- [#6259](https://github.com/withastro/astro/pull/6259) [`dbffee4e3`](https://github.com/withastro/astro/commit/dbffee4e381e74882734039783fae312d3893f2a) Thanks [@y-nk](https://github.com/y-nk)! - Improve RSS schema errors with additional property name context
+
+## 2.1.0
+
+### Minor Changes
+
+- [#5851](https://github.com/withastro/astro/pull/5851) [`81dce94f2`](https://github.com/withastro/astro/commit/81dce94f2a6db598bd9e47fc2a4b9d713e58f286) Thanks [@bholmesdev](https://github.com/bholmesdev)! - 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 };
+ ```
+
+## 2.1.0-beta.0
+
+<details>
+<summary>See changes in 2.1.0-beta.0</summary>
+
+### Minor Changes
+
+- [#5851](https://github.com/withastro/astro/pull/5851) [`81dce94f2`](https://github.com/withastro/astro/commit/81dce94f2a6db598bd9e47fc2a4b9d713e58f286) Thanks [@bholmesdev](https://github.com/bholmesdev)! - 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 };
+ ```
+
+</details>
+
+## 2.0.0
+
+### Major Changes
+
+- [#5612](https://github.com/withastro/astro/pull/5612) [`68c20be66`](https://github.com/withastro/astro/commit/68c20be66b197e6c525cd292823a3a728f238547) Thanks [@equt](https://github.com/equt)! - Filter out draft in RSS generation
+
+## 1.2.1
+
+### Patch Changes
+
+- [#5600](https://github.com/withastro/astro/pull/5600) [`c4155daea`](https://github.com/withastro/astro/commit/c4155daeabe1b8191ad9ed1fa5893759f1fe5c4c) Thanks [@fflaten](https://github.com/fflaten)! - Fix missing type-attribute in xml-stylesheet
+
+## 1.2.0
+
+### Minor Changes
+
+- [`c76e1c810`](https://github.com/withastro/astro/commit/c76e1c810228fb53cd9c34edc73747b0ab64dc28) Thanks [@mattstein](https://github.com/mattstein)! - Fixes a bug that prevented an item’s `customData` from being included.
+
+## 1.1.0
+
+### Minor Changes
+
+- [#5366](https://github.com/withastro/astro/pull/5366) [`081e0a9d2`](https://github.com/withastro/astro/commit/081e0a9d2070b23d596b687ad52ed3a68bc3ac24) Thanks [@smithbm2316](https://github.com/smithbm2316)! - Added the ability for users to include the full content of their posts/items in each RSS feed entry
+ via the new `content` key on the `RSSFeedItem` model.
+
+### Patch Changes
+
+- [#5550](https://github.com/withastro/astro/pull/5550) [`fe0da0185`](https://github.com/withastro/astro/commit/fe0da0185a85762ac5ac5bf66ea91947af1c329d) Thanks [@andersk](https://github.com/andersk)! - Generate RSS feed with proper XML escaping
+
+## 1.0.3
+
+### Patch Changes
+
+- [#5164](https://github.com/withastro/astro/pull/5164) [`4a8a346ca`](https://github.com/withastro/astro/commit/4a8a346ca9a6d6ed8def2fa32329c1db922893d2) Thanks [@MoustaphaDev](https://github.com/MoustaphaDev)! - Add support for markdown files with the following extensions:
+ - `.markdown`
+ - `.mdown`
+ - `.mkdn`
+ - `.mkd`
+ - `.mdwn`
+
+## 1.0.2
+
+### Patch Changes
+
+- [#4842](https://github.com/withastro/astro/pull/4842) [`812658ad2`](https://github.com/withastro/astro/commit/812658ad2ab3732a99e35c4fd903e302e723db46) Thanks [@bluwy](https://github.com/bluwy)! - Add missing dependencies, support strict dependency installation (e.g. pnpm)
+
+- [#4842](https://github.com/withastro/astro/pull/4842) [`812658ad2`](https://github.com/withastro/astro/commit/812658ad2ab3732a99e35c4fd903e302e723db46) Thanks [@bluwy](https://github.com/bluwy)! - Remove path-browserify dependency
+
+## 1.0.1
+
+### Patch Changes
+
+- [#4701](https://github.com/withastro/astro/pull/4701) [`6e1d62fe2`](https://github.com/withastro/astro/commit/6e1d62fe222e45b763b2b60b377b07e431950d54) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix globs for homepage route
+
+## 1.0.0
+
+### Major Changes
+
+- [`04ad44563`](https://github.com/withastro/astro/commit/04ad445632c67bdd60c1704e1e0dcbcaa27b9308) - > Astro v1.0 is out! Read the [official announcement post](https://astro.build/blog/astro-1/).
+
+ **No breaking changes**. This package is now officially stable and compatible with `astro@1.0.0`!
+
+## 0.2.2
+
+### Patch Changes
+
+- [#3956](https://github.com/withastro/astro/pull/3956) [`57e529e4c`](https://github.com/withastro/astro/commit/57e529e4c13f3e7829311ac6f92682eb6333fd96) Thanks [@esafev](https://github.com/esafev)! - Throw the error when 'site' option is missing
+
+## 0.2.1
+
+### Patch Changes
+
+- [#3913](https://github.com/withastro/astro/pull/3913) [`cd2dbfedb`](https://github.com/withastro/astro/commit/cd2dbfedb15969274df40b1c41b6680ea8885e8d) Thanks [@matthewp](https://github.com/matthewp)! - Adds error messages for missing required fields
+
+## 0.2.0
+
+### Minor Changes
+
+- [#3301](https://github.com/withastro/astro/pull/3301) [`0efaf110`](https://github.com/withastro/astro/commit/0efaf110fceba149cd41cbaa0f37311e6887cdec) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Change the optional "canonicalUrl" argument to a required "site" argument. This fixes problems with import.meta.env.SITE. If you want to use your project's "site" field for your RSS feeds, set site: import.meta.env.SITE in the rss function options
+
+## 0.1.1
+
+### Patch Changes
+
+- [`1032e450`](https://github.com/withastro/astro/commit/1032e450cc224e603e8e69ef1422de6dbf184dd2) Thanks [@FredKSchott](https://github.com/FredKSchott)! - 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..ac9d81c47
--- /dev/null
+++ b/packages/astro-rss/README.md
@@ -0,0 +1,316 @@
+# @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 and use
+
+See the [`@astrojs/rss` guide in the Astro docs][docs] for installation and usage examples.
+
+## `rss()` configuration options
+
+The `rss()` utility function offers a number of configuration options to generate your feed.
+
+### 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.
+
+### site
+
+Type: `string (required)`
+
+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[] (required)`
+
+A list of formatted RSS feed items.
+
+An `RSSFeedItem` is a single item in the list of items in your feed. An example feed item might look like:
+
+```js
+const item = {
+ title: 'Alpha Centauri: so close you can touch it',
+ link: '/blog/alpha-centuari',
+ pubDate: new Date('2023-06-04'),
+ description:
+ 'Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.',
+ categories: ['stars', 'space'],
+};
+```
+
+#### `title`
+
+Type: `string (optional)`
+
+The title of the item in the feed. Optional only if a description is set. Otherwise, required.
+
+#### `link`
+
+Type: `string (optional)`
+
+The URL of the item on the web.
+
+#### `pubDate`
+
+Type: `Date (optional)`
+
+Indicates when the item was published.
+
+#### `description`
+
+Type: `string (optional)`
+
+A synopsis of your item when you are publishing the full content of the item in the `content` field. The `description` may alternatively be the full content of the item in the feed if you are not using the `content` field (entity-coded HTML is permitted). Optional only if a title is set. Otherwise, required.
+
+#### `content`
+
+Type: `string (optional)`
+
+The full text content of the item suitable for presentation as HTML. If used, you should also provide a short article summary in the `description` field.
+
+To render Markdown content from a glob result or from a content collection, see the [content rendering guide](https://docs.astro.build/en/guides/rss/#including-full-post-content).
+
+#### `categories`
+
+Type: `string[] (optional)`
+
+A list of any tags or categories to categorize your content. They will be output as multiple `<category>` elements.
+
+#### `author`
+
+Type: `string (optional)`
+
+The email address of the item author. This is useful for indicating the author of a post on multi-author blogs.
+
+#### `commentsUrl`
+
+Type: `string (optional)`
+
+The URL of a web page that contains comments on the item.
+
+#### `source`
+
+Type: `{ title: string, url: string } (optional)`
+
+An object that defines the `title` and `url` of the original feed for items that have been republished from another source. Both are required properties of `source` for proper attribution.
+
+```js
+const item = {
+ title: 'Alpha Centauri: so close you can touch it',
+ link: '/blog/alpha-centuari',
+ pubDate: new Date('2023-06-04'),
+ description:
+ 'Alpha Centauri is a triple star system, containing Proxima Centauri, the closest star to our sun at only 4.24 light-years away.',
+ source: {
+ title: 'The Galactic Times',
+ url: 'https://galactictimes.space/feed.xml',
+ },
+};
+```
+
+#### `enclosure`
+
+Type: `{ url: string, type: string, length: number } (optional)`
+
+An object to specify properties for an included media source (e.g. a podcast) with three required values: `url`, `length`, and `type`.
+
+```js
+const item = {
+ /* ... */
+ enclosure: {
+ url: '/media/alpha-centauri.aac',
+ length: 124568,
+ type: 'audio/aac',
+ },
+};
+```
+
+- `enclosure.url` is the URL where the media can be found. If the media is hosted outside of your own domain you must provide a full URL.
+- `enclosure.length` is the size of the file found at the `url` in bytes.
+- `enclosure.type` is the [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) for the media item found at the `url`.
+
+### 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 can be used to pass additional data outside of the standard RSS spec, and 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 values on the opening `<rss>` tag.
+
+Suffixes expand the available XML tags in your RSS feed, so your content may be read by third-party sources like podcast services or blogging platforms. You'll likely combine `xmlns` with the [`customData`](#customData) attribute to insert custom tags for a given platform.
+
+This example applies the `itunes` suffix to an RSS feed of podcasts, and uses `customData` to define tags for the author and episode details:
+
+```js
+rss({
+ // ...
+ xmlns: {
+ itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
+ },
+ customData: '<itunes:author>MF Doom</itunes:author>',
+ items: episodes.map((episode) => ({
+ // ...
+ customData:
+ `<itunes:episodeType>${episode.frontmatter.type}</itunes:episodeType>` +
+ `<itunes:duration>${episode.frontmatter.duration}</itunes:duration>` +
+ `<itunes:explicit>${episode.frontmatter.explicit || false}</itunes:explicit>`,
+ })),
+});
+```
+
+### `trailingSlash`
+
+Type: `boolean (optional)`
+Default: `true`
+
+By default, trailing slashes will be added to the URLs of your feed entries. To prevent this behavior, add `trailingSlash: false` to the `rss` function.
+
+```js
+import rss from '@astrojs/rss';
+
+export const GET = () =>
+ rss({
+ trailingSlash: false,
+ });
+```
+
+## The `rssSchema` validator
+
+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() }),
+});
+```
+
+## The `pagesGlobToRssItems()` function
+
+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://vite.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}')),
+ });
+}
+```
+
+## The `getRssString()` function
+
+As `rss()` returns a `Response`, you can also use `getRssString()` to get the RSS string directly and use it in your own response:
+
+```ts "getRssString"
+// src/pages/rss.xml.js
+import { getRssString } from '@astrojs/rss';
+
+export async function GET(context) {
+ const rssString = await getRssString({
+ title: 'Buzz’s Blog',
+ ...
+ });
+
+ return new Response(rssString, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ },
+ });
+}
+```
+
+## Support
+
+- Get help in the [Astro Discord][discord]. Post questions in our `#support` forum, or visit our dedicated `#dev` channel to discuss current development and more!
+
+- Check our [Astro Integration Documentation][astro-integration] for more on integrations.
+
+- Submit bug reports and feature requests as [GitHub issues][issues].
+
+## Contributing
+
+This package is maintained by Astro's Core team. You're welcome to submit an issue or PR! These links will help you get started:
+
+- [Contributor Manual][contributing]
+- [Code of Conduct][coc]
+- [Community Guide][community]
+
+## License
+
+MIT
+
+Copyright (c) 2023–present [Astro][astro]
+
+[docs]: https://docs.astro.build/en/guides/rss/
+[astro-endpoints]: https://docs.astro.build/en/core-concepts/astro-pages/#non-html-pages
+[astro]: https://astro.build/
+[docs]: https://docs.astro.build/en/guides/integrations-guide/alpinejs/
+[contributing]: https://github.com/withastro/astro/blob/main/CONTRIBUTING.md
+[coc]: https://github.com/withastro/.github/blob/main/CODE_OF_CONDUCT.md
+[community]: https://github.com/withastro/.github/blob/main/COMMUNITY_GUIDE.md
+[discord]: https://astro.build/chat/
+[issues]: https://github.com/withastro/astro/issues
+[astro-integration]: https://docs.astro.build/en/guides/integrations-guide/
diff --git a/packages/astro-rss/package.json b/packages/astro-rss/package.json
new file mode 100644
index 000000000..b1d96befa
--- /dev/null
+++ b/packages/astro-rss/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@astrojs/rss",
+ "description": "Add RSS feeds to your Astro projects",
+ "version": "4.0.12",
+ "type": "module",
+ "types": "./dist/index.d.ts",
+ "author": "withastro",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+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"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "astro-scripts build \"src/**/*.ts\" && tsc",
+ "build:ci": "astro-scripts build \"src/**/*.ts\"",
+ "dev": "astro-scripts dev \"src/**/*.ts\"",
+ "test": "astro-scripts test \"test/**/*.test.js\""
+ },
+ "devDependencies": {
+ "@types/xml2js": "^0.4.14",
+ "astro": "workspace:*",
+ "astro-scripts": "workspace:*",
+ "xml2js": "0.6.2"
+ },
+ "dependencies": {
+ "fast-xml-parser": "^5.2.0",
+ "kleur": "^4.1.5"
+ }
+}
diff --git a/packages/astro-rss/src/index.ts b/packages/astro-rss/src/index.ts
new file mode 100644
index 000000000..5ce459a3c
--- /dev/null
+++ b/packages/astro-rss/src/index.ts
@@ -0,0 +1,267 @@
+import { z } from 'astro/zod';
+import { XMLBuilder, XMLParser } from 'fast-xml-parser';
+import { yellow } from 'kleur/colors';
+import { rssSchema } from './schema.js';
+import { createCanonicalURL, errorMap, isValidURL } from './util.js';
+
+export { rssSchema };
+
+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 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'] | URL;
+ /** List of RSS feed items to render. */
+ items: RSSFeedItem[] | GlobResult;
+ /** Specify arbitrary metadata on opening <xml> tag */
+ xmlns?: z.infer<typeof rssOptionsValidator>['xmlns'];
+ /**
+ * Specifies a local custom XSL stylesheet. Ex. '/public/custom-feed.xsl'
+ */
+ stylesheet?: z.infer<typeof rssOptionsValidator>['stylesheet'];
+ /** Specify custom data in opening of file */
+ customData?: z.infer<typeof rssOptionsValidator>['customData'];
+ trailingSlash?: z.infer<typeof rssOptionsValidator>['trailingSlash'];
+};
+
+export type RSSFeedItem = {
+ /** Link to item */
+ link?: z.infer<typeof rssSchema>['link'];
+ /** Full content of the item. Should be valid HTML */
+ content?: z.infer<typeof rssSchema>['content'];
+ /** 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 */
+ customData?: z.infer<typeof rssSchema>['customData'];
+ /** Categories or tags related to the item */
+ categories?: z.infer<typeof rssSchema>['categories'];
+ /** The item author's email address */
+ author?: z.infer<typeof rssSchema>['author'];
+ /** A URL of a page for comments related to the item */
+ commentsUrl?: z.infer<typeof rssSchema>['commentsUrl'];
+ /** The RSS channel that the item came from */
+ source?: z.infer<typeof rssSchema>['source'];
+ /** A media object that belongs to the item */
+ enclosure?: z.infer<typeof rssSchema>['enclosure'];
+};
+
+type ValidatedRSSFeedItem = z.infer<typeof rssSchema>;
+type ValidatedRSSOptions = z.infer<typeof rssOptionsValidator>;
+type GlobResult = z.infer<typeof globResultValidator>;
+
+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(rssSchema)
+ .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(),
+ stylesheet: z.union([z.string(), z.boolean()]).optional(),
+ customData: z.string().optional(),
+ trailingSlash: z.boolean().default(true),
+});
+
+export default async function getRssResponse(rssOptions: RSSOptions): Promise<Response> {
+ const rssString = await getRssString(rssOptions);
+ return new Response(rssString, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ },
+ });
+}
+
+export async function getRssString(rssOptions: RSSOptions): Promise<string> {
+ const validatedRssOptions = await validateRssOptions(rssOptions);
+ return 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) => {
+ const path = zodError.path.join('.');
+ const message = `${zodError.message} (${path})`;
+ const code = zodError.code;
+
+ if (path === 'items' && code === 'invalid_union') {
+ return [
+ message,
+ `The \`items\` property requires at least the \`title\` or \`description\` key. They must be properly typed, as well as \`pubDate\` and \`link\` keys if provided.`,
+ `Check your collection's schema, and visit https://docs.astro.build/en/guides/rss/#generating-items for more info.`,
+ ].join('\n');
+ }
+
+ return message;
+ }),
+ ].join('\n'),
+ );
+ throw formattedError;
+}
+
+export function pagesGlobToRssItems(items: GlobResult): Promise<ValidatedRSSFeedItem[]> {
+ return Promise.all(
+ Object.entries(items).map(async ([filePath, getInfo]) => {
+ const { url, frontmatter } = await getInfo();
+ if (url === undefined || url === null) {
+ throw new Error(
+ `[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`,
+ );
+ }
+ const parsedResult = rssSchema
+ .refine((val) => val.title || val.description, {
+ message: 'At least title or description must be provided.',
+ path: ['title', 'description'],
+ })
+ .safeParse({ ...frontmatter, link: url }, { errorMap });
+
+ if (parsedResult.success) {
+ return parsedResult.data;
+ }
+ 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;
+ }),
+ );
+}
+
+/** Generate RSS 2.0 feed */
+async function generateRSS(rssOptions: ValidatedRSSOptions): Promise<string> {
+ const { items, site } = rssOptions;
+
+ const xmlOptions = {
+ ignoreAttributes: false,
+ // Avoid correcting self-closing tags to standard tags
+ // when using `customData`
+ // https://github.com/withastro/astro/issues/5794
+ suppressEmptyNode: true,
+ suppressBooleanAttributes: false,
+ };
+ const parser = new XMLParser(xmlOptions);
+ const root: any = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' } };
+ if (typeof rssOptions.stylesheet === 'string') {
+ const isXSL = /\.xslt?$/i.test(rssOptions.stylesheet);
+ root['?xml-stylesheet'] = {
+ '@_href': rssOptions.stylesheet,
+ ...(isXSL && { '@_type': 'text/xsl' }),
+ };
+ }
+ root.rss = { '@_version': '2.0' };
+ if (items.find((result) => result.content)) {
+ // the namespace to be added to the xmlns:content attribute to enable the <content> RSS feature
+ const XMLContentNamespace = 'http://purl.org/rss/1.0/modules/content/';
+ root.rss['@_xmlns:content'] = XMLContentNamespace;
+ // Ensure that the user hasn't tried to manually include the necessary namespace themselves
+ if (rssOptions.xmlns?.content && rssOptions.xmlns.content === XMLContentNamespace) {
+ delete rssOptions.xmlns.content;
+ }
+ }
+
+ // xmlns
+ if (rssOptions.xmlns) {
+ for (const [k, v] of Object.entries(rssOptions.xmlns)) {
+ root.rss[`@_xmlns:${k}`] = v;
+ }
+ }
+
+ // title, description, customData
+ root.rss.channel = {
+ title: rssOptions.title,
+ description: rssOptions.description,
+ link: createCanonicalURL(site, rssOptions.trailingSlash, undefined),
+ };
+ if (typeof rssOptions.customData === 'string')
+ Object.assign(
+ root.rss.channel,
+ parser.parse(`<channel>${rssOptions.customData}</channel>`).channel,
+ );
+ // items
+ root.rss.channel.item = items.map((result) => {
+ const item: Record<string, unknown> = {};
+
+ if (result.title) {
+ item.title = result.title;
+ }
+ if (typeof result.link === 'string') {
+ // 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, rssOptions.trailingSlash, site);
+ item.link = itemLink;
+ item.guid = { '#text': itemLink, '@_isPermaLink': 'true' };
+ }
+ if (result.description) {
+ item.description = result.description;
+ }
+ if (result.pubDate) {
+ item.pubDate = result.pubDate.toUTCString();
+ }
+ // include the full content of the post if the user supplies it
+ if (typeof result.content === 'string') {
+ item['content:encoded'] = result.content;
+ }
+ if (typeof result.customData === 'string') {
+ Object.assign(item, parser.parse(`<item>${result.customData}</item>`).item);
+ }
+ if (Array.isArray(result.categories)) {
+ item.category = result.categories;
+ }
+ if (typeof result.author === 'string') {
+ item.author = result.author;
+ }
+ if (typeof result.commentsUrl === 'string') {
+ item.comments = isValidURL(result.commentsUrl)
+ ? result.commentsUrl
+ : createCanonicalURL(result.commentsUrl, rssOptions.trailingSlash, site);
+ }
+ if (result.source) {
+ item.source = parser.parse(
+ `<source url="${result.source.url}">${result.source.title}</source>`,
+ ).source;
+ }
+ if (result.enclosure) {
+ const enclosureURL = isValidURL(result.enclosure.url)
+ ? result.enclosure.url
+ : createCanonicalURL(result.enclosure.url, rssOptions.trailingSlash, site);
+ item.enclosure = parser.parse(
+ `<enclosure url="${enclosureURL}" length="${result.enclosure.length}" type="${result.enclosure.type}"/>`,
+ ).enclosure;
+ }
+ return item;
+ });
+
+ return new XMLBuilder(xmlOptions).build(root);
+}
diff --git a/packages/astro-rss/src/schema.ts b/packages/astro-rss/src/schema.ts
new file mode 100644
index 000000000..c4a0fec3f
--- /dev/null
+++ b/packages/astro-rss/src/schema.ts
@@ -0,0 +1,25 @@
+import { z } from 'astro/zod';
+
+export const rssSchema = z.object({
+ title: z.string().optional(),
+ description: z.string().optional(),
+ pubDate: z
+ .union([z.string(), z.number(), z.date()])
+ .transform((value) => new Date(value))
+ .refine((value) => !isNaN(value.getTime()))
+ .optional(),
+ customData: z.string().optional(),
+ categories: z.array(z.string()).optional(),
+ author: z.string().optional(),
+ commentsUrl: z.string().optional(),
+ source: z.object({ url: z.string().url(), title: z.string() }).optional(),
+ enclosure: z
+ .object({
+ url: z.string(),
+ length: z.number().nonnegative().int().finite(),
+ type: z.string(),
+ })
+ .optional(),
+ link: z.string().optional(),
+ content: z.string().optional(),
+});
diff --git a/packages/astro-rss/src/util.ts b/packages/astro-rss/src/util.ts
new file mode 100644
index 000000000..43de370dd
--- /dev/null
+++ b/packages/astro-rss/src/util.ts
@@ -0,0 +1,53 @@
+import type { z } from 'astro/zod';
+import type { RSSOptions } from './index.js';
+
+/** Normalize URL to its canonical form */
+export function createCanonicalURL(
+ url: string,
+ trailingSlash?: RSSOptions['trailingSlash'],
+ base?: string,
+): string {
+ let pathname = url.replace(/\/index.html$/, ''); // index.html is not canonical
+ if (!getUrlExtension(url)) {
+ // add trailing slash if there’s no extension or `trailingSlash` is true
+ pathname = pathname.replace(/\/*$/, '/');
+ }
+
+ pathname = pathname.replace(/\/+/g, '/'); // remove duplicate slashes (URL() won’t)
+
+ const canonicalUrl = new URL(pathname, base).href;
+ if (trailingSlash === false) {
+ // remove the trailing slash
+ return canonicalUrl.replace(/\/*$/, '');
+ }
+ return canonicalUrl;
+}
+
+/** Check if a URL is already valid */
+export function isValidURL(url: string): boolean {
+ try {
+ new URL(url);
+ return true;
+ } catch {}
+ return false;
+}
+
+function getUrlExtension(url: string) {
+ const lastDot = url.lastIndexOf('.');
+ 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 000000000..36613c96c
--- /dev/null
+++ b/packages/astro-rss/test/pagesGlobToRssItems.test.js
@@ -0,0 +1,122 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import { pagesGlobToRssItems } from '../dist/index.js';
+import { phpFeedItem, web1FeedItem } from './test-utils.js';
+
+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);
+ const expected = [
+ {
+ 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,
+ },
+ ];
+
+ assert.deepEqual(
+ items.sort((a, b) => a.pubDate - b.pubDate),
+ expected,
+ );
+ });
+
+ 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 assert.rejects(pagesGlobToRssItems(globResult));
+ });
+
+ it('should fail on missing "title" key and "description"', () => {
+ const globResult = {
+ './posts/php.md': () =>
+ new Promise((resolve) =>
+ resolve({
+ url: phpFeedItem.link,
+ frontmatter: {
+ title: undefined,
+ pubDate: phpFeedItem.pubDate,
+ description: undefined,
+ },
+ }),
+ ),
+ };
+ return assert.rejects(pagesGlobToRssItems(globResult));
+ });
+
+ it('should not fail on missing "title" key if "description" is present', () => {
+ const globResult = {
+ './posts/php.md': () =>
+ new Promise((resolve) =>
+ resolve({
+ url: phpFeedItem.link,
+ frontmatter: {
+ title: undefined,
+ pubDate: phpFeedItem.pubDate,
+ description: phpFeedItem.description,
+ },
+ }),
+ ),
+ };
+ return assert.doesNotReject(pagesGlobToRssItems(globResult));
+ });
+
+ it('should not fail on missing "description" key if "title" is present', () => {
+ const globResult = {
+ './posts/php.md': () =>
+ new Promise((resolve) =>
+ resolve({
+ url: phpFeedItem.link,
+ frontmatter: {
+ title: phpFeedItem.title,
+ pubDate: phpFeedItem.pubDate,
+ description: undefined,
+ },
+ }),
+ ),
+ };
+ return assert.doesNotReject(pagesGlobToRssItems(globResult));
+ });
+});
diff --git a/packages/astro-rss/test/rss.test.js b/packages/astro-rss/test/rss.test.js
new file mode 100644
index 000000000..04fc620ca
--- /dev/null
+++ b/packages/astro-rss/test/rss.test.js
@@ -0,0 +1,302 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import { z } from 'astro/zod';
+import rss, { getRssString } from '../dist/index.js';
+import { rssSchema } from '../dist/schema.js';
+import {
+ description,
+ parseXmlString,
+ phpFeedItem,
+ phpFeedItemWithContent,
+ phpFeedItemWithCustomData,
+ phpFeedItemWithoutDate,
+ site,
+ title,
+ web1FeedItem,
+ web1FeedItemWithAllData,
+ web1FeedItemWithContent,
+} from './test-utils.js';
+
+// note: I spent 30 minutes looking for a nice node-based snapshot tool
+// ...and I gave up. Enjoy big strings!
+
+// biome-ignore format: keep in one line
+const validXmlResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItem.title}]]></title><link>${site}${phpFeedItem.link}/</link><guid isPermaLink="true">${site}${phpFeedItem.link}/</guid><description><![CDATA[${phpFeedItem.description}]]></description><pubDate>${new Date(phpFeedItem.pubDate).toUTCString()}</pubDate></item><item><title><![CDATA[${web1FeedItem.title}]]></title><link>${site}${web1FeedItem.link}/</link><guid isPermaLink="true">${site}${web1FeedItem.link}/</guid><description><![CDATA[${web1FeedItem.description}]]></description><pubDate>${new Date(web1FeedItem.pubDate).toUTCString()}</pubDate></item></channel></rss>`;
+// biome-ignore format: keep in one line
+const validXmlWithContentResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItemWithContent.title}]]></title><link>${site}${phpFeedItemWithContent.link}/</link><guid isPermaLink="true">${site}${phpFeedItemWithContent.link}/</guid><description><![CDATA[${phpFeedItemWithContent.description}]]></description><pubDate>${new Date(phpFeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${phpFeedItemWithContent.content}]]></content:encoded></item><item><title><![CDATA[${web1FeedItemWithContent.title}]]></title><link>${site}${web1FeedItemWithContent.link}/</link><guid isPermaLink="true">${site}${web1FeedItemWithContent.link}/</guid><description><![CDATA[${web1FeedItemWithContent.description}]]></description><pubDate>${new Date(web1FeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${web1FeedItemWithContent.content}]]></content:encoded></item></channel></rss>`;
+// biome-ignore format: keep in one line
+const validXmlResultWithMissingDate = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItemWithoutDate.title}]]></title><link>${site}${phpFeedItemWithoutDate.link}/</link><guid isPermaLink="true">${site}${phpFeedItemWithoutDate.link}/</guid><description><![CDATA[${phpFeedItemWithoutDate.description}]]></description></item><item><title><![CDATA[${phpFeedItem.title}]]></title><link>${site}${phpFeedItem.link}/</link><guid isPermaLink="true">${site}${phpFeedItem.link}/</guid><description><![CDATA[${phpFeedItem.description}]]></description><pubDate>${new Date(phpFeedItem.pubDate).toUTCString()}</pubDate></item></channel></rss>`;
+// biome-ignore format: keep in one line
+const validXmlResultWithAllData = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItem.title}]]></title><link>${site}${phpFeedItem.link}/</link><guid isPermaLink="true">${site}${phpFeedItem.link}/</guid><description><![CDATA[${phpFeedItem.description}]]></description><pubDate>${new Date(phpFeedItem.pubDate).toUTCString()}</pubDate></item><item><title><![CDATA[${web1FeedItemWithAllData.title}]]></title><link>${site}${web1FeedItemWithAllData.link}/</link><guid isPermaLink="true">${site}${web1FeedItemWithAllData.link}/</guid><description><![CDATA[${web1FeedItemWithAllData.description}]]></description><pubDate>${new Date(web1FeedItemWithAllData.pubDate).toUTCString()}</pubDate><category>${web1FeedItemWithAllData.categories[0]}</category><category>${web1FeedItemWithAllData.categories[1]}</category><author>${web1FeedItemWithAllData.author}</author><comments>${web1FeedItemWithAllData.commentsUrl}</comments><source url="${web1FeedItemWithAllData.source.url}">${web1FeedItemWithAllData.source.title}</source><enclosure url="${site}${web1FeedItemWithAllData.enclosure.url}" length="${web1FeedItemWithAllData.enclosure.length}" type="${web1FeedItemWithAllData.enclosure.type}"/></item></channel></rss>`;
+// biome-ignore format: keep in one line
+const validXmlWithCustomDataResult = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link><item><title><![CDATA[${phpFeedItemWithCustomData.title}]]></title><link>${site}${phpFeedItemWithCustomData.link}/</link><guid isPermaLink="true">${site}${phpFeedItemWithCustomData.link}/</guid><description><![CDATA[${phpFeedItemWithCustomData.description}]]></description><pubDate>${new Date(phpFeedItemWithCustomData.pubDate).toUTCString()}</pubDate>${phpFeedItemWithCustomData.customData}</item><item><title><![CDATA[${web1FeedItemWithContent.title}]]></title><link>${site}${web1FeedItemWithContent.link}/</link><guid isPermaLink="true">${site}${web1FeedItemWithContent.link}/</guid><description><![CDATA[${web1FeedItemWithContent.description}]]></description><pubDate>${new Date(web1FeedItemWithContent.pubDate).toUTCString()}</pubDate><content:encoded><![CDATA[${web1FeedItemWithContent.content}]]></content:encoded></item></channel></rss>`;
+// biome-ignore format: keep in one line
+const validXmlWithStylesheet = `<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/feedstylesheet.css"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link></channel></rss>`;
+// biome-ignore format: keep in one line
+const validXmlWithXSLStylesheet = `<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/feedstylesheet.xsl" type="text/xsl"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link></channel></rss>`;
+// biome-ignore format: keep in one line
+const validXmlWithXSLTStylesheet = `<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/feedstylesheet.xslt" type="text/xsl"?><rss version="2.0"><channel><title><![CDATA[${title}]]></title><description><![CDATA[${description}]]></description><link>${site}/</link></channel></rss>`;
+
+function assertXmlDeepEqual(a, b) {
+ const parsedA = parseXmlString(a);
+ const parsedB = parseXmlString(b);
+
+ assert.equal(parsedA.err, null);
+ assert.equal(parsedB.err, null);
+ assert.deepEqual(parsedA.result, parsedB.result);
+}
+
+describe('rss', () => {
+ it('should return a response', async () => {
+ const response = await rss({
+ title,
+ description,
+ items: [phpFeedItem, web1FeedItem],
+ site,
+ });
+
+ const str = await response.text();
+
+ // NOTE: Chai used the below parser to perform the tests, but I have omitted it for now.
+ // parser = new xml2js.Parser({ trim: flag(this, 'deep') });
+
+ assertXmlDeepEqual(str, validXmlResult);
+
+ const contentType = response.headers.get('Content-Type');
+ assert.equal(contentType, 'application/xml');
+ });
+
+ it('should be the same string as getRssString', async () => {
+ const options = {
+ title,
+ description,
+ items: [phpFeedItem, web1FeedItem],
+ site,
+ };
+
+ const response = await rss(options);
+ const str1 = await response.text();
+ const str2 = await getRssString(options);
+
+ assert.equal(str1, str2);
+ });
+});
+
+describe('getRssString', () => {
+ it('should generate on valid RSSFeedItem array', async () => {
+ const str = await getRssString({
+ title,
+ description,
+ items: [phpFeedItem, web1FeedItem],
+ site,
+ });
+
+ assertXmlDeepEqual(str, validXmlResult);
+ });
+
+ it('should generate on valid RSSFeedItem array with HTML content included', async () => {
+ const str = await getRssString({
+ title,
+ description,
+ items: [phpFeedItemWithContent, web1FeedItemWithContent],
+ site,
+ });
+
+ assertXmlDeepEqual(str, validXmlWithContentResult);
+ });
+
+ it('should generate on valid RSSFeedItem array with missing date', async () => {
+ const str = await getRssString({
+ title,
+ description,
+ items: [phpFeedItemWithoutDate, phpFeedItem],
+ site,
+ });
+
+ assertXmlDeepEqual(str, validXmlResultWithMissingDate);
+ });
+
+ it('should generate on valid RSSFeedItem array with all RSS content included', async () => {
+ const str = await getRssString({
+ title,
+ description,
+ items: [phpFeedItem, web1FeedItemWithAllData],
+ site,
+ });
+
+ assertXmlDeepEqual(str, validXmlResultWithAllData);
+ });
+
+ it('should generate on valid RSSFeedItem array with custom data included', async () => {
+ const str = await getRssString({
+ xmlns: {
+ dc: 'http://purl.org/dc/elements/1.1/',
+ },
+ title,
+ description,
+ items: [phpFeedItemWithCustomData, web1FeedItemWithContent],
+ site,
+ });
+
+ assertXmlDeepEqual(str, validXmlWithCustomDataResult);
+ });
+
+ it('should include xml-stylesheet instruction when stylesheet is defined', async () => {
+ const str = await getRssString({
+ title,
+ description,
+ items: [],
+ site,
+ stylesheet: '/feedstylesheet.css',
+ });
+
+ assertXmlDeepEqual(str, validXmlWithStylesheet);
+ });
+
+ it('should include xml-stylesheet instruction with xsl type when stylesheet is set to xsl file', async () => {
+ const str = await getRssString({
+ title,
+ description,
+ items: [],
+ site,
+ stylesheet: '/feedstylesheet.xsl',
+ });
+
+ // xml2js doesn't parse processing instructions. Assert the type is present.
+ assert.equal(str.includes('type="text/xsl"'), true);
+ assertXmlDeepEqual(str, validXmlWithXSLStylesheet);
+ });
+
+ it('should include xml-stylesheet instruction with xslt type when stylesheet is set to xslt file', async () => {
+ const str = await getRssString({
+ title,
+ description,
+ items: [],
+ site,
+ stylesheet: '/feedstylesheet.xslt',
+ });
+
+ // xml2js doesn't parse processing instructions. Assert the type is present.
+ assert.equal(str.includes('type="text/xsl"'), true);
+ assertXmlDeepEqual(str, validXmlWithXSLTStylesheet);
+ });
+
+ it('should preserve self-closing tags on `customData`', async () => {
+ const customData =
+ '<atom:link href="https://example.com/feed.xml" rel="self" type="application/rss+xml"/>';
+ const str = await getRssString({
+ title,
+ description,
+ items: [],
+ site,
+ xmlns: {
+ atom: 'http://www.w3.org/2005/Atom',
+ },
+ customData,
+ });
+
+ assert.ok(str.includes(customData));
+ });
+
+ it('should not append trailing slash to URLs with the given option', async () => {
+ const str = await getRssString({
+ title,
+ description,
+ items: [phpFeedItem],
+ site,
+ trailingSlash: false,
+ });
+
+ assert.ok(str.includes('https://example.com<'));
+ assert.ok(str.includes('https://example.com/php<'));
+ });
+
+ 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,
+ },
+ }),
+ ),
+ './posts/nested/web1.md': () =>
+ new Promise((resolve) =>
+ resolve({
+ url: web1FeedItem.link,
+ frontmatter: {
+ title: web1FeedItem.title,
+ pubDate: web1FeedItem.pubDate,
+ description: web1FeedItem.description,
+ },
+ }),
+ ),
+ };
+
+ const str = await getRssString({
+ title,
+ description,
+ items: globResult,
+ site,
+ });
+
+ assertXmlDeepEqual(str, validXmlResult);
+ });
+
+ it('should fail when an invalid date string is provided', async () => {
+ const res = rssSchema.safeParse({
+ title: phpFeedItem.title,
+ pubDate: 'invalid date',
+ description: phpFeedItem.description,
+ link: phpFeedItem.link,
+ });
+
+ assert.equal(res.success, false);
+ assert.equal(res.error.issues[0].path[0], 'pubDate');
+ });
+
+ it('should be extendable', () => {
+ let error = null;
+ try {
+ rssSchema.extend({
+ category: z.string().optional(),
+ });
+ } catch (e) {
+ error = e.message;
+ }
+ assert.equal(error, null);
+ });
+
+ it('should not fail when an enclosure has a length of 0', async () => {
+ let error = null;
+ try {
+ await getRssString({
+ title,
+ description,
+ items: [
+ {
+ title: 'Title',
+ pubDate: new Date().toISOString(),
+ description: 'Description',
+ link: '/link',
+ enclosure: {
+ url: '/enclosure',
+ length: 0,
+ type: 'audio/mpeg',
+ },
+ },
+ ],
+ site,
+ });
+ } catch (e) {
+ error = e.message;
+ }
+
+ assert.equal(error, null);
+ });
+});
diff --git a/packages/astro-rss/test/test-utils.js b/packages/astro-rss/test/test-utils.js
new file mode 100644
index 000000000..d3ee8ca33
--- /dev/null
+++ b/packages/astro-rss/test/test-utils.js
@@ -0,0 +1,69 @@
+import xml2js from 'xml2js';
+
+export const title = 'My RSS feed';
+export const description = 'This sure is a nice RSS feed';
+export const site = 'https://example.com';
+
+export const phpFeedItemWithoutDate = {
+ link: '/php',
+ title: 'Remember PHP?',
+ 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 phpFeedItem = {
+ ...phpFeedItemWithoutDate,
+ pubDate: '1994-05-03',
+};
+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>`,
+};
+export const web1FeedItemWithAllData = {
+ ...web1FeedItem,
+ categories: ['web1', 'history'],
+ author: 'test@example.com',
+ commentsUrl: 'http://example.com/comments',
+ source: {
+ url: 'http://example.com/source',
+ title: 'The Web 1.0 blog',
+ },
+ enclosure: {
+ url: '/podcast.mp3',
+ length: 256,
+ type: 'audio/mpeg',
+ },
+};
+
+const parser = new xml2js.Parser({ trim: true });
+
+/**
+ *
+ * Utility function to parse an XML string into an object using `xml2js`.
+ *
+ * @param {string} xmlString - Stringified XML to parse.
+ * @return {{ err: Error, result: any }} Represents an option containing the parsed XML string or an Error.
+ */
+export function parseXmlString(xmlString) {
+ let res;
+ parser.parseString(xmlString, (err, result) => {
+ res = { err, result };
+ });
+ return res;
+}
diff --git a/packages/astro-rss/tsconfig.json b/packages/astro-rss/tsconfig.json
new file mode 100644
index 000000000..18443cddf
--- /dev/null
+++ b/packages/astro-rss/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "outDir": "./dist"
+ }
+}