Skip to content

Commit 5361755

Browse files
ascorbicematipicomatthewpsarah11918bluwy
authored
feat: redirect trailing slashes on on-demand rendered pages (#12994)
Co-authored-by: ematipico <[email protected]> Co-authored-by: matthewp <[email protected]> Co-authored-by: sarah11918 <[email protected]> Co-authored-by: bluwy <[email protected]>
1 parent e621712 commit 5361755

File tree

17 files changed

+434
-25
lines changed

17 files changed

+434
-25
lines changed

.changeset/blue-jokes-eat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/internal-helpers': minor
3+
---
4+
5+
Adds `collapseDuplicateTrailingSlashes` function

.changeset/blue-spies-shave.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Redirects trailing slashes for on-demand pages
6+
7+
When the `trailingSlash` option is set to `always` or `never`, on-demand rendered pages will now redirect to the correct URL when the trailing slash doesn't match the configuration option. This was previously the case for static pages, but now works for on-demand pages as well.
8+
9+
Now, it doesn't matter whether your visitor navigates to `/about/`, `/about`, or even `/about///`. In production, they'll always end up on the correct page. For GET requests, the redirect will be a 301 (permanent) redirect, and for all other request methods, it will be a 308 (permanent, and preserve the request method) redirect.
10+
11+
In development, you'll see a helpful 404 page to alert you of a trailing slash mismatch so you can troubleshoot routes.

.changeset/many-fans-battle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Returns a more helpful 404 page in dev if there is a trailing slash mismatch between the route requested and the `trailingSlash` configuration

packages/astro/src/core/app/index.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { collapseDuplicateTrailingSlashes, hasFileExtension } from '@astrojs/internal-helpers/path';
12
import { normalizeTheLocale } from '../../i18n/index.js';
23
import type { RoutesList } from '../../types/astro.js';
34
import type { RouteData, SSRManifest } from '../../types/public/internal.js';
@@ -20,6 +21,7 @@ import {
2021
} from '../path.js';
2122
import { RenderContext } from '../render-context.js';
2223
import { createAssetLink } from '../render/ssr-element.js';
24+
import { redirectTemplate } from '../routing/3xx.js';
2325
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
2426
import { createDefaultRoutes } from '../routing/default.js';
2527
import { matchRoute } from '../routing/match.js';
@@ -250,11 +252,51 @@ export class App {
250252
return pathname;
251253
}
252254

255+
#redirectTrailingSlash(pathname: string): string {
256+
const { trailingSlash } = this.#manifest;
257+
258+
// Ignore root and internal paths
259+
if (pathname === '/' || pathname.startsWith('/_')) {
260+
return pathname;
261+
}
262+
263+
// Redirect multiple trailing slashes to collapsed path
264+
const path = collapseDuplicateTrailingSlashes(pathname, trailingSlash !== 'never');
265+
if (path !== pathname) {
266+
return path;
267+
}
268+
269+
if (trailingSlash === 'ignore') {
270+
return pathname;
271+
}
272+
273+
if (trailingSlash === 'always' && !hasFileExtension(pathname)) {
274+
return appendForwardSlash(pathname);
275+
}
276+
if (trailingSlash === 'never') {
277+
return removeTrailingForwardSlash(pathname);
278+
}
279+
280+
return pathname;
281+
}
282+
253283
async render(request: Request, renderOptions?: RenderOptions): Promise<Response> {
254284
let routeData: RouteData | undefined;
255285
let locals: object | undefined;
256286
let clientAddress: string | undefined;
257287
let addCookieHeader: boolean | undefined;
288+
const url = new URL(request.url);
289+
const redirect = this.#redirectTrailingSlash(url.pathname);
290+
291+
if (redirect !== url.pathname) {
292+
const status = request.method === 'GET' ? 301 : 308;
293+
return new Response(redirectTemplate({ status, location: redirect, from: request.url }), {
294+
status,
295+
headers: {
296+
location: redirect + url.search,
297+
},
298+
});
299+
}
258300

259301
addCookieHeader = renderOptions?.addCookieHeader;
260302
clientAddress = renderOptions?.clientAddress ?? Reflect.get(request, clientAddressSymbol);

packages/astro/src/core/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,4 @@ export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [
100100

101101
// The folder name where to find the middleware
102102
export const MIDDLEWARE_PATH_SEGMENT_NAME = 'middleware';
103+

packages/astro/src/template/4xx.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { appendForwardSlash, removeTrailingForwardSlash } from '@astrojs/internal-helpers/path';
12
import { escape } from 'html-escaper';
23

34
interface ErrorTemplateOptions {
@@ -129,6 +130,21 @@ export function subpathNotUsedTemplate(base: string, pathname: string) {
129130
});
130131
}
131132

133+
export function trailingSlashMismatchTemplate(pathname: string, trailingSlash: 'always' | 'never' | 'ignore') {
134+
const corrected =
135+
trailingSlash === 'always'
136+
? appendForwardSlash(pathname)
137+
: removeTrailingForwardSlash(pathname);
138+
return template({
139+
pathname,
140+
statusCode: 404,
141+
title: 'Not found',
142+
tabTitle: '404: Not Found',
143+
body: `<p>Your site is configured with <code>trailingSlash</code> set to <code>${trailingSlash}</code>. Do you want to go to <a href="${corrected}">${corrected}</a> instead?</p>
144+
<p>See <a href=https://docs.astro.build/en/reference/configuration-reference/#trailingslash">the documentation for <code>trailingSlash</code></a> if you need help.</p>`,
145+
});
146+
}
147+
132148
export function notFoundTemplate(pathname: string, message = 'Not found') {
133149
return template({
134150
pathname,

packages/astro/src/types/public/config.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -236,14 +236,15 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
236236
* @see build.format
237237
* @description
238238
*
239-
* Set the route matching behavior of the dev server. Choose from the following options:
240-
* - `'always'` - Only match URLs that include a trailing slash (ex: "/foo/")
241-
* - `'never'` - Never match URLs that include a trailing slash (ex: "/foo")
242-
* - `'ignore'` - Match URLs regardless of whether a trailing "/" exists
243-
*
244-
* Use this configuration option if your production host has strict handling of how trailing slashes work or do not work.
245-
*
246-
* You can also set this if you prefer to be more strict yourself, so that URLs with or without trailing slashes won't work during development.
239+
* Set the route matching behavior for trailing slashes in the dev server and on-demand rendered pages. Choose from the following options:
240+
* - `'ignore'` - Match URLs regardless of whether a trailing "/" exists. Requests for "/about" and "/about/" will both match the same route.
241+
* - `'always'` - Only match URLs that include a trailing slash (e.g: "/about/"). In production, requests for on-demand rendered URLs without a trailing slash will be redirected to the correct URL for your convenience. However, in development, they will display a warning page reminding you that you have `always` configured.
242+
* - `'never'` - Only match URLs that do not include a trailing slash (e.g: "/about"). In production, requests for on-demand rendered URLs with a trailing slash will be redirected to the correct URL for your convenience. However, in development, they will display a warning page reminding you that you have `never` configured.
243+
*
244+
* When redirects occur in production for GET requests, the redirect will be a 301 (permanent) redirect. For all other request methods, it will be a 308 (permanent, and preserve the request method) redirect.
245+
*
246+
* Trailing slashes on prerendered pages are handled by the hosting platform, and may not respect your chosen configuration.
247+
* See your hosting platform's documentation for more information.
247248
*
248249
* ```js
249250
* {

packages/astro/src/vite-plugin-astro-server/base.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ import type { AstroSettings } from '../types/astro.js';
33

44
import * as fs from 'node:fs';
55
import path from 'node:path';
6-
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
76
import { bold } from 'kleur/colors';
87
import type { Logger } from '../core/logger/core.js';
9-
import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js';
10-
import { writeHtmlResponse, writeRedirectResponse } from './response.js';
11-
12-
const manySlashes = /\/{2,}$/;
8+
import { notFoundTemplate, subpathNotUsedTemplate } from '../template/4xx.js';
9+
import { writeHtmlResponse } from './response.js';
10+
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
1311

1412
export function baseMiddleware(
1513
settings: AstroSettings,
@@ -23,10 +21,6 @@ export function baseMiddleware(
2321

2422
return function devBaseMiddleware(req, res, next) {
2523
const url = req.url!;
26-
if (manySlashes.test(url)) {
27-
const destination = url.replace(manySlashes, '/');
28-
return writeRedirectResponse(res, 301, destination);
29-
}
3024
let pathname: string;
3125
try {
3226
pathname = decodeURI(new URL(url, 'http://localhost').pathname);
@@ -46,12 +40,7 @@ export function baseMiddleware(
4640
}
4741

4842
if (req.headers.accept?.includes('text/html')) {
49-
const html = notFoundTemplate({
50-
statusCode: 404,
51-
title: 'Not found',
52-
tabTitle: '404: Not Found',
53-
pathname,
54-
});
43+
const html = notFoundTemplate(pathname);
5544
return writeHtmlResponse(res, 404, html);
5645
}
5746

packages/astro/src/vite-plugin-astro-server/plugin.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { recordServerError } from './error.js';
2424
import { DevPipeline } from './pipeline.js';
2525
import { handleRequest } from './request.js';
2626
import { setRouteError } from './server-state.js';
27+
import { trailingSlashMiddleware } from './trailing-slash.js';
2728

2829
export interface AstroPluginOptions {
2930
settings: AstroSettings;
@@ -119,6 +120,10 @@ export default function createVitePluginAstroServer({
119120
route: '',
120121
handle: baseMiddleware(settings, logger),
121122
});
123+
viteServer.middlewares.stack.unshift({
124+
route: '',
125+
handle: trailingSlashMiddleware(settings),
126+
});
122127
// Note that this function has a name so other middleware can find it.
123128
viteServer.middlewares.use(async function astroDevHandler(request, response) {
124129
if (request.url === undefined || !request.method) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type * as vite from 'vite';
2+
import type { AstroSettings } from '../types/astro.js';
3+
4+
import { collapseDuplicateTrailingSlashes, hasFileExtension } from '@astrojs/internal-helpers/path';
5+
import { trailingSlashMismatchTemplate } from '../template/4xx.js';
6+
import { writeHtmlResponse, writeRedirectResponse } from './response.js';
7+
8+
export function trailingSlashMiddleware(settings: AstroSettings): vite.Connect.NextHandleFunction {
9+
const { trailingSlash } = settings.config;
10+
11+
return function devTrailingSlash(req, res, next) {
12+
const url = req.url!;
13+
14+
const destination = collapseDuplicateTrailingSlashes(url, true);
15+
if (url && destination !== url) {
16+
return writeRedirectResponse(res, 301, destination);
17+
}
18+
let pathname: string;
19+
try {
20+
pathname = decodeURI(new URL(url, 'http://localhost').pathname);
21+
} catch (e) {
22+
/* malformed uri */
23+
return next(e);
24+
}
25+
if (
26+
(trailingSlash === 'never' && pathname.endsWith('/') && pathname !== '/') ||
27+
(trailingSlash === 'always' && !pathname.endsWith('/') && !hasFileExtension(pathname))
28+
) {
29+
const html = trailingSlashMismatchTemplate(pathname, trailingSlash);
30+
return writeHtmlResponse(res, 404, html);
31+
}
32+
return next();
33+
};
34+
}

0 commit comments

Comments
 (0)