From acf854af13047ce9ba12484ba6e1d6926289e57b Mon Sep 17 00:00:00 2001 From: astrobot-houston Date: Sun, 22 Mar 2026 17:42:30 +0000 Subject: [PATCH 1/7] fix(underscore-redirects): respect trailingSlash config in _redirects file generation --- .../netlify/test/functions/redirects.test.js | 5 +- .../netlify/test/static/redirects.test.js | 9 +++ packages/underscore-redirects/src/astro.ts | 60 ++++++++++++++++--- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/integrations/netlify/test/functions/redirects.test.js b/packages/integrations/netlify/test/functions/redirects.test.js index 01bddc4c9a28..3b821318790c 100644 --- a/packages/integrations/netlify/test/functions/redirects.test.js +++ b/packages/integrations/netlify/test/functions/redirects.test.js @@ -16,9 +16,8 @@ describe( it('Creates a redirects file', async () => { const redirects = await fixture.readFile('./_redirects'); const parts = redirects.split(/\s+/); - assert.deepEqual(parts, ['', '/other', '/', '301', '']); - // Snapshots are not supported in Node.js test yet (https://github.com/nodejs/node/issues/48260) - assert.equal(redirects, '\n/other / 301\n'); + // With trailingSlash: 'ignore' (the default), both variants are generated + assert.deepEqual(parts, ['', '/other/', '/', '301', '/other', '/', '301', '']); }); it('Does not create .html files', async () => { diff --git a/packages/integrations/netlify/test/static/redirects.test.js b/packages/integrations/netlify/test/static/redirects.test.js index cab95483143d..6155e6f8e94a 100644 --- a/packages/integrations/netlify/test/static/redirects.test.js +++ b/packages/integrations/netlify/test/static/redirects.test.js @@ -13,13 +13,22 @@ describe('SSG - Redirects', () => { it('Creates a redirects file', async () => { const redirects = await fixture.readFile('./_redirects'); const parts = redirects.split(/\s+/); + // With trailingSlash: 'ignore' (the default), both variants are generated for static redirects assert.deepEqual(parts, [ '', + '/two/', + '/', + '302', + '/two', '/', '302', + '/other/', + '/', + '301', + '/other', '/', '301', diff --git a/packages/underscore-redirects/src/astro.ts b/packages/underscore-redirects/src/astro.ts index 30ee2ab16037..5575d8cf9cfc 100644 --- a/packages/underscore-redirects/src/astro.ts +++ b/packages/underscore-redirects/src/astro.ts @@ -17,7 +17,7 @@ function getRedirectStatus(route: IntegrationResolvedRoute): ValidRedirectStatus } interface CreateRedirectsFromAstroRoutesParams { - config: Pick; + config: Pick; /** * Maps a `RouteData` to a dynamic target */ @@ -27,6 +27,38 @@ interface CreateRedirectsFromAstroRoutesParams { assets: HookParameters<'astro:build:done'>['assets']; } +/** + * Returns the path(s) to use for a redirect entry based on the trailingSlash config. + * - 'always': ensures the path ends with '/' + * - 'never': ensures the path does not end with '/' + * - 'ignore': returns both with and without trailing slash variants + * + * The root path '/' is always returned as-is since it inherently has a trailing slash. + */ +function getTrailingSlashPaths( + inputPath: string, + trailingSlash: 'always' | 'never' | 'ignore', +): string[] { + // Root path is always just '/' + if (inputPath === '/') { + return ['/']; + } + + const hasTrailingSlash = inputPath.endsWith('/'); + const withoutSlash = hasTrailingSlash ? inputPath.slice(0, -1) : inputPath; + const withSlash = hasTrailingSlash ? inputPath : inputPath + '/'; + + switch (trailingSlash) { + case 'always': + return [withSlash]; + case 'never': + return [withoutSlash]; + case 'ignore': + default: + return [withoutSlash, withSlash]; + } +} + /** * Takes a set of routes and creates a Redirects object from them. */ @@ -57,13 +89,25 @@ export function createRedirectsFromAstroRoutes({ // Use `entrypoint` when available to keep trailing slashes in _redirects. const inputPath = route.type === 'redirect' && route.entrypoint ? route.entrypoint : route.pathname; - redirects.add({ - dynamic: false, - input: `${base}${inputPath}`, - target: typeof route.redirect === 'object' ? route.redirect.destination : route.redirect, - status: getRedirectStatus(route), - weight: 2, - }); + const target = + typeof route.redirect === 'object' ? route.redirect.destination : route.redirect; + const status = getRedirectStatus(route); + + // Generate redirect entries based on trailingSlash config. + // Host platforms like Cloudflare/Netlify use exact path matching in + // _redirects files, so we need to emit both variants when trailingSlash + // is 'ignore' (the default). + const trailingSlash = config.trailingSlash ?? 'ignore'; + const paths = getTrailingSlashPaths(inputPath, trailingSlash); + for (const path of paths) { + redirects.add({ + dynamic: false, + input: `${base}${path}`, + target, + status, + weight: 2, + }); + } continue; } From 856c2328dbe899274c0002572481a46add2bdd0d Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:48:22 +0100 Subject: [PATCH 2/7] cleanup code Signed-off-by: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> --- .../netlify/test/functions/redirects.test.js | 2 +- .../netlify/test/static/redirects.test.js | 8 +------- packages/underscore-redirects/src/astro.ts | 16 ++++------------ 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/integrations/netlify/test/functions/redirects.test.js b/packages/integrations/netlify/test/functions/redirects.test.js index 3b821318790c..2c55aecb9ec5 100644 --- a/packages/integrations/netlify/test/functions/redirects.test.js +++ b/packages/integrations/netlify/test/functions/redirects.test.js @@ -16,7 +16,7 @@ describe( it('Creates a redirects file', async () => { const redirects = await fixture.readFile('./_redirects'); const parts = redirects.split(/\s+/); - // With trailingSlash: 'ignore' (the default), both variants are generated + // based on https://github.com/withastro/astro/issues/16030 for the default option `trailingSlash: 'ignore'` both variants should be generated assert.deepEqual(parts, ['', '/other/', '/', '301', '/other', '/', '301', '']); }); diff --git a/packages/integrations/netlify/test/static/redirects.test.js b/packages/integrations/netlify/test/static/redirects.test.js index 6155e6f8e94a..f7d485584925 100644 --- a/packages/integrations/netlify/test/static/redirects.test.js +++ b/packages/integrations/netlify/test/static/redirects.test.js @@ -13,30 +13,24 @@ describe('SSG - Redirects', () => { it('Creates a redirects file', async () => { const redirects = await fixture.readFile('./_redirects'); const parts = redirects.split(/\s+/); - // With trailingSlash: 'ignore' (the default), both variants are generated for static redirects + // based on https://github.com/withastro/astro/issues/16030 for the default option `trailingSlash: 'ignore'` both variants should be generated assert.deepEqual(parts, [ '', - '/two/', '/', '302', - '/two', '/', '302', - '/other/', '/', '301', - '/other', '/', '301', - '/blog/*', '/team/articles/*/index.html', '301', - '', ]); }); diff --git a/packages/underscore-redirects/src/astro.ts b/packages/underscore-redirects/src/astro.ts index 5575d8cf9cfc..9ddd3855eeaf 100644 --- a/packages/underscore-redirects/src/astro.ts +++ b/packages/underscore-redirects/src/astro.ts @@ -31,15 +31,12 @@ interface CreateRedirectsFromAstroRoutesParams { * Returns the path(s) to use for a redirect entry based on the trailingSlash config. * - 'always': ensures the path ends with '/' * - 'never': ensures the path does not end with '/' - * - 'ignore': returns both with and without trailing slash variants - * - * The root path '/' is always returned as-is since it inherently has a trailing slash. + * - 'ignore'(default): returns both with and without trailing slash variants */ function getTrailingSlashPaths( inputPath: string, trailingSlash: 'always' | 'never' | 'ignore', ): string[] { - // Root path is always just '/' if (inputPath === '/') { return ['/']; } @@ -89,22 +86,17 @@ export function createRedirectsFromAstroRoutes({ // Use `entrypoint` when available to keep trailing slashes in _redirects. const inputPath = route.type === 'redirect' && route.entrypoint ? route.entrypoint : route.pathname; - const target = - typeof route.redirect === 'object' ? route.redirect.destination : route.redirect; - const status = getRedirectStatus(route); // Generate redirect entries based on trailingSlash config. - // Host platforms like Cloudflare/Netlify use exact path matching in - // _redirects files, so we need to emit both variants when trailingSlash - // is 'ignore' (the default). const trailingSlash = config.trailingSlash ?? 'ignore'; const paths = getTrailingSlashPaths(inputPath, trailingSlash); for (const path of paths) { redirects.add({ dynamic: false, input: `${base}${path}`, - target, - status, + target: + typeof route.redirect === 'object' ? route.redirect.destination : route.redirect, + status: getRedirectStatus(route), weight: 2, }); } From 5bcf924643e4e3f58ed9707e351d55b40f167371 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:49:41 +0100 Subject: [PATCH 3/7] add changeset Signed-off-by: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> --- .changeset/thin-memes-boil.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thin-memes-boil.md diff --git a/.changeset/thin-memes-boil.md b/.changeset/thin-memes-boil.md new file mode 100644 index 000000000000..ce9a10363dc3 --- /dev/null +++ b/.changeset/thin-memes-boil.md @@ -0,0 +1,5 @@ +--- +'@astrojs/underscore-redirects': patch +--- + +Fixes redirect file generation to respect trailingSlash config From e5e4be115c9cbbc36c59dd7811fbefef2994888a Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:52:00 +0100 Subject: [PATCH 4/7] add unit tests for getTrailingSlashPaths Signed-off-by: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> --- packages/underscore-redirects/src/astro.ts | 2 +- packages/underscore-redirects/src/index.ts | 1 + .../underscore-redirects/test/astro.test.js | 17 ++++++++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/underscore-redirects/src/astro.ts b/packages/underscore-redirects/src/astro.ts index 9ddd3855eeaf..860a171eedf7 100644 --- a/packages/underscore-redirects/src/astro.ts +++ b/packages/underscore-redirects/src/astro.ts @@ -33,7 +33,7 @@ interface CreateRedirectsFromAstroRoutesParams { * - 'never': ensures the path does not end with '/' * - 'ignore'(default): returns both with and without trailing slash variants */ -function getTrailingSlashPaths( +export function getTrailingSlashPaths( inputPath: string, trailingSlash: 'always' | 'never' | 'ignore', ): string[] { diff --git a/packages/underscore-redirects/src/index.ts b/packages/underscore-redirects/src/index.ts index 8411cc3cabd6..bf9325555a5d 100644 --- a/packages/underscore-redirects/src/index.ts +++ b/packages/underscore-redirects/src/index.ts @@ -1,6 +1,7 @@ export { createHostedRouteDefinition, createRedirectsFromAstroRoutes, + getTrailingSlashPaths } from './astro.js'; export { HostRoutes } from './host-route.js'; export { printAsRedirects } from './print.js'; diff --git a/packages/underscore-redirects/test/astro.test.js b/packages/underscore-redirects/test/astro.test.js index 6a4944dc907a..c118a508f0cf 100644 --- a/packages/underscore-redirects/test/astro.test.js +++ b/packages/underscore-redirects/test/astro.test.js @@ -1,6 +1,6 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { createRedirectsFromAstroRoutes } from '../dist/index.js'; +import { createRedirectsFromAstroRoutes, getTrailingSlashPaths } from '../dist/index.js'; describe('Astro', () => { it('Creates a Redirects object from routes', () => { @@ -25,4 +25,19 @@ describe('Astro', () => { assert.equal(_redirects.definitions.length, 2); }); + + it('Generates correct paths for trailingslash ignore', () => { + assert.deepEqual(getTrailingSlashPaths('/path', 'ignore'), ['/path', '/path/']); + assert.deepEqual(getTrailingSlashPaths('/path/', 'ignore'), ['/path', '/path/']); + }); + + it('Generates correct paths for trailingslash always', () => { + assert.deepEqual(getTrailingSlashPaths('/path', 'always'), ['/path/']); + assert.deepEqual(getTrailingSlashPaths('/path/', 'always'), ['/path/']); + }); + + it('Generates correct paths for trailingslash never', () => { + assert.deepEqual(getTrailingSlashPaths('/path', 'never'), ['/path']); + assert.deepEqual(getTrailingSlashPaths('/path/', 'never'), ['/path']); + }); }); From c0d6aeb13f15a0d0fdfcfe30e94c3403623f367b Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:54:52 +0100 Subject: [PATCH 5/7] add unit test for root code path Signed-off-by: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> --- packages/underscore-redirects/test/astro.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/underscore-redirects/test/astro.test.js b/packages/underscore-redirects/test/astro.test.js index c118a508f0cf..59bfdf405cde 100644 --- a/packages/underscore-redirects/test/astro.test.js +++ b/packages/underscore-redirects/test/astro.test.js @@ -26,6 +26,12 @@ describe('Astro', () => { assert.equal(_redirects.definitions.length, 2); }); + it('Generates correct paths for root', () => { + assert.deepEqual(getTrailingSlashPaths('/', 'ignore'), ['/']); + assert.deepEqual(getTrailingSlashPaths('/', 'always'), ['/']); + assert.deepEqual(getTrailingSlashPaths('/', 'never'), ['/']); + }); + it('Generates correct paths for trailingslash ignore', () => { assert.deepEqual(getTrailingSlashPaths('/path', 'ignore'), ['/path', '/path/']); assert.deepEqual(getTrailingSlashPaths('/path/', 'ignore'), ['/path', '/path/']); From f767609d74fca75d52e74ec2a9c73c0993fd45d4 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:56:29 +0100 Subject: [PATCH 6/7] undo unrelated change Signed-off-by: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> --- packages/integrations/netlify/test/static/redirects.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/integrations/netlify/test/static/redirects.test.js b/packages/integrations/netlify/test/static/redirects.test.js index f7d485584925..9e9d0c87298e 100644 --- a/packages/integrations/netlify/test/static/redirects.test.js +++ b/packages/integrations/netlify/test/static/redirects.test.js @@ -16,21 +16,27 @@ describe('SSG - Redirects', () => { // based on https://github.com/withastro/astro/issues/16030 for the default option `trailingSlash: 'ignore'` both variants should be generated assert.deepEqual(parts, [ '', + '/two/', '/', '302', + '/two', '/', '302', + '/other/', '/', '301', + '/other', '/', '301', + '/blog/*', '/team/articles/*/index.html', '301', + '', ]); }); From 27078fd00d445669753a438cfaef7e37588d5351 Mon Sep 17 00:00:00 2001 From: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:13:31 +0100 Subject: [PATCH 7/7] update changeset --- .changeset/thin-memes-boil.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/thin-memes-boil.md b/.changeset/thin-memes-boil.md index ce9a10363dc3..8eeb7f89cc13 100644 --- a/.changeset/thin-memes-boil.md +++ b/.changeset/thin-memes-boil.md @@ -2,4 +2,4 @@ '@astrojs/underscore-redirects': patch --- -Fixes redirect file generation to respect trailingSlash config +Fixes generated redirect files to respect Astro’s `trailingSlash` configuration, so redirect routes work with the expected URL format in built output instead of returning a 404 when accessed with a trailing slash.