From d27c2d364f1d0b44521177965708583b16cb175a Mon Sep 17 00:00:00 2001 From: astrobot-houston Date: Mon, 6 Jul 2026 02:53:21 +0000 Subject: [PATCH] fix(routing): prevent trailing slash on file extension endpoint paths in stringifyParams When trailingSlash is 'always', stringifyParams (via getRouteGenerator) was appending a trailing slash to endpoint paths with file extensions (e.g. /og/foo.png/). But the route pattern for such endpoints is generated with trailingSlash 'never' by trailingSlashForPath() in create-manifest.ts (e.g. /^\/og\/(.*?)\.png$/). This mismatch caused getParams() to fail matching, leading to NoMatchingStaticPathFound during static builds. Apply the same trailingSlashForPath logic in stringifyParams: when the route is an endpoint with a file extension, force trailingSlash to 'never'. Fixes #17306 --- packages/astro/src/core/routing/params.ts | 9 ++++ .../test/units/routing/get-params.test.ts | 45 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/astro/src/core/routing/params.ts b/packages/astro/src/core/routing/params.ts index 0f9bae073cfb..4603dde72489 100644 --- a/packages/astro/src/core/routing/params.ts +++ b/packages/astro/src/core/routing/params.ts @@ -1,3 +1,4 @@ +import { hasFileExtension } from '@astrojs/internal-helpers/path'; import type { GetStaticPathsItem } from '../../types/public/common.js'; import type { AstroConfig } from '../../types/public/index.js'; import type { RouteData } from '../../types/public/internal.js'; @@ -15,6 +16,14 @@ export function stringifyParams( route: RouteData, trailingSlash: AstroConfig['trailingSlash'], ) { + // Endpoint routes with file extensions (e.g. [slug].png.ts) should never + // append a trailing slash, matching the pattern generated in create-manifest.ts + // by trailingSlashForPath(). Without this, the generated path (e.g. /og/foo.png/) + // won't match the route pattern (e.g. /^\/og\/(.*?)\.png$/) and getParams() fails. + if (route.type === 'endpoint' && hasFileExtension(route.route)) { + trailingSlash = 'never'; + } + // validate parameter values then stringify each value const validatedParams: Record = {}; for (const [key, value] of Object.entries(params)) { diff --git a/packages/astro/test/units/routing/get-params.test.ts b/packages/astro/test/units/routing/get-params.test.ts index 648e72d0b39f..c8c4e43e9117 100644 --- a/packages/astro/test/units/routing/get-params.test.ts +++ b/packages/astro/test/units/routing/get-params.test.ts @@ -3,6 +3,7 @@ import { describe, it } from 'node:test'; import * as cheerio from 'cheerio'; import { createComponent, render } from '../../../dist/runtime/server/index.js'; import { getParams } from '../../../dist/core/render/params-and-props.js'; +import { stringifyParams } from '../../../dist/core/routing/params.js'; import { dynamicPart, makeRoute, spreadPart, staticPart } from './test-helpers.ts'; import { createTestApp, createPage } from '../mocks.ts'; @@ -167,6 +168,50 @@ describe('getParams', () => { }); }); +describe('stringifyParams', () => { + it('should not append trailing slash for file extension endpoint routes with trailingSlash always (issue #17306)', () => { + const route = makeRoute({ + route: '/og/[...slug].png', + segments: [[staticPart('og')], [spreadPart('...slug'), staticPart('.png')]], + trailingSlash: 'never', + pathname: undefined, + type: 'endpoint', + }); + + const result = stringifyParams({ slug: '概率论/参数估计' }, route, 'always'); + assert.equal(result, '/og/概率论/参数估计.png'); + // Verify the generated path matches the route pattern + assert.ok(route.pattern.test(result), 'generated path should match route pattern'); + }); + + it('should not append trailing slash for single dynamic file extension endpoint', () => { + const route = makeRoute({ + route: '/api/[name].json', + segments: [[staticPart('api')], [dynamicPart('name'), staticPart('.json')]], + trailingSlash: 'never', + pathname: undefined, + type: 'endpoint', + }); + + const result = stringifyParams({ name: 'bar' }, route, 'always'); + assert.equal(result, '/api/bar.json'); + assert.ok(route.pattern.test(result), 'generated path should match route pattern'); + }); + + it('should still append trailing slash for endpoints without file extensions', () => { + const route = makeRoute({ + route: '/api/[name]', + segments: [[staticPart('api')], [dynamicPart('name')]], + trailingSlash: 'always', + pathname: undefined, + type: 'endpoint', + }); + + const result = stringifyParams({ name: 'bar' }, route, 'always'); + assert.equal(result, '/api/bar/'); + }); +}); + describe('Params rendered via App', () => { const paramPage = createComponent((result: any, props: any, slots: any) => { const Astro = result.createAstro(props, slots);