Skip to content

Commit

Permalink
feat: redirect trailing slashes on on-demand rendered pages (#12994)
Browse files Browse the repository at this point in the history
Co-authored-by: ematipico <602478+ematipico@users.noreply.github.com>
Co-authored-by: matthewp <361671+matthewp@users.noreply.github.com>
Co-authored-by: sarah11918 <5098874+sarah11918@users.noreply.github.com>
Co-authored-by: bluwy <34116392+bluwy@users.noreply.github.com>
  • Loading branch information
5 people authored Jan 29, 2025
1 parent e621712 commit 5361755
Show file tree
Hide file tree
Showing 17 changed files with 434 additions and 25 deletions.
5 changes: 5 additions & 0 deletions .changeset/blue-jokes-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/internal-helpers': minor
---

Adds `collapseDuplicateTrailingSlashes` function
11 changes: 11 additions & 0 deletions .changeset/blue-spies-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'astro': minor
---

Redirects trailing slashes for on-demand pages

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.

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.

In development, you'll see a helpful 404 page to alert you of a trailing slash mismatch so you can troubleshoot routes.
5 changes: 5 additions & 0 deletions .changeset/many-fans-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Returns a more helpful 404 page in dev if there is a trailing slash mismatch between the route requested and the `trailingSlash` configuration
42 changes: 42 additions & 0 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { collapseDuplicateTrailingSlashes, hasFileExtension } from '@astrojs/internal-helpers/path';
import { normalizeTheLocale } from '../../i18n/index.js';
import type { RoutesList } from '../../types/astro.js';
import type { RouteData, SSRManifest } from '../../types/public/internal.js';
Expand All @@ -20,6 +21,7 @@ import {
} from '../path.js';
import { RenderContext } from '../render-context.js';
import { createAssetLink } from '../render/ssr-element.js';
import { redirectTemplate } from '../routing/3xx.js';
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
import { createDefaultRoutes } from '../routing/default.js';
import { matchRoute } from '../routing/match.js';
Expand Down Expand Up @@ -250,11 +252,51 @@ export class App {
return pathname;
}

#redirectTrailingSlash(pathname: string): string {
const { trailingSlash } = this.#manifest;

// Ignore root and internal paths
if (pathname === '/' || pathname.startsWith('/_')) {
return pathname;
}

// Redirect multiple trailing slashes to collapsed path
const path = collapseDuplicateTrailingSlashes(pathname, trailingSlash !== 'never');
if (path !== pathname) {
return path;
}

if (trailingSlash === 'ignore') {
return pathname;
}

if (trailingSlash === 'always' && !hasFileExtension(pathname)) {
return appendForwardSlash(pathname);
}
if (trailingSlash === 'never') {
return removeTrailingForwardSlash(pathname);
}

return pathname;
}

async render(request: Request, renderOptions?: RenderOptions): Promise<Response> {
let routeData: RouteData | undefined;
let locals: object | undefined;
let clientAddress: string | undefined;
let addCookieHeader: boolean | undefined;
const url = new URL(request.url);
const redirect = this.#redirectTrailingSlash(url.pathname);

if (redirect !== url.pathname) {
const status = request.method === 'GET' ? 301 : 308;
return new Response(redirectTemplate({ status, location: redirect, from: request.url }), {
status,
headers: {
location: redirect + url.search,
},
});
}

addCookieHeader = renderOptions?.addCookieHeader;
clientAddress = renderOptions?.clientAddress ?? Reflect.get(request, clientAddressSymbol);
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,4 @@ export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [

// The folder name where to find the middleware
export const MIDDLEWARE_PATH_SEGMENT_NAME = 'middleware';

16 changes: 16 additions & 0 deletions packages/astro/src/template/4xx.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appendForwardSlash, removeTrailingForwardSlash } from '@astrojs/internal-helpers/path';
import { escape } from 'html-escaper';

interface ErrorTemplateOptions {
Expand Down Expand Up @@ -129,6 +130,21 @@ export function subpathNotUsedTemplate(base: string, pathname: string) {
});
}

