diff --git a/.changeset/popular-rules-divide.md b/.changeset/popular-rules-divide.md
new file mode 100644
index 0000000000..e089fe4925
--- /dev/null
+++ b/.changeset/popular-rules-divide.md
@@ -0,0 +1,15 @@
+---
+'@astrojs/rss': minor
+---
+
+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.
diff --git a/packages/astro-rss/README.md b/packages/astro-rss/README.md
index 7036f5f77c..32d48dc429 100644
--- a/packages/astro-rss/README.md
+++ b/packages/astro-rss/README.md
@@ -73,6 +73,8 @@ export function get(context) {
customData: 'en-us',
// (optional) add arbitrary metadata to opening tag
xmlns: { h: 'http://www.w3.org/TR/html4/' },
+ // (optional) add trailing slashes to URLs (default: true)
+ trailingSlash: false
});
}
```
@@ -185,6 +187,21 @@ The `content` key contains the full content of the post as HTML. This allows you
[See our RSS documentation](https://docs.astro.build/en/guides/rss/#including-full-post-content) for examples using content collections and glob imports.
+### `trailingSlash`
+
+Type: `boolean (optional)`
+Default: `true`
+
+By default, the library will add trailing slashes to the emitted URLs. To prevent this behavior, add `trailingSlash: false` to the `rss` function.
+
+```js
+import rss from '@astrojs/rss';
+
+export const get = () => rss({
+ trailingSlash: false
+});
+```
+
## `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:
diff --git a/packages/astro-rss/src/index.ts b/packages/astro-rss/src/index.ts
index 90c30eb0f7..35bf5f6136 100644
--- a/packages/astro-rss/src/index.ts
+++ b/packages/astro-rss/src/index.ts
@@ -29,6 +29,7 @@ export type RSSOptions = {
customData?: z.infer['customData'];
/** Whether to include drafts or not */
drafts?: z.infer['drafts'];
+ trailingSlash?: z.infer['trailingSlash'];
};
type RSSFeedItem = {
@@ -54,6 +55,7 @@ type GlobResult = z.infer;
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(),
@@ -77,6 +79,7 @@ const rssOptionsValidator = z.object({
drafts: z.boolean().default(false),
stylesheet: z.union([z.string(), z.boolean()]).optional(),
customData: z.string().optional(),
+ trailingSlash: z.boolean().default(true),
});
export default async function getRSS(rssOptions: RSSOptions) {
@@ -171,7 +174,7 @@ async function generateRSS(rssOptions: ValidatedRSSOptions): Promise {
root.rss.channel = {
title: rssOptions.title,
description: rssOptions.description,
- link: createCanonicalURL(site).href,
+ link: createCanonicalURL(site, rssOptions.trailingSlash, undefined).href,
};
if (typeof rssOptions.customData === 'string')
Object.assign(
@@ -183,7 +186,7 @@ async function generateRSS(rssOptions: ValidatedRSSOptions): Promise {
// If the item's link is already a valid URL, don't mess with it.
const itemLink = isValidURL(result.link)
? result.link
- : createCanonicalURL(result.link, site).href;
+ : createCanonicalURL(result.link, rssOptions.trailingSlash, site).href;
const item: any = {
title: result.title,
link: itemLink,
diff --git a/packages/astro-rss/src/util.ts b/packages/astro-rss/src/util.ts
index ad0e40a68c..a87828bfc7 100644
--- a/packages/astro-rss/src/util.ts
+++ b/packages/astro-rss/src/util.ts
@@ -1,10 +1,22 @@
import { z } from 'astro/zod';
+import { RSSOptions } from './index';
/** Normalize URL to its canonical form */
-export function createCanonicalURL(url: string, base?: string): URL {
+export function createCanonicalURL(
+ url: string,
+ trailingSlash?: RSSOptions['trailingSlash'],
+ base?: string
+): URL {
let pathname = url.replace(/\/index.html$/, ''); // index.html is not canonical
pathname = pathname.replace(/\/1\/?$/, ''); // neither is a trailing /1/ (impl. detail of collections)
- if (!getUrlExtension(url)) pathname = pathname.replace(/(\/+)?$/, '/'); // add trailing slash if there’s no extension
+ if (trailingSlash === false) {
+ // remove the trailing slash
+ pathname = pathname.replace(/(\/+)?$/, '');
+ } else 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)
return new URL(pathname, base);
}
diff --git a/packages/astro-rss/test/rss.test.js b/packages/astro-rss/test/rss.test.js
index 744471f8cd..d519d19cab 100644
--- a/packages/astro-rss/test/rss.test.js
+++ b/packages/astro-rss/test/rss.test.js
@@ -107,7 +107,6 @@ describe('rss', () => {
const { body } = await rss({
title,
description,
- drafts: true,
items: [phpFeedItem, { ...web1FeedItem, draft: true }],
site,
drafts: true,
@@ -116,6 +115,20 @@ describe('rss', () => {
chai.expect(body).xml.to.equal(validXmlResult);
});
+ it('should not append trailing slash to URLs with the given option', async () => {
+ const { body } = await rss({
+ title,
+ description,
+ items: [phpFeedItem, { ...web1FeedItem, draft: true }],
+ site,
+ drafts: true,
+ trailingSlash: false,
+ });
+
+ chai.expect(body).xml.to.contain('https://example.com/<');
+ chai.expect(body).xml.to.contain('https://example.com/php<');
+ });
+
it('Deprecated import.meta.glob mapping still works', async () => {
const globResult = {
'./posts/php.md': () =>