Skip to content
Open
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/dark-melons-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a build failure for dynamic file endpoints (e.g. `[name].json.ts`) when `trailingSlash` is set to `"always"`
9 changes: 8 additions & 1 deletion packages/astro/src/core/routing/params.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +16,12 @@ export function stringifyParams(
route: RouteData,
trailingSlash: AstroConfig['trailingSlash'],
) {
// File endpoints (e.g. [name].json.ts) should never have a trailing slash,
// even when trailingSlash is set to 'always'. This matches the route pattern
// generated by trailingSlashForPath in create-manifest.ts.
const effectiveTrailingSlash =
route.type === 'endpoint' && hasFileExtension(route.route) ? 'never' : trailingSlash;
Comment on lines +19 to +23

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like that we recompute this information. Since it's already computed, maybe we should pass it to downstream functions instead

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bot doesn't read these, so if you want to change it you can just push to the PR.

@ematipico ematipico Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah but what you think? Does it make sense? Is it possible? Asking for feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where else is it computed?


// validate parameter values then stringify each value
const validatedParams: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
Expand All @@ -24,5 +31,5 @@ export function stringifyParams(
}
}

return getRouteGenerator(route.segments, trailingSlash)(validatedParams);
return getRouteGenerator(route.segments, effectiveTrailingSlash)(validatedParams);
}
43 changes: 43 additions & 0 deletions packages/astro/test/units/routing/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { describe, it } from 'node:test';
import type { AstroConfig } from '../../../dist/types/public/config.js';
import type { RoutePart } from '../../../dist/types/public/internal.js';
import { getRouteGenerator } from '../../../dist/core/routing/generator.js';
import { stringifyParams } from '../../../dist/core/routing/params.js';
import { dynamicPart, makeRoute, staticPart } from './test-helpers.ts';

interface TestCase {
routeData: RoutePart[][];
Expand Down Expand Up @@ -160,3 +162,44 @@ describe('routing - generator', () => {
assert.throws(() => generator({}), TypeError);
});
});

describe('stringifyParams - file endpoint trailing slash', () => {
it('does not append trailing slash to dynamic file endpoints with trailingSlash always (issue #17241)', () => {
const route = makeRoute({
segments: [[staticPart('api')], [dynamicPart('name'), staticPart('.json')]],
trailingSlash: 'never',
route: '/api/[name].json',
pathname: undefined,
type: 'endpoint',
});

const result = stringifyParams({ name: 'foo' }, route, 'always');
assert.equal(result, '/api/foo.json', 'should not have trailing slash');
});

it('still appends trailing slash to dynamic page routes with trailingSlash always', () => {
const route = makeRoute({
segments: [[staticPart('blog')], [dynamicPart('slug')]],
trailingSlash: 'always',
route: '/blog/[slug]',
pathname: undefined,
type: 'page',
});

const result = stringifyParams({ slug: 'hello' }, route, 'always');
assert.equal(result, '/blog/hello/', 'should have trailing slash');
});

it('does not append trailing slash to dynamic endpoints without file extensions with trailingSlash never', () => {
const route = makeRoute({
segments: [[staticPart('api')], [dynamicPart('name')]],
trailingSlash: 'never',
route: '/api/[name]',
pathname: undefined,
type: 'endpoint',
});

const result = stringifyParams({ name: 'foo' }, route, 'never');
assert.equal(result, '/api/foo', 'should not have trailing slash');
});
});
Loading