export function trailingSlashMismatchTemplate(pathname: string, trailingSlash: 'always' | 'never' | 'ignore') {
const corrected =
trailingSlash === 'always'
? appendForwardSlash(pathname)
: removeTrailingForwardSlash(pathname);
return template({
pathname,
statusCode: 404,
title: 'Not found',
tabTitle: '404: Not Found',
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>
<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>`,
});
}

export function notFoundTemplate(pathname: string, message = 'Not found') {
return template({
pathname,
Expand Down
17 changes: 9 additions & 8 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,15 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* @see build.format
* @description
*
* Set the route matching behavior of the dev server. Choose from the following options:
* - `'always'` - Only match URLs that include a trailing slash (ex: "/foo/")
* - `'never'` - Never match URLs that include a trailing slash (ex: "/foo")
* - `'ignore'` - Match URLs regardless of whether a trailing "/" exists
*
* Use this configuration option if your production host has strict handling of how trailing slashes work or do not work.
*
* 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.
* Set the route matching behavior for trailing slashes in the dev server and on-demand rendered pages. Choose from the following options:
* - `'ignore'` - Match URLs regardless of whether a trailing "/" exists. Requests for "/about" and "/about/" will both match the same route.
* - `'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.
* - `'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.
*
* 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.
*
* Trailing slashes on prerendered pages are handled by the hosting platform, and may not respect your chosen configuration.
* See your hosting platform's documentation for more information.
*
* ```js
* {
Expand Down
19 changes: 4 additions & 15 deletions packages/astro/src/vite-plugin-astro-server/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ import type { AstroSettings } from '../types/astro.js';

import * as fs from 'node:fs';
import path from 'node:path';
import { appendForwardSlash } from '@astrojs/internal-helpers/path';
import { bold } from 'kleur/colors';
import type { Logger } from '../core/logger/core.js';
import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js';
import { writeHtmlResponse, writeRedirectResponse } from './response.js';

const manySlashes = /\/{2,}$/;
import { notFoundTemplate, subpathNotUsedTemplate } from '../template/4xx.js';
import { writeHtmlResponse } from './response.js';
import { appendForwardSlash } from '@astrojs/internal-helpers/path';

export function baseMiddleware(
settings: AstroSettings,
Expand All @@ -23,10 +21,6 @@ export function baseMiddleware(

return function devBaseMiddleware(req, res, next) {
const url = req.url!;
if (manySlashes.test(url)) {
const destination = url.replace(manySlashes, '/');
return writeRedirectResponse(res, 301, destination);
}
let pathname: string;
try {
pathname = decodeURI(new URL(url, 'http://localhost').pathname);
Expand All @@ -46,12 +40,7 @@ export function baseMiddleware(
}

if (req.headers.accept?.includes('text/html')) {
const html = notFoundTemplate({
statusCode: 404,
title: 'Not found',
tabTitle: '404: Not Found',
pathname,
});
const html = notFoundTemplate(pathname);
return writeHtmlResponse(res, 404, html);
}

Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/vite-plugin-astro-server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { recordServerError } from './error.js';
import { DevPipeline } from './pipeline.js';
import { handleRequest } from './request.js';
import { setRouteError } from './server-state.js';
import { trailingSlashMiddleware } from './trailing-slash.js';

export interface AstroPluginOptions {
settings: AstroSettings;
Expand Down Expand Up @@ -119,6 +120,10 @@ export default function createVitePluginAstroServer({
route: '',
handle: baseMiddleware(settings, logger),
});
viteServer.middlewares.stack.unshift({
route: '',
handle: trailingSlashMiddleware(settings),
});
// Note that this function has a name so other middleware can find it.
viteServer.middlewares.use(async function astroDevHandler(request, response) {
if (request.url === undefined || !request.method) {
Expand Down
34 changes: 34 additions & 0 deletions packages/astro/src/vite-plugin-astro-server/trailing-slash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type * as vite from 'vite';
import type { AstroSettings } from '../types/astro.js';

import { collapseDuplicateTrailingSlashes, hasFileExtension } from '@astrojs/internal-helpers/path';
import { trailingSlashMismatchTemplate } from '../template/4xx.js';
import { writeHtmlResponse, writeRedirectResponse } from './response.js';

export function trailingSlashMiddleware(settings: AstroSettings): vite.Connect.NextHandleFunction {
const { trailingSlash } = settings.config;

return function devTrailingSlash(req, res, next) {
const url = req.url!;

const destination = collapseDuplicateTrailingSlashes(url, true);
if (url && destination !== url) {
return writeRedirectResponse(res, 301, destination);
}
let pathname: string;
try {
pathname = decodeURI(new URL(url, 'http://localhost').pathname);
} catch (e) {
/* malformed uri */
return next(e);
}
if (
(trailingSlash === 'never' && pathname.endsWith('/') && pathname !== '/') ||
(trailingSlash === 'always' && !pathname.endsWith('/') && !hasFileExtension(pathname))
) {
const html = trailingSlashMismatchTemplate(pathname, trailingSlash);
return writeHtmlResponse(res, 404, html);
}
return next();
};
}
13 changes: 13 additions & 0 deletions packages/astro/test/fixtures/ssr-response/src/pages/another.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>Hello {Astro.url}</h1>
</body>
</html>
13 changes: 13 additions & 0 deletions packages/astro/test/fixtures/ssr-response/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>Hello /</h1>
</body>
</html>
13 changes: 13 additions & 0 deletions packages/astro/test/fixtures/ssr-response/src/pages/sub/path.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>Hello {Astro.url}</h1>
</body>
</html>
4 changes: 2 additions & 2 deletions packages/astro/test/ssr-error-pages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe('trailing slashes for error pages', () => {
});

it('renders 404 page when a route does not match the request', async () => {
const response = await fixture.fetch('/ashbfjkasn');
const response = await fixture.fetch('/ashbfjkasn/');
assert.equal(response.status, 404);
const html = await response.text();
const $ = cheerio.load(html);
Expand All @@ -181,7 +181,7 @@ describe('trailing slashes for error pages', () => {
});

it('renders 404 page when a route does not match the request', async () => {
const response = await app.render(new Request('http://example.com/ajksalscla'));
const response = await app.render(new Request('http://example.com/ajksalscla/'));
assert.equal(response.status, 404);
const html = await response.text();
const $ = cheerio.load(html);
Expand Down
Loading

0 comments on commit 5361755

Please sign in to comment.