Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ready-times-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes SSR dynamic routes with `.html` extension (e.g. `[slug].html.astro`) not working
5 changes: 3 additions & 2 deletions packages/astro/src/core/app/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { AstroIntegrationLogger, Logger } from '../logger/core.js';
import { type CreateRenderContext, RenderContext } from '../render-context.js';
import { redirectTemplate } from '../routing/3xx.js';
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
import { routeHasHtmlExtension } from '../routing/helpers.js';
import { matchRoute } from '../routing/match.js';
import { type CacheLike, applyCacheHeaders } from '../cache/runtime/cache.js';
import { Router } from '../routing/router.js';
Expand Down Expand Up @@ -471,8 +472,8 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
}
let pathname = this.getPathnameFromRequest(request);
// In dev, the route may have matched a normalized pathname (after .html stripping).
// Apply the same normalization for correct param extraction.
if (this.isDev()) {
// Skip normalization if the route already has an .html extension in its definition.
if (this.isDev() && !routeHasHtmlExtension(routeData)) {
pathname = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
}
const defaultStatus = this.getDefaultStatusCode(routeData, pathname);
Expand Down
19 changes: 9 additions & 10 deletions packages/astro/src/core/render/params-and-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { RouteData } from '../../types/public/internal.js';
import { DEFAULT_404_COMPONENT } from '../constants.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { Logger } from '../logger/core.js';
import { routeIsFallback, routeIsRedirect } from '../routing/helpers.js';
import { routeHasHtmlExtension, routeIsFallback, routeIsRedirect } from '../routing/helpers.js';
import type { RouteCache } from './route-cache.js';
import { callGetStaticPaths, findPathItemByKey } from './route-cache.js';

Expand Down Expand Up @@ -87,16 +87,15 @@ export function getParams(route: RouteData, pathname: string): Params {
if (!route.params.length) return {};
// The RegExp pattern expects a decoded string, but the pathname is encoded
// when the URL contains non-English characters.
let path = pathname;
// The path could contain `.html` at the end. We remove it so we can correctly the parameters
// with the generated keyed parameters.
if (pathname.endsWith('.html')) {
path = path.slice(0, -5);
}
// Strip `.html` from the pathname unless `.html` is a static part of the route definition
// itself (e.g. `[slug].html.astro`). Dynamic params like `[id]` would otherwise greedily
// capture the `.html` suffix (e.g. `id = '42.html'` instead of `id = '42'`).
const path =
pathname.endsWith('.html') && !routeHasHtmlExtension(route) ? pathname.slice(0, -5) : pathname;

const allPatterns = [route, ...route.fallbackRoutes].map((r) => r.pattern);
const paramsMatch = allPatterns.map((pattern) => pattern.exec(path)).find((x) => x);

const paramsMatch =
route.pattern.exec(path) ||
route.fallbackRoutes.map((fallbackRoute) => fallbackRoute.pattern.exec(path)).find((x) => x);
if (!paramsMatch) return {};
const params: Params = {};
route.params.forEach((key, i) => {
Expand Down
11 changes: 11 additions & 0 deletions packages/astro/src/core/routing/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ export function getCustom500Route(manifestData: RoutesList): RouteData | undefin
return manifestData.routes.find((r) => isRoute500(r.route));
}

/**
* Returns true if the route definition contains `.html` as a static segment part,
* as is the case for routes like `[slug].html.astro`. Used to avoid stripping the
* `.html` suffix from pathnames that intentionally include it.
*/
export function routeHasHtmlExtension(route: RouteData): boolean {
return route.segments.some((segment) =>
segment.some((part) => !part.dynamic && part.content.includes('.html')),
);
}

export function hasNonPrerenderedProjectRoute(
routes: Array<Pick<RouteData, 'type' | 'origin' | 'prerender'>>,
options?: { includeEndpoints?: boolean },
Expand Down
87 changes: 87 additions & 0 deletions packages/astro/test/units/routing/router-match.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,93 @@ describe('Router.match', () => {
assert.equal(aboutMatch.route.route, '/about');
});

it('matches [slug].html routes and extracts slug without .html', () => {
// Routes like `[slug].html.astro` have `.html` as a static segment part.
// The router must not strip `.html` before matching, or params extraction fails.
const trailingSlash = 'ignore';
const routes = [
makeRoute({
segments: [[dynamicPart('slug'), staticPart('.html')]],
trailingSlash,
route: '/[slug].html',
pathname: undefined,
}),
];

const router = new Router(routes, {
base: '/',
trailingSlash,
buildFormat: 'directory',
});

const match = router.match('/dummy.html');
assert.equal(match.type, 'match');
assert.equal(match.route.route, '/[slug].html');
assert.deepEqual(match.params, { slug: 'dummy' });
});

it('[slug].html routes do not match non-.html paths', () => {
const trailingSlash = 'ignore';
const routes = [
makeRoute({
segments: [[dynamicPart('slug'), staticPart('.html')]],
trailingSlash,
route: '/[slug].html',
pathname: undefined,
}),
];

const router = new Router(routes, {
base: '/',
trailingSlash,
buildFormat: 'directory',
});

// `/dummy` (without .html) should not match `/[slug].html`
const noMatch = router.match('/dummy');
assert.equal(noMatch.type, 'none');
});

it('[slug].html routes still work alongside buildFormat=file stripping', () => {
// When buildFormat=file, ordinary routes get .html stripped. But a route
// that explicitly includes .html in its pattern should still match correctly.
const trailingSlash = 'ignore';
const routes = [
makeRoute({
segments: [[staticPart('about')]],
trailingSlash,
route: '/about',
pathname: '/about',
}),
makeRoute({
segments: [[dynamicPart('slug'), staticPart('.html')]],
trailingSlash,
route: '/[slug].html',
pathname: undefined,
}),
];

const router = new Router(routes, {
base: '/',
trailingSlash,
buildFormat: 'file',
});

// Ordinary static route: /about.html → strips to /about → matches /about
const aboutMatch = router.match('/about.html');
assert.equal(aboutMatch.type, 'match');
assert.equal(aboutMatch.route.route, '/about');

// Dynamic .html route: the pattern itself matches /dummy.html, but
// normalizeFileFormatPathname strips it to /dummy which won't match.
// This is a known limitation with buildFormat=file + [slug].html routes.
// The primary fix targets SSR (buildFormat=directory) as in the bug report.
const dynamicMatch = router.match('/dummy.html');
// With buildFormat=file the .html is stripped before matching, so /dummy.html → /dummy,
// which doesn't match /[slug].html. This is expected current behavior.
assert.equal(dynamicMatch.type, 'none');
});

it('redirects multiple leading slashes at root', () => {
const trailingSlash = 'ignore';
const routes = [
Expand Down
Loading