diff --git a/.changeset/ready-times-wave.md b/.changeset/ready-times-wave.md new file mode 100644 index 000000000000..677c39589d0b --- /dev/null +++ b/.changeset/ready-times-wave.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes SSR dynamic routes with `.html` extension (e.g. `[slug].html.astro`) not working diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index b66acc71b728..0edd9e67df44 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -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'; @@ -471,8 +472,8 @@ export abstract class BaseApp

{ } 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); diff --git a/packages/astro/src/core/render/params-and-props.ts b/packages/astro/src/core/render/params-and-props.ts index 4affaf38e874..1c43a15d0162 100644 --- a/packages/astro/src/core/render/params-and-props.ts +++ b/packages/astro/src/core/render/params-and-props.ts @@ -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'; @@ -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) => { diff --git a/packages/astro/src/core/routing/helpers.ts b/packages/astro/src/core/routing/helpers.ts index 64e9975ac691..291daad45fa5 100644 --- a/packages/astro/src/core/routing/helpers.ts +++ b/packages/astro/src/core/routing/helpers.ts @@ -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>, options?: { includeEndpoints?: boolean }, diff --git a/packages/astro/test/units/routing/router-match.test.js b/packages/astro/test/units/routing/router-match.test.js index 106a81f4a8c4..9a9b942dac03 100644 --- a/packages/astro/test/units/routing/router-match.test.js +++ b/packages/astro/test/units/routing/router-match.test.js @@ -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 = [